From e2218c97c8fb0efaafee2e9132b8bb367270cec2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Fri, 7 Aug 2026 10:58:28 +0200 Subject: [PATCH 1/8] Add Method type and WithMethod option to NX-API client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a Method string type with constants MethodCLI and MethodCLIASCII. Add WithMethod(m Method) on Request, following the same pattern as WithRollback. The cli_ascii method returns plain text in the result.msg field instead of structured JSON in result.body. The response decoder now handles both fields, returning whichever is populated. This enables callers to retrieve unstructured output such as show running-config as a JSON-encoded string. Signed-off-by: Felix Kästner --- internal/transport/nxapi/nxapi.go | 36 +++++++++++++++---- internal/transport/nxapi/nxapi_test.go | 49 +++++++++++++++++++++----- 2 files changed, 70 insertions(+), 15 deletions(-) diff --git a/internal/transport/nxapi/nxapi.go b/internal/transport/nxapi/nxapi.go index 3727d06e2..8477aa4f6 100644 --- a/internal/transport/nxapi/nxapi.go +++ b/internal/transport/nxapi/nxapi.go @@ -170,7 +170,12 @@ func (c *Client) Do(ctx context.Context, r Request) ([]json.RawMessage, error) { msg := make([]json.RawMessage, len(res)) for i, r := range res { - msg[i] = r.Body.Data + switch { + case len(r.Body.Data) > 0: + msg[i] = r.Body.Data + case len(r.Body.Msg) > 0: + msg[i] = r.Body.Msg + } } return msg, nil @@ -185,12 +190,9 @@ func NewRequest(cmds ...string) Request { for i, c := range cmds { r[i] = cmd{ Jsonrpc: "2.0", - // Other possible values are "cli_ascii" and "cli_array". - // For now, we only support "cli" which is the default. - Method: "cli", + Method: MethodCLI, Params: params{ - Cmd: c, - // Static NX-API version. + Cmd: c, Version: 1, }, ID: i + 1, @@ -199,6 +201,25 @@ func NewRequest(cmds ...string) Request { return r } +// Method is the NX-API command type. +type Method string + +const ( + // MethodCLI returns structured JSON output. + MethodCLI Method = "cli" + // MethodCLIASCII returns plain text output. + MethodCLIASCII Method = "cli_ascii" +) + +// WithMethod sets the NX-API method on each command in the request. +// Use [MethodCLIASCII] for commands that return plain text (e.g., "show running-config"). +func (r Request) WithMethod(m Method) Request { + for i := range r { + r[i].Method = m + } + return r +} + // WithRollback sets the error action on each command in the request, // controlling what NX-OS does if that individual command fails. func (r Request) WithRollback(a ErrorAction) Request { @@ -216,7 +237,7 @@ func (r Request) Encode() ([]byte, error) { // cmd represents a single JSON-RPC command within a [Request]. type cmd struct { Jsonrpc string `json:"jsonrpc"` - Method string `json:"method"` + Method Method `json:"method"` Params params `json:"params"` ID int `json:"id"` Rollback ErrorAction `json:"rollback,omitempty"` @@ -242,6 +263,7 @@ type res struct { Error *RPCError `json:"error"` Body struct { Data json.RawMessage `json:"body"` + Msg json.RawMessage `json:"msg"` } `json:"result"` } diff --git a/internal/transport/nxapi/nxapi_test.go b/internal/transport/nxapi/nxapi_test.go index 5deaf8f58..4c4f1febc 100644 --- a/internal/transport/nxapi/nxapi_test.go +++ b/internal/transport/nxapi/nxapi_test.go @@ -56,13 +56,15 @@ func TestUri(t *testing.T) { func TestEncode(t *testing.T) { tests := []struct { - desc string - cmds []string - want string + desc string + cmds []string + method Method + want string }{ { - desc: "single show command", - cmds: []string{"show crypto ca certificates"}, + desc: "single show command", + cmds: []string{"show crypto ca certificates"}, + method: MethodCLI, want: ` [ { @@ -77,8 +79,9 @@ func TestEncode(t *testing.T) { ]`, }, { - desc: "multiple conf commands", - cmds: []string{"crypto ca trustpoint mytrustpoint", "crypto ca import mytrustpoint pkcs12 bootflash:server.pfx cisco123"}, + desc: "multiple conf commands", + cmds: []string{"crypto ca trustpoint mytrustpoint", "crypto ca import mytrustpoint pkcs12 bootflash:server.pfx cisco123"}, + method: MethodCLI, want: ` [ { @@ -99,12 +102,29 @@ func TestEncode(t *testing.T) { }, "id": 2 } +]`, + }, + { + desc: "cli_ascii method", + cmds: []string{"show running-config"}, + method: MethodCLIASCII, + want: ` +[ + { + "jsonrpc": "2.0", + "method": "cli_ascii", + "params": { + "cmd": "show running-config", + "version": 1 + }, + "id": 1 + } ]`, }, } for _, test := range tests { t.Run(test.desc, func(t *testing.T) { - r := NewRequest(test.cmds...) + r := NewRequest(test.cmds...).WithMethod(test.method) b, err := r.Encode() if err != nil { t.Fatalf("unexpected error: %v", err) @@ -172,6 +192,7 @@ func TestDo(t *testing.T) { statusCode int serverResponse string wantResultLen int + wantResult string // if set, check first result content wantRPCErrors int wantHTTPError bool }{ @@ -196,6 +217,13 @@ func TestDo(t *testing.T) { serverResponse: `{"jsonrpc":"2.0","result":null,"id":1}`, wantResultLen: 1, }, + { + desc: "2xx cli_ascii result with msg field", + statusCode: http.StatusOK, + serverResponse: `{"jsonrpc":"2.0","result":{"msg":"\nhostname leaf1\nfeature bgp\n"},"id":1}`, + wantResultLen: 1, + wantResult: `"\nhostname leaf1\nfeature bgp\n"`, + }, { desc: "non-2xx single RPC error", statusCode: http.StatusBadRequest, @@ -295,6 +323,11 @@ func TestDo(t *testing.T) { if len(results) != test.wantResultLen { t.Fatalf("len(results) = %d, want %d", len(results), test.wantResultLen) } + if test.wantResult != "" { + if got := string(results[0]); got != test.wantResult { + t.Errorf("result[0] = %q, want %q", got, test.wantResult) + } + } }) } } From bf263ce8c17e3139e6c4d2c114c4ee8a5eeb999b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Mon, 10 Aug 2026 13:37:39 +0200 Subject: [PATCH 2/8] Add Remote ConfigBackup type with S3 object storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new ConfigBackupType 'Remote' that fetches the running configuration from the device via RunningConfig() and uploads it to an S3-compatible object store. API changes: - Add ConfigBackupTypeRemote enum value - Add ConfigBackupS3 struct (endpoint, bucket, region, creds) - Add S3 field to ConfigBackupSpec with CEL validation - Add SecretNotFoundReason condition reason - Add GetSecretRefs() helper on ConfigBackup Controller changes: - Add ObjectStorage interface and optional field on reconciler - Add ListRemoteConfigBackups, CreateRemoteConfigBackup, and DeleteRemoteConfigBackups methods - Integrate Remote type into the existing reconcile flow with switch/default dispatch alongside Local/Startup - Watch referenced Secrets to re-reconcile on creation/update - Handle missing Secret with terminal SecretNotFound condition New package internal/objectstorage: - S3-compatible client wrapping aws-sdk-go-v2 with PutObject, ListObjects, and DeleteObjects operations Also adds RunningConfig() to ConfigBackupProvider interface with NX-OS implementation via cli_ascii NX-API method. Signed-off-by: Felix Kästner --- api/core/v1alpha1/configbackup_types.go | 60 +++++- api/core/v1alpha1/groupversion_info.go | 2 + api/core/v1alpha1/zz_generated.deepcopy.go | 21 ++ ...backups.networking.metal.ironcore.dev.yaml | 59 ++++- ...king.metal.ironcore.dev_configbackups.yaml | 59 ++++- docs/api-reference/index.md | 26 ++- go.mod | 12 ++ go.sum | 24 +++ .../core/configbackup_controller.go | 204 +++++++++++++++++- .../core/configbackup_controller_test.go | 105 +++++++++ internal/controller/core/suite_test.go | 16 +- internal/objectstorage/doc.go | 6 + internal/objectstorage/s3.go | 109 ++++++++++ internal/provider/cisco/nxos/provider.go | 15 ++ internal/provider/provider.go | 3 + 15 files changed, 691 insertions(+), 30 deletions(-) create mode 100644 internal/objectstorage/doc.go create mode 100644 internal/objectstorage/s3.go diff --git a/api/core/v1alpha1/configbackup_types.go b/api/core/v1alpha1/configbackup_types.go index 2d6835921..262da73cb 100644 --- a/api/core/v1alpha1/configbackup_types.go +++ b/api/core/v1alpha1/configbackup_types.go @@ -4,8 +4,6 @@ package v1alpha1 import ( - "fmt" - "path" "sync" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -16,8 +14,10 @@ import ( // ConfigBackupSpec defines the desired state of ConfigBackup. // +kubebuilder:validation:XValidation:rule="self.type != 'Startup' || (!has(self.path) || size(self.path) == 0)",message="path must be omitted for Startup backups" // +kubebuilder:validation:XValidation:rule="self.type != 'Local' || (has(self.path) && size(self.path) > 0)",message="path must be set for Local backups" -// +kubebuilder:validation:XValidation:rule="self.type == 'Local' || !has(self.retention)",message="retention must only be specified for Local backups" +// +kubebuilder:validation:XValidation:rule="self.type == 'Local' || self.type == 'Remote' || !has(self.retention)",message="retention must only be specified for Local or Remote backups" // +kubebuilder:validation:XValidation:rule="self.type == 'Local' || !has(self.storageThreshold)",message="storageThreshold must only be specified for Local backups" +// +kubebuilder:validation:XValidation:rule="self.type != 'Remote' || has(self.s3)",message="s3 must be specified for Remote backups" +// +kubebuilder:validation:XValidation:rule="self.type == 'Remote' || !has(self.s3)",message="s3 must only be specified for Remote backups" type ConfigBackupSpec struct { // DeviceRef is a reference to the Device this object belongs to. The Device object must exist in the same namespace. // Immutable. @@ -53,10 +53,14 @@ type ConfigBackupSpec struct { // StorageThreshold defines the minimum free space that must remain before creating a new Local backup. // +optional StorageThreshold *ConfigBackupStorageThreshold `json:"storageThreshold,omitempty"` + + // S3 configures the S3-compatible object storage destination for Remote backups. + // +optional + S3 *ConfigBackupS3 `json:"s3,omitempty"` } // ConfigBackupType defines how the device should persist a configuration backup. -// +kubebuilder:validation:Enum=Local;Startup +// +kubebuilder:validation:Enum=Local;Startup;Remote type ConfigBackupType string const ( @@ -64,6 +68,8 @@ const ( ConfigBackupTypeLocal ConfigBackupType = "Local" // ConfigBackupTypeStartup stores the running configuration as the device startup configuration. ConfigBackupTypeStartup ConfigBackupType = "Startup" + // ConfigBackupTypeRemote uploads the running configuration to an S3-compatible object store. + ConfigBackupTypeRemote ConfigBackupType = "Remote" ) // ConfigBackupRetention defines how many historical backups are kept on the device. @@ -91,6 +97,31 @@ type ConfigBackupStorageThreshold struct { MinFreePercent *int32 `json:"minFreePercent,omitempty"` } +// ConfigBackupS3 configures the S3-compatible object storage destination for Remote backups. +type ConfigBackupS3 struct { + // Endpoint is the S3-compatible endpoint URL (e.g., "https://s3.eu-central-1.amazonaws.com"). + // +required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=2048 + // +kubebuilder:validation:XValidation:rule="self.startsWith('https://') || self.startsWith('http://')",message="endpoint must be a valid URL starting with http:// or https://" + Endpoint string `json:"endpoint"` + + // Bucket is the name of the S3 bucket. + // +required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=63 + Bucket string `json:"bucket"` + + // Region is the endpoint region. Optional for S3-compatible stores that don't require it. + // +optional + // +kubebuilder:validation:MaxLength=63 + Region string `json:"region,omitempty"` + + // CredentialsSecretRef references a Secret containing "accessKeyID" and "secretAccessKey" keys. + // +required + CredentialsSecretRef SecretReference `json:"credentialsSecretRef"` +} + // ConfigBackupStatus defines the observed state of ConfigBackup. type ConfigBackupStatus struct { // Conditions represent the current state of the ConfigBackup resource. @@ -217,12 +248,6 @@ type ConfigBackup struct { Status ConfigBackupStatus `json:"status,omitzero"` } -// Filename returns a string that can be used as a prefix for backup filenames, -// incorporating the namespace and name of the ConfigBackup resource. -func (c *ConfigBackup) Filename() string { - return path.Join(c.Spec.Path, fmt.Sprintf("configbackup-%s-%s-", c.Namespace, c.Name)) -} - // GetConditions implements conditions.Getter. func (c *ConfigBackup) GetConditions() []metav1.Condition { return c.Status.Conditions @@ -233,6 +258,21 @@ func (c *ConfigBackup) SetConditions(conditions []metav1.Condition) { c.Status.Conditions = conditions } +// GetSecretRefs returns the list of SecretReferences used by this ConfigBackup. +// Namespaces are defaulted to the ConfigBackup's namespace if not explicitly set. +func (c *ConfigBackup) GetSecretRefs() []SecretReference { + refs := []SecretReference{} + if c.Spec.S3 != nil { + refs = append(refs, c.Spec.S3.CredentialsSecretRef) + } + for i := range refs { + if refs[i].Namespace == "" { + refs[i].Namespace = c.Namespace + } + } + return refs +} + // +kubebuilder:object:root=true // ConfigBackupList contains a list of ConfigBackup. diff --git a/api/core/v1alpha1/groupversion_info.go b/api/core/v1alpha1/groupversion_info.go index 2bac14340..83ed0f6a6 100644 --- a/api/core/v1alpha1/groupversion_info.go +++ b/api/core/v1alpha1/groupversion_info.go @@ -249,6 +249,8 @@ const ( const ( // PrefixSetNotFoundReason indicates that a referenced PrefixSet was not found. PrefixSetNotFoundReason = "PrefixSetNotFound" + // SecretNotFoundReason indicates that a referenced Secret was not found. + SecretNotFoundReason = "SecretNotFound" ) // Reasons that are specific to [BGPPeer] objects. diff --git a/api/core/v1alpha1/zz_generated.deepcopy.go b/api/core/v1alpha1/zz_generated.deepcopy.go index fd53437d6..5c5dbacb5 100644 --- a/api/core/v1alpha1/zz_generated.deepcopy.go +++ b/api/core/v1alpha1/zz_generated.deepcopy.go @@ -1346,6 +1346,22 @@ func (in *ConfigBackupRunStatus) DeepCopy() *ConfigBackupRunStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConfigBackupS3) DeepCopyInto(out *ConfigBackupS3) { + *out = *in + out.CredentialsSecretRef = in.CredentialsSecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConfigBackupS3. +func (in *ConfigBackupS3) DeepCopy() *ConfigBackupS3 { + if in == nil { + return nil + } + out := new(ConfigBackupS3) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ConfigBackupSpec) DeepCopyInto(out *ConfigBackupSpec) { *out = *in @@ -1365,6 +1381,11 @@ func (in *ConfigBackupSpec) DeepCopyInto(out *ConfigBackupSpec) { *out = new(ConfigBackupStorageThreshold) (*in).DeepCopyInto(*out) } + if in.S3 != nil { + in, out := &in.S3, &out.S3 + *out = new(ConfigBackupS3) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConfigBackupSpec. diff --git a/charts/network-operator/templates/crd/configbackups.networking.metal.ironcore.dev.yaml b/charts/network-operator/templates/crd/configbackups.networking.metal.ironcore.dev.yaml index 765cc9707..b43593385 100644 --- a/charts/network-operator/templates/crd/configbackups.networking.metal.ironcore.dev.yaml +++ b/charts/network-operator/templates/crd/configbackups.networking.metal.ironcore.dev.yaml @@ -138,6 +138,56 @@ spec: minimum: 1 type: integer type: object + s3: + description: S3 configures the S3-compatible object storage destination + for Remote backups. + properties: + bucket: + description: Bucket is the name of the S3 bucket. + maxLength: 63 + minLength: 1 + type: string + credentialsSecretRef: + description: CredentialsSecretRef references a Secret containing + "accessKeyID" and "secretAccessKey" keys. + properties: + name: + description: Name is unique within a namespace to reference + a secret resource. + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace defines the space within which the secret name must be unique. + If omitted, the namespace of the object being reconciled will be used. + maxLength: 63 + minLength: 1 + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + endpoint: + description: Endpoint is the S3-compatible endpoint URL (e.g., + "https://s3.eu-central-1.amazonaws.com"). + maxLength: 2048 + minLength: 1 + type: string + x-kubernetes-validations: + - message: endpoint must be a valid URL starting with http:// + or https:// + rule: self.startsWith('https://') || self.startsWith('http://') + region: + description: Region is the endpoint region. Optional for S3-compatible + stores that don't require it. + maxLength: 63 + type: string + required: + - bucket + - credentialsSecretRef + - endpoint + type: object schedule: description: |- Schedule is an optional cron expression. @@ -170,6 +220,7 @@ spec: enum: - Local - Startup + - Remote type: string required: - deviceRef @@ -181,10 +232,14 @@ spec: == 0) - message: path must be set for Local backups rule: self.type != 'Local' || (has(self.path) && size(self.path) > 0) - - message: retention must only be specified for Local backups - rule: self.type == 'Local' || !has(self.retention) + - message: retention must only be specified for Local or Remote backups + rule: self.type == 'Local' || self.type == 'Remote' || !has(self.retention) - message: storageThreshold must only be specified for Local backups rule: self.type == 'Local' || !has(self.storageThreshold) + - message: s3 must be specified for Remote backups + rule: self.type != 'Remote' || has(self.s3) + - message: s3 must only be specified for Remote backups + rule: self.type == 'Remote' || !has(self.s3) status: description: |- Status of the resource. This is set and updated automatically. diff --git a/config/crd/bases/networking.metal.ironcore.dev_configbackups.yaml b/config/crd/bases/networking.metal.ironcore.dev_configbackups.yaml index f62aa5bf6..021b33fd8 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_configbackups.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_configbackups.yaml @@ -135,6 +135,56 @@ spec: minimum: 1 type: integer type: object + s3: + description: S3 configures the S3-compatible object storage destination + for Remote backups. + properties: + bucket: + description: Bucket is the name of the S3 bucket. + maxLength: 63 + minLength: 1 + type: string + credentialsSecretRef: + description: CredentialsSecretRef references a Secret containing + "accessKeyID" and "secretAccessKey" keys. + properties: + name: + description: Name is unique within a namespace to reference + a secret resource. + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace defines the space within which the secret name must be unique. + If omitted, the namespace of the object being reconciled will be used. + maxLength: 63 + minLength: 1 + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + endpoint: + description: Endpoint is the S3-compatible endpoint URL (e.g., + "https://s3.eu-central-1.amazonaws.com"). + maxLength: 2048 + minLength: 1 + type: string + x-kubernetes-validations: + - message: endpoint must be a valid URL starting with http:// + or https:// + rule: self.startsWith('https://') || self.startsWith('http://') + region: + description: Region is the endpoint region. Optional for S3-compatible + stores that don't require it. + maxLength: 63 + type: string + required: + - bucket + - credentialsSecretRef + - endpoint + type: object schedule: description: |- Schedule is an optional cron expression. @@ -167,6 +217,7 @@ spec: enum: - Local - Startup + - Remote type: string required: - deviceRef @@ -178,10 +229,14 @@ spec: == 0) - message: path must be set for Local backups rule: self.type != 'Local' || (has(self.path) && size(self.path) > 0) - - message: retention must only be specified for Local backups - rule: self.type == 'Local' || !has(self.retention) + - message: retention must only be specified for Local or Remote backups + rule: self.type == 'Local' || self.type == 'Remote' || !has(self.retention) - message: storageThreshold must only be specified for Local backups rule: self.type == 'Local' || !has(self.storageThreshold) + - message: s3 must be specified for Remote backups + rule: self.type != 'Remote' || has(self.s3) + - message: s3 must only be specified for Remote backups + rule: self.type == 'Remote' || !has(self.s3) status: description: |- Status of the resource. This is set and updated automatically. diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index c8d9d1f22..0c0b8385d 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -1410,6 +1410,25 @@ _Appears in:_ | `filepath` _string_ | Filepath is the device-local path of the backup artifact.
This only applies to Local backups, and may be unknown if the controller cannot query the device. | | MinLength: 1
Optional: \{\}
| +#### ConfigBackupS3 + + + +ConfigBackupS3 configures the S3-compatible object storage destination for Remote backups. + + + +_Appears in:_ +- [ConfigBackupSpec](#configbackupspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `endpoint` _string_ | Endpoint is the S3-compatible endpoint URL (e.g., "https://s3.eu-central-1.amazonaws.com"). | | MaxLength: 2048
MinLength: 1
Required: \{\}
| +| `bucket` _string_ | Bucket is the name of the S3 bucket. | | MaxLength: 63
MinLength: 1
Required: \{\}
| +| `region` _string_ | Region is the endpoint region. Optional for S3-compatible stores that don't require it. | | MaxLength: 63
Optional: \{\}
| +| `credentialsSecretRef` _[SecretReference](#secretreference)_ | CredentialsSecretRef references a Secret containing "accessKeyID" and "secretAccessKey" keys. | | Required: \{\}
| + + #### ConfigBackupSpec @@ -1426,10 +1445,11 @@ _Appears in:_ | `deviceRef` _[LocalObjectReference](#localobjectreference)_ | DeviceRef is a reference to the Device this object belongs to. The Device object must exist in the same namespace.
Immutable. | | Required: \{\}
| | `providerConfigRef` _[TypedLocalObjectReference](#typedlocalobjectreference)_ | ProviderConfigRef is a reference to a resource holding the provider-specific configuration of this interface.
This reference is used to link the ConfigBackup to its provider-specific configuration. | | Optional: \{\}
| | `schedule` _string_ | Schedule is an optional cron expression.
If omitted, the controller performs a one-shot backup. | | Optional: \{\}
| -| `type` _[ConfigBackupType](#configbackuptype)_ | Type determines whether the backup is saved as a local file or as startup-config. | | Enum: [Local Startup]
Required: \{\}
| +| `type` _[ConfigBackupType](#configbackuptype)_ | Type determines whether the backup is saved as a local file or as startup-config. | | Enum: [Local Startup Remote]
Required: \{\}
| | `path` _string_ | Path is the device-local destination path for Local backups.
Different providers may accept different path formats, such as "bootflash:///backups/". | | MaxLength: 255
MinLength: 1
Optional: \{\}
| | `retention` _[ConfigBackupRetention](#configbackupretention)_ | Retention configures automatic cleanup of older backups for Local backups. | | Optional: \{\}
| | `storageThreshold` _[ConfigBackupStorageThreshold](#configbackupstoragethreshold)_ | StorageThreshold defines the minimum free space that must remain before creating a new Local backup. | | Optional: \{\}
| +| `s3` _[ConfigBackupS3](#configbackups3)_ | S3 configures the S3-compatible object storage destination for Remote backups. | | Optional: \{\}
| #### ConfigBackupStatus @@ -1499,7 +1519,7 @@ _Underlying type:_ _string_ ConfigBackupType defines how the device should persist a configuration backup. _Validation:_ -- Enum: [Local Startup] +- Enum: [Local Startup Remote] _Appears in:_ - [ConfigBackupSpec](#configbackupspec) @@ -1508,6 +1528,7 @@ _Appears in:_ | --- | --- | | `Local` | ConfigBackupTypeLocal stores the running configuration in a device-local file path.
| | `Startup` | ConfigBackupTypeStartup stores the running configuration as the device startup configuration.
| +| `Remote` | ConfigBackupTypeRemote uploads the running configuration to an S3-compatible object store.
| #### ConfigMapKeySelector @@ -3739,6 +3760,7 @@ in any namespace. _Appears in:_ - [CertificateSource](#certificatesource) - [CertificateSpec](#certificatespec) +- [ConfigBackupS3](#configbackups3) - [Endpoint](#endpoint) - [SecretKeySelector](#secretkeyselector) diff --git a/go.mod b/go.mod index 297aec8ed..7f6faecfd 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,9 @@ go 1.26.0 tool github.com/matryer/moq require ( + github.com/aws/aws-sdk-go-v2 v1.43.4 + github.com/aws/aws-sdk-go-v2/credentials v1.19.34 + github.com/aws/aws-sdk-go-v2/service/s3 v1.106.5 github.com/felix-kaestner/copy v0.0.0-20250930112410-8fbc5c5b74a5 github.com/go-crypt/crypt v0.14.15 github.com/go-logr/logr v1.4.4 @@ -39,6 +42,15 @@ require ( cel.dev/expr v0.25.2 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36 // indirect + github.com/aws/smithy-go v1.27.6 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect diff --git a/go.sum b/go.sum index 822b205e6..f575fa48d 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,30 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/aws/aws-sdk-go-v2 v1.43.4 h1:b9FTvbRwy+JCsfp2Wp6wV/KbOx3Aj7nkoFb2cRX0IhE= +github.com/aws/aws-sdk-go-v2 v1.43.4/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 h1:aiuaKlDweRC5qExJondpWjOgyzMHpofpwspGXUtwn4c= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16/go.mod h1:nG/LOlmox9BDe9HvQnXWzgcK8uKbgBMZ/Hp5pVt/21I= +github.com/aws/aws-sdk-go-v2/credentials v1.19.34 h1:y6GkSmcv5myd1ngrYbGmiLlwQqB6TQhOuN/tbSSuWDY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.34/go.mod h1:w3dTcnDVoQIewjo7JG45hduAToikiIFLC4FIO7fndvw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 h1:kzVuGlatQtYinwBJEEyLAbggepCoavosiaHHX9+fD+c= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35/go.mod h1:0yLx0yEI+SfqeJMPvOtIEFoZbiQYXMGszBueiutQyaI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 h1:WK6CjihTuLisCjSKKbildJ79sGZZgbBz3iNa7VsKIhU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35/go.mod h1:KYleN57luLoe97R7vTnx8PMcVrr9gAcRECtOjl91DNg= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 h1:jbGY4CXLzZElOXgGsexlC3Hi+3YM0rSmk4opFXKqg/k= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36/go.mod h1:uBu/9aKsS/UQGc72RAt3y54kjgYQxmhut8ZD2dXCDNE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 h1:JJLBQxwY+AFwuPAi5ivGc1ChnTdUt4cXMv7e76m2c/Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15/go.mod h1:lQknBIe78MVL0cQOQDlag8KGflMbMEVFx9mB6O8ENvk= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28 h1:Q1TF1J9jVD+vFo0LzNnmNdQ9EAt52TS+MQlq9Ir+Yxo= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28/go.mod h1:4KqXXC/p1hrotmouDFbrRoWaLy962b9PMUReCG6+uWo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 h1:BBEElKh4a+rKshvjrfpajTe9CbpZvrbb4Jkg2PB7RzA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35/go.mod h1:zaZk983w//8beSruBVec/mr4CmDwgZitW/qzGhAAX0g= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36 h1:EUIwBoN+q7UmhAejxgD27APiRjh1vwCFo53gSqdT0BM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36/go.mod h1:6u00gmlTGR6W0b2k9NBrld7MnOEmf1Spqx0VVt6AqyE= +github.com/aws/aws-sdk-go-v2/service/s3 v1.106.5 h1:HpN6GgZ3T8pSvRp81ZsgumNjlvRsa+9M0ZL2o6W4uLY= +github.com/aws/aws-sdk-go-v2/service/s3 v1.106.5/go.mod h1:5FTZoQxhmLEiCAtYVk6V+t0iS/B5yGZVLZ3Wq5FDJZI= +github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA= +github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= diff --git a/internal/controller/core/configbackup_controller.go b/internal/controller/core/configbackup_controller.go index 74c9ba653..04af7ed4d 100644 --- a/internal/controller/core/configbackup_controller.go +++ b/internal/controller/core/configbackup_controller.go @@ -11,6 +11,7 @@ import ( "time" "github.com/robfig/cron/v3" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" @@ -34,6 +35,7 @@ import ( "github.com/ironcore-dev/network-operator/internal/apistatus" "github.com/ironcore-dev/network-operator/internal/conditions" "github.com/ironcore-dev/network-operator/internal/deviceutil" + "github.com/ironcore-dev/network-operator/internal/objectstorage" "github.com/ironcore-dev/network-operator/internal/paused" "github.com/ironcore-dev/network-operator/internal/provider" "github.com/ironcore-dev/network-operator/internal/resourcelock" @@ -56,11 +58,22 @@ type ConfigBackupReconciler struct { // Locker is used to synchronize operations on resources targeting the same device. Locker *resourcelock.ResourceLocker + + // ObjectStorage is an optional pre-configured object storage client for Remote backups. + // If set, it is used instead of creating one from the spec credentials. + ObjectStorage ObjectStorage +} + +// ObjectStorage defines the operations needed for remote config backups. +type ObjectStorage interface { + PutObject(ctx context.Context, obj *objectstorage.Object) error + ListObjects(ctx context.Context, bucket, prefix string) ([]objectstorage.Object, error) + DeleteObjects(ctx context.Context, bucket string, keys ...string) error } // +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=configbackups,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=configbackups/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch +// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch // +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch func (r *ConfigBackupReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, reterr error) { @@ -246,6 +259,12 @@ func (r *ConfigBackupReconciler) SetupWithManager(ctx context.Context, mgr ctrl. }, }), ). + // Watches enqueues ConfigBackups when a referenced S3 credentials Secret is created or updated. + Watches( + &corev1.Secret{}, + handler.EnqueueRequestsFromMapFunc(r.configBackupsForSecret), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). Complete(r) } @@ -270,17 +289,26 @@ func (r *ConfigBackupReconciler) reconcile(ctx context.Context, s *configBackupS } } + var store ObjectStorage + var err error + if s.ConfigBackup.Spec.Type == v1alpha1.ConfigBackupTypeRemote { + store, err = r.objectStorageClient(ctx, s) + if err != nil { + return ctrl.Result{}, err + } + } + var schedule cron.Schedule if s.ConfigBackup.Spec.Schedule != "" { - schedule, reterr = cron.ParseStandard(s.ConfigBackup.Spec.Schedule) - if reterr != nil { + schedule, err = cron.ParseStandard(s.ConfigBackup.Spec.Schedule) + if err != nil { conditions.Set(s.ConfigBackup, metav1.Condition{ Type: v1alpha1.ReadyCondition, Status: metav1.ConditionFalse, Reason: v1alpha1.ScheduleInvalidReason, - Message: reterr.Error(), + Message: err.Error(), }) - return ctrl.Result{}, reconcile.TerminalError(reterr) + return ctrl.Result{}, reconcile.TerminalError(err) } // Determine the last backup time. If no backups have been created yet, @@ -333,7 +361,13 @@ func (r *ConfigBackupReconciler) reconcile(ctx context.Context, s *configBackupS return } - inventory, err := s.Provider.ListConfigBackups(ctx, req) + var inventory *provider.ConfigBackupInventory + switch s.ConfigBackup.Spec.Type { + case v1alpha1.ConfigBackupTypeRemote: + inventory, err = r.ListRemoteConfigBackups(ctx, store, s) + default: + inventory, err = s.Provider.ListConfigBackups(ctx, req) + } if err != nil { reterr = kerrors.NewAggregate([]error{reterr, fmt.Errorf("failed to list backups: %w", err)}) return @@ -377,7 +411,13 @@ func (r *ConfigBackupReconciler) reconcile(ctx context.Context, s *configBackupS return ctrl.Result{}, nil } - inventory, err := s.Provider.ListConfigBackups(ctx, req) + var inventory *provider.ConfigBackupInventory + switch s.ConfigBackup.Spec.Type { + case v1alpha1.ConfigBackupTypeRemote: + inventory, err = r.ListRemoteConfigBackups(ctx, store, s) + default: + inventory, err = s.Provider.ListConfigBackups(ctx, req) + } if err != nil { return ctrl.Result{}, fmt.Errorf("failed to list backups: %w", err) } @@ -396,7 +436,13 @@ func (r *ConfigBackupReconciler) reconcile(ctx context.Context, s *configBackupS now := metav1.Now() s.ConfigBackup.Status.LastAttemptTime = now - file, err := s.Provider.CreateConfigBackup(ctx, req) + var file *provider.ConfigBackupFile + switch s.ConfigBackup.Spec.Type { + case v1alpha1.ConfigBackupTypeRemote: + file, err = r.CreateRemoteConfigBackup(ctx, store, s) + default: + file, err = s.Provider.CreateConfigBackup(ctx, req) + } if err != nil { r.Recorder.Eventf(s.ConfigBackup, nil, "Warning", "BackupFailed", "Reconcile", "Failed to create backup: %v", err) return ctrl.Result{}, err @@ -430,7 +476,13 @@ func (r *ConfigBackupReconciler) reconcile(ctx context.Context, s *configBackupS }) // Delete the oldest backups that exceed the retention limit. backupsToDelete := inventory.Backups[:total-s.ConfigBackup.Spec.Retention.KeepLast] - if err := s.Provider.DeleteConfigBackups(ctx, backupsToDelete...); err != nil { + switch s.ConfigBackup.Spec.Type { + case v1alpha1.ConfigBackupTypeRemote: + err = r.DeleteRemoteConfigBackups(ctx, store, s, backupsToDelete...) + default: + err = s.Provider.DeleteConfigBackups(ctx, backupsToDelete...) + } + if err != nil { return ctrl.Result{}, fmt.Errorf("failed to delete old backups: %w", err) } } @@ -445,6 +497,110 @@ func (r *ConfigBackupReconciler) reconcile(ctx context.Context, s *configBackupS return ctrl.Result{}, nil } +const ( + // S3AccessKeyID is the Secret key for the access key ID in an S3 credentials Secret. + S3AccessKeyID = "accessKeyID" + // S3SecretAccessKey is the Secret key for the secret access key in an S3 credentials Secret. + S3SecretAccessKey = "secretAccessKey" +) + +// objectStorageClient resolves S3 credentials from the referenced Secret and returns an object storage client. +func (r *ConfigBackupReconciler) objectStorageClient(ctx context.Context, s *configBackupScope) (ObjectStorage, error) { + if r.ObjectStorage != nil { + return r.ObjectStorage, nil + } + ref := s.ConfigBackup.Spec.S3.CredentialsSecretRef + ns := ref.Namespace + if ns == "" { + ns = s.ConfigBackup.Namespace + } + var secret corev1.Secret + if err := r.Get(ctx, client.ObjectKey{Name: ref.Name, Namespace: ns}, &secret); err != nil { + if apierrors.IsNotFound(err) { + conditions.Set(s.ConfigBackup, metav1.Condition{ + Type: v1alpha1.ReadyCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.SecretNotFoundReason, + Message: fmt.Sprintf("S3 credentials secret %s/%s not found", ns, ref.Name), + }) + return nil, reconcile.TerminalError(fmt.Errorf("S3 credentials secret %s/%s not found", ns, ref.Name)) + } + return nil, fmt.Errorf("failed to get S3 credentials secret %s/%s: %w", ns, ref.Name, err) + } + accessKeyID, ok := secret.Data[S3AccessKeyID] + if !ok { + return nil, fmt.Errorf("secret %s/%s missing key %q", ns, ref.Name, S3AccessKeyID) + } + secretAccessKey, ok := secret.Data[S3SecretAccessKey] + if !ok { + return nil, fmt.Errorf("secret %s/%s missing key %q", ns, ref.Name, S3SecretAccessKey) + } + return objectstorage.NewClient(objectstorage.Options{ + Endpoint: s.ConfigBackup.Spec.S3.Endpoint, + Region: s.ConfigBackup.Spec.S3.Region, + AccessKeyID: string(accessKeyID), + SecretAccessKey: string(secretAccessKey), + }), nil +} + +// CreateRemoteConfigBackup fetches the running config from the device and uploads it to S3. +func (r *ConfigBackupReconciler) CreateRemoteConfigBackup(ctx context.Context, store ObjectStorage, s *configBackupScope) (*provider.ConfigBackupFile, error) { + data, err := s.Provider.RunningConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get running config: %w", err) + } + now := time.Now().UTC() + key := fmt.Sprintf( + "%sconfigbackup-%s-%s-%s", + s.ConfigBackup.Spec.Path, + s.ConfigBackup.Namespace, + s.ConfigBackup.Name, + now.Format("20060102T150405Z"), + ) + if err := store.PutObject(ctx, &objectstorage.Object{ + Bucket: s.ConfigBackup.Spec.S3.Bucket, + Key: key, + Body: data, + }); err != nil { + return nil, fmt.Errorf("failed to upload backup to S3: %w", err) + } + return &provider.ConfigBackupFile{ + Path: fmt.Sprintf("s3://%s/%s", s.ConfigBackup.Spec.S3.Bucket, key), + SizeBytes: new(int64(len(data))), + CreatedAt: now, + }, nil +} + +// ListRemoteConfigBackups lists backup objects from S3 and returns them as a ConfigBackupInventory. +// Storage fields (TotalBytes, UsedBytes, FreeBytes) are nil since S3 does not expose free-space information. +func (r *ConfigBackupReconciler) ListRemoteConfigBackups(ctx context.Context, store ObjectStorage, s *configBackupScope) (*provider.ConfigBackupInventory, error) { + objects, err := store.ListObjects(ctx, s.ConfigBackup.Spec.S3.Bucket, s.ConfigBackup.Spec.Path) + if err != nil { + return nil, fmt.Errorf("failed to list remote backups: %w", err) + } + backups := make([]*provider.ConfigBackupFile, len(objects)) + for i := range objects { + backups[i] = &provider.ConfigBackupFile{ + Path: objects[i].Key, + SizeBytes: &objects[i].Size, + CreatedAt: objects[i].LastModified, + } + } + return &provider.ConfigBackupInventory{Backups: backups}, nil +} + +// DeleteRemoteConfigBackups deletes the specified backup objects from S3. +func (r *ConfigBackupReconciler) DeleteRemoteConfigBackups(ctx context.Context, store ObjectStorage, s *configBackupScope, files ...*provider.ConfigBackupFile) error { + if len(files) == 0 { + return nil + } + keys := make([]string, len(files)) + for i, f := range files { + keys[i] = f.Path + } + return store.DeleteObjects(ctx, s.ConfigBackup.Spec.S3.Bucket, keys...) +} + func (r *ConfigBackupReconciler) finalize(_ context.Context, _ *configBackupScope) (reterr error) { return nil } @@ -514,3 +670,33 @@ func (r *ConfigBackupReconciler) ConfigBackupsForProviderConfig(ctx context.Cont return requests } + +// configBackupsForSecret is a [handler.MapFunc] that enqueues reconciliation requests +// for ConfigBackups that reference the given Secret as their S3 credentials source. +func (r *ConfigBackupReconciler) configBackupsForSecret(ctx context.Context, obj client.Object) []reconcile.Request { + log := ctrl.LoggerFrom(ctx, "Secret", klog.KObj(obj)) + + list := &v1alpha1.ConfigBackupList{} + if err := r.List(ctx, list); err != nil { + log.Error(err, "Failed to list ConfigBackups") + return nil + } + + var requests []reconcile.Request + for _, m := range list.Items { + for _, ref := range m.GetSecretRefs() { + if ref.Name == obj.GetName() && ref.Namespace == obj.GetNamespace() { + log.V(2).Info("Enqueuing ConfigBackup for reconciliation", "ConfigBackup", klog.KObj(&m)) + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: m.Name, + Namespace: m.Namespace, + }, + }) + break + } + } + } + + return requests +} diff --git a/internal/controller/core/configbackup_controller_test.go b/internal/controller/core/configbackup_controller_test.go index a39ff0275..c16f7606a 100644 --- a/internal/controller/core/configbackup_controller_test.go +++ b/internal/controller/core/configbackup_controller_test.go @@ -4,16 +4,21 @@ package core import ( + "context" + "strings" + "sync" "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/objectstorage" "github.com/ironcore-dev/network-operator/internal/provider" ) @@ -274,5 +279,105 @@ var _ = Describe("ConfigBackup Controller", func() { g.Expect(testProvider.ConfigBackups).To(HaveLen(1)) }).Should(Succeed()) }) + + It("Should successfully reconcile a remote backup to S3", func() { + By("Creating a Secret with S3 credentials") + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "s3-creds-", + Namespace: metav1.NamespaceDefault, + }, + Data: map[string][]byte{ + "accessKeyID": []byte("EXAMPLEACCESSKEY"), + "secretAccessKey": []byte("EXAMPLESECRETKEY"), + }, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + + By("Creating a Remote ConfigBackup resource") + backup = &v1alpha1.ConfigBackup{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-configbackup-remote-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.ConfigBackupSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: device.Name}, + Type: v1alpha1.ConfigBackupTypeRemote, + Path: "leaf-1/", + S3: &v1alpha1.ConfigBackupS3{ + Endpoint: "https://s3.example.com", + Bucket: "network-config-backups", + CredentialsSecretRef: v1alpha1.SecretReference{Name: secret.Name}, + }, + }, + } + Expect(k8sClient.Create(ctx, backup)).To(Succeed()) + + By("Verifying the backup status is populated") + Eventually(func(g Gomega) { + resource := &v1alpha1.ConfigBackup{} + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(backup), resource)).To(Succeed()) + g.Expect(resource.Status.LastBackup).NotTo(BeNil()) + g.Expect(resource.Status.LastBackup.Filepath).To(HavePrefix("s3://network-config-backups/leaf-1/configbackup-")) + g.Expect(resource.Status.LastBackup.SizeBytes).NotTo(BeNil()) + g.Expect(*resource.Status.LastBackup.SizeBytes).To(BeNumerically(">", 0)) + g.Expect(resource.Status.LastAttemptTime.IsZero()).To(BeFalse()) + + cond := meta.FindStatusCondition(resource.Status.Conditions, v1alpha1.ReadyCondition) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + g.Expect(cond.Reason).To(Equal(v1alpha1.BackupSuccessfulReason)) + }).Should(Succeed()) + + By("Verifying the mock S3 server received the upload") + Expect(testS3Store.Objects).To(HaveLen(1)) + for key, body := range testS3Store.Objects { + Expect(key).To(HavePrefix("leaf-1/configbackup-")) + Expect(body).NotTo(BeEmpty()) + } + }) }) }) + +// mockObjectStorage is an in-memory fake implementing the ObjectStorage interface for testing. +type mockObjectStorage struct { + sync.Mutex + + Objects map[string][]byte // key → body +} + +func NewMockObjectStorage() *mockObjectStorage { + return &mockObjectStorage{Objects: make(map[string][]byte)} +} + +func (m *mockObjectStorage) PutObject(_ context.Context, obj *objectstorage.Object) error { + m.Lock() + defer m.Unlock() + m.Objects[obj.Key] = obj.Body + return nil +} + +func (m *mockObjectStorage) ListObjects(_ context.Context, _, prefix string) ([]objectstorage.Object, error) { + m.Lock() + defer m.Unlock() + var result []objectstorage.Object + for k, v := range m.Objects { + if strings.HasPrefix(k, prefix) { + result = append(result, objectstorage.Object{ + Key: k, + Size: int64(len(v)), + LastModified: time.Now().UTC(), + }) + } + } + return result, nil +} + +func (m *mockObjectStorage) DeleteObjects(_ context.Context, _ string, keys ...string) error { + m.Lock() + defer m.Unlock() + for _, key := range keys { + delete(m.Objects, key) + } + return nil +} diff --git a/internal/controller/core/suite_test.go b/internal/controller/core/suite_test.go index 67e774cc9..b15aa7f4a 100644 --- a/internal/controller/core/suite_test.go +++ b/internal/controller/core/suite_test.go @@ -49,6 +49,7 @@ var ( k8sManager ctrl.Manager testProvider = NewProvider() testLocker *resourcelock.ResourceLocker + testS3Store = NewMockObjectStorage() lastRebootTime = time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC) ) @@ -344,11 +345,12 @@ var _ = BeforeSuite(func() { Expect(err).NotTo(HaveOccurred()) err = (&ConfigBackupReconciler{ - Client: k8sManager.GetClient(), - Scheme: k8sManager.GetScheme(), - Recorder: recorder, - Provider: prov, - Locker: testLocker, + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + Recorder: recorder, + Provider: prov, + Locker: testLocker, + ObjectStorage: testS3Store, }).SetupWithManager(ctx, k8sManager) Expect(err).NotTo(HaveOccurred()) @@ -941,6 +943,10 @@ func (p *Provider) GetNVEStatus(_ context.Context, _ *provider.NVERequest) (prov return status, nil } +func (p *Provider) RunningConfig(context.Context) ([]byte, error) { + return []byte("! running-config mock\nhostname test-device\n"), nil +} + func (p *Provider) CreateConfigBackup(_ context.Context, req *provider.ConfigBackupRequest) (*provider.ConfigBackupFile, error) { p.Lock() defer p.Unlock() diff --git a/internal/objectstorage/doc.go b/internal/objectstorage/doc.go new file mode 100644 index 000000000..5f9d6b361 --- /dev/null +++ b/internal/objectstorage/doc.go @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +// Package objectstorage provides a client for uploading, listing, and deleting +// objects on S3-compatible object stores. +package objectstorage diff --git a/internal/objectstorage/s3.go b/internal/objectstorage/s3.go new file mode 100644 index 000000000..ad186ac77 --- /dev/null +++ b/internal/objectstorage/s3.go @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package objectstorage + +import ( + "bytes" + "context" + "fmt" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" +) + +// Options configures an S3-compatible object storage client. +type Options struct { + Endpoint string + Region string + AccessKeyID string + SecretAccessKey string +} + +// Client wraps an S3-compatible client for uploading backup objects. +type Client struct { + s3 *s3.Client +} + +// NewClient creates a new S3-compatible storage client. +// The endpoint must be a full URL (e.g., "https://s3.eu-central-1.amazonaws.com"). +// If no region is specified, "eu-central-1" is used as a default. +func NewClient(opts Options) *Client { + region := opts.Region + if region == "" { + region = "eu-central-1" + } + svc := s3.New(s3.Options{ + Region: region, + Credentials: credentials.NewStaticCredentialsProvider(opts.AccessKeyID, opts.SecretAccessKey, ""), + BaseEndpoint: aws.String(opts.Endpoint), + UsePathStyle: true, + }) + return &Client{s3: svc} +} + +// Object describes an object in the store. +type Object struct { + Bucket string + Key string + Body []byte + Size int64 + // LastModified is the time the object was last modified. + LastModified time.Time +} + +// PutObject uploads a byte slice to the configured S3-compatible store. +func (c *Client) PutObject(ctx context.Context, obj *Object) error { + _, err := c.s3.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(obj.Bucket), + Key: aws.String(obj.Key), + Body: bytes.NewReader(obj.Body), + }) + if err != nil { + return fmt.Errorf("failed to upload object to s3://%s/%s: %w", obj.Bucket, obj.Key, err) + } + return nil +} + +// ListObjects returns all objects in the bucket matching the given key prefix. +func (c *Client) ListObjects(ctx context.Context, bucket, prefix string) ([]Object, error) { + out, err := c.s3.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: aws.String(bucket), + Prefix: aws.String(prefix), + }) + if err != nil { + return nil, fmt.Errorf("failed to list objects in s3://%s/%s: %w", bucket, prefix, err) + } + objects := make([]Object, len(out.Contents)) + for i, obj := range out.Contents { + objects[i] = Object{ + Bucket: bucket, + Key: aws.ToString(obj.Key), + Size: aws.ToInt64(obj.Size), + LastModified: aws.ToTime(obj.LastModified), + } + } + return objects, nil +} + +// DeleteObjects removes the specified objects from the bucket. +func (c *Client) DeleteObjects(ctx context.Context, bucket string, keys ...string) error { + if len(keys) == 0 { + return nil + } + objects := make([]s3types.ObjectIdentifier, len(keys)) + for i, key := range keys { + objects[i] = s3types.ObjectIdentifier{Key: aws.String(key)} + } + _, err := c.s3.DeleteObjects(ctx, &s3.DeleteObjectsInput{ + Bucket: aws.String(bucket), + Delete: &s3types.Delete{Objects: objects, Quiet: aws.Bool(true)}, + }) + if err != nil { + return fmt.Errorf("failed to delete objects from s3://%s: %w", bucket, err) + } + return nil +} diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go index 5ebe16632..6c651a82c 100644 --- a/internal/provider/cisco/nxos/provider.go +++ b/internal/provider/cisco/nxos/provider.go @@ -256,6 +256,21 @@ func (p *Provider) GetLastRebootTime(ctx context.Context) (time.Time, error) { return bt.Time, nil } +func (p *Provider) RunningConfig(ctx context.Context) ([]byte, error) { + res, err := p.nxapi.Do(ctx, nxapi.NewRequest("show running-config").WithMethod(nxapi.MethodCLIASCII)) + if err != nil { + return nil, err + } + if len(res) == 0 { + return nil, errors.New("empty response") + } + var body string + if err := json.Unmarshal(res[0], &body); err != nil { + return nil, fmt.Errorf("failed to decode running config: %w", err) + } + return []byte(body), nil +} + func (p *Provider) CreateConfigBackup(ctx context.Context, req *provider.ConfigBackupRequest) (*provider.ConfigBackupFile, error) { if req.ConfigBackup.Spec.Type == v1alpha1.ConfigBackupTypeStartup { _, err := p.nxapi.Do(ctx, nxapi.NewRequest("copy running-config startup-config").WithRollback(nxapi.Stop)) diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 8c7f06746..c449652cf 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -88,6 +88,9 @@ type DeviceInfo struct { type ConfigBackupProvider interface { Provider + // RunningConfig returns the current running configuration of the device + // in its provider-specific encoding. + RunningConfig(context.Context) ([]byte, error) // CreateConfigBackup writes a new configuration backup to the device. CreateConfigBackup(context.Context, *ConfigBackupRequest) (*ConfigBackupFile, error) // ListConfigBackups lists the backups currently discovered for the ConfigBackup policy. From 318bfc0b0c26546049e8254f7ac24b85301625b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Mon, 10 Aug 2026 16:31:02 +0200 Subject: [PATCH 3/8] Add MinIO dev deployment and remote backup sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a MinIO deployment to config/develop for local S3 testing. Includes a ClusterIP Service and credentials Secret. The Tilt setup port-forwards the MinIO console to localhost:9001. Add a remote-backup sample in the ConfigBackup samples that backs up every minute to the in-cluster MinIO instance with retention of 10 backups. Signed-off-by: Felix Kästner --- Tiltfile | 3 + config/develop/kustomization.yaml | 1 + config/develop/minio.yaml | 72 +++++++++++++++++++++++ config/samples/v1alpha1_configbackup.yaml | 18 ++++++ 4 files changed, 94 insertions(+) create mode 100644 config/develop/minio.yaml diff --git a/Tiltfile b/Tiltfile index 1b2f5e06f..0da6f174f 100644 --- a/Tiltfile +++ b/Tiltfile @@ -49,6 +49,8 @@ manager = str(manager).replace('--provider=openconfig', '--provider={}'.format(p k8s_yaml(blob(manager)) k8s_resource('network-operator-controller-manager', resource_deps=['controller-gen'], labels=['operator']) +k8s_resource('minio', port_forwards=['9001:9001']) + # Sample resources with manual trigger mode def device_yaml(): decoded = read_yaml_stream('./config/samples/v1alpha1_device.yaml') @@ -170,6 +172,7 @@ k8s_resource(new_name='aaa', objects=['aaa-tacacs:aaa', 'tacacs-server-keys:secr k8s_yaml('./config/samples/v1alpha1_configbackup.yaml') k8s_resource(new_name='local-backup', objects=['local-backup:configbackup'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) k8s_resource(new_name='startup-backup', objects=['startup-backup:configbackup'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) +k8s_resource(new_name='remote-backup', objects=['remote-backup:configbackup', 'minio-credentials:secret'], resource_deps=['minio'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) k8s_yaml('./config/samples/v1alpha1_indexpool.yaml') k8s_resource(new_name='indexpool', objects=['indexpool-sample:indexpool'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) diff --git a/config/develop/kustomization.yaml b/config/develop/kustomization.yaml index 495888b9b..cf9c4820f 100644 --- a/config/develop/kustomization.yaml +++ b/config/develop/kustomization.yaml @@ -1,6 +1,7 @@ resources: - ../default - ../prometheus +- minio.yaml patches: - path: manager_patch.yaml diff --git a/config/develop/minio.yaml b/config/develop/minio.yaml new file mode 100644 index 000000000..95adbdccb --- /dev/null +++ b/config/develop/minio.yaml @@ -0,0 +1,72 @@ +# MinIO — S3-compatible object storage for local development. +# Provides a web console on port 9001 for browsing uploaded backups. +# Access the console via: kubectl port-forward svc/minio 9001:9001 +# +# MinIO is used for local development only and is not distributed as part of this project. +# MinIO is licensed under AGPL-3.0: https://github.com/minio/minio/blob/master/LICENSE +# +# Default credentials: minioadmin / minioadmin +# S3 endpoint from within the cluster: https://minio.default.svc:9000 +--- +apiVersion: v1 +kind: Secret +metadata: + name: minio-credentials + namespace: default +type: Opaque +stringData: + accessKeyID: minioadmin + secretAccessKey: minioadmin +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: minio + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app: minio + template: + metadata: + labels: + app: minio + spec: + containers: + - name: minio + image: minio/minio:latest + args: ["server", "/data", "--console-address", ":9001"] + env: + - name: MINIO_ROOT_USER + value: "minioadmin" + - name: MINIO_ROOT_PASSWORD + value: "minioadmin" + ports: + - name: s3 + containerPort: 9000 + - name: console + containerPort: 9001 + readinessProbe: + httpGet: + path: /minio/health/ready + port: 9000 + initialDelaySeconds: 5 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: minio + namespace: default +spec: + type: ClusterIP + selector: + app: minio + ports: + - name: s3 + port: 9000 + targetPort: 9000 + - name: console + port: 9001 + targetPort: 9001 diff --git a/config/samples/v1alpha1_configbackup.yaml b/config/samples/v1alpha1_configbackup.yaml index 72ddba040..0a3cc2eb7 100644 --- a/config/samples/v1alpha1_configbackup.yaml +++ b/config/samples/v1alpha1_configbackup.yaml @@ -21,3 +21,21 @@ spec: deviceRef: name: leaf1 type: Startup +--- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: ConfigBackup +metadata: + name: remote-backup +spec: + deviceRef: + name: leaf1 + schedule: "* * * * *" + type: Remote + path: "leaf-1/" + retention: + keepLast: 10 + s3: + endpoint: "http://minio.default.svc:9000" + bucket: config-backups + credentialsSecretRef: + name: minio-credentials From 284b9ad0d0e3369065a86d30625199d1334dd103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Mon, 10 Aug 2026 16:50:05 +0200 Subject: [PATCH 4/8] Add RemoteEndpointReady condition with HeadBucket check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before performing a Remote backup, the controller now calls HeadBucket to verify the S3 endpoint is reachable and the configured bucket exists. If the check succeeds, RemoteEndpointReady is set to True. If it fails, both RemoteEndpointReady and Ready are set to False with reason RemoteEndpointUnreachable, and the reconciliation stops with an error. Signed-off-by: Felix Kästner --- api/core/v1alpha1/groupversion_info.go | 6 +++++ .../core/configbackup_controller.go | 22 +++++++++++++++++++ .../core/configbackup_controller_test.go | 8 +++++++ internal/objectstorage/s3.go | 11 ++++++++++ 4 files changed, 47 insertions(+) diff --git a/api/core/v1alpha1/groupversion_info.go b/api/core/v1alpha1/groupversion_info.go index 83ed0f6a6..c7e851f26 100644 --- a/api/core/v1alpha1/groupversion_info.go +++ b/api/core/v1alpha1/groupversion_info.go @@ -137,6 +137,10 @@ const ( // This condition is set to True when the controller successfully connects to // the device, and False when the connection attempt fails. ReachableCondition = "Reachable" + + // RemoteEndpointReadyCondition indicates whether the remote object storage + // endpoint is reachable and the configured bucket exists. + RemoteEndpointReadyCondition = "RemoteEndpointReady" ) // Reasons that are used across different objects. @@ -251,6 +255,8 @@ const ( PrefixSetNotFoundReason = "PrefixSetNotFound" // SecretNotFoundReason indicates that a referenced Secret was not found. SecretNotFoundReason = "SecretNotFound" + // RemoteEndpointUnreachableReason indicates that the remote object storage endpoint is not reachable. + RemoteEndpointUnreachableReason = "RemoteEndpointUnreachable" ) // Reasons that are specific to [BGPPeer] objects. diff --git a/internal/controller/core/configbackup_controller.go b/internal/controller/core/configbackup_controller.go index 04af7ed4d..d8ccca4a5 100644 --- a/internal/controller/core/configbackup_controller.go +++ b/internal/controller/core/configbackup_controller.go @@ -66,6 +66,7 @@ type ConfigBackupReconciler struct { // ObjectStorage defines the operations needed for remote config backups. type ObjectStorage interface { + HeadBucket(ctx context.Context, bucket string) error PutObject(ctx context.Context, obj *objectstorage.Object) error ListObjects(ctx context.Context, bucket, prefix string) ([]objectstorage.Object, error) DeleteObjects(ctx context.Context, bucket string, keys ...string) error @@ -296,6 +297,27 @@ func (r *ConfigBackupReconciler) reconcile(ctx context.Context, s *configBackupS if err != nil { return ctrl.Result{}, err } + if err := store.HeadBucket(ctx, s.ConfigBackup.Spec.S3.Bucket); err != nil { + conditions.Set(s.ConfigBackup, metav1.Condition{ + Type: v1alpha1.RemoteEndpointReadyCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.RemoteEndpointUnreachableReason, + Message: err.Error(), + }) + conditions.Set(s.ConfigBackup, metav1.Condition{ + Type: v1alpha1.ReadyCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.RemoteEndpointUnreachableReason, + Message: "Remote object storage endpoint is not reachable", + }) + return ctrl.Result{}, err + } + conditions.Set(s.ConfigBackup, metav1.Condition{ + Type: v1alpha1.RemoteEndpointReadyCondition, + Status: metav1.ConditionTrue, + Reason: v1alpha1.ReadyReason, + Message: "Remote object storage endpoint is reachable", + }) } var schedule cron.Schedule diff --git a/internal/controller/core/configbackup_controller_test.go b/internal/controller/core/configbackup_controller_test.go index c16f7606a..d6a309097 100644 --- a/internal/controller/core/configbackup_controller_test.go +++ b/internal/controller/core/configbackup_controller_test.go @@ -327,6 +327,10 @@ var _ = Describe("ConfigBackup Controller", func() { g.Expect(cond).NotTo(BeNil()) g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) g.Expect(cond.Reason).To(Equal(v1alpha1.BackupSuccessfulReason)) + + cond = meta.FindStatusCondition(resource.Status.Conditions, v1alpha1.RemoteEndpointReadyCondition) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) }).Should(Succeed()) By("Verifying the mock S3 server received the upload") @@ -350,6 +354,10 @@ func NewMockObjectStorage() *mockObjectStorage { return &mockObjectStorage{Objects: make(map[string][]byte)} } +func (m *mockObjectStorage) HeadBucket(_ context.Context, _ string) error { + return nil +} + func (m *mockObjectStorage) PutObject(_ context.Context, obj *objectstorage.Object) error { m.Lock() defer m.Unlock() diff --git a/internal/objectstorage/s3.go b/internal/objectstorage/s3.go index ad186ac77..abfcd0d73 100644 --- a/internal/objectstorage/s3.go +++ b/internal/objectstorage/s3.go @@ -55,6 +55,17 @@ type Object struct { LastModified time.Time } +// HeadBucket checks whether the bucket exists and is accessible. +func (c *Client) HeadBucket(ctx context.Context, bucket string) error { + _, err := c.s3.HeadBucket(ctx, &s3.HeadBucketInput{ + Bucket: aws.String(bucket), + }) + if err != nil { + return fmt.Errorf("failed to reach bucket s3://%s: %w", bucket, err) + } + return nil +} + // PutObject uploads a byte slice to the configured S3-compatible store. func (c *Client) PutObject(ctx context.Context, obj *Object) error { _, err := c.s3.PutObject(ctx, &s3.PutObjectInput{ From 723c79a1c9510b0016fff26b5cfa3349ee8eeb99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Mon, 10 Aug 2026 18:26:20 +0200 Subject: [PATCH 5/8] Add optional encryption for remote config backups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ConfigBackupEncryption struct with support for AES-256-GCM and ChaCha20-Poly1305 algorithms. Encryption is performed in the controller pod before uploading to object storage. The encryption key is resolved from a referenced Secret via the existing clientutil.Secret pattern. If the key Secret is missing, a terminal SecretNotFound condition is set. If encryption fails (e.g., invalid key size), a terminal EncryptionFailed condition is set with a warning event. The LastBackup status now includes EncryptionAlgorithm and EncryptionKeySecret fields so users can inspect which encryption was applied to each backup. GetSecretRefs is extended to include the encryption key Secret so the watcher triggers re-reconciliation on key changes. Signed-off-by: Felix Kästner --- Tiltfile | 2 +- api/core/v1alpha1/configbackup_types.go | 40 ++++++++ api/core/v1alpha1/groupversion_info.go | 2 + api/core/v1alpha1/zz_generated.deepcopy.go | 23 ++++- ...backups.networking.metal.ironcore.dev.yaml | 57 +++++++++++ ...king.metal.ironcore.dev_configbackups.yaml | 57 +++++++++++ config/samples/v1alpha1_configbackup.yaml | 13 +++ docs/api-reference/index.md | 40 ++++++++ .../core/configbackup_controller.go | 65 +++++++++++++ .../core/configbackup_controller_test.go | 95 +++++++++++++++++++ 10 files changed, 392 insertions(+), 2 deletions(-) diff --git a/Tiltfile b/Tiltfile index 0da6f174f..22e1a9ce7 100644 --- a/Tiltfile +++ b/Tiltfile @@ -172,7 +172,7 @@ k8s_resource(new_name='aaa', objects=['aaa-tacacs:aaa', 'tacacs-server-keys:secr k8s_yaml('./config/samples/v1alpha1_configbackup.yaml') k8s_resource(new_name='local-backup', objects=['local-backup:configbackup'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) k8s_resource(new_name='startup-backup', objects=['startup-backup:configbackup'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) -k8s_resource(new_name='remote-backup', objects=['remote-backup:configbackup', 'minio-credentials:secret'], resource_deps=['minio'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) +k8s_resource(new_name='remote-backup', objects=['remote-backup:configbackup', 'minio-credentials:secret', 'backup-encryption-key:secret'], resource_deps=['minio'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) k8s_yaml('./config/samples/v1alpha1_indexpool.yaml') k8s_resource(new_name='indexpool', objects=['indexpool-sample:indexpool'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) diff --git a/api/core/v1alpha1/configbackup_types.go b/api/core/v1alpha1/configbackup_types.go index 262da73cb..840627ea9 100644 --- a/api/core/v1alpha1/configbackup_types.go +++ b/api/core/v1alpha1/configbackup_types.go @@ -120,6 +120,33 @@ type ConfigBackupS3 struct { // CredentialsSecretRef references a Secret containing "accessKeyID" and "secretAccessKey" keys. // +required CredentialsSecretRef SecretReference `json:"credentialsSecretRef"` + + // Encryption configures optional encryption for backup objects, performed in the controller pod before upload. + // If omitted, backups are stored unencrypted. + // +optional + Encryption *ConfigBackupEncryption `json:"encryption,omitempty"` +} + +// EncryptionAlgorithm defines the supported encryption algorithms for remote backups. +// +kubebuilder:validation:Enum="AES-256-GCM";"ChaCha20-Poly1305" +type EncryptionAlgorithm string + +const ( + // EncryptionAES256GCM uses AES-256 in GCM mode. Key must be 32 bytes. + EncryptionAES256GCM EncryptionAlgorithm = "AES-256-GCM" + // EncryptionChaCha20Poly1305 uses ChaCha20-Poly1305. Key must be 32 bytes. + EncryptionChaCha20Poly1305 EncryptionAlgorithm = "ChaCha20-Poly1305" +) + +// ConfigBackupEncryption configures encryption for remote backup objects, performed in the controller pod. +type ConfigBackupEncryption struct { + // Algorithm is the encryption algorithm to use. + // +required + Algorithm EncryptionAlgorithm `json:"algorithm"` + + // KeySecret references the Secret and key containing the 32-byte encryption key. + // +required + KeySecret SecretKeySelector `json:"keySecret"` } // ConfigBackupStatus defines the observed state of ConfigBackup. @@ -195,6 +222,16 @@ type ConfigBackupRunStatus struct { // +optional // +kubebuilder:validation:MinLength=1 Filepath string `json:"filepath,omitempty"` + + // EncryptionAlgorithm is the encryption algorithm used for this backup, if any. + // Only set for encrypted Remote backups. + // +optional + EncryptionAlgorithm EncryptionAlgorithm `json:"encryptionAlgorithm,omitempty"` + + // EncryptionKeySecret is the name of the Secret that provided the encryption key. + // Only set for encrypted Remote backups. + // +optional + EncryptionKeySecret string `json:"encryptionKeySecret,omitempty"` } // ConfigBackupStorageStatus contains storage utilization for the configured backup target. @@ -264,6 +301,9 @@ func (c *ConfigBackup) GetSecretRefs() []SecretReference { refs := []SecretReference{} if c.Spec.S3 != nil { refs = append(refs, c.Spec.S3.CredentialsSecretRef) + if c.Spec.S3.Encryption != nil { + refs = append(refs, c.Spec.S3.Encryption.KeySecret.SecretReference) + } } for i := range refs { if refs[i].Namespace == "" { diff --git a/api/core/v1alpha1/groupversion_info.go b/api/core/v1alpha1/groupversion_info.go index c7e851f26..3656db023 100644 --- a/api/core/v1alpha1/groupversion_info.go +++ b/api/core/v1alpha1/groupversion_info.go @@ -257,6 +257,8 @@ const ( SecretNotFoundReason = "SecretNotFound" // RemoteEndpointUnreachableReason indicates that the remote object storage endpoint is not reachable. RemoteEndpointUnreachableReason = "RemoteEndpointUnreachable" + // EncryptionFailedReason indicates that encryption of the backup data failed. + EncryptionFailedReason = "EncryptionFailed" ) // Reasons that are specific to [BGPPeer] objects. diff --git a/api/core/v1alpha1/zz_generated.deepcopy.go b/api/core/v1alpha1/zz_generated.deepcopy.go index 5c5dbacb5..5e992190b 100644 --- a/api/core/v1alpha1/zz_generated.deepcopy.go +++ b/api/core/v1alpha1/zz_generated.deepcopy.go @@ -1277,6 +1277,22 @@ func (in *ConfigBackup) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConfigBackupEncryption) DeepCopyInto(out *ConfigBackupEncryption) { + *out = *in + out.KeySecret = in.KeySecret +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConfigBackupEncryption. +func (in *ConfigBackupEncryption) DeepCopy() *ConfigBackupEncryption { + if in == nil { + return nil + } + out := new(ConfigBackupEncryption) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ConfigBackupList) DeepCopyInto(out *ConfigBackupList) { *out = *in @@ -1350,6 +1366,11 @@ func (in *ConfigBackupRunStatus) DeepCopy() *ConfigBackupRunStatus { func (in *ConfigBackupS3) DeepCopyInto(out *ConfigBackupS3) { *out = *in out.CredentialsSecretRef = in.CredentialsSecretRef + if in.Encryption != nil { + in, out := &in.Encryption, &out.Encryption + *out = new(ConfigBackupEncryption) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConfigBackupS3. @@ -1384,7 +1405,7 @@ func (in *ConfigBackupSpec) DeepCopyInto(out *ConfigBackupSpec) { if in.S3 != nil { in, out := &in.S3, &out.S3 *out = new(ConfigBackupS3) - **out = **in + (*in).DeepCopyInto(*out) } } diff --git a/charts/network-operator/templates/crd/configbackups.networking.metal.ironcore.dev.yaml b/charts/network-operator/templates/crd/configbackups.networking.metal.ironcore.dev.yaml index b43593385..746a8d030 100644 --- a/charts/network-operator/templates/crd/configbackups.networking.metal.ironcore.dev.yaml +++ b/charts/network-operator/templates/crd/configbackups.networking.metal.ironcore.dev.yaml @@ -168,6 +168,50 @@ spec: - name type: object x-kubernetes-map-type: atomic + encryption: + description: |- + Encryption configures optional encryption for backup objects, performed in the controller pod before upload. + If omitted, backups are stored unencrypted. + properties: + algorithm: + description: Algorithm is the encryption algorithm to use. + enum: + - AES-256-GCM + - ChaCha20-Poly1305 + type: string + keySecret: + description: KeySecret references the Secret and key containing + the 32-byte encryption key. + properties: + key: + description: |- + Key is the of the entry in the secret resource's `data` or `stringData` + field to be used. + maxLength: 253 + minLength: 1 + type: string + name: + description: Name is unique within a namespace to reference + a secret resource. + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace defines the space within which the secret name must be unique. + If omitted, the namespace of the object being reconciled will be used. + maxLength: 63 + minLength: 1 + type: string + required: + - key + - name + type: object + x-kubernetes-map-type: atomic + required: + - algorithm + - keySecret + type: object endpoint: description: Endpoint is the S3-compatible endpoint URL (e.g., "https://s3.eu-central-1.amazonaws.com"). @@ -321,6 +365,19 @@ spec: duration: description: Duration is the duration of the backup operation. type: string + encryptionAlgorithm: + description: |- + EncryptionAlgorithm is the encryption algorithm used for this backup, if any. + Only set for encrypted Remote backups. + enum: + - AES-256-GCM + - ChaCha20-Poly1305 + type: string + encryptionKeySecret: + description: |- + EncryptionKeySecret is the name of the Secret that provided the encryption key. + Only set for encrypted Remote backups. + type: string filepath: description: |- Filepath is the device-local path of the backup artifact. diff --git a/config/crd/bases/networking.metal.ironcore.dev_configbackups.yaml b/config/crd/bases/networking.metal.ironcore.dev_configbackups.yaml index 021b33fd8..2315abfc6 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_configbackups.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_configbackups.yaml @@ -165,6 +165,50 @@ spec: - name type: object x-kubernetes-map-type: atomic + encryption: + description: |- + Encryption configures optional encryption for backup objects, performed in the controller pod before upload. + If omitted, backups are stored unencrypted. + properties: + algorithm: + description: Algorithm is the encryption algorithm to use. + enum: + - AES-256-GCM + - ChaCha20-Poly1305 + type: string + keySecret: + description: KeySecret references the Secret and key containing + the 32-byte encryption key. + properties: + key: + description: |- + Key is the of the entry in the secret resource's `data` or `stringData` + field to be used. + maxLength: 253 + minLength: 1 + type: string + name: + description: Name is unique within a namespace to reference + a secret resource. + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace defines the space within which the secret name must be unique. + If omitted, the namespace of the object being reconciled will be used. + maxLength: 63 + minLength: 1 + type: string + required: + - key + - name + type: object + x-kubernetes-map-type: atomic + required: + - algorithm + - keySecret + type: object endpoint: description: Endpoint is the S3-compatible endpoint URL (e.g., "https://s3.eu-central-1.amazonaws.com"). @@ -318,6 +362,19 @@ spec: duration: description: Duration is the duration of the backup operation. type: string + encryptionAlgorithm: + description: |- + EncryptionAlgorithm is the encryption algorithm used for this backup, if any. + Only set for encrypted Remote backups. + enum: + - AES-256-GCM + - ChaCha20-Poly1305 + type: string + encryptionKeySecret: + description: |- + EncryptionKeySecret is the name of the Secret that provided the encryption key. + Only set for encrypted Remote backups. + type: string filepath: description: |- Filepath is the device-local path of the backup artifact. diff --git a/config/samples/v1alpha1_configbackup.yaml b/config/samples/v1alpha1_configbackup.yaml index 0a3cc2eb7..a55ba2aba 100644 --- a/config/samples/v1alpha1_configbackup.yaml +++ b/config/samples/v1alpha1_configbackup.yaml @@ -39,3 +39,16 @@ spec: bucket: config-backups credentialsSecretRef: name: minio-credentials + encryption: + algorithm: AES-256-GCM + keySecret: + name: backup-encryption-key + key: encryption-key +--- +apiVersion: v1 +kind: Secret +metadata: + name: backup-encryption-key +type: Opaque +stringData: + encryption-key: "EXAMPLE_KEY_MUST_BE_EXACTLY_32B!" diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index 0c0b8385d..a8d0449b3 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -1374,6 +1374,23 @@ ConfigBackup is the Schema for the configbackups API. | `status` _[ConfigBackupStatus](#configbackupstatus)_ | Status of the resource. This is set and updated automatically.
Read-only.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status | | Optional: \{\}
| +#### ConfigBackupEncryption + + + +ConfigBackupEncryption configures encryption for remote backup objects, performed in the controller pod. + + + +_Appears in:_ +- [ConfigBackupS3](#configbackups3) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `algorithm` _[EncryptionAlgorithm](#encryptionalgorithm)_ | Algorithm is the encryption algorithm to use. | | Enum: [AES-256-GCM ChaCha20-Poly1305]
Required: \{\}
| +| `keySecret` _[SecretKeySelector](#secretkeyselector)_ | KeySecret references the Secret and key containing the 32-byte encryption key. | | Required: \{\}
| + + #### ConfigBackupRetention @@ -1408,6 +1425,8 @@ _Appears in:_ | `observedGeneration` _integer_ | ObservedGeneration represents the .metadata.generation that produced this backup. | | Minimum: 0
Optional: \{\}
| | `sizeBytes` _integer_ | SizeBytes is the size in bytes of the backup artifact.
This only applies to Local backups, and may be unknown if the controller cannot query the device. | | Minimum: 0
Optional: \{\}
| | `filepath` _string_ | Filepath is the device-local path of the backup artifact.
This only applies to Local backups, and may be unknown if the controller cannot query the device. | | MinLength: 1
Optional: \{\}
| +| `encryptionAlgorithm` _[EncryptionAlgorithm](#encryptionalgorithm)_ | EncryptionAlgorithm is the encryption algorithm used for this backup, if any.
Only set for encrypted Remote backups. | | Enum: [AES-256-GCM ChaCha20-Poly1305]
Optional: \{\}
| +| `encryptionKeySecret` _string_ | EncryptionKeySecret is the name of the Secret that provided the encryption key.
Only set for encrypted Remote backups. | | Optional: \{\}
| #### ConfigBackupS3 @@ -1427,6 +1446,7 @@ _Appears in:_ | `bucket` _string_ | Bucket is the name of the S3 bucket. | | MaxLength: 63
MinLength: 1
Required: \{\}
| | `region` _string_ | Region is the endpoint region. Optional for S3-compatible stores that don't require it. | | MaxLength: 63
Optional: \{\}
| | `credentialsSecretRef` _[SecretReference](#secretreference)_ | CredentialsSecretRef references a Secret containing "accessKeyID" and "secretAccessKey" keys. | | Required: \{\}
| +| `encryption` _[ConfigBackupEncryption](#configbackupencryption)_ | Encryption configures optional encryption for backup objects, performed in the controller pod before upload.
If omitted, backups are stored unencrypted. | | Optional: \{\}
| #### ConfigBackupSpec @@ -1994,6 +2014,25 @@ _Appears in:_ | `outerTag` _integer_ | OuterTag specifies the outer VLAN ID for QinQ encapsulation.
Only applicable when Type is set to "QinQ". | | Maximum: 4094
Minimum: 1
Optional: \{\}
| +#### EncryptionAlgorithm + +_Underlying type:_ _string_ + +EncryptionAlgorithm defines the supported encryption algorithms for remote backups. + +_Validation:_ +- Enum: [AES-256-GCM ChaCha20-Poly1305] + +_Appears in:_ +- [ConfigBackupEncryption](#configbackupencryption) +- [ConfigBackupRunStatus](#configbackuprunstatus) + +| Field | Description | +| --- | --- | +| `AES-256-GCM` | EncryptionAES256GCM uses AES-256 in GCM mode. Key must be 32 bytes.
| +| `ChaCha20-Poly1305` | EncryptionChaCha20Poly1305 uses ChaCha20-Poly1305. Key must be 32 bytes.
| + + #### Endpoint @@ -3736,6 +3775,7 @@ SecretKeySelector contains enough information to select a key of a Secret. _Appears in:_ - [AAAServerRADIUS](#aaaserverradius) - [AAAServerTACACS](#aaaservertacacs) +- [ConfigBackupEncryption](#configbackupencryption) - [PasswordSource](#passwordsource) - [SSHPublicKeySource](#sshpublickeysource) - [TLS](#tls) diff --git a/internal/controller/core/configbackup_controller.go b/internal/controller/core/configbackup_controller.go index d8ccca4a5..67b6d74f7 100644 --- a/internal/controller/core/configbackup_controller.go +++ b/internal/controller/core/configbackup_controller.go @@ -5,12 +5,16 @@ package core import ( "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" "errors" "fmt" "sort" "time" "github.com/robfig/cron/v3" + "golang.org/x/crypto/chacha20poly1305" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -33,6 +37,7 @@ import ( "github.com/ironcore-dev/network-operator/api/core/v1alpha1" "github.com/ironcore-dev/network-operator/internal/apistatus" + "github.com/ironcore-dev/network-operator/internal/clientutil" "github.com/ironcore-dev/network-operator/internal/conditions" "github.com/ironcore-dev/network-operator/internal/deviceutil" "github.com/ironcore-dev/network-operator/internal/objectstorage" @@ -484,6 +489,10 @@ func (r *ConfigBackupReconciler) reconcile(ctx context.Context, s *configBackupS configBackupSizeBytes.WithLabelValues(string(s.ConfigBackup.Spec.Type)).Observe(float64(*file.SizeBytes)) } } + if s.ConfigBackup.Spec.S3 != nil && s.ConfigBackup.Spec.S3.Encryption != nil { + s.ConfigBackup.Status.LastBackup.EncryptionAlgorithm = s.ConfigBackup.Spec.S3.Encryption.Algorithm + s.ConfigBackup.Status.LastBackup.EncryptionKeySecret = s.ConfigBackup.Spec.S3.Encryption.KeySecret.Name + } r.Recorder.Eventf(s.ConfigBackup, nil, "Normal", "BackupSuccessful", "Reconcile", "Backup completed successfully") @@ -571,6 +580,32 @@ func (r *ConfigBackupReconciler) CreateRemoteConfigBackup(ctx context.Context, s if err != nil { return nil, fmt.Errorf("failed to get running config: %w", err) } + if enc := s.ConfigBackup.Spec.S3.Encryption; enc != nil { + key, err := clientutil.NewClient(r.Client, s.ConfigBackup.Namespace).Secret(ctx, &enc.KeySecret) + if err != nil { + if apierrors.IsNotFound(err) { + conditions.Set(s.ConfigBackup, metav1.Condition{ + Type: v1alpha1.ReadyCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.SecretNotFoundReason, + Message: fmt.Sprintf("encryption key secret %q not found", enc.KeySecret.Name), + }) + return nil, reconcile.TerminalError(err) + } + return nil, fmt.Errorf("failed to resolve encryption key: %w", err) + } + data, err = encrypt(data, enc.Algorithm, key) + if err != nil { + conditions.Set(s.ConfigBackup, metav1.Condition{ + Type: v1alpha1.ReadyCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.EncryptionFailedReason, + Message: err.Error(), + }) + r.Recorder.Eventf(s.ConfigBackup, nil, "Warning", "EncryptionFailed", "Reconcile", "Failed to encrypt backup: %v", err) + return nil, reconcile.TerminalError(fmt.Errorf("failed to encrypt backup: %w", err)) + } + } now := time.Now().UTC() key := fmt.Sprintf( "%sconfigbackup-%s-%s-%s", @@ -623,6 +658,36 @@ func (r *ConfigBackupReconciler) DeleteRemoteConfigBackups(ctx context.Context, return store.DeleteObjects(ctx, s.ConfigBackup.Spec.S3.Bucket, keys...) } +// encrypt performs encryption of data before uploading to remote storage. +// The nonce is prepended to the ciphertext. +func encrypt(data []byte, algorithm v1alpha1.EncryptionAlgorithm, key []byte) ([]byte, error) { + var c cipher.AEAD + switch algorithm { + case v1alpha1.EncryptionAES256GCM: + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("failed to create AES cipher: %w", err) + } + c, err = cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("failed to create GCM: %w", err) + } + case v1alpha1.EncryptionChaCha20Poly1305: + var err error + c, err = chacha20poly1305.New(key) + if err != nil { + return nil, fmt.Errorf("failed to create ChaCha20-Poly1305 cipher: %w", err) + } + default: + return nil, fmt.Errorf("unsupported encryption algorithm: %s", algorithm) + } + nonce := make([]byte, c.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("failed to generate nonce: %w", err) + } + return c.Seal(nonce, nonce, data, nil), nil +} + func (r *ConfigBackupReconciler) finalize(_ context.Context, _ *configBackupScope) (reterr error) { return nil } diff --git a/internal/controller/core/configbackup_controller_test.go b/internal/controller/core/configbackup_controller_test.go index d6a309097..f783b4da9 100644 --- a/internal/controller/core/configbackup_controller_test.go +++ b/internal/controller/core/configbackup_controller_test.go @@ -5,6 +5,8 @@ package core import ( "context" + "crypto/aes" + "crypto/cipher" "strings" "sync" "time" @@ -340,6 +342,99 @@ var _ = Describe("ConfigBackup Controller", func() { Expect(body).NotTo(BeEmpty()) } }) + + It("Should successfully reconcile an encrypted remote backup to S3", func() { + By("Creating a Secret with S3 credentials") + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "s3-creds-enc-", + Namespace: metav1.NamespaceDefault, + }, + Data: map[string][]byte{ + "accessKeyID": []byte("EXAMPLEACCESSKEY"), + "secretAccessKey": []byte("EXAMPLESECRETKEY"), + }, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + + By("Creating a Secret with a 32-byte encryption key") + encSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "enc-key-", + Namespace: metav1.NamespaceDefault, + }, + Data: map[string][]byte{ + "encryption-key": []byte("0123456789abcdef0123456789abcdef"), // 32 bytes + }, + } + Expect(k8sClient.Create(ctx, encSecret)).To(Succeed()) + + By("Resetting the mock object storage") + testS3Store.Lock() + testS3Store.Objects = make(map[string][]byte) + testS3Store.Unlock() + + By("Creating a Remote ConfigBackup resource with encryption") + backup = &v1alpha1.ConfigBackup{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-configbackup-remote-enc-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.ConfigBackupSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: device.Name}, + Type: v1alpha1.ConfigBackupTypeRemote, + Path: "encrypted/", + S3: &v1alpha1.ConfigBackupS3{ + Endpoint: "https://s3.example.com", + Bucket: "network-config-backups", + CredentialsSecretRef: v1alpha1.SecretReference{Name: secret.Name}, + Encryption: &v1alpha1.ConfigBackupEncryption{ + Algorithm: v1alpha1.EncryptionAES256GCM, + KeySecret: v1alpha1.SecretKeySelector{ + SecretReference: v1alpha1.SecretReference{Name: encSecret.Name}, + Key: "encryption-key", + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, backup)).To(Succeed()) + + By("Verifying the backup status is populated") + Eventually(func(g Gomega) { + resource := &v1alpha1.ConfigBackup{} + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(backup), resource)).To(Succeed()) + g.Expect(resource.Status.LastBackup).NotTo(BeNil()) + g.Expect(resource.Status.LastBackup.Filepath).To(HavePrefix("s3://network-config-backups/encrypted/configbackup-")) + g.Expect(resource.Status.LastBackup.SizeBytes).NotTo(BeNil()) + g.Expect(*resource.Status.LastBackup.SizeBytes).To(BeNumerically(">", 0)) + + g.Expect(resource.Status.LastBackup.EncryptionAlgorithm).To(Equal(v1alpha1.EncryptionAES256GCM)) + g.Expect(resource.Status.LastBackup.EncryptionKeySecret).To(Equal(encSecret.Name)) + + cond := meta.FindStatusCondition(resource.Status.Conditions, v1alpha1.ReadyCondition) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + g.Expect(cond.Reason).To(Equal(v1alpha1.BackupSuccessfulReason)) + }).Should(Succeed()) + + By("Verifying the uploaded data can be decrypted to the original config") + Expect(testS3Store.Objects).To(HaveLen(1)) + for _, body := range testS3Store.Objects { + Expect(body).NotTo(BeEmpty()) + // Decrypt using AES-256-GCM + block, err := aes.NewCipher([]byte("0123456789abcdef0123456789abcdef")) + Expect(err).NotTo(HaveOccurred()) + gcm, err := cipher.NewGCM(block) + Expect(err).NotTo(HaveOccurred()) + nonceSize := gcm.NonceSize() + Expect(len(body)).To(BeNumerically(">", nonceSize)) + nonce, ciphertext := body[:nonceSize], body[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + Expect(err).NotTo(HaveOccurred()) + Expect(string(plaintext)).To(ContainSubstring("running-config mock")) + } + }) }) }) From d200242292ce63ed08c5b9a0576e5014d6d7dc28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Mon, 10 Aug 2026 18:28:15 +0200 Subject: [PATCH 6/8] Add decrypt-backup tool for retrieving remote backups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add hack/decrypt-backup, a CLI tool that downloads and decrypts a remote ConfigBackup from S3. It reads the ConfigBackup resource to determine the S3 endpoint, credentials, encryption settings, and the object key from status.lastBackup.filepath. Also adds GetObject to the objectstorage client for downloading objects from S3. Signed-off-by: Felix Kästner --- hack/decrypt-backup/main.go | 181 +++++++++++++++++++++++++++++++++++ internal/objectstorage/s3.go | 14 +++ 2 files changed, 195 insertions(+) create mode 100644 hack/decrypt-backup/main.go diff --git a/hack/decrypt-backup/main.go b/hack/decrypt-backup/main.go new file mode 100644 index 000000000..ed569e7c6 --- /dev/null +++ b/hack/decrypt-backup/main.go @@ -0,0 +1,181 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +// Command decrypt-backup retrieves a remote ConfigBackup from S3 and decrypts it. +// +// Usage: +// +// go run ./hack/decrypt-backup [-n namespace] [-o output-file] +package main + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "errors" + "flag" + "fmt" + "os" + "os/signal" + "strings" + + "golang.org/x/crypto/chacha20poly1305" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/config" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + controllerv1alpha1 "github.com/ironcore-dev/network-operator/internal/controller/core" + "github.com/ironcore-dev/network-operator/internal/objectstorage" +) + +func usage() { + fmt.Fprintf(os.Stderr, "Usage: decrypt-backup [-n namespace] [-o output-file] \n") + flag.PrintDefaults() +} + +func main() { + namespace := flag.String("n", "default", "namespace of the ConfigBackup resource") + output := flag.String("o", "", "output file (default: stdout)") + flag.Usage = usage + flag.Parse() + + if flag.NArg() != 1 { + usage() + os.Exit(1) + } + name := flag.Arg(0) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + + if err := run(ctx, name, *namespace, *output); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + stop() + os.Exit(1) + } + stop() +} + +func run(ctx context.Context, name, namespace, output string) error { + if err := v1alpha1.AddToScheme(scheme.Scheme); err != nil { + return fmt.Errorf("failed to register scheme: %w", err) + } + + cfg, err := config.GetConfig() + if err != nil { + return fmt.Errorf("failed to get kubeconfig: %w", err) + } + + k8s, err := client.New(cfg, client.Options{Scheme: scheme.Scheme}) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + var backup v1alpha1.ConfigBackup + if err := k8s.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, &backup); err != nil { + return fmt.Errorf("failed to get ConfigBackup %s/%s: %w", namespace, name, err) + } + + if backup.Spec.Type != v1alpha1.ConfigBackupTypeRemote { + return fmt.Errorf("ConfigBackup %s is not of type Remote (got %s)", name, backup.Spec.Type) + } + if backup.Spec.S3 == nil { + return fmt.Errorf("ConfigBackup %s has no S3 configuration", name) + } + if backup.Status.LastBackup == nil { + return fmt.Errorf("ConfigBackup %s has no successful backup yet", name) + } + + ref := backup.Spec.S3.CredentialsSecretRef + if ref.Namespace == "" { + ref.Namespace = namespace + } + + var secret corev1.Secret + if err := k8s.Get(ctx, types.NamespacedName{Name: ref.Name, Namespace: ref.Namespace}, &secret); err != nil { + return fmt.Errorf("failed to get S3 credentials secret: %w", err) + } + + store := objectstorage.NewClient(objectstorage.Options{ + Endpoint: backup.Spec.S3.Endpoint, + Region: backup.Spec.S3.Region, + AccessKeyID: string(secret.Data[controllerv1alpha1.S3AccessKeyID]), + SecretAccessKey: string(secret.Data[controllerv1alpha1.S3SecretAccessKey]), + }) + + filepath := backup.Status.LastBackup.Filepath + prefix := fmt.Sprintf("s3://%s/", backup.Spec.S3.Bucket) + if !strings.HasPrefix(filepath, prefix) { + return fmt.Errorf("unexpected filepath format: %s", filepath) + } + key := strings.TrimPrefix(filepath, prefix) + + data, err := store.GetObject(ctx, backup.Spec.S3.Bucket, key) + if err != nil { + return fmt.Errorf("failed to download %s: %w", filepath, err) + } + fmt.Fprintf(os.Stderr, "Downloaded %s (%d bytes)\n", filepath, len(data)) + + if backup.Spec.S3.Encryption != nil { + enc := backup.Spec.S3.Encryption + ns := enc.KeySecret.Namespace + if ns == "" { + ns = namespace + } + var encSecret corev1.Secret + if err := k8s.Get(ctx, types.NamespacedName{Name: enc.KeySecret.Name, Namespace: ns}, &encSecret); err != nil { + return fmt.Errorf("failed to get encryption key secret: %w", err) + } + encKey, ok := encSecret.Data[enc.KeySecret.Key] + if !ok { + return fmt.Errorf("encryption key secret missing key %q", enc.KeySecret.Key) + } + + data, err = decrypt(data, enc.Algorithm, encKey) + if err != nil { + return fmt.Errorf("failed to decrypt backup: %w", err) + } + fmt.Fprintf(os.Stderr, "Decrypted successfully (%d bytes plaintext)\n", len(data)) + } + + if output == "" { + _, err = os.Stdout.Write(data) + } else { + err = os.WriteFile(output, data, 0o644) + if err == nil { + fmt.Fprintf(os.Stderr, "Written to %s\n", output) + } + } + return err +} + +func decrypt(data []byte, algorithm v1alpha1.EncryptionAlgorithm, key []byte) ([]byte, error) { + var aead cipher.AEAD + switch algorithm { + case v1alpha1.EncryptionAES256GCM: + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + aead, err = cipher.NewGCM(block) + if err != nil { + return nil, err + } + case v1alpha1.EncryptionChaCha20Poly1305: + var err error + aead, err = chacha20poly1305.New(key) + if err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("unsupported algorithm: %s", algorithm) + } + + if len(data) < aead.NonceSize() { + return nil, errors.New("ciphertext too short") + } + nonce, ciphertext := data[:aead.NonceSize()], data[aead.NonceSize():] + return aead.Open(nil, nonce, ciphertext, nil) +} diff --git a/internal/objectstorage/s3.go b/internal/objectstorage/s3.go index abfcd0d73..2fd7ac7bd 100644 --- a/internal/objectstorage/s3.go +++ b/internal/objectstorage/s3.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "fmt" + "io" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -66,6 +67,19 @@ func (c *Client) HeadBucket(ctx context.Context, bucket string) error { return nil } +// GetObject downloads an object from the store and returns its body. +func (c *Client) GetObject(ctx context.Context, bucket, key string) ([]byte, error) { + out, err := c.s3.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + }) + if err != nil { + return nil, fmt.Errorf("failed to get object s3://%s/%s: %w", bucket, key, err) + } + defer out.Body.Close() + return io.ReadAll(out.Body) +} + // PutObject uploads a byte slice to the configured S3-compatible store. func (c *Client) PutObject(ctx context.Context, obj *Object) error { _, err := c.s3.PutObject(ctx, &s3.PutObjectInput{ From ca2a2c4e8f58cc10769a192df7409295fcfa5ab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Mon, 10 Aug 2026 18:32:39 +0200 Subject: [PATCH 7/8] Update config backup documentation with Remote type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the new Remote backup type including S3 configuration, encrypted backups, supported algorithms, status conditions, and the decrypt-backup tool usage. Signed-off-by: Felix Kästner --- docs/concepts/config-backup.md | 85 +++++++++++++++++++++++++++++----- 1 file changed, 73 insertions(+), 12 deletions(-) diff --git a/docs/concepts/config-backup.md b/docs/concepts/config-backup.md index 7553378a5..3f02a6836 100644 --- a/docs/concepts/config-backup.md +++ b/docs/concepts/config-backup.md @@ -1,22 +1,22 @@ # Config Backups -`ConfigBackup` defines an on-device configuration backup policy for a `Device`. +`ConfigBackup` defines a configuration backup policy for a `Device`. -The controller can either: +The controller supports three backup types: -- write timestamped backups to a device-local filesystem path, or -- persist the running configuration as the device startup configuration - -This resource is intended for fast local restore workflows and for auditing recent configuration history directly on the device. +- **Local** — write timestamped backups to a device-local filesystem path +- **Startup** — persist the running configuration as the device startup configuration +- **Remote** — upload the running configuration to an S3-compatible object store ## Key Behaviors - Optional cron-based scheduling for recurring backups - One-shot backups when `spec.schedule` is omitted -- Automatic rotation of old local backup files -- Device storage threshold checks before writing new backups +- Automatic rotation of old backups (Local and Remote) +- Device storage threshold checks before writing new local backups +- Remote endpoint health check (`RemoteEndpointReady` condition) +- Optional client-side encryption for remote backups (AES-256-GCM or ChaCha20-Poly1305) - Status reporting for last backup result, next scheduled backup, and discovered backup inventory -- Best-effort checksum reporting when the implementation can retrieve it ## Local Backup Example @@ -30,7 +30,7 @@ spec: name: leaf-switch-1 schedule: "0 2 * * *" type: Local - path: "" + path: "bootflash:///backups/" retention: keepLast: 5 storageThreshold: @@ -50,9 +50,70 @@ spec: type: Startup ``` +## Remote Backup Example + +```yaml +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: ConfigBackup +metadata: + name: leaf-1-remote +spec: + deviceRef: + name: leaf-switch-1 + schedule: "0 */4 * * *" + type: Remote + path: "leaf-1/" + retention: + keepLast: 10 + s3: + endpoint: "https://s3.eu-central-1.amazonaws.com" + bucket: network-config-backups + region: eu-central-1 + credentialsSecretRef: + name: s3-backup-credentials +``` + +The `credentialsSecretRef` must point to a Secret containing `accessKeyID` and `secretAccessKey` keys. + +### Encrypted Remote Backup + +To enable encryption, add the `encryption` field to the S3 configuration: + +```diff + s3: + endpoint: "https://s3.eu-central-1.amazonaws.com" + bucket: network-config-backups + region: eu-central-1 + credentialsSecretRef: + name: s3-backup-credentials ++ encryption: ++ algorithm: AES-256-GCM ++ keySecret: ++ name: backup-encryption-key ++ key: encryption-key +``` + +Supported algorithms: + +| Algorithm | Key Size | Description | +| ------------------- | -------- | -------------------------------------------------- | +| `AES-256-GCM` | 32 bytes | AES-256 in Galois/Counter Mode | +| `ChaCha20-Poly1305` | 32 bytes | ChaCha20 stream cipher with Poly1305 authenticator | + +Encryption is performed in the controller pod before upload. The nonce is prepended to the ciphertext. The `status.lastBackup` reports which algorithm and key Secret were used. + +## Status Conditions + +| Condition | Description | +| --------------------- | -------------------------------------------------- | +| `Ready` | Whether the last backup operation succeeded | +| `RemoteEndpointReady` | Whether the S3 endpoint is reachable (Remote only) | + ## Notes - `Startup` backups always keep a single logical copy. - Local backup rotation only applies to `type: Local`. -- `spec.path` is interpreted by the backing implementation and may use provider-specific device-local path formats. -- `checksum` and `sizeBytes` are optional status fields and depend on what the implementation can retrieve from the device. +- Remote backup rotation uses S3 ListObjects/DeleteObjects to enforce `retention.keepLast`. +- `spec.path` is the device-local path for Local backups, or the S3 key prefix for Remote backups. +- `storageThreshold` only applies to Local backups (S3 does not expose free-space information). +- The controller watches referenced Secrets and re-reconciles when they are created or updated. From b97436d11806530b90f3fb599aa8178ba7a34ba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Tue, 11 Aug 2026 16:09:06 +0200 Subject: [PATCH 8/8] Add namespace to configbackup size metric MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prefix the configbackup_size_bytes histogram with the network_operator namespace for consistent metric naming. Signed-off-by: Felix Kästner --- internal/controller/core/configbackup_metrics.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/controller/core/configbackup_metrics.go b/internal/controller/core/configbackup_metrics.go index a1e98092f..adf11f66c 100644 --- a/internal/controller/core/configbackup_metrics.go +++ b/internal/controller/core/configbackup_metrics.go @@ -10,9 +10,10 @@ import ( var configBackupSizeBytes = prometheus.NewHistogramVec( prometheus.HistogramOpts{ - Name: "configbackup_size_bytes", - Help: "Observed size of successful config backups.", - Buckets: []float64{1 << 10, 1 << 12, 1 << 14, 1 << 16, 1 << 18, 1 << 20, 1 << 22, 1 << 24, 1 << 26}, + Namespace: "network_operator", + Name: "configbackup_size_bytes", + Help: "Observed size of successful config backups.", + Buckets: []float64{1 << 10, 1 << 12, 1 << 14, 1 << 16, 1 << 18, 1 << 20, 1 << 22, 1 << 24, 1 << 26}, }, []string{"type"}, )