diff --git a/.golangci.yml b/.golangci.yml index ec50a982..058f79a5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -9,7 +9,6 @@ linters: - errcheck - exportloopref - goconst - - gocyclo - gofmt - goimports - gosimple diff --git a/api/v1alpha1/oob_types.go b/api/v1alpha1/oob_types.go index 068cf68c..76fc18c4 100644 --- a/api/v1alpha1/oob_types.go +++ b/api/v1alpha1/oob_types.go @@ -49,8 +49,7 @@ const ( type ConsoleProtocol struct { Name ConsoleProtocolName `json:"name"` - - Port int32 `json:"port"` + Port int32 `json:"port"` } type ConsoleProtocolName string @@ -79,7 +78,7 @@ type OOBStatus struct { // +optional FirmwareVersion string `json:"firmwareVersion,omitempty"` - // +kubebuilder:validation:Enum=Ready;Unready;Error + // +kubebuilder:validation:Enum=Ready;Unready;Ignored;Error // +optional State OOBState `json:"state,omitempty"` @@ -102,9 +101,18 @@ type OOBState string const ( OOBStateReady OOBState = "Ready" OOBStateUnready OOBState = "Unready" + OOBStateIgnored OOBState = "Ignored" OOBStateError OOBState = "Error" ) +const ( + OOBConditionTypeReady = "Ready" + OOBConditionReasonInProgress = "InProgress" + OOBConditionReasonNoEndpoint = "NoEndpoint" + OOBConditionReasonIgnored = "Ignored" + OOBConditionReasonError = "Error" +) + // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:resource:scope=Cluster diff --git a/client/applyconfiguration/internal/internal.go b/client/applyconfiguration/internal/internal.go index 3e392d87..d29c1dd8 100644 --- a/client/applyconfiguration/internal/internal.go +++ b/client/applyconfiguration/internal/internal.go @@ -26,46 +26,538 @@ func Parser() *typed.Parser { var parserOnce sync.Once var parser *typed.Parser var schemaYAML = typed.YAMLObject(`types: +- name: com.github.ironcore-dev.metal.api.v1alpha1.ConsoleProtocol + map: + fields: + - name: name + type: + scalar: string + default: "" + - name: port + type: + scalar: numeric + default: 0 - name: com.github.ironcore-dev.metal.api.v1alpha1.Machine - scalar: untyped - list: - elementType: - namedType: __untyped_atomic_ - elementRelationship: atomic map: - elementType: - namedType: __untyped_deduced_ - elementRelationship: separable + fields: + - name: apiVersion + type: + scalar: string + - name: kind + type: + scalar: string + - name: metadata + type: + namedType: io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta + default: {} + - name: spec + type: + namedType: com.github.ironcore-dev.metal.api.v1alpha1.MachineSpec + default: {} + - name: status + type: + namedType: com.github.ironcore-dev.metal.api.v1alpha1.MachineStatus + default: {} - name: com.github.ironcore-dev.metal.api.v1alpha1.MachineClaim - scalar: untyped - list: - elementType: - namedType: __untyped_atomic_ - elementRelationship: atomic map: - elementType: - namedType: __untyped_deduced_ - elementRelationship: separable + fields: + - name: apiVersion + type: + scalar: string + - name: kind + type: + scalar: string + - name: metadata + type: + namedType: io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta + default: {} + - name: spec + type: + namedType: com.github.ironcore-dev.metal.api.v1alpha1.MachineClaimSpec + default: {} + - name: status + type: + namedType: com.github.ironcore-dev.metal.api.v1alpha1.MachineClaimStatus + default: {} +- name: com.github.ironcore-dev.metal.api.v1alpha1.MachineClaimNetworkInterface + map: + fields: + - name: name + type: + scalar: string + default: "" + - name: prefix + type: + namedType: com.github.ironcore-dev.metal.api.v1alpha1.Prefix +- name: com.github.ironcore-dev.metal.api.v1alpha1.MachineClaimSpec + map: + fields: + - name: ignitionSecretRef + type: + namedType: io.k8s.api.core.v1.LocalObjectReference + - name: image + type: + scalar: string + default: "" + - name: machineRef + type: + namedType: io.k8s.api.core.v1.LocalObjectReference + - name: machineSelector + type: + namedType: io.k8s.apimachinery.pkg.apis.meta.v1.LabelSelector + - name: networkInterfaces + type: + list: + elementType: + namedType: com.github.ironcore-dev.metal.api.v1alpha1.MachineClaimNetworkInterface + elementRelationship: atomic + - name: power + type: + scalar: string + default: "" +- name: com.github.ironcore-dev.metal.api.v1alpha1.MachineClaimStatus + map: + fields: + - name: phase + type: + scalar: string +- name: com.github.ironcore-dev.metal.api.v1alpha1.MachineNetworkInterface + map: + fields: + - name: IPRef + type: + namedType: io.k8s.api.core.v1.LocalObjectReference + - name: macAddress + type: + scalar: string + default: "" + - name: name + type: + scalar: string + default: "" + - name: switchRef + type: + namedType: io.k8s.api.core.v1.LocalObjectReference +- name: com.github.ironcore-dev.metal.api.v1alpha1.MachineSpec + map: + fields: + - name: asn + type: + scalar: string + - name: inventoryRef + type: + namedType: io.k8s.api.core.v1.LocalObjectReference + - name: locatorLED + type: + scalar: string + - name: loopbackAddressRef + type: + namedType: io.k8s.api.core.v1.LocalObjectReference + - name: machineClaimRef + type: + namedType: io.k8s.api.core.v1.ObjectReference + - name: oobRef + type: + namedType: io.k8s.api.core.v1.LocalObjectReference + default: {} + - name: power + type: + scalar: string + - name: uuid + type: + scalar: string + default: "" +- name: com.github.ironcore-dev.metal.api.v1alpha1.MachineStatus + map: + fields: + - name: conditions + type: + list: + elementType: + namedType: io.k8s.apimachinery.pkg.apis.meta.v1.Condition + elementRelationship: associative + keys: + - type + - name: locatorLED + type: + scalar: string + - name: manufacturer + type: + scalar: string + - name: networkInterfaces + type: + list: + elementType: + namedType: com.github.ironcore-dev.metal.api.v1alpha1.MachineNetworkInterface + elementRelationship: atomic + - name: power + type: + scalar: string + - name: serialNumber + type: + scalar: string + - name: shutdownDeadline + type: + namedType: io.k8s.apimachinery.pkg.apis.meta.v1.Time + - name: sku + type: + scalar: string + - name: state + type: + scalar: string - name: com.github.ironcore-dev.metal.api.v1alpha1.OOB - scalar: untyped - list: - elementType: - namedType: __untyped_atomic_ - elementRelationship: atomic map: - elementType: - namedType: __untyped_deduced_ - elementRelationship: separable + fields: + - name: apiVersion + type: + scalar: string + - name: kind + type: + scalar: string + - name: metadata + type: + namedType: io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta + default: {} + - name: spec + type: + namedType: com.github.ironcore-dev.metal.api.v1alpha1.OOBSpec + default: {} + - name: status + type: + namedType: com.github.ironcore-dev.metal.api.v1alpha1.OOBStatus + default: {} - name: com.github.ironcore-dev.metal.api.v1alpha1.OOBSecret + map: + fields: + - name: apiVersion + type: + scalar: string + - name: kind + type: + scalar: string + - name: metadata + type: + namedType: io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta + default: {} + - name: spec + type: + namedType: com.github.ironcore-dev.metal.api.v1alpha1.OOBSecretSpec + default: {} + - name: status + type: + namedType: com.github.ironcore-dev.metal.api.v1alpha1.OOBSecretStatus + default: {} +- name: com.github.ironcore-dev.metal.api.v1alpha1.OOBSecretSpec + map: + fields: + - name: expirationTime + type: + namedType: io.k8s.apimachinery.pkg.apis.meta.v1.Time + - name: macAddress + type: + scalar: string + default: "" + - name: password + type: + scalar: string + default: "" + - name: username + type: + scalar: string + default: "" +- name: com.github.ironcore-dev.metal.api.v1alpha1.OOBSecretStatus + map: + fields: + - name: conditions + type: + list: + elementType: + namedType: io.k8s.apimachinery.pkg.apis.meta.v1.Condition + elementRelationship: associative + keys: + - type +- name: com.github.ironcore-dev.metal.api.v1alpha1.OOBSpec + map: + fields: + - name: consoleProtocol + type: + namedType: com.github.ironcore-dev.metal.api.v1alpha1.ConsoleProtocol + - name: endpointRef + type: + namedType: io.k8s.api.core.v1.LocalObjectReference + - name: flags + type: + map: + elementType: + scalar: string + - name: macAddress + type: + scalar: string + default: "" + - name: protocol + type: + namedType: com.github.ironcore-dev.metal.api.v1alpha1.Protocol + - name: secretRef + type: + namedType: io.k8s.api.core.v1.LocalObjectReference +- name: com.github.ironcore-dev.metal.api.v1alpha1.OOBStatus + map: + fields: + - name: conditions + type: + list: + elementType: + namedType: io.k8s.apimachinery.pkg.apis.meta.v1.Condition + elementRelationship: associative + keys: + - type + - name: firmwareVersion + type: + scalar: string + - name: manufacturer + type: + scalar: string + - name: serialNumber + type: + scalar: string + - name: sku + type: + scalar: string + - name: state + type: + scalar: string + - name: type + type: + scalar: string +- name: com.github.ironcore-dev.metal.api.v1alpha1.Prefix scalar: untyped - list: - elementType: - namedType: __untyped_atomic_ +- name: com.github.ironcore-dev.metal.api.v1alpha1.Protocol + map: + fields: + - name: name + type: + scalar: string + default: "" + - name: port + type: + scalar: numeric + default: 0 +- name: io.k8s.api.core.v1.LocalObjectReference + map: + fields: + - name: name + type: + scalar: string elementRelationship: atomic +- name: io.k8s.api.core.v1.ObjectReference + map: + fields: + - name: apiVersion + type: + scalar: string + - name: fieldPath + type: + scalar: string + - name: kind + type: + scalar: string + - name: name + type: + scalar: string + - name: namespace + type: + scalar: string + - name: resourceVersion + type: + scalar: string + - name: uid + type: + scalar: string + elementRelationship: atomic +- name: io.k8s.apimachinery.pkg.apis.meta.v1.Condition + map: + fields: + - name: lastTransitionTime + type: + namedType: io.k8s.apimachinery.pkg.apis.meta.v1.Time + - name: message + type: + scalar: string + default: "" + - name: observedGeneration + type: + scalar: numeric + - name: reason + type: + scalar: string + default: "" + - name: status + type: + scalar: string + default: "" + - name: type + type: + scalar: string + default: "" +- name: io.k8s.apimachinery.pkg.apis.meta.v1.FieldsV1 map: elementType: - namedType: __untyped_deduced_ - elementRelationship: separable + scalar: untyped + list: + elementType: + namedType: __untyped_atomic_ + elementRelationship: atomic + map: + elementType: + namedType: __untyped_deduced_ + elementRelationship: separable +- name: io.k8s.apimachinery.pkg.apis.meta.v1.LabelSelector + map: + fields: + - name: matchExpressions + type: + list: + elementType: + namedType: io.k8s.apimachinery.pkg.apis.meta.v1.LabelSelectorRequirement + elementRelationship: atomic + - name: matchLabels + type: + map: + elementType: + scalar: string + elementRelationship: atomic +- name: io.k8s.apimachinery.pkg.apis.meta.v1.LabelSelectorRequirement + map: + fields: + - name: key + type: + scalar: string + default: "" + - name: operator + type: + scalar: string + default: "" + - name: values + type: + list: + elementType: + scalar: string + elementRelationship: atomic +- name: io.k8s.apimachinery.pkg.apis.meta.v1.ManagedFieldsEntry + map: + fields: + - name: apiVersion + type: + scalar: string + - name: fieldsType + type: + scalar: string + - name: fieldsV1 + type: + namedType: io.k8s.apimachinery.pkg.apis.meta.v1.FieldsV1 + - name: manager + type: + scalar: string + - name: operation + type: + scalar: string + - name: subresource + type: + scalar: string + - name: time + type: + namedType: io.k8s.apimachinery.pkg.apis.meta.v1.Time +- name: io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta + map: + fields: + - name: annotations + type: + map: + elementType: + scalar: string + - name: creationTimestamp + type: + namedType: io.k8s.apimachinery.pkg.apis.meta.v1.Time + - name: deletionGracePeriodSeconds + type: + scalar: numeric + - name: deletionTimestamp + type: + namedType: io.k8s.apimachinery.pkg.apis.meta.v1.Time + - name: finalizers + type: + list: + elementType: + scalar: string + elementRelationship: associative + - name: generateName + type: + scalar: string + - name: generation + type: + scalar: numeric + - name: labels + type: + map: + elementType: + scalar: string + - name: managedFields + type: + list: + elementType: + namedType: io.k8s.apimachinery.pkg.apis.meta.v1.ManagedFieldsEntry + elementRelationship: atomic + - name: name + type: + scalar: string + - name: namespace + type: + scalar: string + - name: ownerReferences + type: + list: + elementType: + namedType: io.k8s.apimachinery.pkg.apis.meta.v1.OwnerReference + elementRelationship: associative + keys: + - uid + - name: resourceVersion + type: + scalar: string + - name: selfLink + type: + scalar: string + - name: uid + type: + scalar: string +- name: io.k8s.apimachinery.pkg.apis.meta.v1.OwnerReference + map: + fields: + - name: apiVersion + type: + scalar: string + default: "" + - name: blockOwnerDeletion + type: + scalar: boolean + - name: controller + type: + scalar: boolean + - name: kind + type: + scalar: string + default: "" + - name: name + type: + scalar: string + default: "" + - name: uid + type: + scalar: string + default: "" + elementRelationship: atomic +- name: io.k8s.apimachinery.pkg.apis.meta.v1.Time + scalar: untyped - name: __untyped_atomic_ scalar: untyped list: diff --git a/client/openapi/doc.go b/client/openapi/doc.go new file mode 100644 index 00000000..62ab2176 --- /dev/null +++ b/client/openapi/doc.go @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +// +kubebuilder:object:generate=false + +package openapi diff --git a/cmd/main.go b/cmd/main.go index b579c78f..a7de4d3a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -14,6 +14,7 @@ import ( "syscall" "github.com/go-logr/logr" + ipamv1alpha1 "github.com/ironcore-dev/ipam/api/ipam/v1alpha1" "github.com/spf13/pflag" "github.com/spf13/viper" "k8s.io/apimachinery/pkg/runtime" @@ -46,6 +47,10 @@ type params struct { enableMachineController bool enableMachineClaimController bool enableOOBController bool + oobIpLabelSelector string + oobMacDB string + oobUsernamePrefix string + oobTemporaryPasswordSecret string enableOOBSecretController bool } @@ -65,6 +70,10 @@ func parseCmdLine() params { pflag.Bool("enable-machine-controller", true, "Enable the Machine controller.") pflag.Bool("enable-machineclaim-controller", true, "Enable the MachineClaim controller.") pflag.Bool("enable-oob-controller", true, "Enable the OOB controller.") + pflag.String("oob-ip-label-selector", "", "OOB: Filter IP objects by labels.") + pflag.String("oob-mac-db", "", "OOB: Load MAC DB from file.") + pflag.String("oob-username-prefix", "metal-", "OOB: Use a prefix when creating BMC users. Cannot be empty.") + pflag.String("oob-temporary-password-secret", "bmc-temporary-password", "OOB: Secret to store a temporary password in. Will be generated if it does not exist.") pflag.Bool("enable-oobsecret-controller", true, "Enable the OOBSecret controller.") var help bool @@ -95,6 +104,10 @@ func parseCmdLine() params { enableMachineController: viper.GetBool("enable-machine-controller"), enableMachineClaimController: viper.GetBool("enable-machineclaim-controller"), enableOOBController: viper.GetBool("enable-oob-controller"), + oobIpLabelSelector: viper.GetString("oob-ip-label-selector"), + oobMacDB: viper.GetString("oob-mac-db"), + oobUsernamePrefix: viper.GetString("oob-username-prefix"), + oobTemporaryPasswordSecret: viper.GetString("oob-temporary-password-secret"), enableOOBSecretController: viper.GetBool("enable-oobsecret-controller"), } } @@ -147,6 +160,12 @@ func main() { exitCode = 1 return } + err = ipamv1alpha1.AddToScheme(scheme) + if err != nil { + log.Error(ctx, fmt.Errorf("cannot create type scheme: %w", err)) + exitCode = 1 + return + } //+kubebuilder:scaffold:scheme var kcfg *rest.Config @@ -240,7 +259,7 @@ func main() { if p.enableOOBController { var oobReconciler *controller.OOBReconciler - oobReconciler, err = controller.NewOOBReconciler() + oobReconciler, err = controller.NewOOBReconciler(p.systemNamespace, p.oobIpLabelSelector, p.oobMacDB, p.oobUsernamePrefix, p.oobTemporaryPasswordSecret) if err != nil { log.Error(ctx, fmt.Errorf("cannot create controller: %w", err), "controller", "OOB") exitCode = 1 diff --git a/config/crd/bases/metal.ironcore.dev_oobs.yaml b/config/crd/bases/metal.ironcore.dev_oobs.yaml index 46e1dca4..0243f940 100644 --- a/config/crd/bases/metal.ironcore.dev_oobs.yaml +++ b/config/crd/bases/metal.ironcore.dev_oobs.yaml @@ -209,6 +209,7 @@ spec: enum: - Ready - Unready + - Ignored - Error type: string type: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 5b6a495c..21214fb2 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -4,6 +4,27 @@ kind: ClusterRole metadata: name: manager-role rules: +- apiGroups: + - "" + resources: + - secrets + verbs: + - get + - list +- apiGroups: + - ipam.metal.ironcore.dev + resources: + - ips + verbs: + - get + - list + - watch +- apiGroups: + - ipam.metal.ironcore.dev + resources: + - ips/status + verbs: + - get - apiGroups: - metal.ironcore.dev resources: diff --git a/go.mod b/go.mod index f4903c7b..e337e1fc 100644 --- a/go.mod +++ b/go.mod @@ -8,12 +8,15 @@ require ( github.com/golangci/golangci-lint v1.57.2 github.com/google/addlicense v1.1.1 github.com/google/uuid v1.6.0 + github.com/ironcore-dev/ipam v0.2.1 github.com/ironcore-dev/vgopath v0.1.4 github.com/onsi/ginkgo/v2 v2.17.1 github.com/onsi/gomega v1.32.0 github.com/rs/zerolog v1.32.0 + github.com/sethvargo/go-password v0.2.0 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.18.2 + gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.29.3 k8s.io/apimachinery v0.29.3 k8s.io/client-go v0.29.3 @@ -215,6 +218,7 @@ require ( go.uber.org/automaxprocs v1.5.3 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f // indirect golang.org/x/mod v0.16.0 // indirect @@ -233,7 +237,6 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect honnef.co/go/tools v0.4.7 // indirect k8s.io/apiextensions-apiserver v0.29.2 // indirect k8s.io/component-base v0.29.2 // indirect diff --git a/go.sum b/go.sum index 275eb375..62b33169 100644 --- a/go.sum +++ b/go.sum @@ -337,6 +337,8 @@ github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/ironcore-dev/ipam v0.2.1 h1:IKV3ojpD4xEQjd/GXxeUfEEy9jMUDPnO45zMluD0K3M= +github.com/ironcore-dev/ipam v0.2.1/go.mod h1:okbNL8imniqLS0XiZPZBS0Wk9bb+AKjc8KYiKQD1j0A= github.com/ironcore-dev/vgopath v0.1.4 h1:hBMuv7+wnZp5JHkVfdg4mtP8hsIGvuv42+l+F2wmQxk= github.com/ironcore-dev/vgopath v0.1.4/go.mod h1:PTGnX8xW/QDytFR7oU4kcXr1RPDLCgAJ0ZUa5Rp8vyI= github.com/jgautheron/goconst v1.7.1 h1:VpdAG7Ca7yvvJk5n8dMwQhfEZJh95kl/Hl9S1OI5Jkk= @@ -537,6 +539,8 @@ github.com/securego/gosec/v2 v2.19.0 h1:gl5xMkOI0/E6Hxx0XCY2XujA3V7SNSefA8sC+3f1 github.com/securego/gosec/v2 v2.19.0/go.mod h1:hOkDcHz9J/XIgIlPDXalxjeVYsHxoWUc5zJSHxcB8YM= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sethvargo/go-password v0.2.0 h1:BTDl4CC/gjf/axHMaDQtw507ogrXLci6XRiLc7i/UHI= +github.com/sethvargo/go-password v0.2.0/go.mod h1:Ym4Mr9JXLBycr02MFuVQ/0JHidNetSgbzutTr3zsYXE= github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c h1:W65qqJCIOVP4jpqPQ0YvHYKwcMEMVWIzWC5iNQQfBTU= github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c/go.mod h1:/PevMnwAxekIXwN8qQyfc5gl2NlkB3CQlkizAbOkeBs= github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= @@ -654,6 +658,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= diff --git a/internal/bmc/bmc.go b/internal/bmc/bmc.go new file mode 100644 index 00000000..e33b46fb --- /dev/null +++ b/internal/bmc/bmc.go @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package bmc + +type Credentials struct { + Username string `yaml:"username"` + Password string `yaml:"password"` +} diff --git a/internal/controller/indexes.go b/internal/controller/indexes.go index da1317ba..e0c47d21 100644 --- a/internal/controller/indexes.go +++ b/internal/controller/indexes.go @@ -27,5 +27,16 @@ func CreateIndexes(ctx context.Context, mgr manager.Manager) error { return fmt.Errorf("cannot index field %s: %w", MachineClaimSpecMachineRef, err) } + err = indexer.IndexField(ctx, &metalv1alpha1.OOB{}, OOBSpecMACAddress, func(obj client.Object) []string { + oob := obj.(*metalv1alpha1.OOB) + if oob.Spec.MACAddress == "" { + return nil + } + return []string{oob.Spec.MACAddress} + }) + if err != nil { + return fmt.Errorf("cannot index field %s: %w", OOBSpecMACAddress, err) + } + return nil } diff --git a/internal/controller/machineclaim_controller.go b/internal/controller/machineclaim_controller.go index 26cd02f3..7c17b57b 100644 --- a/internal/controller/machineclaim_controller.go +++ b/internal/controller/machineclaim_controller.go @@ -19,7 +19,7 @@ import ( metalv1alpha1 "github.com/ironcore-dev/metal/api/v1alpha1" metalv1alpha1apply "github.com/ironcore-dev/metal/client/applyconfiguration/api/v1alpha1" "github.com/ironcore-dev/metal/internal/log" - "github.com/ironcore-dev/metal/internal/patch" + "github.com/ironcore-dev/metal/internal/ssa" "github.com/ironcore-dev/metal/internal/util" ) @@ -31,9 +31,9 @@ import ( // +kubebuilder:rbac:groups=metal.ironcore.dev,resources=machines/finalizers,verbs=update const ( - MachineClaimFieldOwner string = "metal.ironcore.dev/machineclaim" - MachineClaimFinalizer string = "metal.ironcore.dev/machineclaim" - MachineClaimSpecMachineRef string = ".spec.machineRef.Name" + MachineClaimFieldManager = "metal.ironcore.dev/machineclaim" + MachineClaimFinalizer = "metal.ironcore.dev/machineclaim" + MachineClaimSpecMachineRef = ".spec.machineRef.Name" ) func NewMachineClaimReconciler() (*MachineClaimReconciler, error) { @@ -66,130 +66,285 @@ func (r *MachineClaimReconciler) finalize(ctx context.Context, claim *metalv1alp } log.Debug(ctx, "Finalizing") - switch { - default: - if claim.Spec.MachineRef == nil { - break - } - ctx = log.WithValues(ctx, "machine", claim.Spec.MachineRef.Name) - - log.Debug(ctx, "Getting Machine") - var machine metalv1alpha1.Machine - err := r.Get(ctx, client.ObjectKey{Name: claim.Spec.MachineRef.Name}, &machine) - if err != nil { - if errors.IsNotFound(err) { - break - } - return fmt.Errorf("cannot get Machine: %w", err) - } - if machine.Spec.MachineClaimRef == nil { - break - } - if machine.Spec.MachineClaimRef.UID != claim.UID { - return fmt.Errorf("MachineClaimRef in Machine does not match MachineClaim UID") - } - - log.Debug(ctx, "Updating Machine") - machineApply := metalv1alpha1apply.Machine(machine.Name, machine.Namespace).WithFinalizers().WithSpec(metalv1alpha1apply.MachineSpec()) - err = r.Patch(ctx, &machine, patch.Apply(machineApply), client.FieldOwner(MachineClaimFieldOwner), client.ForceOwnership) - if err != nil { - return fmt.Errorf("cannot patch Machine: %w", err) - } + err := r.finalizeMachine(ctx, claim) + if err != nil { + return err } log.Debug(ctx, "Removing finalizer") - apply := metalv1alpha1apply.MachineClaim(claim.Name, claim.Namespace).WithFinalizers() - err := r.Patch(ctx, claim, patch.Apply(apply), client.FieldOwner(MachineClaimFieldOwner), client.ForceOwnership) + var apply *metalv1alpha1apply.MachineClaimApplyConfiguration + apply, err = metalv1alpha1apply.ExtractMachineClaim(claim, MachineClaimFieldManager) if err != nil { - return fmt.Errorf("cannot remove finalizer: %w", err) + return err + } + apply.Finalizers = util.Clear(apply.Finalizers, MachineClaimFinalizer) + err = r.Patch(ctx, claim, ssa.Apply(apply), client.FieldOwner(MachineClaimFieldManager), client.ForceOwnership) + if err != nil { + return fmt.Errorf("cannot apply MachineClaim: %w", err) } log.Debug(ctx, "Finalized successfully") return nil } +func (r *MachineClaimReconciler) finalizeMachine(ctx context.Context, claim *metalv1alpha1.MachineClaim) error { + if claim.Spec.MachineRef == nil { + return nil + } + ctx = log.WithValues(ctx, "machine", claim.Spec.MachineRef.Name) + + var machine metalv1alpha1.Machine + err := r.Get(ctx, client.ObjectKey{ + Name: claim.Spec.MachineRef.Name, + }, &machine) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("cannot get Machine: %w", err) + } + if errors.IsNotFound(err) { + return nil + } + + if machine.Spec.MachineClaimRef == nil { + return nil + } + if machine.Spec.MachineClaimRef.UID != claim.UID { + return fmt.Errorf("MachineClaimRef in Machine does not match MachineClaim UID") + } + + log.Debug(ctx, "Removing finalizer from Machine and clearing MachineClaimRef and Power") + var machineApply *metalv1alpha1apply.MachineApplyConfiguration + machineApply, err = metalv1alpha1apply.ExtractMachine(&machine, MachineClaimFieldManager) + if err != nil { + return err + } + machineApply.Finalizers = util.Clear(machineApply.Finalizers, MachineClaimFinalizer) + machineApply.Spec = nil + err = r.Patch(ctx, &machine, ssa.Apply(machineApply), client.FieldOwner(MachineClaimFieldManager), client.ForceOwnership) + if err != nil { + return fmt.Errorf("cannot apply Machine: %w", err) + } + + return nil +} + func (r *MachineClaimReconciler) reconcile(ctx context.Context, claim *metalv1alpha1.MachineClaim) (ctrl.Result, error) { log.Debug(ctx, "Reconciling") - applySpec := metalv1alpha1apply.MachineClaimSpec() - applyStatus := metalv1alpha1apply.MachineClaimStatus().WithPhase(metalv1alpha1.MachineClaimPhaseUnbound) + var ok bool + var err error - var machines []metalv1alpha1.Machine - if claim.Spec.MachineRef != nil { - log.Debug(ctx, "Getting referenced Machine") - var machine metalv1alpha1.Machine - err := r.Get(ctx, client.ObjectKey{Name: claim.Spec.MachineRef.Name}, &machine) - if err != nil && !errors.IsNotFound(err) { - return ctrl.Result{}, fmt.Errorf("cannot get Machine: %w", err) + ctx, ok, err = r.applyOrContinue(ctx, claim, r.processInitialState) + if !ok { + return ctrl.Result{}, err + } + + ctx, ok, err = r.applyOrContinue(ctx, claim, r.processMachine) + if !ok { + return ctrl.Result{}, err + } + + log.Debug(ctx, "Reconciled successfully") + return ctrl.Result{}, nil +} + +type nachineClaimProcessFunc func(context.Context, *metalv1alpha1.MachineClaim) (context.Context, *metalv1alpha1apply.MachineClaimApplyConfiguration, *metalv1alpha1apply.MachineClaimStatusApplyConfiguration, error) + +func (r *MachineClaimReconciler) applyOrContinue(ctx context.Context, claim *metalv1alpha1.MachineClaim, pfunc nachineClaimProcessFunc) (context.Context, bool, error) { + var apply *metalv1alpha1apply.MachineClaimApplyConfiguration + var status *metalv1alpha1apply.MachineClaimStatusApplyConfiguration + var err error + + ctx, apply, status, err = pfunc(ctx, claim) + if err != nil { + return ctx, false, err + } + + if apply != nil { + log.Debug(ctx, "Applying") + err = r.Patch(ctx, claim, ssa.Apply(apply), client.FieldOwner(MachineClaimFieldManager), client.ForceOwnership) + if err != nil { + return ctx, false, fmt.Errorf("cannot apply MachineClaim: %w", err) } - if !errors.IsNotFound(err) { - machines = append(machines, machine) + } + + if status != nil { + apply = metalv1alpha1apply.MachineClaim(claim.Name, claim.Namespace).WithStatus(status) + + log.Debug(ctx, "Applying status") + err = r.Status().Patch(ctx, claim, ssa.Apply(apply), client.FieldOwner(MachineClaimFieldManager), client.ForceOwnership) + if err != nil { + return ctx, false, fmt.Errorf("cannot apply MachineClaim status: %w", err) } - } else if claim.Spec.MachineSelector != nil { - log.Debug(ctx, "Listing Machines with matching labels") - var machineList metalv1alpha1.MachineList - err := r.List(ctx, &machineList, client.MatchingLabels(claim.Spec.MachineSelector.MatchLabels)) + } + + return ctx, apply == nil, err +} + +func (r *MachineClaimReconciler) processInitialState(ctx context.Context, claim *metalv1alpha1.MachineClaim) (context.Context, *metalv1alpha1apply.MachineClaimApplyConfiguration, *metalv1alpha1apply.MachineClaimStatusApplyConfiguration, error) { + var apply *metalv1alpha1apply.MachineClaimApplyConfiguration + var status *metalv1alpha1apply.MachineClaimStatusApplyConfiguration + var err error + + if !controllerutil.ContainsFinalizer(claim, MachineClaimFinalizer) { + apply, err = metalv1alpha1apply.ExtractMachineClaim(claim, MachineClaimFieldManager) if err != nil { - return ctrl.Result{}, fmt.Errorf("cannot list Machines: %w", err) + return ctx, nil, nil, err } - machines = machineList.Items + apply.Finalizers = util.Set(apply.Finalizers, MachineClaimFinalizer) } - for _, m := range machines { - if m.Status.State != metalv1alpha1.MachineStateReady { - continue + if claim.Status.Phase == "" { + var applyst *metalv1alpha1apply.MachineClaimApplyConfiguration + applyst, err = metalv1alpha1apply.ExtractMachineClaimStatus(claim, MachineClaimFieldManager) + if err != nil { + return ctx, nil, nil, err } - if m.Spec.MachineClaimRef != nil && m.Spec.MachineClaimRef.UID != claim.UID { - continue + status = util.Ensure(applyst.Status). + WithPhase(metalv1alpha1.MachineClaimPhaseUnbound) + } + + return ctx, apply, status, nil +} + +func (r *MachineClaimReconciler) processMachine(ctx context.Context, claim *metalv1alpha1.MachineClaim) (context.Context, *metalv1alpha1apply.MachineClaimApplyConfiguration, *metalv1alpha1apply.MachineClaimStatusApplyConfiguration, error) { + var apply *metalv1alpha1apply.MachineClaimApplyConfiguration + var status *metalv1alpha1apply.MachineClaimStatusApplyConfiguration + var err error + + var machine metalv1alpha1.Machine + if claim.Spec.MachineRef != nil { + err = r.Get(ctx, client.ObjectKey{ + Name: claim.Spec.MachineRef.Name, + }, &machine) + if err != nil && !errors.IsNotFound(err) { + return ctx, nil, nil, fmt.Errorf("cannot get Machine: %w", err) } - machine := m - ctx = log.WithValues(ctx, "machine", machine.Name) + if errors.IsNotFound(err) { + claim.Spec.MachineRef = nil - machineApply := metalv1alpha1apply.Machine(machine.Name, machine.Namespace).WithFinalizers(MachineClaimFinalizer).WithSpec(metalv1alpha1apply.MachineSpec(). - WithMachineClaimRef(v1.ObjectReference{ - Namespace: claim.Namespace, - Name: claim.Name, - UID: claim.UID, - }). - WithPower(claim.Spec.Power)) - if !controllerutil.ContainsFinalizer(&machine, MachineClaimFinalizer) || - !util.NilOrEqual(machine.Spec.MachineClaimRef, machineApply.Spec.MachineClaimRef) || - machine.Spec.Power != *machineApply.Spec.Power { - log.Debug(ctx, "Updating Machine") - err := r.Patch(ctx, &machine, patch.Apply(machineApply), client.FieldOwner(MachineClaimFieldOwner), client.ForceOwnership) + apply, err = metalv1alpha1apply.ExtractMachineClaim(claim, MachineClaimFieldManager) if err != nil { - return ctrl.Result{}, fmt.Errorf("cannot patch Machine: %w", err) + return ctx, nil, nil, err } + apply = apply.WithSpec(util.Ensure(apply.Spec)) + apply.Spec.MachineRef = nil } + } + if claim.Spec.MachineRef == nil { + var machineList metalv1alpha1.MachineList + if claim.Spec.MachineSelector != nil { + err = r.List(ctx, &machineList, client.MatchingLabels(claim.Spec.MachineSelector.MatchLabels)) + if err != nil { + return ctx, nil, nil, fmt.Errorf("cannot list Machines: %w", err) + } + } + + found := false + for _, m := range machineList.Items { + if m.DeletionTimestamp != nil || m.Status.State != metalv1alpha1.MachineStateReady || (m.Spec.MachineClaimRef != nil && m.Spec.MachineClaimRef.UID != claim.UID) { + continue + } + machine = m + found = true + ctx = log.WithValues(ctx, "machine", machine.Name) - applySpec = applySpec.WithMachineRef(v1.LocalObjectReference{Name: machine.Name}) - applyStatus = applyStatus.WithPhase(metalv1alpha1.MachineClaimPhaseBound) + claim.Spec.MachineRef = &v1.LocalObjectReference{ + Name: machine.Name, + } - break + if apply == nil { + apply, err = metalv1alpha1apply.ExtractMachineClaim(claim, MachineClaimFieldManager) + if err != nil { + return ctx, nil, nil, err + } + } + apply = apply.WithSpec(util.Ensure(apply.Spec). + WithMachineRef(*claim.Spec.MachineRef)) + + break + } + if !found { + phase := metalv1alpha1.MachineClaimPhaseUnbound + if claim.Status.Phase != phase { + var applyst *metalv1alpha1apply.MachineClaimApplyConfiguration + applyst, err = metalv1alpha1apply.ExtractMachineClaimStatus(claim, MachineClaimFieldManager) + if err != nil { + return ctx, nil, nil, err + } + status = util.Ensure(applyst.Status). + WithPhase(phase) + } + + return ctx, apply, status, nil + } } - apply := metalv1alpha1apply.MachineClaim(claim.Name, claim.Namespace).WithFinalizers(MachineClaimFinalizer).WithSpec(applySpec) - if !controllerutil.ContainsFinalizer(claim, MachineClaimFinalizer) || - !util.NilOrEqual(claim.Spec.MachineRef, apply.Spec.MachineRef) { - log.Debug(ctx, "Updating") - err := r.Patch(ctx, claim, patch.Apply(apply), client.FieldOwner(MachineClaimFieldOwner), client.ForceOwnership) + claimRef := v1.ObjectReference{ + Namespace: claim.Namespace, + Name: claim.Name, + UID: claim.UID, + } + + if machine.Status.State != metalv1alpha1.MachineStateReady { + log.Debug(ctx, "Removing finalizer from Machine and clearing MachineClaimRef and Power") + var machineApply *metalv1alpha1apply.MachineApplyConfiguration + machineApply, err = metalv1alpha1apply.ExtractMachine(&machine, MachineClaimFieldManager) + if err != nil { + return ctx, nil, nil, err + } + machineApply.Finalizers = util.Clear(machineApply.Finalizers, MachineClaimFinalizer) + machineApply = nil + err = r.Patch(ctx, &machine, ssa.Apply(machineApply), client.FieldOwner(MachineClaimFieldManager), client.ForceOwnership) if err != nil { - return ctrl.Result{}, fmt.Errorf("cannot patch MachineClaim: %w", err) + return ctx, nil, nil, fmt.Errorf("cannot apply Machine: %w", err) + } + + phase := metalv1alpha1.MachineClaimPhaseUnbound + if claim.Status.Phase != phase { + var applyst *metalv1alpha1apply.MachineClaimApplyConfiguration + applyst, err = metalv1alpha1apply.ExtractMachineClaimStatus(claim, MachineClaimFieldManager) + if err != nil { + return ctx, nil, nil, err + } + status = util.Ensure(applyst.Status). + WithPhase(phase) + } + + return ctx, apply, status, nil + } + + if !controllerutil.ContainsFinalizer(&machine, MachineClaimFinalizer) || + !util.NilOrEqual(machine.Spec.MachineClaimRef, &claimRef) || + machine.Spec.Power != claim.Spec.Power { + log.Debug(ctx, "Adding finalizer to Machine and setting MachineClaimRef and Power") + var machineApply *metalv1alpha1apply.MachineApplyConfiguration + machineApply, err = metalv1alpha1apply.ExtractMachine(&machine, MachineClaimFieldManager) + if err != nil { + return ctx, nil, nil, err + } + machineApply.Finalizers = util.Set(machineApply.Finalizers, MachineClaimFinalizer) + machineApply = machineApply.WithSpec(util.Ensure(machineApply.Spec). + WithMachineClaimRef(claimRef). + WithPower(claim.Spec.Power)) + err = r.Patch(ctx, &machine, ssa.Apply(machineApply), client.FieldOwner(MachineClaimFieldManager), client.ForceOwnership) + if err != nil { + return ctx, nil, nil, fmt.Errorf("cannot apply Machine: %w", err) } } - apply = metalv1alpha1apply.MachineClaim(claim.Name, claim.Namespace).WithStatus(applyStatus) - if claim.Status.Phase != *apply.Status.Phase { - log.Debug(ctx, "Updating status") - err := r.Status().Patch(ctx, claim, patch.Apply(apply), client.FieldOwner(MachineClaimFieldOwner), client.ForceOwnership) + phase := metalv1alpha1.MachineClaimPhaseBound + if claim.Status.Phase != phase { + var applyst *metalv1alpha1apply.MachineClaimApplyConfiguration + applyst, err = metalv1alpha1apply.ExtractMachineClaimStatus(claim, MachineClaimFieldManager) if err != nil { - return ctrl.Result{}, fmt.Errorf("cannot patch MachineClaim status: %w", err) + return ctx, nil, nil, err } + status = util.Ensure(applyst.Status). + WithPhase(phase) } - log.Debug(ctx, "Reconciled successfully") - return ctrl.Result{}, nil + return ctx, apply, status, nil } // SetupWithManager sets up the controller with the Manager. @@ -206,25 +361,25 @@ func (r *MachineClaimReconciler) enqueueMachineClaimsFromMachine() handler.Event return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { machine := obj.(*metalv1alpha1.Machine) - claimList := &metalv1alpha1.MachineClaimList{} - err := r.List(ctx, claimList, client.MatchingFields{MachineClaimSpecMachineRef: machine.Name}) + claimList := metalv1alpha1.MachineClaimList{} + err := r.List(ctx, &claimList, client.MatchingFields{MachineClaimSpecMachineRef: machine.Name}) if err != nil { log.Error(ctx, fmt.Errorf("cannot list MachineClaims: %w", err)) return nil } - var req []reconcile.Request + var reqs []reconcile.Request for _, c := range claimList.Items { if c.DeletionTimestamp != nil { continue } // TODO: Also watch for machines matching the label selector. - req = append(req, reconcile.Request{NamespacedName: types.NamespacedName{ + reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{ Namespace: c.Namespace, Name: c.Name, }}) } - return req + return reqs }) } diff --git a/internal/controller/machineclaim_controller_test.go b/internal/controller/machineclaim_controller_test.go index e8f09810..ae8a187b 100644 --- a/internal/controller/machineclaim_controller_test.go +++ b/internal/controller/machineclaim_controller_test.go @@ -30,7 +30,7 @@ var _ = Describe("MachineClaim Controller", func() { It("should claim a Machine by ref", func(ctx SpecContext) { By("Creating a Machine") - machine := metalv1alpha1.Machine{ + machine := &metalv1alpha1.Machine{ ObjectMeta: metav1.ObjectMeta{ GenerateName: "test-", }, @@ -41,16 +41,19 @@ var _ = Describe("MachineClaim Controller", func() { }, }, } - Expect(k8sClient.Create(ctx, &machine)).To(Succeed()) - DeferCleanup(k8sClient.Delete, &machine) + Expect(k8sClient.Create(ctx, machine)).To(Succeed()) + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, machine)).To(Succeed()) + Eventually(Get(machine)).Should(Satisfy(errors.IsNotFound)) + }) By("Patching Machine state to Ready") - Eventually(UpdateStatus(&machine, func() { + Eventually(UpdateStatus(machine, func() { machine.Status.State = metalv1alpha1.MachineStateReady })).Should(Succeed()) By("Creating a MachineClaim referencing the Machine") - claim := metalv1alpha1.MachineClaim{ + claim := &metalv1alpha1.MachineClaim{ ObjectMeta: metav1.ObjectMeta{ GenerateName: "test-", Namespace: ns.Name, @@ -63,38 +66,38 @@ var _ = Describe("MachineClaim Controller", func() { Power: metalv1alpha1.PowerOn, }, } - Expect(k8sClient.Create(ctx, &claim)).To(Succeed()) + Expect(k8sClient.Create(ctx, claim)).To(Succeed()) By("Expecting finalizer and phase to be correct on the MachineClaim") - Eventually(Object(&claim)).Should(SatisfyAll( + Eventually(Object(claim)).Should(SatisfyAll( HaveField("Finalizers", ContainElement(MachineClaimFinalizer)), - HaveField("Status.Phase", Equal(metalv1alpha1.MachineClaimPhaseBound)), + HaveField("Status.Phase", metalv1alpha1.MachineClaimPhaseBound), )) By("Expecting finalizer and machineclaimref to be correct on the Machine") - Eventually(Object(&machine)).Should(SatisfyAll( + Eventually(Object(machine)).Should(SatisfyAll( HaveField("Finalizers", ContainElement(MachineClaimFinalizer)), - HaveField("Spec.MachineClaimRef.Namespace", Equal(claim.Namespace)), - HaveField("Spec.MachineClaimRef.Name", Equal(claim.Name)), - HaveField("Spec.MachineClaimRef.UID", Equal(claim.UID)), + HaveField("Spec.MachineClaimRef.Namespace", claim.Namespace), + HaveField("Spec.MachineClaimRef.Name", claim.Name), + HaveField("Spec.MachineClaimRef.UID", claim.UID), )) By("Deleting the MachineClaim") - Expect(k8sClient.Delete(ctx, &claim)).To(Succeed()) + Expect(k8sClient.Delete(ctx, claim)).To(Succeed()) By("Expecting machineclaimref and finalizer to be removed from the Machine") - Eventually(Object(&machine)).Should(SatisfyAll( + Eventually(Object(machine)).Should(SatisfyAll( HaveField("Finalizers", Not(ContainElement(MachineClaimFinalizer))), HaveField("Spec.MachineClaimRef", BeNil()), )) By("Expecting MachineClaim to be removed") - Eventually(Get(&claim)).Should(Satisfy(errors.IsNotFound)) + Eventually(Get(claim)).Should(Satisfy(errors.IsNotFound)) }) It("should claim a Machine by selector", func(ctx SpecContext) { By("Creating a Machine") - machine := metalv1alpha1.Machine{ + machine := &metalv1alpha1.Machine{ ObjectMeta: metav1.ObjectMeta{ GenerateName: "test-", Labels: map[string]string{ @@ -108,16 +111,19 @@ var _ = Describe("MachineClaim Controller", func() { }, }, } - Expect(k8sClient.Create(ctx, &machine)).To(Succeed()) - DeferCleanup(k8sClient.Delete, &machine) + Expect(k8sClient.Create(ctx, machine)).To(Succeed()) + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, machine)).To(Succeed()) + Eventually(Get(machine)).Should(Satisfy(errors.IsNotFound)) + }) By("Patching Machine state to Ready") - Eventually(UpdateStatus(&machine, func() { + Eventually(UpdateStatus(machine, func() { machine.Status.State = metalv1alpha1.MachineStateReady })).Should(Succeed()) By("Creating a MachineClaim with a matching selector") - claim := metalv1alpha1.MachineClaim{ + claim := &metalv1alpha1.MachineClaim{ ObjectMeta: metav1.ObjectMeta{ GenerateName: "test-", Namespace: ns.Name, @@ -132,39 +138,39 @@ var _ = Describe("MachineClaim Controller", func() { Power: metalv1alpha1.PowerOn, }, } - Expect(k8sClient.Create(ctx, &claim)).To(Succeed()) + Expect(k8sClient.Create(ctx, claim)).To(Succeed()) By("Expecting finalizer, machineref, and phase to be correct on the MachineClaim") - Eventually(Object(&claim)).Should(SatisfyAll( + Eventually(Object(claim)).Should(SatisfyAll( HaveField("Finalizers", ContainElement(MachineClaimFinalizer)), - HaveField("Spec.MachineRef.Name", Equal(machine.Name)), - HaveField("Status.Phase", Equal(metalv1alpha1.MachineClaimPhaseBound)), + HaveField("Spec.MachineRef.Name", machine.Name), + HaveField("Status.Phase", metalv1alpha1.MachineClaimPhaseBound), )) By("Expecting finalizer and machineclaimref to be correct on the Machine") - Eventually(Object(&machine)).Should(SatisfyAll( + Eventually(Object(machine)).Should(SatisfyAll( HaveField("Finalizers", ContainElement(MachineClaimFinalizer)), - HaveField("Spec.MachineClaimRef.Namespace", Equal(claim.Namespace)), - HaveField("Spec.MachineClaimRef.Name", Equal(claim.Name)), - HaveField("Spec.MachineClaimRef.UID", Equal(claim.UID)), + HaveField("Spec.MachineClaimRef.Namespace", claim.Namespace), + HaveField("Spec.MachineClaimRef.Name", claim.Name), + HaveField("Spec.MachineClaimRef.UID", claim.UID), )) By("Deleting the MachineClaim") - Expect(k8sClient.Delete(ctx, &claim)).To(Succeed()) + Expect(k8sClient.Delete(ctx, claim)).To(Succeed()) By("Expecting machineclaimref and finalizer to be removed from the Machine") - Eventually(Object(&machine)).Should(SatisfyAll( + Eventually(Object(machine)).Should(SatisfyAll( HaveField("Finalizers", Not(ContainElement(MachineClaimFinalizer))), HaveField("Spec.MachineClaimRef", BeNil()), )) By("Expecting MachineClaim to be removed") - Eventually(Get(&claim)).Should(Satisfy(errors.IsNotFound)) + Eventually(Get(claim)).Should(Satisfy(errors.IsNotFound)) }) It("should not claim a Machine with a wrong ref", func(ctx SpecContext) { By("Creating a MachineClaim referencing the Machine") - claim := metalv1alpha1.MachineClaim{ + claim := &metalv1alpha1.MachineClaim{ ObjectMeta: metav1.ObjectMeta{ GenerateName: "test-", Namespace: ns.Name, @@ -177,24 +183,24 @@ var _ = Describe("MachineClaim Controller", func() { Power: metalv1alpha1.PowerOn, }, } - Expect(k8sClient.Create(ctx, &claim)).To(Succeed()) + Expect(k8sClient.Create(ctx, claim)).To(Succeed()) By("Expecting finalizer and phase to be correct on the MachineClaim") - Eventually(Object(&claim)).Should(SatisfyAll( + Eventually(Object(claim)).Should(SatisfyAll( HaveField("Finalizers", ContainElement(MachineClaimFinalizer)), - HaveField("Status.Phase", Equal(metalv1alpha1.MachineClaimPhaseUnbound)), + HaveField("Status.Phase", metalv1alpha1.MachineClaimPhaseUnbound), )) By("Deleting the MachineClaim") - Expect(k8sClient.Delete(ctx, &claim)).To(Succeed()) + Expect(k8sClient.Delete(ctx, claim)).To(Succeed()) By("Expecting MachineClaim to be removed") - Eventually(Get(&claim)).Should(Satisfy(errors.IsNotFound)) + Eventually(Get(claim)).Should(Satisfy(errors.IsNotFound)) }) It("should not claim a Machine with no matching selector", func(ctx SpecContext) { By("Creating a Machine") - machine := metalv1alpha1.Machine{ + machine := &metalv1alpha1.Machine{ ObjectMeta: metav1.ObjectMeta{ GenerateName: "test-", Labels: map[string]string{ @@ -208,16 +214,19 @@ var _ = Describe("MachineClaim Controller", func() { }, }, } - Expect(k8sClient.Create(ctx, &machine)).To(Succeed()) - DeferCleanup(k8sClient.Delete, &machine) + Expect(k8sClient.Create(ctx, machine)).To(Succeed()) + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, machine)).To(Succeed()) + Eventually(Get(machine)).Should(Satisfy(errors.IsNotFound)) + }) By("Patching Machine state to Ready") - Eventually(UpdateStatus(&machine, func() { + Eventually(UpdateStatus(machine, func() { machine.Status.State = metalv1alpha1.MachineStateReady })).Should(Succeed()) By("Creating a MachineClaim referencing the Machine") - claim := metalv1alpha1.MachineClaim{ + claim := &metalv1alpha1.MachineClaim{ ObjectMeta: metav1.ObjectMeta{ GenerateName: "test-", Namespace: ns.Name, @@ -232,30 +241,30 @@ var _ = Describe("MachineClaim Controller", func() { Power: metalv1alpha1.PowerOn, }, } - Expect(k8sClient.Create(ctx, &claim)).To(Succeed()) + Expect(k8sClient.Create(ctx, claim)).To(Succeed()) By("Expecting finalizer and phase to be correct on the MachineClaim") - Eventually(Object(&claim)).Should(SatisfyAll( + Eventually(Object(claim)).Should(SatisfyAll( HaveField("Finalizers", ContainElement(MachineClaimFinalizer)), - HaveField("Status.Phase", Equal(metalv1alpha1.MachineClaimPhaseUnbound)), + HaveField("Status.Phase", metalv1alpha1.MachineClaimPhaseUnbound), )) By("Expecting no finalizer or claimref on the Machine") - Eventually(Object(&machine)).Should(SatisfyAll( + Eventually(Object(machine)).Should(SatisfyAll( HaveField("Finalizers", Not(ContainElement(MachineClaimFinalizer))), HaveField("Spec.MachineClaimRef", BeNil()), )) By("Deleting the MachineClaim") - Expect(k8sClient.Delete(ctx, &claim)).To(Succeed()) + Expect(k8sClient.Delete(ctx, claim)).To(Succeed()) By("Expecting MachineClaim to be removed") - Eventually(Get(&claim)).Should(Satisfy(errors.IsNotFound)) + Eventually(Get(claim)).Should(Satisfy(errors.IsNotFound)) }) It("should claim a Machine by ref once the Machine becomes Ready", func(ctx SpecContext) { By("Creating a Machine") - machine := metalv1alpha1.Machine{ + machine := &metalv1alpha1.Machine{ ObjectMeta: metav1.ObjectMeta{ GenerateName: "test-", }, @@ -266,16 +275,19 @@ var _ = Describe("MachineClaim Controller", func() { }, }, } - Expect(k8sClient.Create(ctx, &machine)).To(Succeed()) - DeferCleanup(k8sClient.Delete, &machine) + Expect(k8sClient.Create(ctx, machine)).To(Succeed()) + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, machine)).To(Succeed()) + Eventually(Get(machine)).Should(Satisfy(errors.IsNotFound)) + }) By("Patching Machine state to Error") - Eventually(UpdateStatus(&machine, func() { + Eventually(UpdateStatus(machine, func() { machine.Status.State = metalv1alpha1.MachineStateError })).Should(Succeed()) By("Creating a MachineClaim referencing the Machine") - claim := metalv1alpha1.MachineClaim{ + claim := &metalv1alpha1.MachineClaim{ ObjectMeta: metav1.ObjectMeta{ GenerateName: "test-", Namespace: ns.Name, @@ -288,38 +300,41 @@ var _ = Describe("MachineClaim Controller", func() { Power: metalv1alpha1.PowerOn, }, } - Expect(k8sClient.Create(ctx, &claim)).To(Succeed()) - DeferCleanup(k8sClient.Delete, &claim) + Expect(k8sClient.Create(ctx, claim)).To(Succeed()) + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, claim)).To(Succeed()) + Eventually(Get(claim)).Should(Satisfy(errors.IsNotFound)) + }) By("Expecting finalizer and phase to be correct on the MachineClaim") - Eventually(Object(&claim)).Should(SatisfyAll( + Eventually(Object(claim)).Should(SatisfyAll( HaveField("Finalizers", ContainElement(MachineClaimFinalizer)), - HaveField("Status.Phase", Equal(metalv1alpha1.MachineClaimPhaseUnbound)), + HaveField("Status.Phase", metalv1alpha1.MachineClaimPhaseUnbound), )) By("Expecting no finalizer or claimref on the Machine") - Eventually(Object(&machine)).Should(SatisfyAll( + Eventually(Object(machine)).Should(SatisfyAll( HaveField("Finalizers", Not(ContainElement(MachineClaimFinalizer))), HaveField("Spec.MachineClaimRef", BeNil()), )) By("Patching Machine state to Ready") - Eventually(UpdateStatus(&machine, func() { + Eventually(UpdateStatus(machine, func() { machine.Status.State = metalv1alpha1.MachineStateReady })).Should(Succeed()) By("Expecting finalizer and phase to be correct on the MachineClaim") - Eventually(Object(&claim)).Should(SatisfyAll( + Eventually(Object(claim)).Should(SatisfyAll( HaveField("Finalizers", ContainElement(MachineClaimFinalizer)), - HaveField("Status.Phase", Equal(metalv1alpha1.MachineClaimPhaseBound)), + HaveField("Status.Phase", metalv1alpha1.MachineClaimPhaseBound), )) By("Expecting finalizer and machineclaimref to be correct on the Machine") - Eventually(Object(&machine)).Should(SatisfyAll( + Eventually(Object(machine)).Should(SatisfyAll( HaveField("Finalizers", ContainElement(MachineClaimFinalizer)), - HaveField("Spec.MachineClaimRef.Namespace", Equal(claim.Namespace)), - HaveField("Spec.MachineClaimRef.Name", Equal(claim.Name)), - HaveField("Spec.MachineClaimRef.UID", Equal(claim.UID)), + HaveField("Spec.MachineClaimRef.Namespace", claim.Namespace), + HaveField("Spec.MachineClaimRef.Name", claim.Name), + HaveField("Spec.MachineClaimRef.UID", claim.UID), )) }) }) diff --git a/internal/controller/oob_controller.go b/internal/controller/oob_controller.go index 243af373..7049bad6 100644 --- a/internal/controller/oob_controller.go +++ b/internal/controller/oob_controller.go @@ -5,40 +5,646 @@ package controller import ( "context" + "fmt" + "os" + "regexp" + "strings" + ipamv1alpha1 "github.com/ironcore-dev/ipam/api/ipam/v1alpha1" + ipamv1alpha1apply "github.com/ironcore-dev/ipam/clientgo/applyconfiguration/ipam/v1alpha1" + "github.com/sethvargo/go-password/password" + "gopkg.in/yaml.v3" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + v1apply "k8s.io/client-go/applyconfigurations/core/v1" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" metalv1alpha1 "github.com/ironcore-dev/metal/api/v1alpha1" + metalv1alpha1apply "github.com/ironcore-dev/metal/client/applyconfiguration/api/v1alpha1" + "github.com/ironcore-dev/metal/internal/bmc" + "github.com/ironcore-dev/metal/internal/cru" + "github.com/ironcore-dev/metal/internal/log" + "github.com/ironcore-dev/metal/internal/ssa" + "github.com/ironcore-dev/metal/internal/util" ) // +kubebuilder:rbac:groups=metal.ironcore.dev,resources=oobs,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=metal.ironcore.dev,resources=oobs/status,verbs=get;update;patch // +kubebuilder:rbac:groups=metal.ironcore.dev,resources=oobs/finalizers,verbs=update +// +kubebuilder:rbac:groups=ipam.metal.ironcore.dev,resources=ips,verbs=get;list;watch +// +kubebuilder:rbac:groups=ipam.metal.ironcore.dev,resources=ips/status,verbs=get +// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list -func NewOOBReconciler() (*OOBReconciler, error) { - return &OOBReconciler{}, nil +const ( + OOBFieldManager = "metal.ironcore.dev/oob" + OOBFinalizer = "metal.ironcore.dev/oob" + OOBIPMacLabel = "mac" + OOBIgnoreAnnotation = "metal.ironcore.dev/oob-ignore" + OOBMacRegex = `^[0-9A-Fa-f]{12}$` + OOBUsernameRegexSuffix = `[a-z]{6}` + OOBSpecMACAddress = ".spec.MACAddress" + // OOBTemporaryNamespaceHack TODO: Remove temporary namespace hack. + OOBTemporaryNamespaceHack = "oob" +) + +func NewOOBReconciler(systemNamespace, ipLabelSelector, macDB, usernamePrefix, temporaryPasswordSecret string) (*OOBReconciler, error) { + r := &OOBReconciler{ + systemNamespace: systemNamespace, + usernamePrefix: usernamePrefix, + temporaryPasswordSecret: temporaryPasswordSecret, + } + var err error + + if r.systemNamespace == "" { + return nil, fmt.Errorf("system namespace cannot be empty") + } + if r.usernamePrefix == "" { + return nil, fmt.Errorf("username prefix cannot be empty") + } + if r.temporaryPasswordSecret == "" { + return nil, fmt.Errorf("temporary password secret name cannot be empty") + } + + r.ipLabelSelector, err = labels.Parse(ipLabelSelector) + if err != nil { + return nil, fmt.Errorf("cannot parse IP label selector: %w", err) + } + + r.macDB, err = loadMacDB(macDB) + if err != nil { + return nil, fmt.Errorf("cannot load MAC DB: %w", err) + } + + r.usernameRegex, err = regexp.Compile(r.usernamePrefix + OOBUsernameRegexSuffix) + if err != nil { + return nil, fmt.Errorf("cannot compile username regex: %w", err) + } + + r.macRegex, err = regexp.Compile(OOBMacRegex) + if err != nil { + return nil, fmt.Errorf("cannot compile MAC regex: %w", err) + } + + return r, nil } // OOBReconciler reconciles a OOB object type OOBReconciler struct { client.Client + systemNamespace string + ipLabelSelector labels.Selector + macDB util.PrefixMap[access] + usernamePrefix string + temporaryPassword string + temporaryPasswordSecret string + usernameRegex *regexp.Regexp + macRegex *regexp.Regexp +} + +type access struct { + Ignore bool `yaml:"ignore"` + Protocol metalv1alpha1.Protocol `yaml:"protocol"` + Flags map[string]string `yaml:"flags"` + DefaultCredentials []bmc.Credentials `yaml:"defaultCredentials"` +} + +func (r *OOBReconciler) PreStart(ctx context.Context) error { + return r.ensureTemporaryPassword(ctx) } // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. -func (r *OOBReconciler) Reconcile(ctx context.Context, _ ctrl.Request) (ctrl.Result, error) { - _ = log.FromContext(ctx) +func (r *OOBReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var oob metalv1alpha1.OOB + err := r.Get(ctx, req.NamespacedName, &oob) + if err != nil { + return ctrl.Result{}, client.IgnoreNotFound(fmt.Errorf("cannot get OOB: %w", err)) + } + + if !oob.DeletionTimestamp.IsZero() { + return ctrl.Result{}, r.finalize(ctx, &oob) + } + return r.reconcile(ctx, &oob) +} + +func (r *OOBReconciler) finalize(ctx context.Context, oob *metalv1alpha1.OOB) error { + if !controllerutil.ContainsFinalizer(oob, OOBFinalizer) { + return nil + } + log.Debug(ctx, "Finalizing") + + err := r.finalizeEndpoint(ctx, oob) + if err != nil { + return err + } + + log.Debug(ctx, "Removing finalizer") + var apply *metalv1alpha1apply.OOBApplyConfiguration + apply, err = metalv1alpha1apply.ExtractOOB(oob, OOBFieldManager) + if err != nil { + return err + } + apply.Finalizers = util.Clear(apply.Finalizers, OOBFinalizer) + err = r.Patch(ctx, oob, ssa.Apply(apply), client.FieldOwner(OOBFieldManager), client.ForceOwnership) + if err != nil { + return fmt.Errorf("cannot apply OOB: %w", err) + } + + log.Debug(ctx, "Finalized successfully") + return nil +} + +func (r *OOBReconciler) finalizeEndpoint(ctx context.Context, oob *metalv1alpha1.OOB) error { + if oob.Spec.EndpointRef == nil { + return nil + } + ctx = log.WithValues(ctx, "endpoint", oob.Spec.EndpointRef.Name) + + var ip ipamv1alpha1.IP + err := r.Get(ctx, client.ObjectKey{ + Namespace: OOBTemporaryNamespaceHack, + Name: oob.Spec.EndpointRef.Name, + }, &ip) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("cannot get IP: %w", err) + } + if errors.IsNotFound(err) { + return nil + } + + log.Debug(ctx, "Removing finalizer from IP") + var ipApply *ipamv1alpha1apply.IPApplyConfiguration + ipApply, err = ipamv1alpha1apply.ExtractIP(&ip, OOBFieldManager) + if err != nil { + return err + } + ipApply.Finalizers = util.Clear(ipApply.Finalizers, OOBFinalizer) + ipApply.Spec = nil + err = r.Patch(ctx, &ip, ssa.Apply(ipApply), client.FieldOwner(OOBFieldManager), client.ForceOwnership) + if err != nil { + return fmt.Errorf("cannot apply IP: %w", err) + } + return nil +} + +func (r *OOBReconciler) reconcile(ctx context.Context, oob *metalv1alpha1.OOB) (ctrl.Result, error) { + log.Debug(ctx, "Reconciling") + + var ok bool + var err error + + ctx, ok, err = r.applyOrContinue(ctx, oob, r.processIgnoreAnnotation) + _, ignored := oob.Annotations[OOBIgnoreAnnotation] + if !ok || ignored { + return ctrl.Result{}, err + } + + ctx, ok, err = r.applyOrContinue(ctx, oob, r.processInitialState) + if !ok { + return ctrl.Result{}, err + } + + ctx, ok, err = r.applyOrContinue(ctx, oob, r.processEndpoint) + if !ok { + return ctrl.Result{}, err + } + + log.Debug(ctx, "Reconciled successfully") return ctrl.Result{}, nil } +type oobProcessFunc func(context.Context, *metalv1alpha1.OOB) (context.Context, *metalv1alpha1apply.OOBApplyConfiguration, *metalv1alpha1apply.OOBStatusApplyConfiguration, error) + +func (r *OOBReconciler) applyOrContinue(ctx context.Context, oob *metalv1alpha1.OOB, pfunc oobProcessFunc) (context.Context, bool, error) { + var apply *metalv1alpha1apply.OOBApplyConfiguration + var status *metalv1alpha1apply.OOBStatusApplyConfiguration + var err error + + ctx, apply, status, err = pfunc(ctx, oob) + if err != nil { + return ctx, false, err + } + + if apply != nil { + log.Debug(ctx, "Applying") + err = r.Patch(ctx, oob, ssa.Apply(apply), client.FieldOwner(OOBFieldManager), client.ForceOwnership) + if err != nil { + return ctx, false, fmt.Errorf("cannot apply OOB: %w", err) + } + } + + if status != nil { + apply = metalv1alpha1apply.OOB(oob.Name, oob.Namespace).WithStatus(status) + + log.Debug(ctx, "Applying status") + err = r.Status().Patch(ctx, oob, ssa.Apply(apply), client.FieldOwner(OOBFieldManager), client.ForceOwnership) + if err != nil { + return ctx, false, fmt.Errorf("cannot apply OOB status: %w", err) + } + + cond, ok := ssa.GetCondition(status.Conditions, metalv1alpha1.OOBConditionTypeReady) + if ok && cond.Status == metav1.ConditionFalse && cond.Reason == metalv1alpha1.OOBConditionReasonError { + err = fmt.Errorf(cond.Message) + } + } + + return ctx, apply == nil, err +} + +func (r *OOBReconciler) processIgnoreAnnotation(ctx context.Context, oob *metalv1alpha1.OOB) (context.Context, *metalv1alpha1apply.OOBApplyConfiguration, *metalv1alpha1apply.OOBStatusApplyConfiguration, error) { + _, ok := oob.Annotations[OOBIgnoreAnnotation] + if ok { + var status *metalv1alpha1apply.OOBStatusApplyConfiguration + state := metalv1alpha1.OOBStateIgnored + conds, mod := ssa.SetCondition(oob.Status.Conditions, metav1.Condition{ + Type: metalv1alpha1.OOBConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: metalv1alpha1.OOBConditionReasonIgnored, + }) + if oob.Status.State != state || mod { + applyst, err := metalv1alpha1apply.ExtractOOBStatus(oob, OOBFieldManager) + if err != nil { + return ctx, nil, nil, err + } + status = util.Ensure(applyst.Status). + WithState(state) + status.Conditions = conds + } + return ctx, nil, status, nil + } else if oob.Status.State == metalv1alpha1.OOBStateIgnored { + oob.Status.State = "" + } + + return ctx, nil, nil, nil +} + +func (r *OOBReconciler) processInitialState(ctx context.Context, oob *metalv1alpha1.OOB) (context.Context, *metalv1alpha1apply.OOBApplyConfiguration, *metalv1alpha1apply.OOBStatusApplyConfiguration, error) { + var apply *metalv1alpha1apply.OOBApplyConfiguration + var status *metalv1alpha1apply.OOBStatusApplyConfiguration + var err error + + ctx = log.WithValues(ctx, "mac", oob.Spec.MACAddress) + + if !controllerutil.ContainsFinalizer(oob, OOBFinalizer) { + apply, err = metalv1alpha1apply.ExtractOOB(oob, OOBFieldManager) + if err != nil { + return ctx, nil, nil, err + } + apply.Finalizers = util.Set(apply.Finalizers, OOBFinalizer) + } + + _, ok := ssa.GetCondition(oob.Status.Conditions, metalv1alpha1.OOBConditionTypeReady) + if oob.Status.State == "" || !ok { + var applyst *metalv1alpha1apply.OOBApplyConfiguration + applyst, err = metalv1alpha1apply.ExtractOOBStatus(oob, OOBFieldManager) + if err != nil { + return ctx, nil, nil, err + } + status = util.Ensure(applyst.Status). + WithState(metalv1alpha1.OOBStateUnready) + status.Conditions, _ = ssa.SetCondition(oob.Status.Conditions, metav1.Condition{ + Type: metalv1alpha1.OOBConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: metalv1alpha1.OOBConditionReasonInProgress, + }) + } + + return ctx, apply, status, nil +} + +func (r *OOBReconciler) processEndpoint(ctx context.Context, oob *metalv1alpha1.OOB) (context.Context, *metalv1alpha1apply.OOBApplyConfiguration, *metalv1alpha1apply.OOBStatusApplyConfiguration, error) { + var apply *metalv1alpha1apply.OOBApplyConfiguration + var status *metalv1alpha1apply.OOBStatusApplyConfiguration + + var ip ipamv1alpha1.IP + if oob.Spec.EndpointRef != nil { + err := r.Get(ctx, client.ObjectKey{ + Namespace: OOBTemporaryNamespaceHack, + Name: oob.Spec.EndpointRef.Name, + }, &ip) + if err != nil && !errors.IsNotFound(err) { + return ctx, nil, nil, fmt.Errorf("cannot get IP: %w", err) + } + + valid := ip.DeletionTimestamp == nil && r.ipLabelSelector.Matches(labels.Set(ip.Labels)) && ip.Namespace == OOBTemporaryNamespaceHack + if errors.IsNotFound(err) || !valid { + if !valid && controllerutil.ContainsFinalizer(&ip, OOBFinalizer) { + log.Debug(ctx, "Removing finalizer from IP") + var ipApply *ipamv1alpha1apply.IPApplyConfiguration + ipApply, err = ipamv1alpha1apply.ExtractIP(&ip, OOBFieldManager) + if err != nil { + return ctx, nil, nil, err + } + ipApply.Finalizers = util.Clear(ipApply.Finalizers, OOBFinalizer) + err = r.Patch(ctx, &ip, ssa.Apply(ipApply), client.FieldOwner(OOBFieldManager), client.ForceOwnership) + if err != nil { + return ctx, nil, nil, fmt.Errorf("cannot apply IP: %w", err) + } + } + + oob.Spec.EndpointRef = nil + + apply, err = metalv1alpha1apply.ExtractOOB(oob, OOBFieldManager) + if err != nil { + return ctx, nil, nil, err + } + apply = apply.WithSpec(util.Ensure(apply.Spec)) + apply.Spec.EndpointRef = nil + } else if ip.Status.Reserved != nil { + ctx = log.WithValues(ctx, "ip", ip.Status.Reserved.String()) + } + } + if oob.Spec.EndpointRef == nil { + var ipList ipamv1alpha1.IPList + err := r.List(ctx, &ipList, client.MatchingLabelsSelector{Selector: r.ipLabelSelector}, client.MatchingLabels{OOBIPMacLabel: oob.Spec.MACAddress}) + if err != nil { + return ctx, nil, nil, fmt.Errorf("cannot list OOBs: %w", err) + } + + found := false + for _, i := range ipList.Items { + if i.Namespace != OOBTemporaryNamespaceHack { + continue + } + if i.DeletionTimestamp != nil || i.Status.State != ipamv1alpha1.CFinishedIPState || i.Status.Reserved == nil || !i.Status.Reserved.Net.IsValid() { + continue + } + ip = i + found = true + ctx = log.WithValues(ctx, "ip", ip.Status.Reserved.String()) + + oob.Spec.EndpointRef = &v1.LocalObjectReference{ + Name: ip.Name, + } + + if apply == nil { + apply, err = metalv1alpha1apply.ExtractOOB(oob, OOBFieldManager) + if err != nil { + return ctx, nil, nil, err + } + } + apply = apply.WithSpec(util.Ensure(apply.Spec). + WithEndpointRef(*oob.Spec.EndpointRef)) + + state := metalv1alpha1.OOBStateUnready + conds, mod := ssa.SetCondition(oob.Status.Conditions, metav1.Condition{ + Type: metalv1alpha1.OOBConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: metalv1alpha1.OOBConditionReasonInProgress, + }) + if oob.Status.State != state || mod { + var applyst *metalv1alpha1apply.OOBApplyConfiguration + applyst, err = metalv1alpha1apply.ExtractOOBStatus(oob, OOBFieldManager) + if err != nil { + return ctx, nil, nil, err + } + status = util.Ensure(applyst.Status). + WithState(state) + status.Conditions = conds + } + + break + } + if !found { + state := metalv1alpha1.OOBStateUnready + conds, mod := ssa.SetCondition(oob.Status.Conditions, metav1.Condition{ + Type: metalv1alpha1.OOBConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: metalv1alpha1.OOBConditionReasonNoEndpoint, + }) + if oob.Status.State != state || mod { + var applyst *metalv1alpha1apply.OOBApplyConfiguration + applyst, err = metalv1alpha1apply.ExtractOOBStatus(oob, OOBFieldManager) + if err != nil { + return ctx, nil, nil, err + } + status = util.Ensure(applyst.Status). + WithState(state) + status.Conditions = conds + } + return ctx, apply, status, nil + } + } + + if !controllerutil.ContainsFinalizer(&ip, OOBFinalizer) { + log.Debug(ctx, "Adding finalizer to IP") + ipApply, err := ipamv1alpha1apply.ExtractIP(&ip, OOBFieldManager) + if err != nil { + return ctx, nil, nil, err + } + ipApply.Finalizers = util.Set(ipApply.Finalizers, OOBFinalizer) + err = r.Patch(ctx, &ip, ssa.Apply(ipApply), client.FieldOwner(OOBFieldManager), client.ForceOwnership) + if err != nil { + return ctx, nil, nil, fmt.Errorf("cannot apply IP: %w", err) + } + } + + if ip.Labels[OOBIPMacLabel] != oob.Spec.MACAddress { + state := metalv1alpha1.OOBStateError + conds, mod := ssa.SetErrorCondition(oob.Status.Conditions, metalv1alpha1.OOBConditionTypeReady, + fmt.Errorf("BadEndpoint: endpoint has incorrect MAC address: expected %s, actual %s", oob.Spec.MACAddress, ip.Labels[OOBIPMacLabel])) + if oob.Status.State != state || mod { + applyst, err := metalv1alpha1apply.ExtractOOBStatus(oob, OOBFieldManager) + if err != nil { + return ctx, nil, nil, err + } + status = util.Ensure(applyst.Status). + WithState(state) + status.Conditions = conds + } + return ctx, apply, status, nil + } + + if ip.Status.State != ipamv1alpha1.CFinishedIPState || ip.Status.Reserved == nil || !ip.Status.Reserved.Net.IsValid() { + state := metalv1alpha1.OOBStateError + conds, mod := ssa.SetErrorCondition(oob.Status.Conditions, metalv1alpha1.OOBConditionTypeReady, + fmt.Errorf("BadEndpoint: endpoint has no valid IP address")) + if oob.Status.State != state || mod { + applyst, err := metalv1alpha1apply.ExtractOOBStatus(oob, OOBFieldManager) + if err != nil { + return ctx, nil, nil, err + } + status = util.Ensure(applyst.Status). + WithState(state) + status.Conditions = conds + } + return ctx, apply, status, nil + } + + if oob.Status.State == metalv1alpha1.OOBStateError { + cond, _ := ssa.GetCondition(oob.Status.Conditions, metalv1alpha1.OOBConditionTypeReady) + if strings.HasPrefix(cond.Message, "BadEndpoint: ") { + state := metalv1alpha1.OOBStateUnready + conds, _ := ssa.SetCondition(oob.Status.Conditions, metav1.Condition{ + Type: metalv1alpha1.OOBConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: metalv1alpha1.OOBConditionReasonInProgress, + }) + applyst, err := metalv1alpha1apply.ExtractOOBStatus(oob, OOBFieldManager) + if err != nil { + return ctx, nil, nil, err + } + status = util.Ensure(applyst.Status). + WithState(state) + status.Conditions = conds + + return ctx, apply, status, nil + } + } + + return ctx, apply, status, nil +} + // SetupWithManager sets up the controller with the Manager. func (r *OOBReconciler) SetupWithManager(mgr ctrl.Manager) error { r.Client = mgr.GetClient() - return ctrl.NewControllerManagedBy(mgr). - For(&metalv1alpha1.OOB{}). - Complete(r) + c, err := cru.CreateController(mgr, &metalv1alpha1.OOB{}, r) + if err != nil { + return err + } + + err = c.Watch(source.Kind(mgr.GetCache(), &ipamv1alpha1.IP{}), r.enqueueOOBFromIP()) + if err != nil { + return err + } + + return mgr.Add(c) +} + +func (r *OOBReconciler) enqueueOOBFromIP() handler.EventHandler { + return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { + ip := obj.(*ipamv1alpha1.IP) + + if ip.Namespace != OOBTemporaryNamespaceHack { + return nil + } + if !r.ipLabelSelector.Matches(labels.Set(ip.Labels)) { + return nil + } + + mac, ok := ip.Labels[OOBIPMacLabel] + if !ok || !r.macRegex.MatchString(mac) { + log.Error(ctx, fmt.Errorf("invalid MAC address: %s", mac)) + return nil + } + + oobList := metalv1alpha1.OOBList{} + err := r.List(ctx, &oobList, client.MatchingFields{OOBSpecMACAddress: mac}) + if err != nil { + log.Error(ctx, fmt.Errorf("cannot list OOBs: %w", err)) + return nil + } + + var reqs []reconcile.Request + for _, o := range oobList.Items { + if o.DeletionTimestamp != nil { + continue + } + + reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{ + Name: o.Name, + }}) + } + + if len(oobList.Items) == 0 && ip.Status.State == ipamv1alpha1.CFinishedIPState && ip.Status.Reserved != nil { + oob := metalv1alpha1.OOB{ + ObjectMeta: metav1.ObjectMeta{ + Name: mac, + }, + } + apply := metalv1alpha1apply.OOB(oob.Name, oob.Namespace). + WithFinalizers(OOBFinalizer). + WithSpec(metalv1alpha1apply.OOBSpec(). + WithMACAddress(mac)) + err = r.Patch(ctx, &oob, ssa.Apply(apply), client.FieldOwner(OOBFieldManager), client.ForceOwnership) + if err != nil { + log.Error(ctx, fmt.Errorf("cannot apply OOB: %w", err)) + } + } + + return reqs + }) +} + +func loadMacDB(dbFile string) (util.PrefixMap[access], error) { + if dbFile == "" { + return make(util.PrefixMap[access]), nil + } + + data, err := os.ReadFile(dbFile) + if err != nil { + return nil, fmt.Errorf("cannot read %s: %w", dbFile, err) + } + + var dbf struct { + MACs []struct { + Prefix string `yaml:"prefix"` + access `yaml:",inline"` + } `yaml:"macs"` + } + err = yaml.Unmarshal(data, &dbf) + if err != nil { + return nil, fmt.Errorf("cannot unmarshal %s: %w", dbFile, err) + } + + db := make(util.PrefixMap[access], len(dbf.MACs)) + for _, m := range dbf.MACs { + db[m.Prefix] = m.access + } + return db, nil +} + +func (r *OOBReconciler) ensureTemporaryPassword(ctx context.Context) error { + secret := v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: r.temporaryPasswordSecret, + Namespace: r.systemNamespace, + }, + } + + err := r.Get(ctx, client.ObjectKeyFromObject(&secret), &secret) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("cannot get secret %s: %w", r.temporaryPasswordSecret, err) + } + ctx = log.WithValues(ctx, "name", secret.Name, "namesapce", secret.Namespace) + + if errors.IsNotFound(err) { + var pw string + pw, err = password.Generate(12, 0, 0, false, true) + if err != nil { + return fmt.Errorf("cannot generate temporary password: %w", err) + } + + log.Info(ctx, "Creating new temporary password Secret") + apply := v1apply.Secret(secret.Name, secret.Namespace). + WithType(v1.SecretTypeBasicAuth). + WithStringData(map[string]string{v1.BasicAuthPasswordKey: pw}) + err = r.Patch(ctx, &secret, ssa.Apply(apply), client.FieldOwner(OOBFieldManager), client.ForceOwnership) + if err != nil { + return fmt.Errorf("cannot apply Secret: %w", err) + } + } else { + log.Info(ctx, "Loading existing temporary password Secret") + } + + if secret.Type != v1.SecretTypeBasicAuth { + return fmt.Errorf("cannot use Secret with incorrect type: %s", secret.Type) + } + + r.temporaryPassword = string(secret.Data[v1.BasicAuthPasswordKey]) + if r.temporaryPassword == "" { + return fmt.Errorf("cannot use Secret with missing or empty password") + } + + return nil } diff --git a/internal/controller/oob_controller_test.go b/internal/controller/oob_controller_test.go index 4b5d42ec..2ebee87b 100644 --- a/internal/controller/oob_controller_test.go +++ b/internal/controller/oob_controller_test.go @@ -4,8 +4,317 @@ package controller import ( + "fmt" + + ipamv1alpha1 "github.com/ironcore-dev/ipam/api/ipam/v1alpha1" . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + . "sigs.k8s.io/controller-runtime/pkg/envtest/komega" + + metalv1alpha1 "github.com/ironcore-dev/metal/api/v1alpha1" + "github.com/ironcore-dev/metal/internal/ssa" ) var _ = Describe("OOB Controller", func() { + It("should create an OOB from an IP", func(ctx SpecContext) { + By("Creating an IP") + ip := &ipamv1alpha1.IP{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-", + Namespace: OOBTemporaryNamespaceHack, + Labels: map[string]string{ + OOBIPMacLabel: "aabbccddeeff", + "test": "test", + }, + }, + } + Expect(k8sClient.Create(ctx, ip)).To(Succeed()) + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, ip)).To(Succeed()) + Eventually(Get(ip)).Should(Satisfy(errors.IsNotFound)) + }) + + By("Patching IP reservation and state") + ipAddr, err := ipamv1alpha1.IPAddrFromString("1.2.3.4") + Expect(err).NotTo(HaveOccurred()) + Eventually(UpdateStatus(ip, func() { + ip.Status.Reserved = ipAddr + ip.Status.State = ipamv1alpha1.CFinishedIPState + })).Should(Succeed()) + + By("Expecting finalizer, mac, and endpointref to be correct on the OOB") + oob := &metalv1alpha1.OOB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "aabbccddeeff", + }, + } + Eventually(Object(oob)).Should(SatisfyAll( + HaveField("Finalizers", ContainElement(OOBFinalizer)), + HaveField("Spec.MACAddress", "aabbccddeeff"), + HaveField("Spec.EndpointRef.Name", ip.Name), + HaveField("Status.State", metalv1alpha1.OOBStateUnready), + WithTransform(readyReason, Equal(metalv1alpha1.OOBConditionReasonInProgress)), + )) + + By("Expecting finalizer to be correct on the IP") + Eventually(Object(ip)).Should(HaveField("Finalizers", ContainElement(OOBFinalizer))) + + By("Deleting the OOB") + Expect(k8sClient.Delete(ctx, oob)).To(Succeed()) + + By("Expecting OOB to be deleted") + Eventually(Get(oob)).Should(Satisfy(errors.IsNotFound)) + + By("Expecting finalizer to be cleared on the IP") + Eventually(Object(ip)).Should(HaveField("Finalizers", Not(ContainElement(OOBFinalizer)))) + }) + + It("should set the OOB to ignored if the ignore annotation is set", func(ctx SpecContext) { + By("Creating an IP") + ip := &ipamv1alpha1.IP{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-", + Namespace: OOBTemporaryNamespaceHack, + Labels: map[string]string{ + OOBIPMacLabel: "aabbccddeeff", + "test": "test", + }, + }, + } + Expect(k8sClient.Create(ctx, ip)).To(Succeed()) + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, ip)).To(Succeed()) + Eventually(Get(ip)).Should(Satisfy(errors.IsNotFound)) + }) + + By("Patching IP reservation and state") + ipAddr, err := ipamv1alpha1.IPAddrFromString("1.2.3.4") + Expect(err).NotTo(HaveOccurred()) + Eventually(UpdateStatus(ip, func() { + ip.Status.Reserved = ipAddr + ip.Status.State = ipamv1alpha1.CFinishedIPState + })).Should(Succeed()) + + oob := &metalv1alpha1.OOB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "aabbccddeeff", + }, + } + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, oob)).To(Succeed()) + Eventually(Get(oob)).Should(Satisfy(errors.IsNotFound)) + }) + + By("Setting an ignore annoation on the OOB") + Eventually(Update(oob, func() { + if oob.Annotations == nil { + oob.Annotations = make(map[string]string, 1) + } + oob.Annotations[OOBIgnoreAnnotation] = "" + })).Should(Succeed()) + + By("Expecting OOB to be ignored") + Eventually(Object(oob)).Should(SatisfyAll( + HaveField("Status.State", metalv1alpha1.OOBStateIgnored), + WithTransform(readyReason, Equal(metalv1alpha1.OOBConditionReasonIgnored)), + )) + + By("Clearing the ignore annoation on the OOB") + Eventually(Update(oob, func() { + delete(oob.Annotations, OOBIgnoreAnnotation) + })).Should(Succeed()) + + By("Expecting OOB not to be ignored") + Eventually(Object(oob)).Should(SatisfyAll( + HaveField("Status.State", metalv1alpha1.OOBStateUnready), + WithTransform(readyReason, Equal(metalv1alpha1.OOBConditionReasonInProgress)), + )) + }) + + It("should handle an unavailable endpoint", func(ctx SpecContext) { + By("Creating an IP") + ip := &ipamv1alpha1.IP{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-", + Namespace: OOBTemporaryNamespaceHack, + Labels: map[string]string{ + OOBIPMacLabel: "aabbccddeeff", + "test": "test", + }, + }, + } + Expect(k8sClient.Create(ctx, ip)).To(Succeed()) + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, ip)).To(Succeed()) + Eventually(Get(ip)).Should(Satisfy(errors.IsNotFound)) + }) + + By("Patching IP reservation and state") + ipAddr, err := ipamv1alpha1.IPAddrFromString("1.2.3.4") + Expect(err).NotTo(HaveOccurred()) + Eventually(UpdateStatus(ip, func() { + ip.Status.Reserved = ipAddr + ip.Status.State = ipamv1alpha1.CFinishedIPState + })).Should(Succeed()) + + oob := &metalv1alpha1.OOB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "aabbccddeeff", + }, + } + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, oob)).To(Succeed()) + Eventually(Get(oob)).Should(Satisfy(errors.IsNotFound)) + }) + + By("Expecting finalizer, mac, and endpointref to be correct on the OOB") + Eventually(Object(oob)).Should(SatisfyAll( + HaveField("Finalizers", ContainElement(OOBFinalizer)), + HaveField("Spec.MACAddress", "aabbccddeeff"), + HaveField("Spec.EndpointRef.Name", ip.Name), + HaveField("Status.State", metalv1alpha1.OOBStateUnready), + WithTransform(readyReason, Equal(metalv1alpha1.OOBConditionReasonInProgress)), + )) + + By("Deleting the IP") + Expect(k8sClient.Delete(ctx, ip)).To(Succeed()) + Eventually(Get(ip)).Should(Satisfy(errors.IsNotFound)) + + By("Expecting the OOB to have no endpoint") + Eventually(Object(oob)).Should(SatisfyAll( + HaveField("Spec.EndpointRef", BeNil()), + HaveField("Status.State", metalv1alpha1.OOBStateUnready), + WithTransform(readyReason, Equal(metalv1alpha1.OOBConditionReasonNoEndpoint)), + )) + + By("Recreating the IP") + ip = &ipamv1alpha1.IP{ + ObjectMeta: metav1.ObjectMeta{ + Name: ip.Name, + Namespace: OOBTemporaryNamespaceHack, + Labels: map[string]string{ + OOBIPMacLabel: "aabbccddeeff", + "test": "test", + }, + }, + } + Expect(k8sClient.Create(ctx, ip)).To(Succeed()) + Eventually(UpdateStatus(ip, func() { + ip.Status.Reserved = ipAddr + ip.Status.State = ipamv1alpha1.CFinishedIPState + })).Should(Succeed()) + + By("Expecting the OOB to have an endpoint") + Eventually(Object(oob)).Should(SatisfyAll( + HaveField("Spec.EndpointRef.Name", ip.Name), + HaveField("Status.State", metalv1alpha1.OOBStateUnready), + WithTransform(readyReason, Equal(metalv1alpha1.OOBConditionReasonInProgress)), + )) + }) + + It("should handle a bad endpoint", func(ctx SpecContext) { + By("Creating an IP") + ip := &ipamv1alpha1.IP{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-", + Namespace: OOBTemporaryNamespaceHack, + Labels: map[string]string{ + OOBIPMacLabel: "aabbccddeeff", + "test": "test", + }, + }, + } + Expect(k8sClient.Create(ctx, ip)).To(Succeed()) + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, ip)).To(Succeed()) + Eventually(Get(ip)).Should(Satisfy(errors.IsNotFound)) + }) + + By("Patching IP reservation and state") + ipAddr, err := ipamv1alpha1.IPAddrFromString("1.2.3.4") + Expect(err).NotTo(HaveOccurred()) + Eventually(UpdateStatus(ip, func() { + ip.Status.Reserved = ipAddr + ip.Status.State = ipamv1alpha1.CFinishedIPState + })).Should(Succeed()) + + oob := &metalv1alpha1.OOB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "aabbccddeeff", + }, + } + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, oob)).To(Succeed()) + Eventually(Get(oob)).Should(Satisfy(errors.IsNotFound)) + }) + + By("Expecting finalizer, mac, and endpointref to be correct on the OOB") + Eventually(Object(oob)).Should(SatisfyAll( + HaveField("Finalizers", ContainElement(OOBFinalizer)), + HaveField("Spec.MACAddress", "aabbccddeeff"), + HaveField("Spec.EndpointRef.Name", ip.Name), + HaveField("Status.State", metalv1alpha1.OOBStateUnready), + WithTransform(readyReason, Equal(metalv1alpha1.OOBConditionReasonInProgress)), + )) + + By("Setting an incorrect MAC on the IP") + Eventually(Update(ip, func() { + ip.Labels[OOBIPMacLabel] = "xxxxxxyyyyyy" + })).Should(Succeed()) + + By("Expecting the OOB to be in an error state") + Eventually(Object(oob)).Should(SatisfyAll( + HaveField("Status.State", metalv1alpha1.OOBStateError), + WithTransform(readyReason, Equal(metalv1alpha1.OOBConditionReasonError)), + )) + + By("Restoring the MAC on the IP") + Eventually(Update(ip, func() { + ip.Labels[OOBIPMacLabel] = "aabbccddeeff" + })).Should(Succeed()) + + By("Expecting the OOB to recover") + Eventually(Object(oob)).Should(SatisfyAll( + HaveField("Status.State", metalv1alpha1.OOBStateUnready), + WithTransform(readyReason, Equal(metalv1alpha1.OOBConditionReasonInProgress)), + )) + + By("Setting a failed state on the IP") + Eventually(UpdateStatus(ip, func() { + ip.Status.State = ipamv1alpha1.CFailedIPState + })).Should(Succeed()) + + By("Expecting the OOB to be in an error state") + Eventually(Object(oob)).Should(SatisfyAll( + HaveField("Status.State", metalv1alpha1.OOBStateError), + WithTransform(readyReason, Equal(metalv1alpha1.OOBConditionReasonError)), + )) + + By("Restoring the state on the IP") + Eventually(UpdateStatus(ip, func() { + ip.Status.State = ipamv1alpha1.CFinishedIPState + })).Should(Succeed()) + + By("Expecting the OOB to recover") + Eventually(Object(oob)).Should(SatisfyAll( + HaveField("Status.State", metalv1alpha1.OOBStateUnready), + WithTransform(readyReason, Equal(metalv1alpha1.OOBConditionReasonInProgress)), + )) + }) }) + +func readyReason(o client.Object) (string, error) { + oob, ok := o.(*metalv1alpha1.OOB) + if !ok { + return "", fmt.Errorf("%s is not an OOB", o.GetName()) + } + var cond metav1.Condition + cond, ok = ssa.GetCondition(oob.Status.Conditions, metalv1alpha1.OOBConditionTypeReady) + if !ok { + return "", fmt.Errorf("%s has no condition of type %s", oob.Name, metalv1alpha1.OOBConditionTypeReady) + } + return cond.Reason, nil +} diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 88b898e4..2122557d 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/go-logr/logr" + ipamv1alpha1 "github.com/ironcore-dev/ipam/api/ipam/v1alpha1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" v1 "k8s.io/api/core/v1" @@ -59,10 +60,16 @@ var _ = BeforeSuite(func() { Expect(metalv1alpha1.AddToScheme(scheme)).To(Succeed()) //+kubebuilder:scaffold:scheme + Expect(kscheme.AddToScheme(scheme)).To(Succeed()) + Expect(metalv1alpha1.AddToScheme(scheme)).To(Succeed()) + Expect(ipamv1alpha1.AddToScheme(scheme)).To(Succeed()) + //+kubebuilder:scaffold:scheme + testEnv := &envtest.Environment{ ErrorIfCRDPathMissing: true, CRDDirectoryPaths: []string{ filepath.Join("..", "..", "config", "crd", "bases"), + filepath.Join("..", "..", "test", "ipam.metal.ironcore.dev_ips.yaml"), }, } var cfg *rest.Config @@ -76,13 +83,25 @@ var _ = BeforeSuite(func() { Expect(k8sClient).NotTo(BeNil()) SetClient(k8sClient) - ns := v1.Namespace{ + ns := &v1.Namespace{ ObjectMeta: metav1.ObjectMeta{ GenerateName: "system-", }, } - Expect(k8sClient.Create(ctx, &ns)).To(Succeed()) - DeferCleanup(k8sClient.Delete, &ns) + Expect(k8sClient.Create(ctx, ns)).To(Succeed()) + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, ns)).To(Succeed()) + }) + + ns = &v1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: OOBTemporaryNamespaceHack, + }, + } + Expect(k8sClient.Create(ctx, ns)).To(Succeed()) + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, ns)).To(Succeed()) + }) var mgr manager.Manager mgr, err = ctrl.NewManager(cfg, ctrl.Options{ @@ -108,7 +127,7 @@ var _ = BeforeSuite(func() { Expect(machineClaimReconciler.SetupWithManager(mgr)).To(Succeed()) var oobReconciler *OOBReconciler - oobReconciler, err = NewOOBReconciler() + oobReconciler, err = NewOOBReconciler(ns.Name, "", "", "metal-", "bmc-temporary-password") Expect(err).NotTo(HaveOccurred()) Expect(oobReconciler).NotTo(BeNil()) Expect(oobReconciler.SetupWithManager(mgr)).To(Succeed()) diff --git a/internal/cru/cru.go b/internal/cru/cru.go new file mode 100644 index 00000000..11cbb82e --- /dev/null +++ b/internal/cru/cru.go @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package cru + +import ( + "context" + "os" + "strings" + + "github.com/go-logr/logr" + "k8s.io/klog/v2" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" +) + +type preStartReconciler interface { + reconcile.Reconciler + PreStart(context.Context) error +} + +type preStartController struct { + controller.Controller + preStart func(context.Context) error +} + +func (c *preStartController) Start(ctx context.Context) error { + err := c.preStart(ctx) + if err != nil { + return err + } + + return c.Controller.Start(ctx) +} + +func CreateController(mgr ctrl.Manager, obj client.Object, reconciler reconcile.Reconciler) (controller.Controller, error) { + gvk, err := apiutil.GVKForObject(obj, mgr.GetScheme()) + if err != nil { + return nil, err + } + + name := strings.ToLower(gvk.Kind) + cl := mgr.GetLogger().WithValues("controller", name, "controllerGroup", gvk.Group, "controllerKind", gvk.Kind) + + var c controller.Controller + c, err = controller.NewUnmanaged(name, mgr, controller.Options{ + MaxConcurrentReconciles: mgr.GetControllerOptions().GroupKindConcurrency[gvk.GroupKind().String()], + Reconciler: reconciler, + LogConstructor: func(req *reconcile.Request) logr.Logger { + rl := cl + if req != nil { + rl = rl.WithValues(gvk.Kind, klog.KRef(req.Namespace, req.Name), "namespace", req.Namespace, "name", req.Name) + } + return rl + }, + }) + if err != nil { + return nil, err + } + + err = c.Watch(source.Kind(mgr.GetCache(), obj), &handler.EnqueueRequestForObject{}) + if err != nil { + return nil, err + } + + psr, ok := reconciler.(preStartReconciler) + if ok { + return &preStartController{ + Controller: c, + preStart: psr.PreStart, + }, nil + } + + return c, nil +} + +func InClusterNamespace() string { + ns, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace") + if err != nil { + return "" + } + return string(ns) +} diff --git a/internal/patch/patch.go b/internal/patch/patch.go deleted file mode 100644 index 403d8b4c..00000000 --- a/internal/patch/patch.go +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors -// SPDX-License-Identifier: Apache-2.0 - -package patch - -import ( - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/json" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -func Apply(applyConfig interface{}) client.Patch { - return applyPatch{ - applyConfig: applyConfig, - } -} - -type applyPatch struct { - applyConfig interface{} -} - -func (p applyPatch) Type() types.PatchType { - return types.ApplyPatchType -} - -func (p applyPatch) Data(_ client.Object) ([]byte, error) { - return json.Marshal(p.applyConfig) -} diff --git a/internal/ssa/ssa.go b/internal/ssa/ssa.go new file mode 100644 index 00000000..09d634e0 --- /dev/null +++ b/internal/ssa/ssa.go @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package ssa + +import ( + "slices" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/json" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func Apply(applyConfig interface{}) client.Patch { + return applyPatch{ + applyConfig: applyConfig, + } +} + +type applyPatch struct { + applyConfig interface{} +} + +func (p applyPatch) Type() types.PatchType { + return types.ApplyPatchType +} + +func (p applyPatch) Data(_ client.Object) ([]byte, error) { + return json.Marshal(p.applyConfig) +} + +func Add(fins []string, fin string) []string { + for _, f := range fins { + if f == fin { + return fins + } + } + return append(fins, fin) +} + +func GetCondition(conds []metav1.Condition, typ string) (metav1.Condition, bool) { + for _, c := range conds { + if c.Type == typ { + return c, true + } + } + return metav1.Condition{}, false +} + +func SetCondition(conds []metav1.Condition, cond metav1.Condition) ([]metav1.Condition, bool) { + if cond.LastTransitionTime.IsZero() { + cond.LastTransitionTime = metav1.Now() + } + + for i, c := range conds { + if c.Type == cond.Type { + if cond.Status == c.Status && cond.Reason == c.Reason && cond.Message == c.Message { + return conds, false + } + return slices.Concat(conds[:i], []metav1.Condition{cond}, conds[i+1:]), true + } + } + + return append(conds, cond), true +} + +func SetErrorCondition(conds []metav1.Condition, typ string, err error) ([]metav1.Condition, bool) { + return SetCondition(conds, metav1.Condition{ + Type: typ, + Status: metav1.ConditionFalse, + Reason: "Error", + Message: err.Error(), + }) +} diff --git a/internal/tools/generate.sh b/internal/tools/generate.sh index a1f800c4..21975679 100755 --- a/internal/tools/generate.sh +++ b/internal/tools/generate.sh @@ -23,7 +23,7 @@ go run k8s.io/code-generator/cmd/openapi-gen \ -O zz_generated.openapi \ --report-filename "/dev/null" -go run github.com/ironcore-dev/metal/internal/tools/models-schema --openapi-package "github.com/ironcore-dev/metal/client/openapi" --openapi-title "metal" > "$MODELSSCHEMA" +go run github.com/ironcore-dev/metal/internal/tools/models-schema > "$MODELSSCHEMA" go run k8s.io/code-generator/cmd/applyconfiguration-gen \ --output-base "$GOPATH/src" \ --go-header-file hack/boilerplate.go.txt \ diff --git a/internal/tools/models-schema/main.go b/internal/tools/models-schema/main.go index caf0ca71..745feaea 100644 --- a/internal/tools/models-schema/main.go +++ b/internal/tools/models-schema/main.go @@ -4,86 +4,67 @@ package main import ( - _ "embed" - "errors" - "flag" + "encoding/json" "fmt" - "io/fs" "os" - "os/exec" - "text/template" + "strings" - "github.com/go-logr/logr" - "sigs.k8s.io/controller-runtime/pkg/log/zap" -) - -var ( - //go:embed main.go.tmpl - mainGoTemplateData string + "k8s.io/kube-openapi/pkg/common" + "k8s.io/kube-openapi/pkg/validation/spec" - mainGoTemplate = template.Must(template.New("main.go").Parse(mainGoTemplateData)) + "github.com/ironcore-dev/metal/client/openapi" ) -type mainGoTemplateArgs struct { - OpenAPIPackage string - OpenAPITitle string -} - func main() { - var ( - zapOpts = zap.Options{Development: true} - log logr.Logger - openapiPackage string - openapiTitle string - ) - - zapOpts.BindFlags(flag.CommandLine) - flag.StringVar(&openapiPackage, "openapi-package", "", "Package containing the openapi definitions.") - flag.StringVar(&openapiTitle, "openapi-title", "", "Title for the generated openapi json definition.") - flag.Parse() - log = zap.New(zap.UseFlagOptions(&zapOpts)) - - if openapiPackage == "" { - log.Error(fmt.Errorf("must specify openapi-package"), "Invalid flags") - os.Exit(1) - } - if openapiTitle == "" { - log.Error(fmt.Errorf("must specify openapi-title"), "Invalid flags") - os.Exit(1) + refFunc := func(name string) spec.Ref { + return spec.MustCreateRef(fmt.Sprintf("#/definitions/%s", friendlyName(name))) } - - err := run(log, openapiPackage, openapiTitle) - if err != nil { - log.Error(err, "Error running models-schema") + defs := openapi.GetOpenAPIDefinitions(refFunc) + schemaDefs := make(map[string]spec.Schema, len(defs)) + for k, v := range defs { + schema, ok := v.Schema.Extensions[common.ExtensionV2Schema] + if ok { + v2Schema, isOpenAPISchema := schema.(spec.Schema) + if isOpenAPISchema { + schemaDefs[friendlyName(k)] = v2Schema + continue + } + } + schemaDefs[friendlyName(k)] = v.Schema } -} -func run(log logr.Logger, openapiPackage, openapiTitle string) error { - tmpFile, err := os.CreateTemp("", "models-schema-*.go") + data, err := json.Marshal(&spec.Swagger{ + SwaggerProps: spec.SwaggerProps{ + Definitions: schemaDefs, + Info: &spec.Info{ + InfoProps: spec.InfoProps{ + Title: "metal", + Version: "unversioned", + }, + }, + Swagger: "2.0", + }, + }) if err != nil { - return fmt.Errorf("error creating temporary file: %w", err) + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) } - defer func() { - err = os.Remove(tmpFile.Name()) - if err != nil && !errors.Is(err, fs.ErrNotExist) { - log.Error(err, "Error cleaning up temporary file") - } - }() - err = mainGoTemplate.Execute(tmpFile, mainGoTemplateArgs{ - OpenAPIPackage: openapiPackage, - OpenAPITitle: openapiTitle, - }) + _, err = os.Stdout.Write(data) if err != nil { - return fmt.Errorf("error executing template: %w", err) + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) } +} - cmd := exec.Command("go", "run", tmpFile.Name()) - cmd.Stderr = os.Stderr - cmd.Stdout = os.Stdout - err = cmd.Run() - if err != nil { - return fmt.Errorf("error running command: %w", err) +func friendlyName(name string) string { + nameParts := strings.Split(name, "/") + if len(nameParts) > 0 && strings.Contains(nameParts[0], ".") { + parts := strings.Split(nameParts[0], ".") + for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 { + parts[i], parts[j] = parts[j], parts[i] + } + nameParts[0] = strings.Join(parts, ".") } - return nil + return strings.Join(nameParts, ".") } diff --git a/internal/tools/models-schema/main.go.tmpl b/internal/tools/models-schema/main.go.tmpl deleted file mode 100644 index 5718ae11..00000000 --- a/internal/tools/models-schema/main.go.tmpl +++ /dev/null @@ -1,79 +0,0 @@ -// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "encoding/json" - "fmt" - "os" - "strings" - - "k8s.io/apiextensions-apiserver/pkg/generated/openapi" - "k8s.io/kube-openapi/pkg/common" - "k8s.io/kube-openapi/pkg/validation/spec" -) - -// Outputs openAPI schema JSON containing the schema definitions in zz_generated.openapi.go. -func main() { - err := output() - if err != nil { - os.Stderr.WriteString(fmt.Sprintf("Failed: %v", err)) - os.Exit(1) - } -} - -func output() error { - refFunc := func(name string) spec.Ref { - return spec.MustCreateRef(fmt.Sprintf("#/definitions/%s", friendlyName(name))) - } - defs := openapi.GetOpenAPIDefinitions(refFunc) - schemaDefs := make(map[string]spec.Schema, len(defs)) - for k, v := range defs { - // Replace top-level schema with v2 if a v2 schema is embedded - // so that the output of this program is always in OpenAPI v2. - // This is done by looking up an extension that marks the embedded v2 - // schema, and, if the v2 schema is found, make it the resulting schema for - // the type. - if schema, ok := v.Schema.Extensions[common.ExtensionV2Schema]; ok { - v2Schema, isOpenAPISchema := schema.(spec.Schema) - if isOpenAPISchema { - schemaDefs[friendlyName(k)] = v2Schema - continue - } - } - - schemaDefs[friendlyName(k)] = v.Schema - } - data, err := json.Marshal(&spec.Swagger{ - SwaggerProps: spec.SwaggerProps{ - Definitions: schemaDefs, - Info: &spec.Info{ - InfoProps: spec.InfoProps{ - Title: "{{ .OpenAPITitle }}", - Version: "unversioned", - }, - }, - Swagger: "2.0", - }, - }) - if err != nil { - return fmt.Errorf("error serializing api definitions: %w", err) - } - os.Stdout.Write(data) - return nil -} - -// From k8s.io/apiserver/pkg/endpoints/openapi/openapi.go -func friendlyName(name string) string { - nameParts := strings.Split(name, "/") - // Reverse first part. e.g., io.k8s... instead of k8s.io... - if len(nameParts) > 0 && strings.Contains(nameParts[0], ".") { - parts := strings.Split(nameParts[0], ".") - for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 { - parts[i], parts[j] = parts[j], parts[i] - } - nameParts[0] = strings.Join(parts, ".") - } - return strings.Join(nameParts, ".") -} diff --git a/internal/util/utils.go b/internal/util/utils.go index b07e9d44..4722db55 100644 --- a/internal/util/utils.go +++ b/internal/util/utils.go @@ -3,6 +3,47 @@ package util +import ( + "slices" +) + func NilOrEqual[T comparable](x, y *T) bool { return (x == nil && y == nil) || (x != nil && y != nil && *x == *y) } + +func Ensure[T any](x *T) *T { + if x == nil { + return new(T) + } + return x +} + +func Set[T comparable](s []T, x T) []T { + for _, e := range s { + if e == x { + return s + } + } + return append(s, x) +} + +func Clear[T comparable](s []T, x T) []T { + for i, e := range s { + if e == x { + return slices.Concat(s[:i], s[i+1:]) + } + } + return s +} + +type PrefixMap[T any] map[string]T + +func (m PrefixMap[T]) Get(p string) (T, bool) { + for i := len(p); i > 0; i-- { + l, ok := m[p[:i]] + if ok { + return l, true + } + } + return *new(T), false +} diff --git a/test/ipam.metal.ironcore.dev_ips.yaml b/test/ipam.metal.ironcore.dev_ips.yaml new file mode 100644 index 00000000..8ffa0235 --- /dev/null +++ b/test/ipam.metal.ironcore.dev_ips.yaml @@ -0,0 +1,131 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.14.0 + name: ips.ipam.metal.ironcore.dev +spec: + group: ipam.metal.ironcore.dev + names: + kind: IP + listKind: IPList + plural: ips + singular: ip + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: IP Address + jsonPath: .status.reserved + name: IP + type: string + - description: Subnet + jsonPath: .spec.subnet.name + name: Subnet + type: string + - description: Consumer Group + jsonPath: .spec.consumer.apiVersion + name: Consumer Group + type: string + - description: Consumer Kind + jsonPath: .spec.consumer.kind + name: Consumer Kind + type: string + - description: Consumer Name + jsonPath: .spec.consumer.name + name: Consumer Name + type: string + - description: Processing state + jsonPath: .status.state + name: State + type: string + - description: Message + jsonPath: .status.message + name: Message + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: IP is the Schema for the ips 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: IPSpec defines the desired state of IP + properties: + consumer: + description: Consumer refers to resource IP has been booked for + properties: + apiVersion: + description: APIVersion is resource's API group + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-./a-z0-9]*[a-z0-9])?$ + type: string + kind: + description: Kind is CRD Kind for lookup + maxLength: 63 + minLength: 1 + pattern: ^[A-Z]([-A-Za-z0-9]*[A-Za-z0-9])?$ + type: string + name: + description: Name is CRD Name for lookup + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + required: + - kind + - name + type: object + ip: + description: IP allows to set desired IP address explicitly + type: string + subnet: + description: SubnetName is referring to parent subnet that holds requested + IP + properties: + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, uid? + type: string + type: object + x-kubernetes-map-type: atomic + required: + - subnet + type: object + status: + description: IPStatus defines the observed state of IP + properties: + message: + description: Message contains error details if the one has occurred + type: string + reserved: + description: Reserved is a reserved IP + type: string + state: + description: State is a network creation request processing state + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {}