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
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
userRefs
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
adminUserRef
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
userRefs
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
string
alias)Value | +Description | +
---|---|
"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. + |
+
string
alias)@@ -5300,6 +5381,317 @@ int32
User is the Schema for the users API
+Field | +Description | +||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
+metadata + + +Kubernetes meta/v1.ObjectMeta + + + |
+
+Refer to the Kubernetes API documentation for the fields of the
+metadata field.
+ |
+||||||||||||||||
+spec + + +UserSpec + + + |
+
+ + +
|
+||||||||||||||||
+status + + +UserStatus + + + |
++ | +
UserSpec defines the desired state of User
+Field | +Description | +
---|---|
+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. + |
+
+(Appears on:User) +
+UserStatus defines the observed state of User
+Field | +Description | +
---|---|
+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())