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)
+ }
+ }
})
}
}