diff --git a/Tiltfile b/Tiltfile index 1b2f5e06f..22e1a9ce7 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', '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 2d6835921..840627ea9 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,58 @@ 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"` + + // 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. type ConfigBackupStatus struct { // Conditions represent the current state of the ConfigBackup resource. @@ -164,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. @@ -217,12 +285,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 +295,24 @@ 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) + if c.Spec.S3.Encryption != nil { + refs = append(refs, c.Spec.S3.Encryption.KeySecret.SecretReference) + } + } + 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..3656db023 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. @@ -249,6 +253,12 @@ const ( const ( // PrefixSetNotFoundReason indicates that a referenced PrefixSet was not found. 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" + // 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 fd53437d6..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 @@ -1346,6 +1362,27 @@ 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 + 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. +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 +1402,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) + (*in).DeepCopyInto(*out) + } } // 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..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 @@ -138,6 +138,100 @@ 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 + 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"). + 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 +264,7 @@ spec: enum: - Local - Startup + - Remote type: string required: - deviceRef @@ -181,10 +276,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. @@ -266,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 f62aa5bf6..2315abfc6 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_configbackups.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_configbackups.yaml @@ -135,6 +135,100 @@ 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 + 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"). + 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 +261,7 @@ spec: enum: - Local - Startup + - Remote type: string required: - deviceRef @@ -178,10 +273,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. @@ -263,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/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..a55ba2aba 100644 --- a/config/samples/v1alpha1_configbackup.yaml +++ b/config/samples/v1alpha1_configbackup.yaml @@ -21,3 +21,34 @@ 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 + 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 c8d9d1f22..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,28 @@ _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 + + + +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: \{\}
| +| `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 @@ -1426,10 +1465,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 +1539,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 +1548,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 @@ -1973,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 @@ -3715,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) @@ -3739,6 +3800,7 @@ in any namespace. _Appears in:_ - [CertificateSource](#certificatesource) - [CertificateSpec](#certificatespec) +- [ConfigBackupS3](#configbackups3) - [Endpoint](#endpoint) - [SecretKeySelector](#secretkeyselector) 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. 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/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/controller/core/configbackup_controller.go b/internal/controller/core/configbackup_controller.go index 74c9ba653..67b6d74f7 100644 --- a/internal/controller/core/configbackup_controller.go +++ b/internal/controller/core/configbackup_controller.go @@ -5,12 +5,17 @@ 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" "k8s.io/apimachinery/pkg/api/meta" @@ -32,8 +37,10 @@ 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" "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 +63,23 @@ 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 { + 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 } // +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 +265,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 +295,47 @@ 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 + } + 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 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 +388,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 +438,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 +463,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 @@ -416,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") @@ -430,7 +507,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 +528,166 @@ 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) + } + 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", + 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...) +} + +// 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 } @@ -514,3 +757,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..f783b4da9 100644 --- a/internal/controller/core/configbackup_controller_test.go +++ b/internal/controller/core/configbackup_controller_test.go @@ -4,16 +4,23 @@ package core import ( + "context" + "crypto/aes" + "crypto/cipher" + "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 +281,206 @@ 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)) + + 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") + Expect(testS3Store.Objects).To(HaveLen(1)) + for key, body := range testS3Store.Objects { + Expect(key).To(HavePrefix("leaf-1/configbackup-")) + 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")) + } + }) }) }) + +// 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) HeadBucket(_ context.Context, _ string) error { + return nil +} + +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/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"}, ) 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..2fd7ac7bd --- /dev/null +++ b/internal/objectstorage/s3.go @@ -0,0 +1,134 @@ +// 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" + "io" + "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 +} + +// 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 +} + +// 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{ + 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. 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) + } + } }) } }