diff --git a/PROJECT b/PROJECT index cdb33ed6..08159eec 100644 --- a/PROJECT +++ b/PROJECT @@ -29,6 +29,9 @@ resources: kind: BMCSecret path: github.com/ironcore-dev/metal-operator/api/v1alpha1 version: v1alpha1 + webhooks: + validation: true + webhookVersion: v1 - api: crdVersion: v1 controller: true @@ -107,6 +110,14 @@ resources: webhooks: validation: true webhookVersion: v1 +- api: + crdVersion: v1 + controller: true + domain: ironcore.dev + group: metal + kind: User + path: github.com/ironcore-dev/metal-operator/api/v1alpha1 + version: v1alpha1 - api: crdVersion: v1 controller: true diff --git a/api/v1alpha1/bmc_types.go b/api/v1alpha1/bmc_types.go index 9554da87..7904ad96 100644 --- a/api/v1alpha1/bmc_types.go +++ b/api/v1alpha1/bmc_types.go @@ -20,6 +20,15 @@ const ( ProtocolRedfishKube = "RedfishKube" ) +type PasswordPolicy string + +const ( + // PasswordPolicyExternal indicates that the password policy is managed externally, such as by an external identity provider. + PasswordPolicyExternal PasswordPolicy = "External" + // PasswordPolicyInternal indicates that the password policy is managed internally, such as by the BMC itself. + PasswordPolicyInternal PasswordPolicy = "Internal" +) + // BMCSpec defines the desired state of BMC // +kubebuilder:validation:XValidation:rule="has(self.access) != has(self.endpointRef)",message="exactly one of access or endpointRef needs to be set" type BMCSpec struct { @@ -47,6 +56,12 @@ type BMCSpec struct { // +required BMCSecretRef v1.LocalObjectReference `json:"bmcSecretRef"` + // AdminUserRef is a reference to the Kubernetes Secret object that contains the credentials to access the BMC. + // This secret is used for administrative access to the BMC and may include elevated privileges. + // It will replqce the BMCSecretRef for administrative operations. + // +optional + AdminUserRef *v1.LocalObjectReference `json:"adminUserRef,omitempty"` + // Protocol specifies the protocol to be used for communicating with the BMC. // It could be a standard protocol such as IPMI or Redfish. // +required @@ -57,6 +72,11 @@ type BMCSpec struct { // +optional ConsoleProtocol *ConsoleProtocol `json:"consoleProtocol,omitempty"` + // UserAccounts is a list of user accounts that can be used to access the BMC. + // Each account includes a name, role ID, description, and other relevant details. + // +optional + UserRefs []UserSpec `json:"userRefs,omitempty"` + // BMCSettingRef is a reference to a BMCSettings object that specifies // the BMC configuration for this BMC. // +optional diff --git a/api/v1alpha1/constants.go b/api/v1alpha1/constants.go index e02fff52..17582d8b 100644 --- a/api/v1alpha1/constants.go +++ b/api/v1alpha1/constants.go @@ -12,6 +12,8 @@ const ( OperationAnnotationRetry = "retry" // InstanceTypeAnnotation is used to specify the type of Server. InstanceTypeAnnotation = "metal.ironcore.dev/instance-type" + // OperationAnnotationRotateCredentials is used to indicate that credentials should be rotated. + OperationAnnotationRotateCredentials = "rotate-credentials" // ForceUpdateAnnotation is used to indicate that the spec should be forcefully updated. ForceUpdateAnnotation = "metal.ironcore.dev/force-update-resource" // OperationAnnotationForceUpdateOrDeleteInProgress allows update/Delete of a resource even if it is in progress. diff --git a/api/v1alpha1/user_types.go b/api/v1alpha1/user_types.go new file mode 100644 index 00000000..4ee99082 --- /dev/null +++ b/api/v1alpha1/user_types.go @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! +// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. + +// UserSpec defines the desired state of User +type UserSpec struct { + UserName string `json:"userName"` + RoleID string `json:"roleID"` + Description string `json:"description,omitempty"` + RotationPolicy *metav1.Duration `json:"rotationPeriod,omitempty"` + BMCSecretRef *v1.LocalObjectReference `json:"bmcSecretRef,omitempty"` + BMCRef *v1.LocalObjectReference `json:"bmcRef,omitempty"` + Enabled bool `json:"enabled"` + // set if the user should be used by the BMC reconciler to access the system. + UseForBMCAccess bool `json:"useForBMCAccess,omitempty"` +} + +// UserStatus defines the observed state of User +type UserStatus struct { + EffectiveBMCSecretRef *v1.LocalObjectReference `json:"effectiveBMCSecretRef,omitempty"` + LastRotation *metav1.Time `json:"lastRotation,omitempty"` + PasswordExpiration string `json:"passwordExpiration,omitempty"` + ID string `json:"id,omitempty"` // ID of the user in the BMC system +} + +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster +// +kubebuilder:subresource:status + +// User is the Schema for the users API +type User struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec UserSpec `json:"spec,omitempty"` + Status UserStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// UserList contains a list of User +type UserList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []User `json:"items"` +} + +func init() { + SchemeBuilder.Register(&User{}, &UserList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 347f5f82..b3e07317 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -777,12 +777,24 @@ func (in *BMCSpec) DeepCopyInto(out *BMCSpec) { (*in).DeepCopyInto(*out) } out.BMCSecretRef = in.BMCSecretRef + if in.AdminUserRef != nil { + in, out := &in.AdminUserRef, &out.AdminUserRef + *out = new(v1.LocalObjectReference) + **out = **in + } out.Protocol = in.Protocol if in.ConsoleProtocol != nil { in, out := &in.ConsoleProtocol, &out.ConsoleProtocol *out = new(ConsoleProtocol) **out = **in } + if in.UserRefs != nil { + in, out := &in.UserRefs, &out.UserRefs + *out = make([]UserSpec, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.BMCSettingRef != nil { in, out := &in.BMCSettingRef, &out.BMCSettingRef *out = new(v1.LocalObjectReference) @@ -1850,3 +1862,116 @@ func (in *Task) DeepCopy() *Task { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *User) DeepCopyInto(out *User) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new User. +func (in *User) DeepCopy() *User { + if in == nil { + return nil + } + out := new(User) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *User) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UserList) DeepCopyInto(out *UserList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]User, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserList. +func (in *UserList) DeepCopy() *UserList { + if in == nil { + return nil + } + out := new(UserList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *UserList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UserSpec) DeepCopyInto(out *UserSpec) { + *out = *in + if in.RotationPolicy != nil { + in, out := &in.RotationPolicy, &out.RotationPolicy + *out = new(metav1.Duration) + **out = **in + } + if in.BMCSecretRef != nil { + in, out := &in.BMCSecretRef, &out.BMCSecretRef + *out = new(v1.LocalObjectReference) + **out = **in + } + if in.BMCRef != nil { + in, out := &in.BMCRef, &out.BMCRef + *out = new(v1.LocalObjectReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserSpec. +func (in *UserSpec) DeepCopy() *UserSpec { + if in == nil { + return nil + } + out := new(UserSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UserStatus) DeepCopyInto(out *UserStatus) { + *out = *in + if in.EffectiveBMCSecretRef != nil { + in, out := &in.EffectiveBMCSecretRef, &out.EffectiveBMCSecretRef + *out = new(v1.LocalObjectReference) + **out = **in + } + if in.LastRotation != nil { + in, out := &in.LastRotation, &out.LastRotation + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserStatus. +func (in *UserStatus) DeepCopy() *UserStatus { + if in == nil { + return nil + } + out := new(UserStatus) + in.DeepCopyInto(out) + return out +} diff --git a/bmc/bmc.go b/bmc/bmc.go index e7bd1cd0..5978d70b 100644 --- a/bmc/bmc.go +++ b/bmc/bmc.go @@ -116,6 +116,12 @@ type BMC interface { // WaitForServerPowerState waits for the server to reach the specified power state. WaitForServerPowerState(ctx context.Context, systemURI string, powerState redfish.PowerState) error + // CreateOrUpdateAccount creates or updates a BMC user account. + CreateOrUpdateAccount(ctx context.Context, userName, role, password string, enabled bool) error + + // GetAccounts retrieves all BMC user accounts. + GetAccounts(ctx context.Context) ([]*redfish.ManagerAccount, error) + // UpgradeBMCVersion upgrades the BMC version for the system. UpgradeBMCVersion(ctx context.Context, manufacturer string, parameters *redfish.SimpleUpdateParameters) (string, bool, error) diff --git a/bmc/mockup.go b/bmc/mockup.go index 7f23f15f..40808054 100644 --- a/bmc/mockup.go +++ b/bmc/mockup.go @@ -3,7 +3,10 @@ package bmc -import "github.com/stmcginnis/gofish/redfish" +import ( + "github.com/stmcginnis/gofish/common" + "github.com/stmcginnis/gofish/redfish" +) // RedfishMockUps is an implementation of the BMC interface for Redfish. type RedfishMockUps struct { @@ -21,6 +24,8 @@ type RedfishMockUps struct { BMCUpgradingVersion string BMCUpgradeTaskIndex int BMCUpgradeTaskStatus []redfish.Task + + Accounts map[string]*redfish.ManagerAccount } func (r *RedfishMockUps) InitializeDefaults() { @@ -104,6 +109,40 @@ func (r *RedfishMockUps) InitializeDefaults() { PercentComplete: 100, }, } + + r.Accounts = map[string]*redfish.ManagerAccount{ + "foo": { + Entity: common.Entity{ + ID: "0", + }, + UserName: "foo", + Enabled: true, + RoleID: "ReadOnly", + Locked: false, + Password: "bar", + }, + "admin": { + Entity: common.Entity{ + ID: "1", + }, + + UserName: "admin", + Enabled: true, + RoleID: "Administrator", + Locked: false, + Password: "adminpass", + }, + "user": { + Entity: common.Entity{ + ID: "2", + }, + UserName: "user", + Enabled: true, + RoleID: "ReadOnly", + Locked: false, + Password: "userpass", + }, + } } func (r *RedfishMockUps) ResetBIOSSettings() { diff --git a/bmc/redfish.go b/bmc/redfish.go index ed83b944..d4584874 100644 --- a/bmc/redfish.go +++ b/bmc/redfish.go @@ -68,15 +68,16 @@ var pxeBootWithoutSettingUEFIBootMode = redfish.Boot{ // NewRedfishBMCClient creates a new RedfishBMC with the given connection details. func NewRedfishBMCClient(ctx context.Context, options Options) (*RedfishBMC, error) { clientConfig := gofish.ClientConfig{ - Endpoint: options.Endpoint, - Username: options.Username, - Password: options.Password, - Insecure: true, - BasicAuth: options.BasicAuth, + Endpoint: options.Endpoint, + Username: options.Username, + Password: options.Password, + Insecure: true, + ReuseConnections: true, + BasicAuth: options.BasicAuth, } client, err := gofish.ConnectContext(ctx, clientConfig) if err != nil { - return nil, fmt.Errorf("failed to connect to redfish endpoint: %w", err) + return nil, err } bmc := &RedfishBMC{client: client} if options.ResourcePollingInterval == 0 { @@ -703,6 +704,52 @@ func (r *RedfishBMC) GetStorages(ctx context.Context, systemURI string) ([]Stora return result, nil } +func (r *RedfishBMC) CreateOrUpdateAccount( + ctx context.Context, userName, + role, password string, enabled bool, +) error { + service, err := r.client.GetService().AccountService() + if err != nil { + return fmt.Errorf("failed to get account service: %w", err) + } + accounts, err := service.Accounts() + if err != nil { + return fmt.Errorf("failed to get accounts: %w", err) + } + for _, a := range accounts { + if a.UserName == userName { + a.RoleID = role + a.UserName = userName + a.Enabled = enabled + if err := a.Update(); err != nil { + return fmt.Errorf("failed to update account: %w", err) + } + if password != "" { + if err := a.ChangePassword(password, r.options.Password); err != nil { + return fmt.Errorf("failed to change account password: %w", err) + } + } + } + } + _, err = service.CreateAccount(userName, password, role) + if err != nil { + return fmt.Errorf("failed to update account: %w", err) + } + return nil +} + +func (r *RedfishBMC) GetAccounts(ctx context.Context) ([]*redfish.ManagerAccount, error) { + service, err := r.client.GetService().AccountService() + if err != nil { + return nil, fmt.Errorf("failed to get account service: %w", err) + } + accounts, err := service.Accounts() + if err != nil { + return nil, fmt.Errorf("failed to get accounts: %w", err) + } + return accounts, nil +} + func (r *RedfishBMC) getSystemFromUri(ctx context.Context, systemURI string) (*redfish.ComputerSystem, error) { if len(systemURI) == 0 { return nil, fmt.Errorf("can not process empty URI") diff --git a/bmc/redfish_local.go b/bmc/redfish_local.go index 9180e391..9b97c423 100644 --- a/bmc/redfish_local.go +++ b/bmc/redfish_local.go @@ -9,6 +9,7 @@ import ( "time" "github.com/ironcore-dev/metal-operator/bmc/common" + gofishCommon "github.com/stmcginnis/gofish/common" "github.com/stmcginnis/gofish/redfish" "sigs.k8s.io/controller-runtime/pkg/log" ) @@ -24,7 +25,18 @@ type RedfishLocalBMC struct { func NewRedfishLocalBMCClient(ctx context.Context, options Options) (BMC, error) { bmc, err := NewRedfishBMCClient(ctx, options) if err != nil { - return nil, fmt.Errorf("failed to create RedfishBMC client: %w", err) + return nil, err + } + if acc, ok := UnitTestMockUps.Accounts[options.Username]; ok { + if acc.Password != options.Password { + return nil, &gofishCommon.Error{ + HTTPReturnedStatusCode: 401, + } + } + } else { + return nil, &gofishCommon.Error{ + HTTPReturnedStatusCode: 401, + } } return &RedfishLocalBMC{RedfishBMC: bmc}, nil } @@ -160,6 +172,41 @@ func (r *RedfishLocalBMC) CheckBiosAttributes(attrs redfish.SettingsAttributes) return r.checkAttribues(attrs, filtered) } +// GetAccounts retrieves all user accounts from the BMC. +func (r *RedfishLocalBMC) GetAccounts(ctx context.Context) ([]*redfish.ManagerAccount, error) { + accounts := make([]*redfish.ManagerAccount, 0, len(UnitTestMockUps.Accounts)) + for _, a := range UnitTestMockUps.Accounts { + accounts = append(accounts, a) + } + return accounts, nil +} + +// CreateOrUpdateAccount creates or updates a user account on the BMC. +func (r *RedfishLocalBMC) CreateOrUpdateAccount( + ctx context.Context, userName, role, password string, enabled bool, +) error { + for _, a := range UnitTestMockUps.Accounts { + if a.UserName == userName { + a.RoleID = role + a.UserName = userName + a.Enabled = enabled + a.Password = password + return nil + } + } + newAccount := redfish.ManagerAccount{ + Entity: gofishCommon.Entity{ + ID: fmt.Sprintf("%d", len(UnitTestMockUps.Accounts)+1), + }, + UserName: userName, + RoleID: role, + Enabled: enabled, + Password: password, + } + UnitTestMockUps.Accounts[userName] = &newAccount + return nil +} + // GetBiosVersion retrieves the BIOS version. func (r *RedfishLocalBMC) GetBiosVersion(ctx context.Context, systemUUID string) (string, error) { if UnitTestMockUps.BIOSVersion == "" { diff --git a/cmd/manager/main.go b/cmd/manager/main.go index d7fb8f9a..5045c76e 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -383,6 +383,21 @@ func main() { // nolint: gocyclo setupLog.Error(err, "unable to create controller", "controller", "BIOSSettings") os.Exit(1) } + if err = (&controller.UserReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Insecure: insecure, + BMCOptions: bmc.Options{ + BasicAuth: true, + PowerPollingInterval: powerPollingInterval, + PowerPollingTimeout: powerPollingTimeout, + ResourcePollingInterval: resourcePollingInterval, + ResourcePollingTimeout: resourcePollingTimeout, + }, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "User") + os.Exit(1) + } // nolint:goconst if os.Getenv("ENABLE_WEBHOOKS") != "false" { if err = webhookmetalv1alpha1.SetupBIOSSettingsWebhookWithManager(mgr); err != nil { diff --git a/config/certmanager/issuer.yaml b/config/certmanager/issuer.yaml index 641e4b24..f5fb5fb9 100644 --- a/config/certmanager/issuer.yaml +++ b/config/certmanager/issuer.yaml @@ -10,4 +10,4 @@ metadata: name: selfsigned-issuer namespace: system spec: - selfSigned: {} \ No newline at end of file + selfSigned: {} diff --git a/config/crd/bases/metal.ironcore.dev_bmcs.yaml b/config/crd/bases/metal.ironcore.dev_bmcs.yaml index f07212c1..92b1ad2d 100644 --- a/config/crd/bases/metal.ironcore.dev_bmcs.yaml +++ b/config/crd/bases/metal.ironcore.dev_bmcs.yaml @@ -85,6 +85,23 @@ spec: x-kubernetes-validations: - message: access is immutable rule: self == oldSelf + adminUserRef: + description: |- + AdminUserRef is a reference to the Kubernetes Secret object that contains the credentials to access the BMC. + This secret is used for administrative access to the BMC and may include elevated privileges. + It will replqce the BMCSecretRef for administrative operations. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic bmcSecretRef: description: |- BMCSecretRef is a reference to the Kubernetes Secret object that contains the credentials @@ -187,6 +204,65 @@ spec: - name - port type: object + userRefs: + description: |- + UserAccounts is a list of user accounts that can be used to access the BMC. + Each account includes a name, role ID, description, and other relevant details. + items: + description: UserSpec defines the desired state of User + properties: + bmcRef: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + bmcSecretRef: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + description: + type: string + enabled: + type: boolean + roleID: + type: string + rotationPeriod: + type: string + useForBMCAccess: + description: set if the user should be used by the BMC reconciler + to access the system. + type: boolean + userName: + type: string + required: + - enabled + - roleID + - userName + type: object + type: array required: - bmcSecretRef - protocol diff --git a/config/crd/bases/metal.ironcore.dev_users.yaml b/config/crd/bases/metal.ironcore.dev_users.yaml new file mode 100644 index 00000000..90ea97d8 --- /dev/null +++ b/config/crd/bases/metal.ironcore.dev_users.yaml @@ -0,0 +1,124 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: users.metal.ironcore.dev +spec: + group: metal.ironcore.dev + names: + kind: User + listKind: UserList + plural: users + singular: user + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: User is the Schema for the users API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: UserSpec defines the desired state of User + properties: + bmcRef: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + bmcSecretRef: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + description: + type: string + enabled: + type: boolean + roleID: + type: string + rotationPeriod: + type: string + useForBMCAccess: + description: set if the user should be used by the BMC reconciler + to access the system. + type: boolean + userName: + type: string + required: + - enabled + - roleID + - userName + type: object + status: + description: UserStatus defines the observed state of User + properties: + effectiveBMCSecretRef: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + id: + type: string + lastRotation: + format: date-time + type: string + passwordExpiration: + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index fe50c4d9..0a14aef7 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -37,6 +37,7 @@ patches: #- path: patches/cainjection_in_servers.yaml #- path: patches/cainjection_in_serverbootconfigurations.yaml #- path: patches/cainjection_in_serverclaims.yaml +#- path: patches/cainjection_in_accounts.yaml #+kubebuilder:scaffold:crdkustomizecainjectionpatch # [WEBHOOK] To enable webhook, uncomment the following section diff --git a/config/rbac/account_editor_role.yaml b/config/rbac/account_editor_role.yaml new file mode 100644 index 00000000..35ca4cf9 --- /dev/null +++ b/config/rbac/account_editor_role.yaml @@ -0,0 +1,27 @@ +# permissions for end users to edit accounts. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: metal-operator + app.kubernetes.io/managed-by: kustomize + name: account-editor-role +rules: +- apiGroups: + - metal.ironcore.dev + resources: + - accounts + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - metal.ironcore.dev + resources: + - accounts/status + verbs: + - get diff --git a/config/rbac/account_viewer_role.yaml b/config/rbac/account_viewer_role.yaml new file mode 100644 index 00000000..fb3f4336 --- /dev/null +++ b/config/rbac/account_viewer_role.yaml @@ -0,0 +1,23 @@ +# permissions for end users to view accounts. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: metal-operator + app.kubernetes.io/managed-by: kustomize + name: account-viewer-role +rules: +- apiGroups: + - metal.ironcore.dev + resources: + - accounts + verbs: + - get + - list + - watch +- apiGroups: + - metal.ironcore.dev + resources: + - accounts/status + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index d7ef4606..7c75b477 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -31,6 +31,7 @@ rules: - apiGroups: - metal.ironcore.dev resources: + - accounts - biossettings - biossettingssets - biosversions @@ -57,6 +58,7 @@ rules: - apiGroups: - metal.ironcore.dev resources: + - accounts/finalizers - biossettings/finalizers - biossettingssets/finalizers - biosversions/finalizers @@ -76,6 +78,7 @@ rules: - apiGroups: - metal.ironcore.dev resources: + - accounts/status - biossettings/status - biossettingssets/status - biosversions/status diff --git a/config/samples/metal_v1alpha1_account.yaml b/config/samples/metal_v1alpha1_account.yaml new file mode 100644 index 00000000..d5a36c11 --- /dev/null +++ b/config/samples/metal_v1alpha1_account.yaml @@ -0,0 +1,9 @@ +apiVersion: metal.ironcore.dev/v1alpha1 +kind: Account +metadata: + labels: + app.kubernetes.io/name: metal-operator + app.kubernetes.io/managed-by: kustomize + name: account-sample +spec: + # TODO(user): Add fields here diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index f7a403e0..a8965404 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -46,6 +46,26 @@ webhooks: resources: - biosversions sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-metal-ironcore-dev-v1alpha1-bmcsecret + failurePolicy: Fail + name: vbmcsecret-v1alpha1.kb.io + rules: + - apiGroups: + - metal.ironcore.dev + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - bmcsecrets + sideEffects: None - admissionReviewVersions: - v1 clientConfig: diff --git a/dist/chart/templates/crd/metal.ironcore.dev_bmcs.yaml b/dist/chart/templates/crd/metal.ironcore.dev_bmcs.yaml index 633fbfe3..fb9ab52f 100755 --- a/dist/chart/templates/crd/metal.ironcore.dev_bmcs.yaml +++ b/dist/chart/templates/crd/metal.ironcore.dev_bmcs.yaml @@ -106,6 +106,23 @@ spec: x-kubernetes-validations: - message: access is immutable rule: self == oldSelf + adminUserRef: + description: |- + AdminUserRef is a reference to the Kubernetes Secret object that contains the credentials to access the BMC. + This secret is used for administrative access to the BMC and may include elevated privileges. + It will replqce the BMCSecretRef for administrative operations. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic bmcSecretRef: description: |- BMCSecretRef is a reference to the Kubernetes Secret object that contains the credentials @@ -208,6 +225,65 @@ spec: - name - port type: object + userRefs: + description: |- + UserAccounts is a list of user accounts that can be used to access the BMC. + Each account includes a name, role ID, description, and other relevant details. + items: + description: UserSpec defines the desired state of User + properties: + bmcRef: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + bmcSecretRef: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + description: + type: string + enabled: + type: boolean + roleID: + type: string + rotationPeriod: + type: string + useForBMCAccess: + description: set if the user should be used by the BMC reconciler + to access the system. + type: boolean + userName: + type: string + required: + - enabled + - roleID + - userName + type: object + type: array required: - bmcSecretRef - protocol diff --git a/dist/chart/templates/crd/metal.ironcore.dev_users.yaml b/dist/chart/templates/crd/metal.ironcore.dev_users.yaml new file mode 100755 index 00000000..c3f93bc4 --- /dev/null +++ b/dist/chart/templates/crd/metal.ironcore.dev_users.yaml @@ -0,0 +1,131 @@ +{{- if .Values.crd.enable }} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + labels: + {{- include "chart.labels" . | nindent 4 }} + annotations: + {{- if .Values.crd.keep }} + "helm.sh/resource-policy": keep + {{- end }} + controller-gen.kubebuilder.io/version: v0.18.0 + name: users.metal.ironcore.dev +spec: + group: metal.ironcore.dev + names: + kind: User + listKind: UserList + plural: users + singular: user + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: User is the Schema for the users API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: UserSpec defines the desired state of User + properties: + bmcRef: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + bmcSecretRef: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + description: + type: string + enabled: + type: boolean + roleID: + type: string + rotationPeriod: + type: string + useForBMCAccess: + description: set if the user should be used by the BMC reconciler + to access the system. + type: boolean + userName: + type: string + required: + - enabled + - roleID + - userName + type: object + status: + description: UserStatus defines the observed state of User + properties: + effectiveBMCSecretRef: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + id: + type: string + lastRotation: + format: date-time + type: string + passwordExpiration: + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} +{{- end -}} diff --git a/dist/chart/templates/rbac/account_editor_role.yaml b/dist/chart/templates/rbac/account_editor_role.yaml new file mode 100755 index 00000000..0cf1b750 --- /dev/null +++ b/dist/chart/templates/rbac/account_editor_role.yaml @@ -0,0 +1,28 @@ +{{- if .Values.rbac.enable }} +# permissions for end users to edit accounts. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + {{- include "chart.labels" . | nindent 4 }} + name: account-editor-role +rules: +- apiGroups: + - metal.ironcore.dev + resources: + - accounts + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - metal.ironcore.dev + resources: + - accounts/status + verbs: + - get +{{- end -}} diff --git a/dist/chart/templates/rbac/account_viewer_role.yaml b/dist/chart/templates/rbac/account_viewer_role.yaml new file mode 100755 index 00000000..3193d8df --- /dev/null +++ b/dist/chart/templates/rbac/account_viewer_role.yaml @@ -0,0 +1,24 @@ +{{- if .Values.rbac.enable }} +# permissions for end users to view accounts. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + {{- include "chart.labels" . | nindent 4 }} + name: account-viewer-role +rules: +- apiGroups: + - metal.ironcore.dev + resources: + - accounts + verbs: + - get + - list + - watch +- apiGroups: + - metal.ironcore.dev + resources: + - accounts/status + verbs: + - get +{{- end -}} diff --git a/dist/chart/templates/rbac/role.yaml b/dist/chart/templates/rbac/role.yaml index 6cbf5f3a..19271afa 100755 --- a/dist/chart/templates/rbac/role.yaml +++ b/dist/chart/templates/rbac/role.yaml @@ -34,6 +34,7 @@ rules: - apiGroups: - metal.ironcore.dev resources: + - accounts - biossettings - biossettingssets - biosversions @@ -60,6 +61,7 @@ rules: - apiGroups: - metal.ironcore.dev resources: + - accounts/finalizers - biossettings/finalizers - biossettingssets/finalizers - biosversions/finalizers @@ -79,6 +81,7 @@ rules: - apiGroups: - metal.ironcore.dev resources: + - accounts/status - biossettings/status - biossettingssets/status - biosversions/status diff --git a/dist/chart/templates/webhook/webhooks.yaml b/dist/chart/templates/webhook/webhooks.yaml index 3794a7d9..99cc9aea 100644 --- a/dist/chart/templates/webhook/webhooks.yaml +++ b/dist/chart/templates/webhook/webhooks.yaml @@ -53,6 +53,26 @@ webhooks: - v1alpha1 resources: - biosversions + - name: vbmcsecret-v1alpha1.kb.io + clientConfig: + service: + name: metal-operator-webhook-service + namespace: {{ .Release.Namespace }} + path: /validate-metal-ironcore-dev-v1alpha1-bmcsecret + failurePolicy: Fail + sideEffects: None + admissionReviewVersions: + - v1 + rules: + - operations: + - CREATE + - UPDATE + apiGroups: + - metal.ironcore.dev + apiVersions: + - v1alpha1 + resources: + - bmcsecrets - name: vbmcsettings-v1alpha1.kb.io clientConfig: service: diff --git a/docs/api-reference/api.md b/docs/api-reference/api.md index 72416c64..8ee3c3b8 100644 --- a/docs/api-reference/api.md +++ b/docs/api-reference/api.md @@ -1251,6 +1251,22 @@ required to access the BMC. This secret includes sensitive information such as u +adminUserRef
+ + +Kubernetes core/v1.LocalObjectReference + + + + +(Optional) +

AdminUserRef is a reference to the Kubernetes Secret object that contains the credentials to access the BMC. +This secret is used for administrative access to the BMC and may include elevated privileges. +It will replqce the BMCSecretRef for administrative operations.

+ + + + protocol
@@ -1280,6 +1296,21 @@ This field is optional and can be omitted if console access is not required.

+userRefs
+ +
+[]UserSpec + + + + +(Optional) +

UserAccounts is a list of user accounts that can be used to access the BMC. +Each account includes a name, role ID, description, and other relevant details.

+ + + + bmcSettingsRef
@@ -1835,6 +1866,22 @@ required to access the BMC. This secret includes sensitive information such as u +adminUserRef
+ +
+Kubernetes core/v1.LocalObjectReference + + + + +(Optional) +

AdminUserRef is a reference to the Kubernetes Secret object that contains the credentials to access the BMC. +This secret is used for administrative access to the BMC and may include elevated privileges. +It will replqce the BMCSecretRef for administrative operations.

+ + + + protocol
@@ -1864,6 +1911,21 @@ This field is optional and can be omitted if console access is not required.

+userRefs
+ +
+[]UserSpec + + + + +(Optional) +

UserAccounts is a list of user accounts that can be used to access the BMC. +Each account includes a name, role ID, description, and other relevant details.

+ + + + bmcSettingsRef
@@ -3059,6 +3121,25 @@ string +

PasswordPolicy +(string alias)

+
+
+ + + + + + + + + + + + +
ValueDescription

"External"

PasswordPolicyExternal indicates that the password policy is managed externally, such as by an external identity provider.

+

"Internal"

PasswordPolicyInternal indicates that the password policy is managed internally, such as by the BMC itself.

+

Phase (string alias)

@@ -5300,6 +5381,317 @@ int32 +

User +

+
+

User is the Schema for the users API

+
+ + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+metadata
+ + +Kubernetes meta/v1.ObjectMeta + + +
+Refer to the Kubernetes API documentation for the fields of the +metadata field. +
+spec
+ + +UserSpec + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+userName
+ +string + +
+
+roleID
+ +string + +
+
+description
+ +string + +
+
+rotationPeriod
+ + +Kubernetes meta/v1.Duration + + +
+
+bmcSecretRef
+ + +Kubernetes core/v1.LocalObjectReference + + +
+
+bmcRef
+ + +Kubernetes core/v1.LocalObjectReference + + +
+
+enabled
+ +bool + +
+
+useForBMCAccess
+ +bool + +
+

set if the user should be used by the BMC reconciler to access the system.

+
+
+status
+ + +UserStatus + + +
+
+

UserSpec +

+

+(Appears on:BMCSpec, User) +

+
+

UserSpec defines the desired state of User

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+userName
+ +string + +
+
+roleID
+ +string + +
+
+description
+ +string + +
+
+rotationPeriod
+ + +Kubernetes meta/v1.Duration + + +
+
+bmcSecretRef
+ + +Kubernetes core/v1.LocalObjectReference + + +
+
+bmcRef
+ + +Kubernetes core/v1.LocalObjectReference + + +
+
+enabled
+ +bool + +
+
+useForBMCAccess
+ +bool + +
+

set if the user should be used by the BMC reconciler to access the system.

+
+

UserStatus +

+

+(Appears on:User) +

+
+

UserStatus defines the observed state of User

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+effectiveBMCSecretRef
+ + +Kubernetes core/v1.LocalObjectReference + + +
+
+lastRotation
+ + +Kubernetes meta/v1.Time + + +
+
+passwordExpiration
+ +string + +
+
+id
+ +string + +
+

Generated with gen-crd-api-reference-docs diff --git a/internal/bmcutils/bmcutils.go b/internal/bmcutils/bmcutils.go index bbd68443..5fe759f1 100644 --- a/internal/bmcutils/bmcutils.go +++ b/internal/bmcutils/bmcutils.go @@ -23,17 +23,19 @@ func GetProtocolScheme(scheme metalv1alpha1.ProtocolScheme, insecure bool) metal return metalv1alpha1.HTTPSProtocolScheme } -func GetBMCCredentialsFromSecret(secret *metalv1alpha1.BMCSecret) (string, string, error) { +func GetBMCCredentialsFromSecret(secret *metalv1alpha1.BMCSecret) (username string, password string, err error) { // TODO: use constants for secret keys - username, ok := secret.Data["username"] + user, ok := secret.Data["username"] if !ok { - return "", "", fmt.Errorf("no username found in the BMC secret") + return username, password, fmt.Errorf("no username found in the BMC secret") } - password, ok := secret.Data["password"] + username = string(user) + pw, ok := secret.Data["password"] if !ok { - return "", "", fmt.Errorf("no password found in the BMC secret") + return username, password, fmt.Errorf("no password found in the BMC secret") } - return string(username), string(password), nil + password = string(pw) + return } func GetBMCFromBMCName(ctx context.Context, c client.Client, bmcName string) (*metalv1alpha1.BMC, error) { @@ -116,18 +118,29 @@ func GetBMCClientFromBMC(ctx context.Context, c client.Client, bmcObj *metalv1al } address = endpoint.Spec.IP.String() } - if bmcObj.Spec.Endpoint != nil { address = bmcObj.Spec.Endpoint.IP.String() } bmcSecret := &metalv1alpha1.BMCSecret{} + protocolScheme := GetProtocolScheme(bmcObj.Spec.Protocol.Scheme, insecure) + + if bmcObj.Spec.AdminUserRef != nil { + user := &metalv1alpha1.User{} + if err := c.Get(ctx, client.ObjectKey{Name: bmcObj.Spec.AdminUserRef.Name}, user); err != nil { + return nil, fmt.Errorf("failed to get admin user: %w", err) + } + if user.Status.EffectiveBMCSecretRef == nil { + return nil, fmt.Errorf("admin user %s has no effective BMC secret reference", user.Name) + } + if err := c.Get(ctx, client.ObjectKey{Name: user.Status.EffectiveBMCSecretRef.Name}, bmcSecret); err != nil { + return nil, fmt.Errorf("failed to get BMC secret: %w", err) + } + return CreateBMCClient(ctx, c, protocolScheme, bmcObj.Spec.Protocol.Name, address, bmcObj.Spec.Protocol.Port, bmcSecret, options) + } if err := c.Get(ctx, client.ObjectKey{Name: bmcObj.Spec.BMCSecretRef.Name}, bmcSecret); err != nil { return nil, fmt.Errorf("failed to get BMC secret: %w", err) } - - protocolScheme := GetProtocolScheme(bmcObj.Spec.Protocol.Scheme, insecure) - return CreateBMCClient(ctx, c, protocolScheme, bmcObj.Spec.Protocol.Name, address, bmcObj.Spec.Protocol.Port, bmcSecret, options) } @@ -154,17 +167,17 @@ func CreateBMCClient( case metalv1alpha1.ProtocolRedfish: bmcClient, err = bmc.NewRedfishBMCClient(ctx, bmcOptions) if err != nil { - return nil, fmt.Errorf("failed to create Redfish client: %w", err) + return nil, err } case metalv1alpha1.ProtocolRedfishLocal: bmcClient, err = bmc.NewRedfishLocalBMCClient(ctx, bmcOptions) if err != nil { - return nil, fmt.Errorf("failed to create Redfish client: %w", err) + return nil, err } case metalv1alpha1.ProtocolRedfishKube: bmcClient, err = bmc.NewRedfishKubeBMCClient(ctx, bmcOptions, c, DefaultKubeNamespace) if err != nil { - return nil, fmt.Errorf("failed to create Redfish client: %w", err) + return nil, err } default: return nil, fmt.Errorf("unsupported BMC protocol %s", bmcProtocol) diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index bf8b1ae2..e736cd96 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -97,6 +97,9 @@ func DeleteAllMetalResources(ctx context.Context, namespace string) { Eventually(deleteAndList(ctx, &metalv1alpha1.BIOSSettings{}, &metalv1alpha1.BIOSSettingsList{})).Should( HaveField("Items", BeEmpty())) + Eventually(deleteAndList(ctx, &metalv1alpha1.User{}, &metalv1alpha1.UserList{})).Should( + HaveField("Items", BeEmpty())) + Eventually(deleteAndList(ctx, &metalv1alpha1.BIOSVersion{}, &metalv1alpha1.BIOSVersionList{})).Should( HaveField("Items", BeEmpty())) Eventually(deleteAndList(ctx, &metalv1alpha1.BIOSVersionSet{}, &metalv1alpha1.BIOSVersionSetList{})).Should( @@ -109,6 +112,9 @@ func DeleteAllMetalResources(ctx context.Context, namespace string) { HaveField("Items", BeEmpty())) Eventually(deleteAndList(ctx, &metalv1alpha1.BMCVersionSet{}, &metalv1alpha1.BMCVersionList{})).Should( HaveField("Items", BeEmpty())) + + Eventually(deleteAndList(ctx, &metalv1alpha1.BMCSecret{}, &metalv1alpha1.BMCSecretList{})).Should( + HaveField("Items", BeEmpty())) } func deleteAndList(ctx context.Context, obj client.Object, objList client.ObjectList, namespaceOpt ...client.DeleteAllOfOption) func() (client.ObjectList, error) { @@ -310,6 +316,17 @@ func SetupTest() *corev1.Namespace { }, }).SetupWithManager(k8sManager)).To(Succeed()) + Expect((&UserReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + Insecure: true, + BMCOptions: bmc.Options{ + PowerPollingInterval: 50 * time.Millisecond, + PowerPollingTimeout: 200 * time.Millisecond, + BasicAuth: true, + }, + }).SetupWithManager(k8sManager)).To(Succeed()) + Expect((&BMCVersionReconciler{ Client: k8sManager.GetClient(), ManagerNamespace: ns.Name, diff --git a/internal/controller/user_controller.go b/internal/controller/user_controller.go new file mode 100644 index 00000000..f041d46d --- /dev/null +++ b/internal/controller/user_controller.go @@ -0,0 +1,411 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "fmt" + "time" + + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + "github.com/go-logr/logr" + metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1" + "github.com/ironcore-dev/metal-operator/bmc" + "github.com/ironcore-dev/metal-operator/internal/bmcutils" + "github.com/stmcginnis/gofish/common" +) + +// UserReconciler reconciles a Account object +type UserReconciler struct { + client.Client + Insecure bool + BMCOptions bmc.Options + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=metal.ironcore.dev,resources=accounts,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=metal.ironcore.dev,resources=accounts/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=metal.ironcore.dev,resources=accounts/finalizers,verbs=update + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// TODO(user): Modify the Reconcile function to compare the state specified by +// the Account object against the actual cluster state, and then +// perform operations to make the cluster state reflect the state specified by +// the user. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.18.4/pkg/reconcile +func (r *UserReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := ctrl.LoggerFrom(ctx) + user := &metalv1alpha1.User{} + if err := r.Get(ctx, req.NamespacedName, user); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + return r.reconcileExists(ctx, log, user) +} + +func (r *UserReconciler) reconcileExists(ctx context.Context, log logr.Logger, user *metalv1alpha1.User) (ctrl.Result, error) { + if !user.DeletionTimestamp.IsZero() { + return r.delete(ctx, log, user) + } + return r.reconcile(ctx, log, user) +} + +func (r *UserReconciler) reconcile(ctx context.Context, log logr.Logger, user *metalv1alpha1.User) (ctrl.Result, error) { + if user.Spec.BMCRef == nil { + log.Info("No BMC reference set for User, skipping reconciliation", "User", user.Name) + return ctrl.Result{}, nil + } + bmcObj := &metalv1alpha1.BMC{} + if err := r.Get(ctx, client.ObjectKey{ + Namespace: user.Namespace, + Name: user.Spec.BMCRef.Name, + }, bmcObj); err != nil { + return ctrl.Result{}, err + } + // Add this check once we use the user CRD also for BMC admin users + /*if bmcObj.Spec.AdminUserRef == nil { + return ctrl.Result{}, fmt.Errorf("BMC %s does not have an admin user reference set", bmcObj.Name) + } + */ + if err := r.updateEffectiveSecret(ctx, log, user, bmcObj); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to update effective BMCSecret: %w", err) + } + bmcClient, err := r.getBMCClient(ctx, log, bmcObj, user) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get BMC client: %w", err) + } + defer bmcClient.Logout() + err = r.patchUserStatus(ctx, log, user, bmcClient) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to update User status: %w", err) + } + + if user.Spec.BMCSecretRef == nil { + log.Info("No BMCSecret reference set for User, creating a new one", "User", user.Name) + if err := r.handleMissingBMCSecretRef(ctx, log, bmcClient, user); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to handle missing BMCSecret reference: %w", err) + } + } + bmcSecret := &metalv1alpha1.BMCSecret{} + if err := r.Get(ctx, client.ObjectKey{ + Namespace: user.Namespace, + Name: user.Spec.BMCSecretRef.Name, + }, bmcSecret); err != nil { + return ctrl.Result{}, err + } + if user.Status.ID == "" { + log.Info("No BMC account ID set in User status, creating or updating BMC account", "User", user.Name) + _, password, err := bmcutils.GetBMCCredentialsFromSecret(bmcSecret) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get credentials from BMCSecret: %w", err) + } + if err = bmcClient.CreateOrUpdateAccount(ctx, user.Spec.UserName, user.Spec.RoleID, password, r.Insecure); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to create or update BMC account with new password: %w", err) + } + if err = r.patchUserStatus(ctx, log, user, bmcClient); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to update User status after creating BMC account: %w", err) + } + + } + if user.Status.EffectiveBMCSecretRef != nil && user.Spec.BMCSecretRef.Name != user.Status.EffectiveBMCSecretRef.Name { + log.Info("BMCSecret reference has changed, updating BMC account", "User", user.Name) + if err := r.handleUpdatedSecretRef(ctx, log, user, bmcSecret, bmcClient); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to handle updated BMCSecret reference: %w", err) + } + } + return r.handleRotatingPassword(ctx, log, user, bmcClient) +} + +func (r *UserReconciler) patchUserStatus(ctx context.Context, log logr.Logger, user *metalv1alpha1.User, bmcClient bmc.BMC) error { + accounts, err := bmcClient.GetAccounts(ctx) + if err != nil { + return fmt.Errorf("failed to get BMC accounts: %w", err) + } + for _, account := range accounts { + if account.UserName == user.Spec.UserName { + log.V(1).Info("BMC account already exists", "User", user.Name, "ID", account.ID) + userBase := user.DeepCopy() + user.Status.ID = account.ID + user.Status.PasswordExpiration = account.PasswordExpiration + if err := r.Status().Patch(ctx, user, client.MergeFrom(userBase)); err != nil { + return fmt.Errorf("failed to patch User status with BMC account ID: %w", err) + } + log.Info("Updated User status with BMC account ID", "User", user.Name, "AccountID", account.ID) + return nil + } + } + return nil +} + +func (r *UserReconciler) handleRotatingPassword(ctx context.Context, log logr.Logger, user *metalv1alpha1.User, bmcClient bmc.BMC) (ctrl.Result, error) { + log.V(1).Info("BMC user password rotation is not needed yet", "User", user.Name) + forceRotation := false + if user.GetAnnotations() != nil && user.GetAnnotations()[metalv1alpha1.OperationAnnotation] == metalv1alpha1.OperationAnnotationRotateCredentials { + log.Info("User has rotation annotation set, triggering password rotation", "User", user.Name) + forceRotation = true + } + if user.Status.PasswordExpiration != "" { + exp, err := time.Parse(time.RFC3339, user.Status.PasswordExpiration) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to parse password expiration time: %w", err) + } + if exp.Before(metav1.Now().Time) { + log.Info("BMC user password has expired, rotating password", "User", user.Name) + // If the password has expired, we need to rotate it + forceRotation = true + } + + } + if user.Spec.RotationPolicy == nil && !forceRotation { + log.V(1).Info("No rotation period set for BMC user, skipping password rotation", "User", user.Name) + return ctrl.Result{}, nil + } + log.V(1).Info("BMC user password rotation is not needed yet", "User", user.Name) + if user.Status.LastRotation != nil && user.Status.LastRotation.Add(user.Spec.RotationPolicy.Duration).After(metav1.Now().Time) && !forceRotation { + log.V(1).Info("BMC user password rotation is not needed yet", "User", user.Name) + return ctrl.Result{ + Requeue: true, + RequeueAfter: user.Spec.RotationPolicy.Duration, + }, nil + } + log.Info("Rotating BMC user password", "User", user.Name) + newPassword, err := GenerateRandomPassword(16) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to generate new password for BMC user %s: %w", user.Name, err) + } + if err := r.createSecret(ctx, log, user, string(newPassword)); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to create BMCSecret: %w", err) + } + if err := bmcClient.CreateOrUpdateAccount(ctx, user.Spec.UserName, user.Spec.RoleID, string(newPassword), r.Insecure); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to create or update BMC account with new password: %w", err) + } + // Update the last rotation time + userBase := user.DeepCopy() + user.Status.LastRotation = &metav1.Time{Time: metav1.Now().Time} + if err := r.Status().Patch(ctx, user, client.MergeFrom(userBase)); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to patch User status with last rotation time: %w", err) + } + log.Info("Updated last rotation time for BMC user", "User", user.Name) + return ctrl.Result{}, nil +} + +func (r *UserReconciler) handleMissingBMCSecretRef(ctx context.Context, log logr.Logger, bmcClient bmc.BMC, user *metalv1alpha1.User) error { + log.Info("No BMCSecret reference set for User, creating a new one", "User", user.Name) + newPassword, err := GenerateRandomPassword(16) + if err != nil { + return fmt.Errorf("failed to generate new password for BMC account %s: %w", user.Name, err) + } + if err := r.createSecret(ctx, log, user, string(newPassword)); err != nil { + return fmt.Errorf("failed to create BMCSecret: %w", err) + } + log.Info("Creating BMC account with new password", "Account", user.Name) + if err := bmcClient.CreateOrUpdateAccount(ctx, user.Spec.UserName, user.Spec.RoleID, string(newPassword), r.Insecure); err != nil { + return fmt.Errorf("failed to create or update BMC account with new password: %w", err) + } + log.Info("BMC account created with new password", "Account", user.Name) + return nil +} + +func (r *UserReconciler) handleUpdatedSecretRef(ctx context.Context, log logr.Logger, user *metalv1alpha1.User, bmcSecret *metalv1alpha1.BMCSecret, bmcClient bmc.BMC) error { + log.Info("BMCSecret credentials have changed, updating BMC user", "User", user.Name) + _, password, err := bmcutils.GetBMCCredentialsFromSecret(bmcSecret) + if err != nil { + return fmt.Errorf("failed to get credentials from BMCSecret: %w", err) + } + // Update the BMC account with the new password + if err := bmcClient.CreateOrUpdateAccount(ctx, user.Spec.UserName, user.Spec.RoleID, password, r.Insecure); err != nil { + return fmt.Errorf("failed to create or update BMC account with new password: %w", err) + } + return nil +} + +func (r *UserReconciler) removeEffectiveSecret(ctx context.Context, log logr.Logger, user *metalv1alpha1.User) error { + log.Info("Removing effective BMCSecret for User", "User", user.Name) + userBase := user.DeepCopy() + user.Status.EffectiveBMCSecretRef = nil + if err := r.Status().Patch(ctx, user, client.MergeFrom(userBase)); err != nil { + return fmt.Errorf("failed to patch User status to remove effective BMCSecretRef: %w", err) + } + log.V(1).Info("Removed effective BMCSecret reference from User status", "User", user.Name) + return nil +} + +func (r *UserReconciler) createSecret(ctx context.Context, log logr.Logger, user *metalv1alpha1.User, password string) error { + log.Info("Creating BMCSecret for User", "User", user.Name) + if password == "" { + passwordBytes, err := GenerateRandomPassword(16) + if err != nil { + return fmt.Errorf("failed to generate new password for BMC account %s: %w", user.Name, err) + } + password = string(passwordBytes) + } + secret := &metalv1alpha1.BMCSecret{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: user.Name + "-bmcsecret-", + }, + Data: map[string][]byte{ + metalv1alpha1.BMCSecretUsernameKeyName: []byte(user.Spec.UserName), + metalv1alpha1.BMCSecretPasswordKeyName: []byte(password), + }, + Immutable: &[]bool{true}[0], // Make the secret immutable + } + op, err := controllerutil.CreateOrPatch(ctx, r.Client, secret, func() error { + if err := controllerutil.SetControllerReference(user, secret, r.Scheme); err != nil { + return fmt.Errorf("failed to set controller reference for BMCSecret: %w", err) + } + return nil + }) + if err != nil { + return fmt.Errorf("failed to create or patch BMCSecret: %w", err) + } + log.V(1).Info("BMCSecret created or patched", "BMCSecret", secret.Name, "Operation", op) + userBase := user.DeepCopy() + user.Spec.BMCSecretRef = &v1.LocalObjectReference{Name: secret.Name} + if err := r.Patch(ctx, user, client.MergeFrom(userBase)); err != nil { + return fmt.Errorf("failed to patch User status with effective BMCSecretRef: %w", err) + } + return nil +} + +func (r *UserReconciler) setEffectiveSecretRef(ctx context.Context, log logr.Logger, user *metalv1alpha1.User, secret *metalv1alpha1.BMCSecret) error { + log.Info("Setting effective BMCSecret", "User", user.Name) + userBase := user.DeepCopy() + if user.Status.EffectiveBMCSecretRef == nil { + user.Status.EffectiveBMCSecretRef = &v1.LocalObjectReference{} + } + user.Status.EffectiveBMCSecretRef.Name = secret.Name + if err := r.Status().Patch(ctx, user, client.MergeFrom(userBase)); err != nil { + return fmt.Errorf("failed to patch User status with effective BMCSecretRef: %w", err) + } + return nil +} + +func (r *UserReconciler) getBMCClient(ctx context.Context, log logr.Logger, bmcObj *metalv1alpha1.BMC, user *metalv1alpha1.User) (bmcClient bmc.BMC, err error) { + if bmcObj.Spec.AdminUserRef != nil && bmcObj.Spec.AdminUserRef.Name == user.Name { + if user.Spec.BMCSecretRef == nil { + // if this user is the admin user, we cannot create a BMC client without a BMCSecretRef (password) + return bmcClient, fmt.Errorf("BMC %s admin user %s does not have a BMCSecretRef set", bmcObj.Name, user.Name) + } + log.Info("User is the admin user for the BMC", "User", user.Name) + protocolScheme := bmcutils.GetProtocolScheme(bmcObj.Spec.Protocol.Scheme, r.Insecure) + address, err := bmcutils.GetBMCAddressForBMC(ctx, r.Client, bmcObj) + if err != nil { + return bmcClient, fmt.Errorf("failed to get BMC address: %w", err) + } + bmcSecret := &metalv1alpha1.BMCSecret{} + if err := r.Get(ctx, client.ObjectKey{ + Namespace: user.Namespace, + Name: user.Spec.BMCSecretRef.Name, + }, bmcSecret); err != nil { + return bmcClient, err + } + bmcClient, err = bmcutils.CreateBMCClient(ctx, r.Client, protocolScheme, bmcObj.Spec.Protocol.Name, address, bmcObj.Spec.Protocol.Port, bmcSecret, r.BMCOptions) + if err != nil { + return bmcClient, fmt.Errorf("failed to create BMC client: %w", err) + } + } else { + bmcClient, err = bmcutils.GetBMCClientFromBMC(ctx, r.Client, bmcObj, r.Insecure, r.BMCOptions) + if err != nil { + return bmcClient, fmt.Errorf("failed to create BMC client: %w", err) + } + } + return +} + +func (r *UserReconciler) updateEffectiveSecret(ctx context.Context, log logr.Logger, user *metalv1alpha1.User, bmcObj *metalv1alpha1.BMC) error { + if user.Spec.BMCSecretRef == nil { + return nil + } + secret := &metalv1alpha1.BMCSecret{} + if err := r.Get(ctx, client.ObjectKey{ + Namespace: user.Namespace, + Name: user.Spec.BMCSecretRef.Name, + }, secret); err != nil { + return fmt.Errorf("failed to get BMCSecret %s: %w", user.Spec.BMCSecretRef.Name, err) + } + + invalidCredentials, err := r.bmcConnectionTest(secret, bmcObj) + if err != nil { + return fmt.Errorf("failed to test BMC connection with BMCSecret %s: %w", secret.Name, err) + } + if invalidCredentials { + log.Info("New BMCSecret is invalid, will not update effective BMCSecret", "User", user.Name, "NewBMCSecret", secret.Name) + return nil + } + if user.Status.EffectiveBMCSecretRef == nil && !invalidCredentials { + if err := r.setEffectiveSecretRef(ctx, log, user, secret); err != nil { + return fmt.Errorf("failed to update effective BMCSecret: %w", err) + } + log.Info("Set effective BMCSecret for User", "User", user.Name) + return nil + } + + effSecret := &metalv1alpha1.BMCSecret{} + if user.Status.EffectiveBMCSecretRef != nil { + if err := r.Get(ctx, client.ObjectKey{ + Namespace: user.Namespace, + Name: user.Status.EffectiveBMCSecretRef.Name, + }, effSecret); err != nil { + return fmt.Errorf("failed to get effective BMCSecret %s: %w", user.Status.EffectiveBMCSecretRef.Name, err) + } + } + + invalidCredentials, err = r.bmcConnectionTest(effSecret, bmcObj) + if err != nil { + return fmt.Errorf("failed to test BMC connection with effectiveSecret %s: %w", effSecret.Name, err) + } + if invalidCredentials { + log.Info("Effective BMCSecret is invalid", "User", user.Name, "EffectiveBMCSecret", effSecret.Name, "NewBMCSecret", secret.Name) + if err := r.setEffectiveSecretRef(ctx, log, user, secret); err != nil { + return fmt.Errorf("failed to update effective BMCSecret: %w", err) + } + log.Info("Updated effective BMCSecret for User", "User", user.Name) + } + return nil +} + +func (r *UserReconciler) bmcConnectionTest(secret *metalv1alpha1.BMCSecret, bmcObj *metalv1alpha1.BMC) (bool, error) { + protocolScheme := bmcutils.GetProtocolScheme(bmcObj.Spec.Protocol.Scheme, r.Insecure) + address, err := bmcutils.GetBMCAddressForBMC(context.Background(), r.Client, bmcObj) + if err != nil { + return false, fmt.Errorf("failed to get BMC address: %w", err) + } + _, err = bmcutils.CreateBMCClient(context.Background(), r.Client, protocolScheme, bmcObj.Spec.Protocol.Name, address, bmcObj.Spec.Protocol.Port, secret, r.BMCOptions) + if err != nil { + if httpErr, ok := err.(*common.Error); ok { + if httpErr.HTTPReturnedStatusCode == 401 || httpErr.HTTPReturnedStatusCode == 403 { + return true, nil + } + } + return false, fmt.Errorf("failed to create BMC client: %w", err) + } + return false, nil +} + +func (r *UserReconciler) delete(ctx context.Context, log logr.Logger, user *metalv1alpha1.User) (ctrl.Result, error) { + log.V(1).Info("Deleting User", "User", user.Name) + if user.Status.EffectiveBMCSecretRef != nil { + log.V(1).Info("Removing effective BMCSecret reference from User", "User", user.Name) + if err := r.removeEffectiveSecret(ctx, log, user); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to remove effective BMCSecret reference: %w", err) + } + } + return ctrl.Result{}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *UserReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&metalv1alpha1.User{}). + Complete(r) +} diff --git a/internal/controller/user_controller_test.go b/internal/controller/user_controller_test.go new file mode 100644 index 00000000..40b835e5 --- /dev/null +++ b/internal/controller/user_controller_test.go @@ -0,0 +1,247 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "time" + + metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + . "sigs.k8s.io/controller-runtime/pkg/envtest/komega" +) + +var _ = Describe("User Controller", func() { + ns := SetupTest() + + var bmc *metalv1alpha1.BMC + var bmcSecret *metalv1alpha1.BMCSecret + + BeforeEach(func(ctx SpecContext) { + By("Creating a BMCSecret for the User") + bmcSecret = &metalv1alpha1.BMCSecret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-user-secret", + }, + Data: map[string][]byte{ + "username": []byte("admin"), + "password": []byte("adminpass"), + }, + } + Expect(k8sClient.Create(ctx, bmcSecret)).To(Succeed()) + By("Ensuring that the BMCSecret has been created") + Eventually(Get(bmcSecret)).Should(Succeed()) + + By("Creating a BMC resource") + bmc = &metalv1alpha1.BMC{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-bmc-", + }, + Spec: metalv1alpha1.BMCSpec{ + Endpoint: &metalv1alpha1.InlineEndpoint{ + IP: metalv1alpha1.MustParseIP("127.0.0.1"), + MACAddress: "23:11:8A:33:CF:EA", + }, + Protocol: metalv1alpha1.Protocol{ + Name: metalv1alpha1.ProtocolRedfishLocal, + Port: 8000, + }, + BMCSecretRef: v1.LocalObjectReference{ + Name: bmcSecret.Name, + }, + }, + } + Expect(k8sClient.Create(ctx, bmc)).To(Succeed()) + By("Ensuring that the BMC resource has been created") + Eventually(Get(bmc)).Should(Succeed()) + }) + + AfterEach(func(ctx SpecContext) { + DeleteAllMetalResources(ctx, ns.Name) + }) + + It("Should create a bmc user and secret", func(ctx SpecContext) { + By("Creating a User resource") + user := &metalv1alpha1.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-user", + }, + Spec: metalv1alpha1.UserSpec{ + UserName: "user", + RoleID: "ReadOnly", + BMCRef: &v1.LocalObjectReference{ + Name: bmc.Name, + }, + }, + } + Expect(k8sClient.Create(ctx, user)).To(Succeed()) + By("Ensuring that the User resource has been created") + Eventually(Get(user)).Should(Succeed()) + + By("Ensuring that the User resource has been patched with the BMC secret reference") + Eventually(Object(user)).Should(SatisfyAll( + HaveField("Status.EffectiveBMCSecretRef", Not(BeNil())), + )) + + By("Ensuring the effective bmcSecret has been created") + effectiveSecret := &metalv1alpha1.BMCSecret{ + ObjectMeta: metav1.ObjectMeta{ + Name: user.Status.EffectiveBMCSecretRef.Name, + }, + } + Eventually(Get(effectiveSecret)).Should(Succeed()) + + By("Ensuring the effective bmcSecret has the correct data") + Expect(effectiveSecret.Data).To(HaveKeyWithValue("username", []byte("user"))) + }) + + It("Should just create additional bmc users", func(ctx SpecContext) { + user01 := &metalv1alpha1.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "user01", + }, + Spec: metalv1alpha1.UserSpec{ + UserName: "user01", + RoleID: "Readonly", + BMCRef: &v1.LocalObjectReference{ + Name: bmc.Name, + }, + }, + } + Expect(k8sClient.Create(ctx, user01)).To(Succeed()) + Eventually(Get(user01)).Should(Succeed()) + By("Ensuring that the User resource has EffectiveBMCSecretRef") + Eventually(Object(user01), "4s").Should(SatisfyAll( + HaveField("Status.EffectiveBMCSecretRef", Not(BeNil())), + HaveField("Status.ID", Not(BeEmpty())), + )) + By("Ensuring that the BMCSecret has been created") + effectiveSecret := &metalv1alpha1.BMCSecret{ + ObjectMeta: metav1.ObjectMeta{ + Name: user01.Status.EffectiveBMCSecretRef.Name, + }, + } + Eventually(Get(effectiveSecret)).Should(Succeed()) + Expect(effectiveSecret.Data).To(HaveKeyWithValue("username", []byte("user01"))) + Expect(effectiveSecret.Data).To(HaveKeyWithValue("password", Not(BeEmpty()))) + + By("Creating a second user with the same BMCRef") + user02 := &metalv1alpha1.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "user02", + }, + Spec: metalv1alpha1.UserSpec{ + UserName: "user02", + RoleID: "Readonly", + BMCRef: &v1.LocalObjectReference{ + Name: bmc.Name, + }, + }, + } + Expect(k8sClient.Create(ctx, user02)).To(Succeed()) + Eventually(Get(user02)).Should(Succeed()) + By("Ensuring that the User resource has EffectiveBMCSecretRef") + Eventually(Object(user02)).Should(SatisfyAll( + HaveField("Status.EffectiveBMCSecretRef", Not(BeNil())), + HaveField("Status.ID", Not(BeEmpty())), + )) + By("Ensuring that the BMCSecret has been created") + effectiveSecret02 := &metalv1alpha1.BMCSecret{ + ObjectMeta: metav1.ObjectMeta{ + Name: user02.Status.EffectiveBMCSecretRef.Name, + }, + } + Eventually(Get(effectiveSecret02)).Should(Succeed()) + + user03Secret := metalv1alpha1.BMCSecret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "user03-secret", + }, + Data: map[string][]byte{ + "username": []byte("user03"), + "password": []byte("userpass"), + }, + } + + By("Creating a BMCSecret for the third User") + Expect(k8sClient.Create(ctx, &user03Secret)).To(Succeed()) + Eventually(Get(&user03Secret)).Should(Succeed()) + + By("Creating a second user with the same BMCRef") + user03 := &metalv1alpha1.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "user03", + }, + Spec: metalv1alpha1.UserSpec{ + UserName: "user03", + RoleID: "Readonly", + BMCRef: &v1.LocalObjectReference{ + Name: bmc.Name, + }, + BMCSecretRef: &v1.LocalObjectReference{ + Name: user03Secret.Name, + }, + }, + } + Expect(k8sClient.Create(ctx, user03)).To(Succeed()) + Eventually(Get(user03)).Should(Succeed()) + By("Ensuring that the User resource has EffectiveBMCSecretRef") + Eventually(Object(user03)).Should(SatisfyAll( + HaveField("Status.EffectiveBMCSecretRef", Equal(&v1.LocalObjectReference{ + Name: user03Secret.Name, + })), + HaveField("Status.ID", Not(BeEmpty())), + )) + By("Ensuring that the BMCSecret has been created") + }) + + It("Should rotate password if rotationPeriod is set", func(ctx SpecContext) { + By("Creating a BMCSecret for the User") + adminUser := &metalv1alpha1.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "admin-user", + }, + Spec: metalv1alpha1.UserSpec{ + UserName: "admin", + RoleID: "Administrator", + BMCRef: &v1.LocalObjectReference{ + Name: bmc.Name, + }, + BMCSecretRef: &v1.LocalObjectReference{ + Name: bmcSecret.Name, + }, + RotationPolicy: &metav1.Duration{ + Duration: 1 * time.Second, + }, + }, + } + By("Creating a User resource") + Expect(k8sClient.Create(ctx, adminUser)).To(Succeed()) + By("Ensuring that the User resource has been created") + Eventually(Get(adminUser)).Should(Succeed()) + // update bmc spec to use tbe user password + Eventually(Update(bmc, func() { + bmc.Spec.AdminUserRef = &v1.LocalObjectReference{ + Name: adminUser.Name, + } + })).Should(Succeed()) + + By("Ensuring that a new secret with new password has been rotated and set to EffectiveBMCSecretRef") + Eventually(Object(adminUser), "4s").Should(SatisfyAll( + HaveField("Status.LastRotation", Not(BeNil())), + HaveField("Status.EffectiveBMCSecretRef", Not(BeNil())), + HaveField("Status.EffectiveBMCSecretRef.Name", Not(Equal(bmcSecret.Name))), + )) + newSecret := &metalv1alpha1.BMCSecret{ + ObjectMeta: metav1.ObjectMeta{ + Name: adminUser.Status.EffectiveBMCSecretRef.Name, + }, + } + Eventually(Get(newSecret)).Should(Succeed()) + Expect(newSecret.Data).To(Not(HaveKeyWithValue("password", []byte("bar")))) + + }) +}) diff --git a/internal/webhook/v1alpha1/bmcsecret_webhook.go b/internal/webhook/v1alpha1/bmcsecret_webhook.go new file mode 100644 index 00000000..5dc65b7f --- /dev/null +++ b/internal/webhook/v1alpha1/bmcsecret_webhook.go @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + "context" + "fmt" + "reflect" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1" +) + +// nolint:unused +// log is for logging in this package. +var bmcsecretlog = logf.Log.WithName("bmcsecret-resource") + +// SetupBMCSecretWebhookWithManager registers the webhook for BMCSecret in the manager. +func SetupBMCSecretWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr).For(&metalv1alpha1.BMCSecret{}). + WithValidator(&BMCSecretCustomValidator{}). + Complete() +} + +// TODO(user): EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! + +// TODO(user): change verbs to "verbs=create;update;delete" if you want to enable deletion validation. +// NOTE: The 'path' attribute must follow a specific pattern and should not be modified directly here. +// Modifying the path for an invalid path can cause API server errors; failing to locate the webhook. +// +kubebuilder:webhook:path=/validate-metal-ironcore-dev-v1alpha1-bmcsecret,mutating=false,failurePolicy=fail,sideEffects=None,groups=metal.ironcore.dev,resources=bmcsecrets,verbs=create;update,versions=v1alpha1,name=vbmcsecret-v1alpha1.kb.io,admissionReviewVersions=v1 + +// BMCSecretCustomValidator struct is responsible for validating the BMCSecret resource +// when it is created, updated, or deleted. +// +// NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods, +// as this struct is used only for temporary operations and does not need to be deeply copied. +type BMCSecretCustomValidator struct { + // TODO(user): Add more fields as needed for validation +} + +var _ webhook.CustomValidator = &BMCSecretCustomValidator{} + +// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type BMCSecret. +func (v *BMCSecretCustomValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + bmcsecret, ok := obj.(*metalv1alpha1.BMCSecret) + if !ok { + return nil, fmt.Errorf("expected a BMCSecret object but got %T", obj) + } + bmcsecretlog.Info("Validation for BMCSecret upon creation", "name", bmcsecret.GetName()) + + // TODO(user): fill in your validation logic upon object creation. + + return nil, nil +} + +// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type BMCSecret. +func (v *BMCSecretCustomValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + bmcsecret, ok := newObj.(*metalv1alpha1.BMCSecret) + if !ok { + return nil, fmt.Errorf("expected a BMCSecret object for the newObj but got %T", newObj) + } + oldSecret, ok := oldObj.(*metalv1alpha1.BMCSecret) + if !ok { + return nil, fmt.Errorf("expected a BMCSecret object for the oldObj but got %T", oldObj) + } + bmcsecretlog.Info("Validation for BMCSecret upon update", "name", bmcsecret.GetName()) + + if bmcsecret.Immutable != nil && *bmcsecret.Immutable { + if !reflect.DeepEqual(bmcsecret.Data, oldSecret.Data) { + return nil, fmt.Errorf("data field is immutable and cannot be updated") + } + if !reflect.DeepEqual(bmcsecret.StringData, oldSecret.StringData) { + return nil, fmt.Errorf("stringData field is immutable and cannot be updated") + } + } + + return nil, nil +} + +// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type BMCSecret. +func (v *BMCSecretCustomValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + bmcsecret, ok := obj.(*metalv1alpha1.BMCSecret) + if !ok { + return nil, fmt.Errorf("expected a BMCSecret object but got %T", obj) + } + bmcsecretlog.Info("Validation for BMCSecret upon deletion", "name", bmcsecret.GetName()) + + // TODO(user): fill in your validation logic upon object deletion. + + return nil, nil +} diff --git a/internal/webhook/v1alpha1/bmcsecret_webhook_test.go b/internal/webhook/v1alpha1/bmcsecret_webhook_test.go new file mode 100644 index 00000000..9c881be2 --- /dev/null +++ b/internal/webhook/v1alpha1/bmcsecret_webhook_test.go @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1" + // TODO (user): Add any additional imports if needed +) + +var _ = Describe("BMCSecret Webhook", func() { + var ( + obj *metalv1alpha1.BMCSecret + oldObj *metalv1alpha1.BMCSecret + validator BMCSecretCustomValidator + ) + + BeforeEach(func() { + obj = &metalv1alpha1.BMCSecret{} + oldObj = &metalv1alpha1.BMCSecret{} + validator = BMCSecretCustomValidator{} + Expect(validator).NotTo(BeNil(), "Expected validator to be initialized") + Expect(oldObj).NotTo(BeNil(), "Expected oldObj to be initialized") + Expect(obj).NotTo(BeNil(), "Expected obj to be initialized") + // TODO (user): Add any setup logic common to all tests + }) + + AfterEach(func() { + // TODO (user): Add any teardown logic common to all tests + }) + + Context("When creating or updating BMCSecret under Validating Webhook", func() { + // TODO (user): Add logic for validating webhooks + // Example: + // It("Should deny creation if a required field is missing", func() { + // By("simulating an invalid creation scenario") + // obj.SomeRequiredField = "" + // Expect(validator.ValidateCreate(ctx, obj)).Error().To(HaveOccurred()) + // }) + // + // It("Should admit creation if all required fields are present", func() { + // By("simulating an invalid creation scenario") + // obj.SomeRequiredField = "valid_value" + // Expect(validator.ValidateCreate(ctx, obj)).To(BeNil()) + // }) + // + // It("Should validate updates correctly", func() { + // By("simulating a valid update scenario") + // oldObj.SomeRequiredField = "updated_value" + // obj.SomeRequiredField = "updated_value" + // Expect(validator.ValidateUpdate(ctx, oldObj, obj)).To(BeNil()) + // }) + }) + +}) diff --git a/internal/webhook/v1alpha1/webhook_suite_test.go b/internal/webhook/v1alpha1/webhook_suite_test.go index 9e55aa9d..a5be89a3 100644 --- a/internal/webhook/v1alpha1/webhook_suite_test.go +++ b/internal/webhook/v1alpha1/webhook_suite_test.go @@ -115,6 +115,9 @@ var _ = BeforeSuite(func() { err = SetupEndpointWebhookWithManager(mgr) Expect(err).NotTo(HaveOccurred()) + err = SetupBMCSecretWebhookWithManager(mgr) + Expect(err).NotTo(HaveOccurred()) + err = SetupBIOSSettingsWebhookWithManager(mgr) Expect(err).NotTo(HaveOccurred())