From 8734c38da37e01c6e95c0adc0e1338a04da277b9 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Mon, 20 Apr 2026 15:53:41 +0300 Subject: [PATCH 01/16] chore(etcd-operator): update vendored chart to v0.4.3 Upstream release notes: https://github.com/aenix-io/etcd-operator/releases/tag/v0.4.3 Notable changes: - Add EtcdBackup CRD for declarative etcd snapshots (#307) - Add EtcdBackupSchedule CRD for scheduled backups (#307) - Expose metrics port on client service (#299) - Replace deprecated gcr.io/kubebuilder/kube-rbac-proxy with quay.io/brancz/kube-rbac-proxy:v0.18.1 (#308) - Update Kamaji example manifest Note: Helm 3 does not install CRDs on upgrade. Existing clusters require manual application of the new EtcdBackup and EtcdBackupSchedule CRDs: kubectl apply --filename packages/system/etcd-operator/charts/etcd-operator/crds/ Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- .../charts/etcd-operator/Chart.yaml | 4 +- .../crds/etcd-backup-schedule.yaml | 227 ++++++++++++++++++ .../etcd-operator/crds/etcd-backup.yaml | 206 ++++++++++++++++ .../etcd-operator/crds/etcd-cluster.yaml | 74 ++++++ .../validatingwebhookconfiguration.yml | 40 +++ .../rbac/clusterrole-manager-role.yml | 70 ++++++ .../templates/workload/deployment.yml | 2 + 7 files changed, 621 insertions(+), 2 deletions(-) create mode 100644 packages/system/etcd-operator/charts/etcd-operator/crds/etcd-backup-schedule.yaml create mode 100644 packages/system/etcd-operator/charts/etcd-operator/crds/etcd-backup.yaml diff --git a/packages/system/etcd-operator/charts/etcd-operator/Chart.yaml b/packages/system/etcd-operator/charts/etcd-operator/Chart.yaml index 9ab731ce..44504579 100644 --- a/packages/system/etcd-operator/charts/etcd-operator/Chart.yaml +++ b/packages/system/etcd-operator/charts/etcd-operator/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 -appVersion: v0.4.2 +appVersion: v0.4.3 name: etcd-operator type: application -version: 0.4.2 +version: 0.4.3 diff --git a/packages/system/etcd-operator/charts/etcd-operator/crds/etcd-backup-schedule.yaml b/packages/system/etcd-operator/charts/etcd-operator/crds/etcd-backup-schedule.yaml new file mode 100644 index 00000000..465aa992 --- /dev/null +++ b/packages/system/etcd-operator/charts/etcd-operator/crds/etcd-backup-schedule.yaml @@ -0,0 +1,227 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: etcd-operator-system/etcd-operator-serving-cert + controller-gen.kubebuilder.io/version: v0.15.0 + name: etcdbackupschedules.etcd.aenix.io +spec: + conversion: + strategy: None + group: etcd.aenix.io + names: + kind: EtcdBackupSchedule + listKind: EtcdBackupScheduleList + plural: etcdbackupschedules + singular: etcdbackupschedule + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.clusterRef.name + name: Cluster + type: string + - jsonPath: .spec.schedule + name: Schedule + type: string + - jsonPath: .status.lastSuccessfulBackupTime + name: Last Backup + type: date + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: EtcdBackupSchedule is the Schema for the etcdbackupschedules API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: EtcdBackupScheduleSpec defines the desired state of EtcdBackupSchedule + properties: + clusterRef: + description: ClusterRef references the EtcdCluster to back up. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + type: object + x-kubernetes-map-type: atomic + destination: + description: Destination defines where the backup will be stored. + properties: + pvc: + description: PVC defines a PersistentVolumeClaim as the backup destination. + properties: + claimName: + description: ClaimName is the name of the PersistentVolumeClaim to use. + type: string + subPath: + description: |- + SubPath is an optional sub-directory within the PVC volume. + The operator appends the backup filename automatically. + type: string + required: + - claimName + type: object + s3: + description: S3 defines S3-compatible storage as the backup destination. + properties: + bucket: + description: Bucket is the name of the S3 bucket. + type: string + credentialsSecretRef: + description: CredentialsSecretRef references a Secret containing AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY keys. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + type: object + x-kubernetes-map-type: atomic + endpoint: + description: Endpoint is the S3-compatible endpoint URL (e.g., "https://s3.amazonaws.com"). + type: string + forcePathStyle: + description: |- + ForcePathStyle forces path-style S3 URLs (e.g., endpoint/bucket/key) + instead of virtual-hosted-style (e.g., bucket.endpoint/key). + Most S3-compatible providers (MinIO, Ceph) require path style. + type: boolean + key: + description: |- + Key is the key prefix (directory path) within the bucket. + The operator appends the backup filename automatically. + type: string + region: + description: Region is the AWS region for the S3 bucket. + type: string + required: + - bucket + - credentialsSecretRef + - endpoint + type: object + type: object + failedJobsHistoryLimit: + description: FailedJobsHistoryLimit is the number of failed finished CronJob children to retain. + format: int32 + minimum: 0 + type: integer + schedule: + description: Schedule is a cron expression defining when backups should be taken. + type: string + successfulJobsHistoryLimit: + description: SuccessfulJobsHistoryLimit is the number of successful finished CronJob children to retain. + format: int32 + minimum: 0 + type: integer + required: + - clusterRef + - destination + - schedule + type: object + status: + description: EtcdBackupScheduleStatus defines the observed state of EtcdBackupSchedule + properties: + conditions: + items: + description: "Condition contains details for one aspect of the current state of this API Resource.\n---\nThis struct is intended for direct use as an array at the field path .status.conditions. For example,\n\n\n\ttype FooStatus struct{\n\t // Represents the observations of a foo's current state.\n\t // Known .status.conditions.type are: \"Available\", \"Progressing\", and \"Degraded\"\n\t // +patchMergeKey=type\n\t // +patchStrategy=merge\n\t // +listType=map\n\t // +listMapKey=type\n\t Conditions []metav1.Condition `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"`\n\n\n\t // other fields\n\t}" + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: |- + type of condition in CamelCase or in foo.example.com/CamelCase. + --- + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be + useful (see .node.status.conditions), the ability to deconflict is important. + The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastScheduleTime: + format: date-time + type: string + lastSuccessfulBackupTime: + format: date-time + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/packages/system/etcd-operator/charts/etcd-operator/crds/etcd-backup.yaml b/packages/system/etcd-operator/charts/etcd-operator/crds/etcd-backup.yaml new file mode 100644 index 00000000..c94c56ea --- /dev/null +++ b/packages/system/etcd-operator/charts/etcd-operator/crds/etcd-backup.yaml @@ -0,0 +1,206 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: etcd-operator-system/etcd-operator-serving-cert + controller-gen.kubebuilder.io/version: v0.15.0 + name: etcdbackups.etcd.aenix.io +spec: + conversion: + strategy: None + group: etcd.aenix.io + names: + kind: EtcdBackup + listKind: EtcdBackupList + plural: etcdbackups + singular: etcdbackup + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.clusterRef.name + name: Cluster + type: string + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: EtcdBackup is the Schema for the etcdbackups API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: EtcdBackupSpec defines the desired state of EtcdBackup + properties: + clusterRef: + description: ClusterRef references the EtcdCluster to back up. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + type: object + x-kubernetes-map-type: atomic + destination: + description: Destination defines where the backup will be stored. + properties: + pvc: + description: PVC defines a PersistentVolumeClaim as the backup destination. + properties: + claimName: + description: ClaimName is the name of the PersistentVolumeClaim to use. + type: string + subPath: + description: |- + SubPath is an optional sub-directory within the PVC volume. + The operator appends the backup filename automatically. + type: string + required: + - claimName + type: object + s3: + description: S3 defines S3-compatible storage as the backup destination. + properties: + bucket: + description: Bucket is the name of the S3 bucket. + type: string + credentialsSecretRef: + description: CredentialsSecretRef references a Secret containing AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY keys. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + type: object + x-kubernetes-map-type: atomic + endpoint: + description: Endpoint is the S3-compatible endpoint URL (e.g., "https://s3.amazonaws.com"). + type: string + forcePathStyle: + description: |- + ForcePathStyle forces path-style S3 URLs (e.g., endpoint/bucket/key) + instead of virtual-hosted-style (e.g., bucket.endpoint/key). + Most S3-compatible providers (MinIO, Ceph) require path style. + type: boolean + key: + description: |- + Key is the key prefix (directory path) within the bucket. + The operator appends the backup filename automatically. + type: string + region: + description: Region is the AWS region for the S3 bucket. + type: string + required: + - bucket + - credentialsSecretRef + - endpoint + type: object + type: object + required: + - clusterRef + - destination + type: object + status: + description: EtcdBackupStatus defines the observed state of EtcdBackup + properties: + conditions: + items: + description: "Condition contains details for one aspect of the current state of this API Resource.\n---\nThis struct is intended for direct use as an array at the field path .status.conditions. For example,\n\n\n\ttype FooStatus struct{\n\t // Represents the observations of a foo's current state.\n\t // Known .status.conditions.type are: \"Available\", \"Progressing\", and \"Degraded\"\n\t // +patchMergeKey=type\n\t // +patchStrategy=merge\n\t // +listType=map\n\t // +listMapKey=type\n\t Conditions []metav1.Condition `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"`\n\n\n\t // other fields\n\t}" + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: |- + type of condition in CamelCase or in foo.example.com/CamelCase. + --- + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be + useful (see .node.status.conditions), the ability to deconflict is important. + The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + phase: + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/packages/system/etcd-operator/charts/etcd-operator/crds/etcd-cluster.yaml b/packages/system/etcd-operator/charts/etcd-operator/crds/etcd-cluster.yaml index 279e4b63..53bc4f00 100644 --- a/packages/system/etcd-operator/charts/etcd-operator/crds/etcd-cluster.yaml +++ b/packages/system/etcd-operator/charts/etcd-operator/crds/etcd-cluster.yaml @@ -49,6 +49,80 @@ spec: spec: description: EtcdClusterSpec defines the desired state of EtcdCluster properties: + bootstrap: + description: |- + Bootstrap defines initialization from an existing data source (e.g., snapshot restore). + This is only used during initial cluster creation and is ignored afterward. + properties: + restore: + description: Restore configures cluster initialization from an etcd snapshot. + properties: + source: + description: Source defines where to get the snapshot for restoration. + properties: + pvc: + description: PVC defines a PersistentVolumeClaim as the backup destination. + properties: + claimName: + description: ClaimName is the name of the PersistentVolumeClaim to use. + type: string + subPath: + description: |- + SubPath is an optional sub-directory within the PVC volume. + The operator appends the backup filename automatically. + type: string + required: + - claimName + type: object + s3: + description: S3 defines S3-compatible storage as the backup destination. + properties: + bucket: + description: Bucket is the name of the S3 bucket. + type: string + credentialsSecretRef: + description: CredentialsSecretRef references a Secret containing AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY keys. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + TODO: Add other useful fields. apiVersion, kind, uid? + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Drop `kubebuilder:default` when controller-gen doesn't need it https://github.com/kubernetes-sigs/kubebuilder/issues/3896. + type: string + type: object + x-kubernetes-map-type: atomic + endpoint: + description: Endpoint is the S3-compatible endpoint URL (e.g., "https://s3.amazonaws.com"). + type: string + forcePathStyle: + description: |- + ForcePathStyle forces path-style S3 URLs (e.g., endpoint/bucket/key) + instead of virtual-hosted-style (e.g., bucket.endpoint/key). + Most S3-compatible providers (MinIO, Ceph) require path style. + type: boolean + key: + description: |- + Key is the key prefix (directory path) within the bucket. + The operator appends the backup filename automatically. + type: string + region: + description: Region is the AWS region for the S3 bucket. + type: string + required: + - bucket + - credentialsSecretRef + - endpoint + type: object + type: object + required: + - source + type: object + type: object headlessServiceTemplate: description: HeadlessService defines the desired state of HeadlessService for etcd members. If not specified, default values will be used. properties: diff --git a/packages/system/etcd-operator/charts/etcd-operator/templates/cert-manager/validatingwebhookconfiguration.yml b/packages/system/etcd-operator/charts/etcd-operator/templates/cert-manager/validatingwebhookconfiguration.yml index 0c2f7c51..4ba1bb67 100644 --- a/packages/system/etcd-operator/charts/etcd-operator/templates/cert-manager/validatingwebhookconfiguration.yml +++ b/packages/system/etcd-operator/charts/etcd-operator/templates/cert-manager/validatingwebhookconfiguration.yml @@ -7,6 +7,46 @@ metadata: {{- include "etcd-operator.labels" . | nindent 4 }} name: {{ include "etcd-operator.fullname" . }}-validating-webhook-configuration webhooks: + - admissionReviewVersions: + - v1 + clientConfig: + service: + name: {{ include "etcd-operator.fullname" . }}-webhook-service + namespace: {{ .Release.Namespace }} + path: /validate-etcd-aenix-io-v1alpha1-etcdbackup + failurePolicy: Fail + name: vetcdbackup.kb.io + rules: + - apiGroups: + - etcd.aenix.io + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - etcdbackups + sideEffects: None + - admissionReviewVersions: + - v1 + clientConfig: + service: + name: {{ include "etcd-operator.fullname" . }}-webhook-service + namespace: {{ .Release.Namespace }} + path: /validate-etcd-aenix-io-v1alpha1-etcdbackupschedule + failurePolicy: Fail + name: vetcdbackupschedule.kb.io + rules: + - apiGroups: + - etcd.aenix.io + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - etcdbackupschedules + sideEffects: None - admissionReviewVersions: - v1 clientConfig: diff --git a/packages/system/etcd-operator/charts/etcd-operator/templates/rbac/clusterrole-manager-role.yml b/packages/system/etcd-operator/charts/etcd-operator/templates/rbac/clusterrole-manager-role.yml index 825c726c..6d17d7f3 100644 --- a/packages/system/etcd-operator/charts/etcd-operator/templates/rbac/clusterrole-manager-role.yml +++ b/packages/system/etcd-operator/charts/etcd-operator/templates/rbac/clusterrole-manager-role.yml @@ -74,6 +74,76 @@ rules: - get - list - watch + - apiGroups: + - batch + resources: + - cronjobs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - batch + resources: + - jobs + verbs: + - create + - delete + - get + - list + - watch + - apiGroups: + - etcd.aenix.io + resources: + - etcdbackups + verbs: + - get + - list + - patch + - update + - watch + - apiGroups: + - etcd.aenix.io + resources: + - etcdbackups/finalizers + verbs: + - update + - apiGroups: + - etcd.aenix.io + resources: + - etcdbackups/status + verbs: + - get + - patch + - update + - apiGroups: + - etcd.aenix.io + resources: + - etcdbackupschedules + verbs: + - get + - list + - patch + - update + - watch + - apiGroups: + - etcd.aenix.io + resources: + - etcdbackupschedules/finalizers + verbs: + - update + - apiGroups: + - etcd.aenix.io + resources: + - etcdbackupschedules/status + verbs: + - get + - patch + - update - apiGroups: - etcd.aenix.io resources: diff --git a/packages/system/etcd-operator/charts/etcd-operator/templates/workload/deployment.yml b/packages/system/etcd-operator/charts/etcd-operator/templates/workload/deployment.yml index 206860b8..dbaa029f 100644 --- a/packages/system/etcd-operator/charts/etcd-operator/templates/workload/deployment.yml +++ b/packages/system/etcd-operator/charts/etcd-operator/templates/workload/deployment.yml @@ -66,6 +66,8 @@ spec: fieldRef: apiVersion: v1 fieldPath: metadata.namespace + - name: OPERATOR_IMAGE + value: {{ .Values.etcdOperator.image.repository }}:{{ .Values.etcdOperator.image.tag | default .Chart.AppVersion }} volumeMounts: - mountPath: /tmp/k8s-webhook-server/serving-certs name: cert From 51c8192e1ee73dbd6596d25568243d1211554c70 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Mon, 20 Apr 2026 15:56:40 +0300 Subject: [PATCH 02/16] feat(etcd): add S3 backup schedule support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the EtcdBackupSchedule CRD (etcd.aenix.io/v1alpha1) through optional values so operators can declaratively configure periodic S3 backups of the platform etcd cluster. Kamaji stores tenant control-plane etcd data here, so losing this cluster means losing every tenant cluster — a backup schedule is critical. The backup is disabled by default (backup.enabled: false), keeping existing installations unchanged. When enabled, a Secret is created with AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY keys referenced by the new EtcdBackupSchedule resource. Field naming mirrors the existing postgres backup pattern (destinationPath, endpointURL, s3AccessKey, s3SecretKey, schedule) for consistency. Requires etcd-operator v0.4.3+ (shipped in the previous commit). Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- packages/extra/etcd/README.md | 17 ++++ .../extra/etcd/templates/backup-secret.yaml | 10 ++ .../etcd/templates/etcd-backup-schedule.yaml | 30 ++++++ packages/extra/etcd/values.schema.json | 96 +++++++++++++++---- packages/extra/etcd/values.yaml | 29 ++++++ packages/system/etcd-rd/cozyrds/etcd.yaml | 4 +- 6 files changed, 166 insertions(+), 20 deletions(-) create mode 100644 packages/extra/etcd/templates/backup-secret.yaml create mode 100644 packages/extra/etcd/templates/etcd-backup-schedule.yaml diff --git a/packages/extra/etcd/README.md b/packages/extra/etcd/README.md index daf9f114..b946d6c9 100644 --- a/packages/extra/etcd/README.md +++ b/packages/extra/etcd/README.md @@ -13,3 +13,20 @@ | `resources.cpu` | Number of CPU cores allocated. | `quantity` | `1000m` | | `resources.memory` | Amount of memory allocated. | `quantity` | `512Mi` | + +### Backup parameters + +| Name | Description | Type | Value | +| ----------------------------------- | ----------------------------------------------------------------------------- | -------- | ----------------------------------- | +| `backup` | Backup configuration. | `object` | `{}` | +| `backup.enabled` | Enable scheduled S3 backups. | `bool` | `false` | +| `backup.schedule` | Cron schedule for automated backups. | `string` | `0 2 * * *` | +| `backup.destinationPath` | Destination path for backups (e.g. s3://bucket/path/). | `string` | `s3://bucket/path/to/folder/` | +| `backup.endpointURL` | S3 endpoint URL for uploads. | `string` | `http://minio-gateway-service:9000` | +| `backup.region` | S3 region. | `string` | `""` | +| `backup.forcePathStyle` | Use path-style S3 URLs (required for MinIO and most S3-compatible providers). | `bool` | `true` | +| `backup.s3AccessKey` | Access key for S3 authentication. | `string` | `` | +| `backup.s3SecretKey` | Secret key for S3 authentication. | `string` | `` | +| `backup.successfulJobsHistoryLimit` | Number of successful backup jobs to retain. | `int` | `3` | +| `backup.failedJobsHistoryLimit` | Number of failed backup jobs to retain. | `int` | `1` | + diff --git a/packages/extra/etcd/templates/backup-secret.yaml b/packages/extra/etcd/templates/backup-secret.yaml new file mode 100644 index 00000000..15aec23f --- /dev/null +++ b/packages/extra/etcd/templates/backup-secret.yaml @@ -0,0 +1,10 @@ +{{- if .Values.backup.enabled }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Release.Name }}-s3-creds +stringData: + AWS_ACCESS_KEY_ID: {{ required "s3AccessKey is not specified!" .Values.backup.s3AccessKey | quote }} + AWS_SECRET_ACCESS_KEY: {{ required "s3SecretKey is not specified!" .Values.backup.s3SecretKey | quote }} +{{- end }} diff --git a/packages/extra/etcd/templates/etcd-backup-schedule.yaml b/packages/extra/etcd/templates/etcd-backup-schedule.yaml new file mode 100644 index 00000000..1de48ef9 --- /dev/null +++ b/packages/extra/etcd/templates/etcd-backup-schedule.yaml @@ -0,0 +1,30 @@ +{{- if .Values.backup.enabled }} +{{- $path := .Values.backup.destinationPath | trimPrefix "s3://" | trimSuffix "/" -}} +{{- $parts := splitList "/" $path -}} +{{- $bucket := first $parts -}} +{{- $key := rest $parts | join "/" -}} +--- +apiVersion: etcd.aenix.io/v1alpha1 +kind: EtcdBackupSchedule +metadata: + name: {{ .Release.Name }} +spec: + clusterRef: + name: etcd + schedule: {{ .Values.backup.schedule | quote }} + successfulJobsHistoryLimit: {{ .Values.backup.successfulJobsHistoryLimit }} + failedJobsHistoryLimit: {{ .Values.backup.failedJobsHistoryLimit }} + destination: + s3: + endpoint: {{ required "backup.endpointURL is not specified!" .Values.backup.endpointURL | quote }} + bucket: {{ required "backup.destinationPath must contain a bucket (e.g. s3://bucket/path/)" $bucket | quote }} + {{- with $key }} + key: {{ . | quote }} + {{- end }} + {{- with .Values.backup.region }} + region: {{ . | quote }} + {{- end }} + forcePathStyle: {{ .Values.backup.forcePathStyle }} + credentialsSecretRef: + name: {{ .Release.Name }}-s3-creds +{{- end }} diff --git a/packages/extra/etcd/values.schema.json b/packages/extra/etcd/values.schema.json index 3c66664c..94c70cfe 100644 --- a/packages/extra/etcd/values.schema.json +++ b/packages/extra/etcd/values.schema.json @@ -2,24 +2,65 @@ "title": "Chart Values", "type": "object", "properties": { - "size": { - "description": "Persistent Volume size.", - "default": "4Gi", - "pattern": "^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$", - "anyOf": [ - { - "type": "integer" - }, - { - "type": "string" - } + "backup": { + "description": "Backup configuration.", + "type": "object", + "default": {}, + "required": [ + "enabled" ], - "x-kubernetes-int-or-string": true - }, - "storageClass": { - "description": "StorageClass used to store the data.", - "type": "string", - "default": "" + "properties": { + "destinationPath": { + "description": "Destination path for backups (e.g. s3://bucket/path/).", + "type": "string", + "default": "s3://bucket/path/to/folder/" + }, + "enabled": { + "description": "Enable scheduled S3 backups.", + "type": "boolean", + "default": false + }, + "endpointURL": { + "description": "S3 endpoint URL for uploads.", + "type": "string", + "default": "http://minio-gateway-service:9000" + }, + "failedJobsHistoryLimit": { + "description": "Number of failed backup jobs to retain.", + "type": "integer", + "default": 1 + }, + "forcePathStyle": { + "description": "Use path-style S3 URLs (required for MinIO and most S3-compatible providers).", + "type": "boolean", + "default": true + }, + "region": { + "description": "S3 region.", + "type": "string", + "default": "" + }, + "s3AccessKey": { + "description": "Access key for S3 authentication.", + "type": "string", + "default": "\u003cyour-access-key\u003e" + }, + "s3SecretKey": { + "description": "Secret key for S3 authentication.", + "type": "string", + "default": "\u003cyour-secret-key\u003e" + }, + "schedule": { + "description": "Cron schedule for automated backups.", + "type": "string", + "default": "0 2 * * *" + }, + "successfulJobsHistoryLimit": { + "description": "Number of successful backup jobs to retain.", + "type": "integer", + "default": 3 + } + } }, "replicas": { "description": "Number of etcd replicas.", @@ -60,6 +101,25 @@ "x-kubernetes-int-or-string": true } } + }, + "size": { + "description": "Persistent Volume size.", + "default": "4Gi", + "pattern": "^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$", + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ], + "x-kubernetes-int-or-string": true + }, + "storageClass": { + "description": "StorageClass used to store the data.", + "type": "string", + "default": "" } } -} +} \ No newline at end of file diff --git a/packages/extra/etcd/values.yaml b/packages/extra/etcd/values.yaml index 8dac251d..b12054b0 100644 --- a/packages/extra/etcd/values.yaml +++ b/packages/extra/etcd/values.yaml @@ -19,3 +19,32 @@ replicas: 3 resources: cpu: 1000m memory: 512Mi + +## +## @section Backup parameters +## + +## @typedef {struct} Backup - Backup configuration. +## @field {bool} enabled - Enable scheduled S3 backups. +## @field {string} [schedule] - Cron schedule for automated backups. +## @field {string} [destinationPath] - Destination path for backups (e.g. s3://bucket/path/). +## @field {string} [endpointURL] - S3 endpoint URL for uploads. +## @field {string} [region] - S3 region. +## @field {bool} [forcePathStyle] - Use path-style S3 URLs (required for MinIO and most S3-compatible providers). +## @field {string} [s3AccessKey] - Access key for S3 authentication. +## @field {string} [s3SecretKey] - Secret key for S3 authentication. +## @field {int} [successfulJobsHistoryLimit] - Number of successful backup jobs to retain. +## @field {int} [failedJobsHistoryLimit] - Number of failed backup jobs to retain. + +## @param {Backup} backup - Backup configuration. +backup: + enabled: false + schedule: "0 2 * * *" + destinationPath: "s3://bucket/path/to/folder/" + endpointURL: "http://minio-gateway-service:9000" + region: "" + forcePathStyle: true + s3AccessKey: "" + s3SecretKey: "" + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 1 diff --git a/packages/system/etcd-rd/cozyrds/etcd.yaml b/packages/system/etcd-rd/cozyrds/etcd.yaml index 7ff7c39c..90c736e2 100644 --- a/packages/system/etcd-rd/cozyrds/etcd.yaml +++ b/packages/system/etcd-rd/cozyrds/etcd.yaml @@ -8,7 +8,7 @@ spec: plural: etcds singular: etcd openAPISchema: |- - {"title":"Chart Values","type":"object","properties":{"size":{"description":"Persistent Volume size.","default":"4Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"storageClass":{"description":"StorageClass used to store the data.","type":"string","default":""},"replicas":{"description":"Number of etcd replicas.","type":"integer","default":3},"resources":{"description":"Resource configuration for etcd.","type":"object","default":{},"properties":{"cpu":{"description":"Number of CPU cores allocated.","default":"1000m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Amount of memory allocated.","default":"512Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}}}} + {"title":"Chart Values","type":"object","properties":{"backup":{"description":"Backup configuration.","type":"object","default":{},"required":["enabled"],"properties":{"destinationPath":{"description":"Destination path for backups (e.g. s3://bucket/path/).","type":"string","default":"s3://bucket/path/to/folder/"},"enabled":{"description":"Enable scheduled S3 backups.","type":"boolean","default":false},"endpointURL":{"description":"S3 endpoint URL for uploads.","type":"string","default":"http://minio-gateway-service:9000"},"failedJobsHistoryLimit":{"description":"Number of failed backup jobs to retain.","type":"integer","default":1},"forcePathStyle":{"description":"Use path-style S3 URLs (required for MinIO and most S3-compatible providers).","type":"boolean","default":true},"region":{"description":"S3 region.","type":"string","default":""},"s3AccessKey":{"description":"Access key for S3 authentication.","type":"string","default":""},"s3SecretKey":{"description":"Secret key for S3 authentication.","type":"string","default":""},"schedule":{"description":"Cron schedule for automated backups.","type":"string","default":"0 2 * * *"},"successfulJobsHistoryLimit":{"description":"Number of successful backup jobs to retain.","type":"integer","default":3}}},"replicas":{"description":"Number of etcd replicas.","type":"integer","default":3},"resources":{"description":"Resource configuration for etcd.","type":"object","default":{},"properties":{"cpu":{"description":"Number of CPU cores allocated.","default":"1000m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Amount of memory allocated.","default":"512Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"size":{"description":"Persistent Volume size.","default":"4Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"storageClass":{"description":"StorageClass used to store the data.","type":"string","default":""}}} release: prefix: "" labels: @@ -26,7 +26,7 @@ spec: description: Storage for Kubernetes clusters module: true icon: PHN2ZyB3aWR0aD0iMTQ0IiBoZWlnaHQ9IjE0NCIgdmlld0JveD0iMCAwIDE0NCAxNDQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHg9Ii0wLjAwMTk1MzEyIiB3aWR0aD0iMTQ0IiBoZWlnaHQ9IjE0NCIgcng9IjI0IiBmaWxsPSJ1cmwoI3BhaW50MF9saW5lYXJfNjgzXzI5NjMpIi8+CjxwYXRoIGQ9Ik0xMjIuNDQyIDczLjQ3MjlDMTIxLjk1OSA3My41MTM0IDEyMS40NzQgNzMuNTMyMiAxMjAuOTU4IDczLjUzMjJDMTE3Ljk2NSA3My41MzIyIDExNS4wNjEgNzIuODMwNCAxMTIuNDQyIDcxLjU0NTFDMTEzLjMxNCA2Ni41NDIxIDExMy42ODUgNjEuNTAxOSAxMTMuNTg4IDU2LjQ4MDJDMTEwLjc0OCA1Mi4zNzIzIDEwNy41MDIgNDguNDk2NSAxMDMuODM4IDQ0LjkyNTdDMTA1LjQyOCA0MS45NDU0IDEwNy43NzggMzkuMzgxMSAxMTAuNzExIDM3LjU2MjhMMTExLjk3MSAzNi43ODQyTDExMC45ODkgMzUuNjc3NEMxMDUuOTMyIDI5Ljk4MzIgOTkuODk3MSAyNS41ODA5IDkzLjA1NDcgMjIuNTkzN0w5MS42OTAyIDIyTDkxLjM0MzcgMjMuNDQyM0M5MC41Mjc3IDI2LjgwMzYgODguODIyMiAyOS44MzYgODYuNDgwNyAzMi4yNjk1QzgxLjk4MDMgMjkuODc3NCA3Ny4yNzg4IDI3Ljk0NCA3Mi40MzA1IDI2LjQ3OTdDNjcuNTkzNyAyNy45NDA4IDYyLjkwMDUgMjkuODY4NiA1OC40MDIgMzIuMjU3MUM1Ni4wNzAxIDI5LjgyNjggNTQuMzY4OCAyNi44MDE4IDUzLjU1NiAyMy40NTAxTDUzLjIwNzIgMjIuMDA4M0w1MS44NDc3IDIyLjU5OTJDNDUuMDkxNCAyNS41NDMxIDM4Ljg5MDEgMzAuMDY0NyAzMy45MTYyIDM1LjY3NDJMMzIuOTMxOCAzNi43ODMzTDM0LjE5IDM3LjU2MTlDMzcuMTE0MiAzOS4zNzMzIDM5LjQ1NzYgNDEuOTIyNCA0MS4wNDQ0IDQ0Ljg4NjZDMzcuMzkxNyA0OC40NDM1IDM0LjE0OTUgNTIuMzA3IDMxLjMxMTkgNTYuMzk1OUMzMS4yMDE0IDYxLjQxNTQgMzEuNTUzNSA2Ni40OTI0IDMyLjQyOTcgNzEuNTY0NEMyOS44MjMxIDcyLjgzNzggMjYuOTM1OCA3My41MzE4IDIzLjk2MjggNzMuNTMxOEMyMy40NDA5IDczLjUzMTggMjIuOTUyNyA3My41MTI5IDIyLjQ3ODIgNzMuNDczM0wyMSA3My4zNjA2TDIxLjEzODUgNzQuODM2NUMyMS44NjI5IDgyLjMwMzMgMjQuMTgxNCA4OS40MDUzIDI4LjAzMzQgOTUuOTQ3MUwyOC43ODUzIDk3LjIyMzdMMjkuOTE0MiA5Ni4yNjU2QzMyLjUzMDUgOTQuMDQ2NSAzNS42OTE3IDkyLjU3NyAzOS4wNTMgOTEuOTg0N0M0MS4yNjg5IDk2LjUxNTUgNDMuODk1MyAxMDAuNzcyIDQ2Ljg3NDcgMTA0LjcyNUM1MS42Mjg3IDEwNi4zODcgNTYuNTgxOSAxMDcuNjI5IDYxLjY5NzEgMTA4LjM2N0M2Mi4xODc3IDExMS43NSA2MS43OTcgMTE1LjI0OSA2MC40NjI0IDExOC40ODRMNTkuODk5NSAxMTkuODU1TDYxLjM0NjkgMTIwLjE3NEM2NS4wNTI5IDEyMC45ODkgNjguNzkxNyAxMjEuNDA0IDcyLjQ1MjYgMTIxLjQwNEw4My41NTUxIDEyMC4xNzRMODUuMDAzOSAxMTkuODU1TDg0LjQzOTcgMTE4LjQ4MkM4My4xMDg3IDExNS4yNDYgODIuNzE4IDExMS43NDMgODMuMjA4NiAxMDguMzZDODguMzAzNiAxMDcuNjIxIDkzLjIzODQgMTA2LjM4MiA5Ny45NzQ4IDEwNC43MjVDMTAwLjk1NyAxMDAuNzY5IDEwMy41ODYgOTYuNTA5NSAxMDUuODA1IDkxLjk3MjhDMTA5LjE3NyA5Mi41NjE0IDExMi4zNTYgOTQuMDMxNyAxMTQuOTg5IDk2LjI1NzNMMTE2LjExOCA5Ny4yMTQxTDExNi44NjYgOTUuOTQwN0MxMjAuNzI1IDg5LjM5MDUgMTIzLjA0MyA4Mi4yODkxIDEyMy43NTYgNzQuODM0MkwxMjMuODk1IDczLjM2MUwxMjIuNDQyIDczLjQ3MjlaTTg4LjMxOTcgOTEuNTE4MUM4My4wNjczIDkyLjk0NjYgNzcuNzMzIDkzLjY2NzcgNzIuNDMwNSA5My42Njc3QzY3LjExMzcgOTMuNjY3NyA2MS43ODU5IDkyLjk0NyA1Ni41MjkgOTEuNTE4MUM1My42NDQ4IDg3LjAzNjYgNTEuMzY0NSA4Mi4yMzU3IDQ5LjcyMzQgNzcuMTgxMkM0OC4wODkyIDcyLjE1MDIgNDcuMTMyOSA2Ni44Nzk1IDQ2Ljg1NTQgNjEuNDUyMkM1MC4yNTA0IDU3LjI1NDcgNTQuMTExIDUzLjU3NzYgNTguMzc2NyA1MC40ODIzQzYyLjcxMTQgNDcuMzI5NCA2Ny40MjcxIDQ0Ljc2NzkgNzIuNDMwNSA0Mi44NDFDNzcuNDI1NiA0NC43NjgzIDgyLjEzMjYgNDcuMzI2MiA4Ni40NTcyIDUwLjQ2NTdDOTAuNzM5NCA1My41Nzc2IDk0LjYxNzEgNTcuMjgzMiA5OC4wMjg3IDYxLjUwN0M5Ny43Mzc4IDY2LjkwMzQgOTYuNzcgNzIuMTQzOCA5NS4xMzMgNzcuMTY2NUM5My40OTYxIDgyLjIyIDkxLjIwODQgODcuMDM2MSA4OC4zMTk3IDkxLjUxODFaTTc2Ljc2ODQgNjYuMTk3NEM3Ni43Njg0IDY5LjkwODEgNzkuNzc1NCA3Mi45MDk2IDgzLjQ4MSA3Mi45MDk2Qzg3LjE4NTcgNzIuOTA5NiA5MC4xODk1IDY5LjkwODYgOTAuMTg5NSA2Ni4xOTc0QzkwLjE4OTUgNjIuNTAxIDg3LjE4NTcgNTkuNDg4MSA4My40ODEgNTkuNDg4MUM3OS43NzU0IDU5LjQ4ODEgNzYuNzY4NCA2Mi41MDEgNzYuNzY4NCA2Ni4xOTc0Wk02OC4wOTU0IDY2LjE5NzRDNjguMDk1NCA2OS45MDgxIDY1LjA4ODggNzIuOTA5NiA2MS4zODMyIDcyLjkwOTZDNTcuNjc0OSA3Mi45MDk2IDU0LjY3NjYgNjkuOTA4NiA1NC42NzY2IDY2LjE5NzRDNTQuNjc2NiA2Mi41MDI0IDU3LjY3NTMgNTkuNDg5NCA2MS4zODMyIDU5LjQ4OTRDNjUuMDg4OCA1OS40ODk0IDY4LjA5NTQgNjIuNTAyNCA2OC4wOTU0IDY2LjE5NzRaIiBmaWxsPSJ3aGl0ZSIvPgo8ZGVmcz4KPGxpbmVhckdyYWRpZW50IGlkPSJwYWludDBfbGluZWFyXzY4M18yOTYzIiB4MT0iNS41IiB5MT0iMTEiIHgyPSIxNDEiIHkyPSIxMjQuNSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPgo8c3RvcCBzdG9wLWNvbG9yPSIjNTNCMkYwIi8+CjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzQxOUVEQSIvPgo8L2xpbmVhckdyYWRpZW50Pgo8L2RlZnM+Cjwvc3ZnPgo= - keysOrder: [["apiVersion"], ["appVersion"], ["kind"], ["metadata"], ["metadata", "name"], ["spec", "size"], ["spec", "storageClass"], ["spec", "replicas"], ["spec", "resources"], ["spec", "resources", "cpu"], ["spec", "resources", "memory"]] + keysOrder: [["apiVersion"], ["appVersion"], ["kind"], ["metadata"], ["metadata", "name"], ["spec", "size"], ["spec", "storageClass"], ["spec", "replicas"], ["spec", "resources"], ["spec", "resources", "cpu"], ["spec", "resources", "memory"], ["spec", "backup"], ["spec", "backup", "enabled"], ["spec", "backup", "schedule"], ["spec", "backup", "destinationPath"], ["spec", "backup", "endpointURL"], ["spec", "backup", "region"], ["spec", "backup", "forcePathStyle"], ["spec", "backup", "s3AccessKey"], ["spec", "backup", "s3SecretKey"], ["spec", "backup", "successfulJobsHistoryLimit"], ["spec", "backup", "failedJobsHistoryLimit"]] secrets: exclude: [] include: [] From ee8ceecc0eb112f5de3d93063c4f3402df3c5d64 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Mon, 20 Apr 2026 15:58:55 +0300 Subject: [PATCH 03/16] test(etcd): add e2e for etcd application and backup schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two bats cases: a baseline Etcd managed-app deployment and a deployment with backup.enabled=true that verifies the EtcdBackupSchedule resource and the S3 credentials Secret are materialized. The backup case uses fake S3 credentials — the operator will not successfully push backups, but the schedule resource itself is reconciled, which is what this test validates. Also hardcodes the EtcdBackupSchedule and backup Secret names to 'etcd' / 'etcd-s3-creds' to match the existing hardcoded EtcdCluster name in the chart (one etcd per tenant namespace). Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/e2e-apps/etcd.bats | 58 +++++++++++++++++++ .../extra/etcd/templates/backup-secret.yaml | 2 +- .../etcd/templates/etcd-backup-schedule.yaml | 4 +- 3 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 hack/e2e-apps/etcd.bats diff --git a/hack/e2e-apps/etcd.bats b/hack/e2e-apps/etcd.bats new file mode 100644 index 00000000..cdee713d --- /dev/null +++ b/hack/e2e-apps/etcd.bats @@ -0,0 +1,58 @@ +#!/usr/bin/env bats + +@test "Create Etcd" { + name='test' + kubectl -n tenant-test delete etcd.apps.cozystack.io $name --ignore-not-found --timeout=2m || true + kubectl apply -f- < Date: Mon, 20 Apr 2026 16:08:43 +0300 Subject: [PATCH 04/16] fix(etcd): reject placeholder backup values and harden e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found three issues: 1. The guard in backup-secret.yaml only triggered on empty strings, not on the placeholder defaults ('' etc). A user enabling backup.enabled=true without overriding the values would produce a Secret containing the literal placeholder, and the backup CronJob would fail at runtime with an opaque 403 far from the source. Add explicit template guards that fail rendering when any of the four required fields still hold their placeholder default. 2. The backup e2e test waited on .spec.schedule of the newly-created EtcdBackupSchedule, which is set at apply time — the wait was tautological and did not prove the etcd-operator reconciled anything. Replace it with a wait on the EtcdCluster's Ready condition plus a poll for the CronJob the operator generates from the schedule (selected via the etcd.aenix.io/etcdbackupschedule-name label). 3. The backup e2e test also did not wait for EtcdCluster readiness before tearing down. Add the same Ready wait as the baseline test, and verify the Secret contains the real access key value rather than just existing. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/e2e-apps/etcd.bats | 19 +++++++++++-------- .../extra/etcd/templates/backup-secret.yaml | 4 ++++ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/hack/e2e-apps/etcd.bats b/hack/e2e-apps/etcd.bats index cdee713d..7195e0ad 100644 --- a/hack/e2e-apps/etcd.bats +++ b/hack/e2e-apps/etcd.bats @@ -19,7 +19,7 @@ spec: EOF sleep 5 kubectl -n tenant-test wait hr etcd-$name --timeout=60s --for=condition=ready - kubectl -n tenant-test wait etcdcluster.etcd.aenix.io etcd --timeout=120s --for=jsonpath='{.status.conditions[?(@.type=="Ready")].status}'=True + kubectl -n tenant-test wait etcdcluster.etcd.aenix.io etcd --timeout=180s --for=jsonpath='{.status.conditions[?(@.type=="Ready")].status}'=True kubectl -n tenant-test delete etcd.apps.cozystack.io $name } @@ -41,18 +41,21 @@ spec: memory: 128Mi backup: enabled: true - schedule: "0 2 * * *" + schedule: "*/1 * * * *" destinationPath: "s3://test-bucket/etcd-backups/" - endpointURL: "http://minio-gateway-service:9000" + endpointURL: "http://minio-e2e.tenant-test.svc:9000" forcePathStyle: true - s3AccessKey: "test-access-key" - s3SecretKey: "test-secret-key" - successfulJobsHistoryLimit: 3 + s3AccessKey: "e2e-access-key" + s3SecretKey: "e2e-secret-key" + successfulJobsHistoryLimit: 1 failedJobsHistoryLimit: 1 EOF sleep 5 kubectl -n tenant-test wait hr etcd-$name --timeout=60s --for=condition=ready - kubectl -n tenant-test wait etcdbackupschedule.etcd.aenix.io etcd --timeout=60s --for=jsonpath='{.spec.schedule}'='0 2 * * *' - kubectl -n tenant-test get secret etcd-s3-creds + kubectl -n tenant-test wait etcdcluster.etcd.aenix.io etcd --timeout=180s --for=jsonpath='{.status.conditions[?(@.type=="Ready")].status}'=True + kubectl -n tenant-test get etcdbackupschedule.etcd.aenix.io etcd + kubectl -n tenant-test get secret etcd-s3-creds -o jsonpath='{.data.AWS_ACCESS_KEY_ID}' | base64 -d | grep -q '^e2e-access-key$' + # The etcd-operator generates a CronJob from the EtcdBackupSchedule. Wait for it. + timeout 120 sh -ec "until [ \"\$(kubectl -n tenant-test get cronjob -l etcd.aenix.io/etcdbackupschedule-name=etcd -o jsonpath='{.items[*].metadata.name}' 2>/dev/null)\" != '' ]; do sleep 5; done" kubectl -n tenant-test delete etcd.apps.cozystack.io $name } diff --git a/packages/extra/etcd/templates/backup-secret.yaml b/packages/extra/etcd/templates/backup-secret.yaml index 99db7c30..afeefc3e 100644 --- a/packages/extra/etcd/templates/backup-secret.yaml +++ b/packages/extra/etcd/templates/backup-secret.yaml @@ -1,4 +1,8 @@ {{- if .Values.backup.enabled }} +{{- if eq .Values.backup.s3AccessKey "" }}{{ fail "backup.s3AccessKey must be set when backup.enabled=true" }}{{- end }} +{{- if eq .Values.backup.s3SecretKey "" }}{{ fail "backup.s3SecretKey must be set when backup.enabled=true" }}{{- end }} +{{- if eq .Values.backup.destinationPath "s3://bucket/path/to/folder/" }}{{ fail "backup.destinationPath must be set to a real s3:// URL when backup.enabled=true" }}{{- end }} +{{- if eq .Values.backup.endpointURL "http://minio-gateway-service:9000" }}{{ fail "backup.endpointURL must be set to a real S3 endpoint when backup.enabled=true" }}{{- end }} --- apiVersion: v1 kind: Secret From 561bc4dc23a911d90dd52de90ac88650aad9d50f Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Mon, 20 Apr 2026 16:15:56 +0300 Subject: [PATCH 05/16] fix(etcd): address second review round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mark backup.enabled as optional in the values typedef so generated JSON schema no longer lists it in 'required'. Previously, 'backup: {}' (relying on the enabled:false default) failed schema validation at CRD admission. - Validate backup.destinationPath has s3:// prefix. Without this check, passing a path without the scheme silently used the first path segment as the bucket name. - Add tail newline to values.schema.json to match repo convention (cozyvalues-gen output lacks it — worth fixing in that project separately). - Add bats setup() that clears tenant-test etcd resources before each test so a failure mid-test does not leak into subsequent tests. Hardcoded chart-level resource names ('etcd', 'etcd-s3-creds') make two Etcd apps in the same namespace collide; setup() enforces a single owner per test. - Add dump_diagnostics() helper that dumps operator logs + the relevant CRD state on wait-for-ready timeouts, so CI failures surface actionable context instead of bare exit code 124. - Add a third e2e case 'Create Etcd with empty backup block' that asserts backup:{} admits and produces no backup resources — a regression test for the required:['enabled'] bug fixed here. Known scope: the upstream EtcdCluster.spec.bootstrap restore field (also new in v0.4.3) is not exposed through this values.yaml yet. Restoring a cluster from an existing snapshot is a follow-up PR. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/e2e-apps/etcd.bats | 52 ++++++++++++++++--- .../etcd/templates/etcd-backup-schedule.yaml | 1 + packages/extra/etcd/values.schema.json | 5 +- packages/extra/etcd/values.yaml | 2 +- packages/system/etcd-rd/cozyrds/etcd.yaml | 2 +- 5 files changed, 48 insertions(+), 14 deletions(-) diff --git a/hack/e2e-apps/etcd.bats b/hack/e2e-apps/etcd.bats index 7195e0ad..a9db9030 100644 --- a/hack/e2e-apps/etcd.bats +++ b/hack/e2e-apps/etcd.bats @@ -1,8 +1,18 @@ #!/usr/bin/env bats +setup() { + kubectl -n tenant-test delete etcd.apps.cozystack.io --all --ignore-not-found --timeout=2m || true +} + +dump_diagnostics() { + echo "# --- diagnostics ---" >&3 + kubectl -n tenant-test get etcdcluster,etcdbackupschedule,cronjob -o wide 2>&1 >&3 || true + kubectl -n tenant-test describe etcdbackupschedule etcd 2>&1 >&3 || true + kubectl -n cozy-etcd-operator logs -l app.kubernetes.io/name=etcd-operator --tail=100 2>&1 >&3 || true +} + @test "Create Etcd" { name='test' - kubectl -n tenant-test delete etcd.apps.cozystack.io $name --ignore-not-found --timeout=2m || true kubectl apply -f- </dev/null)\" != '' ]; do sleep 5; done" + timeout 120 sh -ec "until [ \"\$(kubectl -n tenant-test get cronjob -l etcd.aenix.io/etcdbackupschedule-name=etcd -o jsonpath='{.items[*].metadata.name}' 2>/dev/null)\" != '' ]; do sleep 5; done" || { dump_diagnostics; false; } kubectl -n tenant-test delete etcd.apps.cozystack.io $name } diff --git a/packages/extra/etcd/templates/etcd-backup-schedule.yaml b/packages/extra/etcd/templates/etcd-backup-schedule.yaml index 8b632689..9ff3d0b7 100644 --- a/packages/extra/etcd/templates/etcd-backup-schedule.yaml +++ b/packages/extra/etcd/templates/etcd-backup-schedule.yaml @@ -1,4 +1,5 @@ {{- if .Values.backup.enabled }} +{{- if not (hasPrefix "s3://" .Values.backup.destinationPath) }}{{ fail "backup.destinationPath must start with s3://" }}{{- end }} {{- $path := .Values.backup.destinationPath | trimPrefix "s3://" | trimSuffix "/" -}} {{- $parts := splitList "/" $path -}} {{- $bucket := first $parts -}} diff --git a/packages/extra/etcd/values.schema.json b/packages/extra/etcd/values.schema.json index 94c70cfe..cde3365f 100644 --- a/packages/extra/etcd/values.schema.json +++ b/packages/extra/etcd/values.schema.json @@ -6,9 +6,6 @@ "description": "Backup configuration.", "type": "object", "default": {}, - "required": [ - "enabled" - ], "properties": { "destinationPath": { "description": "Destination path for backups (e.g. s3://bucket/path/).", @@ -122,4 +119,4 @@ "default": "" } } -} \ No newline at end of file +} diff --git a/packages/extra/etcd/values.yaml b/packages/extra/etcd/values.yaml index b12054b0..160c5ad9 100644 --- a/packages/extra/etcd/values.yaml +++ b/packages/extra/etcd/values.yaml @@ -25,7 +25,7 @@ resources: ## ## @typedef {struct} Backup - Backup configuration. -## @field {bool} enabled - Enable scheduled S3 backups. +## @field {bool} [enabled] - Enable scheduled S3 backups. ## @field {string} [schedule] - Cron schedule for automated backups. ## @field {string} [destinationPath] - Destination path for backups (e.g. s3://bucket/path/). ## @field {string} [endpointURL] - S3 endpoint URL for uploads. diff --git a/packages/system/etcd-rd/cozyrds/etcd.yaml b/packages/system/etcd-rd/cozyrds/etcd.yaml index 90c736e2..317f9c27 100644 --- a/packages/system/etcd-rd/cozyrds/etcd.yaml +++ b/packages/system/etcd-rd/cozyrds/etcd.yaml @@ -8,7 +8,7 @@ spec: plural: etcds singular: etcd openAPISchema: |- - {"title":"Chart Values","type":"object","properties":{"backup":{"description":"Backup configuration.","type":"object","default":{},"required":["enabled"],"properties":{"destinationPath":{"description":"Destination path for backups (e.g. s3://bucket/path/).","type":"string","default":"s3://bucket/path/to/folder/"},"enabled":{"description":"Enable scheduled S3 backups.","type":"boolean","default":false},"endpointURL":{"description":"S3 endpoint URL for uploads.","type":"string","default":"http://minio-gateway-service:9000"},"failedJobsHistoryLimit":{"description":"Number of failed backup jobs to retain.","type":"integer","default":1},"forcePathStyle":{"description":"Use path-style S3 URLs (required for MinIO and most S3-compatible providers).","type":"boolean","default":true},"region":{"description":"S3 region.","type":"string","default":""},"s3AccessKey":{"description":"Access key for S3 authentication.","type":"string","default":""},"s3SecretKey":{"description":"Secret key for S3 authentication.","type":"string","default":""},"schedule":{"description":"Cron schedule for automated backups.","type":"string","default":"0 2 * * *"},"successfulJobsHistoryLimit":{"description":"Number of successful backup jobs to retain.","type":"integer","default":3}}},"replicas":{"description":"Number of etcd replicas.","type":"integer","default":3},"resources":{"description":"Resource configuration for etcd.","type":"object","default":{},"properties":{"cpu":{"description":"Number of CPU cores allocated.","default":"1000m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Amount of memory allocated.","default":"512Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"size":{"description":"Persistent Volume size.","default":"4Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"storageClass":{"description":"StorageClass used to store the data.","type":"string","default":""}}} + {"title":"Chart Values","type":"object","properties":{"backup":{"description":"Backup configuration.","type":"object","default":{},"properties":{"destinationPath":{"description":"Destination path for backups (e.g. s3://bucket/path/).","type":"string","default":"s3://bucket/path/to/folder/"},"enabled":{"description":"Enable scheduled S3 backups.","type":"boolean","default":false},"endpointURL":{"description":"S3 endpoint URL for uploads.","type":"string","default":"http://minio-gateway-service:9000"},"failedJobsHistoryLimit":{"description":"Number of failed backup jobs to retain.","type":"integer","default":1},"forcePathStyle":{"description":"Use path-style S3 URLs (required for MinIO and most S3-compatible providers).","type":"boolean","default":true},"region":{"description":"S3 region.","type":"string","default":""},"s3AccessKey":{"description":"Access key for S3 authentication.","type":"string","default":""},"s3SecretKey":{"description":"Secret key for S3 authentication.","type":"string","default":""},"schedule":{"description":"Cron schedule for automated backups.","type":"string","default":"0 2 * * *"},"successfulJobsHistoryLimit":{"description":"Number of successful backup jobs to retain.","type":"integer","default":3}}},"replicas":{"description":"Number of etcd replicas.","type":"integer","default":3},"resources":{"description":"Resource configuration for etcd.","type":"object","default":{},"properties":{"cpu":{"description":"Number of CPU cores allocated.","default":"1000m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Amount of memory allocated.","default":"512Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"size":{"description":"Persistent Volume size.","default":"4Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"storageClass":{"description":"StorageClass used to store the data.","type":"string","default":""}}} release: prefix: "" labels: From 12fc9cd41500e4132374c70a27ad5050b9ac715b Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Mon, 20 Apr 2026 16:35:00 +0300 Subject: [PATCH 06/16] fix(etcd): address third review round - Use name='etcd' in all three e2e cases. The chart pins the Helm release name to 'etcd' via check-release-name.yaml, so any other name causes template rendering to fail before the HelmRelease can reconcile. This is why the tenant chart hardcodes HR name 'etcd' in packages/apps/tenant/templates/etcd.yaml. - Consolidate all backup validation guards into etcd-backup-schedule.yaml so Helm's alphabetical render order no longer determines which failure a user sees first. Previously, a bad destinationPath would be masked by the earlier placeholder check in backup-secret.yaml. - Use regex match '^<.*>$' for placeholder detection instead of exact string equality. The previous check silently stopped firing if anyone updated the default placeholder string in values.yaml; the regex form catches any angle-bracketed default. - Reject destinationPath strings containing '//' after the s3:// prefix. Previously 's3://bucket//subdir' silently produced a key beginning with '/', confusing S3 clients and retention policies. - Document the backup contract in README.md: what enabling it produces, which fields are required, why placeholders are rejected, and that restore is a known follow-up gap. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/e2e-apps/etcd.bats | 27 ++++++++++--------- packages/extra/etcd/README.md | 8 ++++++ .../extra/etcd/templates/backup-secret.yaml | 8 ++---- .../etcd/templates/etcd-backup-schedule.yaml | 13 ++++++--- 4 files changed, 34 insertions(+), 22 deletions(-) diff --git a/hack/e2e-apps/etcd.bats b/hack/e2e-apps/etcd.bats index a9db9030..209b3c6a 100644 --- a/hack/e2e-apps/etcd.bats +++ b/hack/e2e-apps/etcd.bats @@ -1,5 +1,10 @@ #!/usr/bin/env bats +# The etcd chart pins the Helm release name to 'etcd' via +# packages/extra/etcd/templates/check-release-name.yaml, so every test +# must apply its Etcd with metadata.name: etcd. setup() clears any +# prior run so tests remain independent despite the singleton name. + setup() { kubectl -n tenant-test delete etcd.apps.cozystack.io --all --ignore-not-found --timeout=2m || true } @@ -12,12 +17,11 @@ dump_diagnostics() { } @test "Create Etcd" { - name='test' kubectl apply -f- </dev/null)\" != '' ]; do sleep 5; done" || { dump_diagnostics; false; } - kubectl -n tenant-test delete etcd.apps.cozystack.io $name } diff --git a/packages/extra/etcd/README.md b/packages/extra/etcd/README.md index b946d6c9..5718da18 100644 --- a/packages/extra/etcd/README.md +++ b/packages/extra/etcd/README.md @@ -1,5 +1,13 @@ # Etcd-cluster +## Backups + +When `backup.enabled` is set to `true`, the chart renders an `EtcdBackupSchedule` (etcd.aenix.io/v1alpha1) and an S3 credentials `Secret`. The etcd-operator v0.4.3+ reconciles the schedule into a `CronJob` that periodically snapshots the cluster to S3. + +Enabling backup requires explicit values for `backup.s3AccessKey`, `backup.s3SecretKey`, `backup.destinationPath` (must start with `s3://` and have no `//` segments), and `backup.endpointURL`. The chart intentionally `fail`s at template time if any of these are left at their placeholder defaults — this avoids silently shipping a Secret containing the literal string `` to the backup job, which would later fail with an opaque S3 403. + +**Restore** (`EtcdCluster.spec.bootstrap`) is not yet exposed through this chart — restoring from a snapshot currently requires hand-applying an `EtcdCluster` with the bootstrap block. + ## Parameters ### Common parameters diff --git a/packages/extra/etcd/templates/backup-secret.yaml b/packages/extra/etcd/templates/backup-secret.yaml index afeefc3e..41a17829 100644 --- a/packages/extra/etcd/templates/backup-secret.yaml +++ b/packages/extra/etcd/templates/backup-secret.yaml @@ -1,14 +1,10 @@ {{- if .Values.backup.enabled }} -{{- if eq .Values.backup.s3AccessKey "" }}{{ fail "backup.s3AccessKey must be set when backup.enabled=true" }}{{- end }} -{{- if eq .Values.backup.s3SecretKey "" }}{{ fail "backup.s3SecretKey must be set when backup.enabled=true" }}{{- end }} -{{- if eq .Values.backup.destinationPath "s3://bucket/path/to/folder/" }}{{ fail "backup.destinationPath must be set to a real s3:// URL when backup.enabled=true" }}{{- end }} -{{- if eq .Values.backup.endpointURL "http://minio-gateway-service:9000" }}{{ fail "backup.endpointURL must be set to a real S3 endpoint when backup.enabled=true" }}{{- end }} --- apiVersion: v1 kind: Secret metadata: name: etcd-s3-creds stringData: - AWS_ACCESS_KEY_ID: {{ required "s3AccessKey is not specified!" .Values.backup.s3AccessKey | quote }} - AWS_SECRET_ACCESS_KEY: {{ required "s3SecretKey is not specified!" .Values.backup.s3SecretKey | quote }} + AWS_ACCESS_KEY_ID: {{ required "backup.s3AccessKey is not specified!" .Values.backup.s3AccessKey | quote }} + AWS_SECRET_ACCESS_KEY: {{ required "backup.s3SecretKey is not specified!" .Values.backup.s3SecretKey | quote }} {{- end }} diff --git a/packages/extra/etcd/templates/etcd-backup-schedule.yaml b/packages/extra/etcd/templates/etcd-backup-schedule.yaml index 9ff3d0b7..5d21342d 100644 --- a/packages/extra/etcd/templates/etcd-backup-schedule.yaml +++ b/packages/extra/etcd/templates/etcd-backup-schedule.yaml @@ -1,7 +1,12 @@ {{- if .Values.backup.enabled }} -{{- if not (hasPrefix "s3://" .Values.backup.destinationPath) }}{{ fail "backup.destinationPath must start with s3://" }}{{- end }} -{{- $path := .Values.backup.destinationPath | trimPrefix "s3://" | trimSuffix "/" -}} -{{- $parts := splitList "/" $path -}} +{{- /* All backup-related guards live here (alphabetically first of the backup templates) so ordering relative to backup-secret.yaml doesn't matter. */ -}} +{{- if regexMatch "^<.*>$" .Values.backup.s3AccessKey }}{{ fail "backup.s3AccessKey must be set to a real value when backup.enabled=true" }}{{- end }} +{{- if regexMatch "^<.*>$" .Values.backup.s3SecretKey }}{{ fail "backup.s3SecretKey must be set to a real value when backup.enabled=true" }}{{- end }} +{{- if not (hasPrefix "s3://" .Values.backup.destinationPath) }}{{ fail "backup.destinationPath must start with s3:// when backup.enabled=true" }}{{- end }} +{{- $rawPath := .Values.backup.destinationPath | trimPrefix "s3://" | trimSuffix "/" -}} +{{- if or (eq $rawPath "bucket/path/to/folder") (contains "//" $rawPath) }}{{ fail "backup.destinationPath must be set to a real s3:// URL without '//' segments when backup.enabled=true" }}{{- end }} +{{- if or (eq .Values.backup.endpointURL "") (eq .Values.backup.endpointURL "http://minio-gateway-service:9000") }}{{ fail "backup.endpointURL must be set to a real S3 endpoint when backup.enabled=true" }}{{- end }} +{{- $parts := splitList "/" $rawPath -}} {{- $bucket := first $parts -}} {{- $key := rest $parts | join "/" -}} --- @@ -17,7 +22,7 @@ spec: failedJobsHistoryLimit: {{ .Values.backup.failedJobsHistoryLimit }} destination: s3: - endpoint: {{ required "backup.endpointURL is not specified!" .Values.backup.endpointURL | quote }} + endpoint: {{ .Values.backup.endpointURL | quote }} bucket: {{ required "backup.destinationPath must contain a bucket (e.g. s3://bucket/path/)" $bucket | quote }} {{- with $key }} key: {{ . | quote }} From ed77e9c6e3dd4db0fc06867459fcfbd965743da7 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Mon, 20 Apr 2026 16:51:41 +0300 Subject: [PATCH 07/16] fix(etcd): add helm unit tests, simplify backup guards, harden e2e Fourth review round fixes. Simplify backup validation by dropping placeholder-regex guards and switching default values of s3AccessKey, s3SecretKey, destinationPath, and endpointURL from angle-bracketed hints to empty strings. A single 'required' check on each field is now the whole validation for presence; the regex guard was brittle (silently stopped matching if the hint ever changed) and dropping it makes the values.yaml defaults match the behavior contract the README describes. s3:// prefix and '//' rejection still live in etcd-backup-schedule.yaml as template-time fail branches. Add helm-unittest suites in packages/extra/etcd/tests/ covering both templates end-to-end: rendered output assertions for the happy path (bucket/key split, region passthrough, conditional rendering of optional fields) and failedTemplate assertions for every fail branch. A 'test' target is wired into the package Makefile so hack/helm-unit-tests.sh picks it up on 'make unit-tests'. Add type: Opaque to backup-secret.yaml for consistency with the mongodb template. E2E: wait for HelmRelease, Secret, and EtcdBackupSchedule deletion in setup() so the empty-backup case does not flake against lingering state from the previous test. Replace the fictitious minio-e2e hostname with no-such-endpoint.invalid and explicitly document that the e2e verifies schedule/CronJob materialization, not backup execution. Restore via EtcdCluster.spec.bootstrap and the one-shot EtcdBackup CRD are still not exposed through this chart; both are explicit follow-ups called out in the README. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/e2e-apps/etcd.bats | 11 +- packages/extra/etcd/Makefile | 3 + packages/extra/etcd/README.md | 30 +-- .../extra/etcd/templates/backup-secret.yaml | 5 +- .../etcd/templates/etcd-backup-schedule.yaml | 17 +- .../extra/etcd/tests/backup-secret_test.yaml | 104 +++++++++++ .../etcd/tests/etcd-backup-schedule_test.yaml | 174 ++++++++++++++++++ packages/extra/etcd/values.schema.json | 8 +- packages/extra/etcd/values.yaml | 8 +- packages/system/etcd-rd/cozyrds/etcd.yaml | 2 +- 10 files changed, 326 insertions(+), 36 deletions(-) create mode 100644 packages/extra/etcd/tests/backup-secret_test.yaml create mode 100644 packages/extra/etcd/tests/etcd-backup-schedule_test.yaml diff --git a/hack/e2e-apps/etcd.bats b/hack/e2e-apps/etcd.bats index 209b3c6a..13bd5b5d 100644 --- a/hack/e2e-apps/etcd.bats +++ b/hack/e2e-apps/etcd.bats @@ -7,6 +7,11 @@ setup() { kubectl -n tenant-test delete etcd.apps.cozystack.io --all --ignore-not-found --timeout=2m || true + # HelmRelease teardown is async relative to the CR deletion above; wait for + # downstream resources so the next test starts from a clean state. + kubectl -n tenant-test wait hr/etcd --for=delete --timeout=2m --ignore-not-found || true + kubectl -n tenant-test wait secret/etcd-s3-creds --for=delete --timeout=1m --ignore-not-found || true + kubectl -n tenant-test wait etcdbackupschedule.etcd.aenix.io/etcd --for=delete --timeout=1m --ignore-not-found || true } dump_diagnostics() { @@ -78,8 +83,12 @@ spec: backup: enabled: true schedule: "*/1 * * * *" + # This test verifies that the chart renders EtcdBackupSchedule/Secret and + # that the etcd-operator materializes a CronJob from the schedule — it does + # NOT verify that backups reach S3. The endpoint below intentionally + # resolves nowhere to keep the test self-contained. destinationPath: "s3://test-bucket/etcd-backups/" - endpointURL: "http://minio-e2e.tenant-test.svc:9000" + endpointURL: "http://no-such-endpoint.invalid:9000" region: "us-east-1" forcePathStyle: true s3AccessKey: "e2e-access-key" diff --git a/packages/extra/etcd/Makefile b/packages/extra/etcd/Makefile index f37d6e1e..2b3ed61e 100644 --- a/packages/extra/etcd/Makefile +++ b/packages/extra/etcd/Makefile @@ -5,3 +5,6 @@ include ../../../hack/package.mk generate: cozyvalues-gen -v values.yaml -s values.schema.json -r README.md ../../../hack/update-crd.sh + +test: + helm unittest . diff --git a/packages/extra/etcd/README.md b/packages/extra/etcd/README.md index 5718da18..71125347 100644 --- a/packages/extra/etcd/README.md +++ b/packages/extra/etcd/README.md @@ -4,9 +4,9 @@ When `backup.enabled` is set to `true`, the chart renders an `EtcdBackupSchedule` (etcd.aenix.io/v1alpha1) and an S3 credentials `Secret`. The etcd-operator v0.4.3+ reconciles the schedule into a `CronJob` that periodically snapshots the cluster to S3. -Enabling backup requires explicit values for `backup.s3AccessKey`, `backup.s3SecretKey`, `backup.destinationPath` (must start with `s3://` and have no `//` segments), and `backup.endpointURL`. The chart intentionally `fail`s at template time if any of these are left at their placeholder defaults — this avoids silently shipping a Secret containing the literal string `` to the backup job, which would later fail with an opaque S3 403. +Enabling backup requires the following fields to be explicitly set (defaults are empty strings so that missing values fail fast at template render time): `backup.s3AccessKey`, `backup.s3SecretKey`, `backup.destinationPath` (must start with `s3://` and have no `//` segments), and `backup.endpointURL`. S3 credentials passed through plain values end up in the HelmRelease manifest — for production deployments prefer an external secret management tool (ESO, Sealed Secrets, etc.) over committing the keys to Git. -**Restore** (`EtcdCluster.spec.bootstrap`) is not yet exposed through this chart — restoring from a snapshot currently requires hand-applying an `EtcdCluster` with the bootstrap block. +**Restore** (`EtcdCluster.spec.bootstrap`) and the one-shot `EtcdBackup` CRD shipped upstream in v0.4.3 are not yet exposed through this chart. Restoring from a snapshot or taking an ad-hoc backup currently requires hand-applying the corresponding CRD. ## Parameters @@ -24,17 +24,17 @@ Enabling backup requires explicit values for `backup.s3AccessKey`, `backup.s3Sec ### Backup parameters -| Name | Description | Type | Value | -| ----------------------------------- | ----------------------------------------------------------------------------- | -------- | ----------------------------------- | -| `backup` | Backup configuration. | `object` | `{}` | -| `backup.enabled` | Enable scheduled S3 backups. | `bool` | `false` | -| `backup.schedule` | Cron schedule for automated backups. | `string` | `0 2 * * *` | -| `backup.destinationPath` | Destination path for backups (e.g. s3://bucket/path/). | `string` | `s3://bucket/path/to/folder/` | -| `backup.endpointURL` | S3 endpoint URL for uploads. | `string` | `http://minio-gateway-service:9000` | -| `backup.region` | S3 region. | `string` | `""` | -| `backup.forcePathStyle` | Use path-style S3 URLs (required for MinIO and most S3-compatible providers). | `bool` | `true` | -| `backup.s3AccessKey` | Access key for S3 authentication. | `string` | `` | -| `backup.s3SecretKey` | Secret key for S3 authentication. | `string` | `` | -| `backup.successfulJobsHistoryLimit` | Number of successful backup jobs to retain. | `int` | `3` | -| `backup.failedJobsHistoryLimit` | Number of failed backup jobs to retain. | `int` | `1` | +| Name | Description | Type | Value | +| ----------------------------------- | ----------------------------------------------------------------------------- | -------- | ----------- | +| `backup` | Backup configuration. | `object` | `{}` | +| `backup.enabled` | Enable scheduled S3 backups. | `bool` | `false` | +| `backup.schedule` | Cron schedule for automated backups. | `string` | `0 2 * * *` | +| `backup.destinationPath` | Destination path for backups (e.g. s3://bucket/path/). | `string` | `""` | +| `backup.endpointURL` | S3 endpoint URL for uploads. | `string` | `""` | +| `backup.region` | S3 region. | `string` | `""` | +| `backup.forcePathStyle` | Use path-style S3 URLs (required for MinIO and most S3-compatible providers). | `bool` | `true` | +| `backup.s3AccessKey` | Access key for S3 authentication. | `string` | `""` | +| `backup.s3SecretKey` | Secret key for S3 authentication. | `string` | `""` | +| `backup.successfulJobsHistoryLimit` | Number of successful backup jobs to retain. | `int` | `3` | +| `backup.failedJobsHistoryLimit` | Number of failed backup jobs to retain. | `int` | `1` | diff --git a/packages/extra/etcd/templates/backup-secret.yaml b/packages/extra/etcd/templates/backup-secret.yaml index 41a17829..080bff21 100644 --- a/packages/extra/etcd/templates/backup-secret.yaml +++ b/packages/extra/etcd/templates/backup-secret.yaml @@ -4,7 +4,8 @@ apiVersion: v1 kind: Secret metadata: name: etcd-s3-creds +type: Opaque stringData: - AWS_ACCESS_KEY_ID: {{ required "backup.s3AccessKey is not specified!" .Values.backup.s3AccessKey | quote }} - AWS_SECRET_ACCESS_KEY: {{ required "backup.s3SecretKey is not specified!" .Values.backup.s3SecretKey | quote }} + AWS_ACCESS_KEY_ID: {{ required "backup.s3AccessKey is required when backup.enabled=true" .Values.backup.s3AccessKey | quote }} + AWS_SECRET_ACCESS_KEY: {{ required "backup.s3SecretKey is required when backup.enabled=true" .Values.backup.s3SecretKey | quote }} {{- end }} diff --git a/packages/extra/etcd/templates/etcd-backup-schedule.yaml b/packages/extra/etcd/templates/etcd-backup-schedule.yaml index 5d21342d..58b8f03b 100644 --- a/packages/extra/etcd/templates/etcd-backup-schedule.yaml +++ b/packages/extra/etcd/templates/etcd-backup-schedule.yaml @@ -1,13 +1,12 @@ {{- if .Values.backup.enabled }} -{{- /* All backup-related guards live here (alphabetically first of the backup templates) so ordering relative to backup-secret.yaml doesn't matter. */ -}} -{{- if regexMatch "^<.*>$" .Values.backup.s3AccessKey }}{{ fail "backup.s3AccessKey must be set to a real value when backup.enabled=true" }}{{- end }} -{{- if regexMatch "^<.*>$" .Values.backup.s3SecretKey }}{{ fail "backup.s3SecretKey must be set to a real value when backup.enabled=true" }}{{- end }} -{{- if not (hasPrefix "s3://" .Values.backup.destinationPath) }}{{ fail "backup.destinationPath must start with s3:// when backup.enabled=true" }}{{- end }} -{{- $rawPath := .Values.backup.destinationPath | trimPrefix "s3://" | trimSuffix "/" -}} -{{- if or (eq $rawPath "bucket/path/to/folder") (contains "//" $rawPath) }}{{ fail "backup.destinationPath must be set to a real s3:// URL without '//' segments when backup.enabled=true" }}{{- end }} -{{- if or (eq .Values.backup.endpointURL "") (eq .Values.backup.endpointURL "http://minio-gateway-service:9000") }}{{ fail "backup.endpointURL must be set to a real S3 endpoint when backup.enabled=true" }}{{- end }} +{{- $endpointURL := required "backup.endpointURL is required when backup.enabled=true" .Values.backup.endpointURL -}} +{{- $destPath := required "backup.destinationPath is required when backup.enabled=true" .Values.backup.destinationPath -}} +{{- if not (hasPrefix "s3://" $destPath) }}{{ fail "backup.destinationPath must start with s3://" }}{{- end }} +{{- $rawPath := $destPath | trimPrefix "s3://" | trimSuffix "/" -}} +{{- if contains "//" $rawPath }}{{ fail "backup.destinationPath must not contain '//' segments" }}{{- end }} {{- $parts := splitList "/" $rawPath -}} {{- $bucket := first $parts -}} +{{- if eq $bucket "" }}{{ fail "backup.destinationPath must contain a bucket (e.g. s3://bucket/path/)" }}{{- end }} {{- $key := rest $parts | join "/" -}} --- apiVersion: etcd.aenix.io/v1alpha1 @@ -22,8 +21,8 @@ spec: failedJobsHistoryLimit: {{ .Values.backup.failedJobsHistoryLimit }} destination: s3: - endpoint: {{ .Values.backup.endpointURL | quote }} - bucket: {{ required "backup.destinationPath must contain a bucket (e.g. s3://bucket/path/)" $bucket | quote }} + endpoint: {{ $endpointURL | quote }} + bucket: {{ $bucket | quote }} {{- with $key }} key: {{ . | quote }} {{- end }} diff --git a/packages/extra/etcd/tests/backup-secret_test.yaml b/packages/extra/etcd/tests/backup-secret_test.yaml new file mode 100644 index 00000000..02d81977 --- /dev/null +++ b/packages/extra/etcd/tests/backup-secret_test.yaml @@ -0,0 +1,104 @@ +suite: backup secret tests + +templates: + - templates/backup-secret.yaml + +tests: + - it: does not render when backup disabled + release: + name: etcd + namespace: tenant-test + set: + backup: + enabled: false + asserts: + - hasDocuments: + count: 0 + + - it: renders when backup enabled with valid credentials + release: + name: etcd + namespace: tenant-test + set: + backup: + enabled: true + s3AccessKey: "AKIAIOSFODNN7EXAMPLE" + s3SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + destinationPath: "s3://bucket/path/" + endpointURL: "https://s3.example.com" + asserts: + - hasDocuments: + count: 1 + - isKind: + of: Secret + - equal: + path: metadata.name + value: etcd-s3-creds + - equal: + path: type + value: Opaque + + - it: secret name is hardcoded regardless of release name + release: + name: etcd + namespace: tenant-test + set: + backup: + enabled: true + s3AccessKey: "k" + s3SecretKey: "s" + destinationPath: "s3://b/" + endpointURL: "https://s3.example.com" + asserts: + - equal: + path: metadata.name + value: etcd-s3-creds + + - it: contains AWS credentials in expected keys + release: + name: etcd + namespace: tenant-test + set: + backup: + enabled: true + s3AccessKey: "MYACCESSKEY" + s3SecretKey: "MYSECRETKEY" + destinationPath: "s3://b/" + endpointURL: "https://s3.example.com" + asserts: + - equal: + path: stringData.AWS_ACCESS_KEY_ID + value: "MYACCESSKEY" + - equal: + path: stringData.AWS_SECRET_ACCESS_KEY + value: "MYSECRETKEY" + + - it: fails when s3AccessKey is empty + release: + name: etcd + namespace: tenant-test + set: + backup: + enabled: true + s3AccessKey: "" + s3SecretKey: "secretkey" + destinationPath: "s3://b/" + endpointURL: "https://s3.example.com" + asserts: + - failedTemplate: + errorMessage: "backup.s3AccessKey is required when backup.enabled=true" + + - it: fails when s3SecretKey is empty + release: + name: etcd + namespace: tenant-test + set: + backup: + enabled: true + s3AccessKey: "accesskey" + s3SecretKey: "" + destinationPath: "s3://b/" + endpointURL: "https://s3.example.com" + asserts: + - failedTemplate: + errorMessage: "backup.s3SecretKey is required when backup.enabled=true" diff --git a/packages/extra/etcd/tests/etcd-backup-schedule_test.yaml b/packages/extra/etcd/tests/etcd-backup-schedule_test.yaml new file mode 100644 index 00000000..0f019712 --- /dev/null +++ b/packages/extra/etcd/tests/etcd-backup-schedule_test.yaml @@ -0,0 +1,174 @@ +suite: etcd backup schedule tests + +templates: + - templates/etcd-backup-schedule.yaml + +tests: + - it: does not render when backup disabled + release: + name: etcd + namespace: tenant-test + set: + backup: + enabled: false + asserts: + - hasDocuments: + count: 0 + + - it: renders when backup enabled with valid values + release: + name: etcd + namespace: tenant-test + set: + backup: + enabled: true + schedule: "0 2 * * *" + destinationPath: "s3://my-bucket/etcd/prod/" + endpointURL: "https://s3.example.com" + region: "us-east-1" + forcePathStyle: true + s3AccessKey: "k" + s3SecretKey: "s" + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 1 + asserts: + - hasDocuments: + count: 1 + - isKind: + of: EtcdBackupSchedule + - equal: + path: metadata.name + value: etcd + - equal: + path: spec.clusterRef.name + value: etcd + - equal: + path: spec.schedule + value: "0 2 * * *" + - equal: + path: spec.destination.s3.endpoint + value: "https://s3.example.com" + - equal: + path: spec.destination.s3.bucket + value: "my-bucket" + - equal: + path: spec.destination.s3.key + value: "etcd/prod" + - equal: + path: spec.destination.s3.region + value: "us-east-1" + - equal: + path: spec.destination.s3.forcePathStyle + value: true + - equal: + path: spec.destination.s3.credentialsSecretRef.name + value: etcd-s3-creds + + - it: omits key when destinationPath points at bucket root + release: + name: etcd + namespace: tenant-test + set: + backup: + enabled: true + destinationPath: "s3://bucket-only/" + endpointURL: "https://s3.example.com" + s3AccessKey: "k" + s3SecretKey: "s" + asserts: + - equal: + path: spec.destination.s3.bucket + value: "bucket-only" + - notExists: + path: spec.destination.s3.key + + - it: omits region when not set + release: + name: etcd + namespace: tenant-test + set: + backup: + enabled: true + destinationPath: "s3://b/k/" + endpointURL: "https://s3.example.com" + region: "" + s3AccessKey: "k" + s3SecretKey: "s" + asserts: + - notExists: + path: spec.destination.s3.region + + - it: fails when destinationPath is empty + release: + name: etcd + namespace: tenant-test + set: + backup: + enabled: true + destinationPath: "" + endpointURL: "https://s3.example.com" + s3AccessKey: "k" + s3SecretKey: "s" + asserts: + - failedTemplate: + errorMessage: "backup.destinationPath is required when backup.enabled=true" + + - it: fails when endpointURL is empty + release: + name: etcd + namespace: tenant-test + set: + backup: + enabled: true + destinationPath: "s3://b/" + endpointURL: "" + s3AccessKey: "k" + s3SecretKey: "s" + asserts: + - failedTemplate: + errorMessage: "backup.endpointURL is required when backup.enabled=true" + + - it: fails when destinationPath lacks s3:// prefix + release: + name: etcd + namespace: tenant-test + set: + backup: + enabled: true + destinationPath: "my-bucket/path/" + endpointURL: "https://s3.example.com" + s3AccessKey: "k" + s3SecretKey: "s" + asserts: + - failedTemplate: + errorMessage: "backup.destinationPath must start with s3://" + + - it: fails when destinationPath contains double slashes + release: + name: etcd + namespace: tenant-test + set: + backup: + enabled: true + destinationPath: "s3://bucket//subdir/" + endpointURL: "https://s3.example.com" + s3AccessKey: "k" + s3SecretKey: "s" + asserts: + - failedTemplate: + errorMessage: "backup.destinationPath must not contain '//' segments" + + - it: fails when destinationPath has no bucket + release: + name: etcd + namespace: tenant-test + set: + backup: + enabled: true + destinationPath: "s3:///path/" + endpointURL: "https://s3.example.com" + s3AccessKey: "k" + s3SecretKey: "s" + asserts: + - failedTemplate: + errorMessage: "backup.destinationPath must contain a bucket (e.g. s3://bucket/path/)" diff --git a/packages/extra/etcd/values.schema.json b/packages/extra/etcd/values.schema.json index cde3365f..fbe35054 100644 --- a/packages/extra/etcd/values.schema.json +++ b/packages/extra/etcd/values.schema.json @@ -10,7 +10,7 @@ "destinationPath": { "description": "Destination path for backups (e.g. s3://bucket/path/).", "type": "string", - "default": "s3://bucket/path/to/folder/" + "default": "" }, "enabled": { "description": "Enable scheduled S3 backups.", @@ -20,7 +20,7 @@ "endpointURL": { "description": "S3 endpoint URL for uploads.", "type": "string", - "default": "http://minio-gateway-service:9000" + "default": "" }, "failedJobsHistoryLimit": { "description": "Number of failed backup jobs to retain.", @@ -40,12 +40,12 @@ "s3AccessKey": { "description": "Access key for S3 authentication.", "type": "string", - "default": "\u003cyour-access-key\u003e" + "default": "" }, "s3SecretKey": { "description": "Secret key for S3 authentication.", "type": "string", - "default": "\u003cyour-secret-key\u003e" + "default": "" }, "schedule": { "description": "Cron schedule for automated backups.", diff --git a/packages/extra/etcd/values.yaml b/packages/extra/etcd/values.yaml index 160c5ad9..897dfe48 100644 --- a/packages/extra/etcd/values.yaml +++ b/packages/extra/etcd/values.yaml @@ -40,11 +40,11 @@ resources: backup: enabled: false schedule: "0 2 * * *" - destinationPath: "s3://bucket/path/to/folder/" - endpointURL: "http://minio-gateway-service:9000" + destinationPath: "" + endpointURL: "" region: "" forcePathStyle: true - s3AccessKey: "" - s3SecretKey: "" + s3AccessKey: "" + s3SecretKey: "" successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 1 diff --git a/packages/system/etcd-rd/cozyrds/etcd.yaml b/packages/system/etcd-rd/cozyrds/etcd.yaml index 317f9c27..4110813a 100644 --- a/packages/system/etcd-rd/cozyrds/etcd.yaml +++ b/packages/system/etcd-rd/cozyrds/etcd.yaml @@ -8,7 +8,7 @@ spec: plural: etcds singular: etcd openAPISchema: |- - {"title":"Chart Values","type":"object","properties":{"backup":{"description":"Backup configuration.","type":"object","default":{},"properties":{"destinationPath":{"description":"Destination path for backups (e.g. s3://bucket/path/).","type":"string","default":"s3://bucket/path/to/folder/"},"enabled":{"description":"Enable scheduled S3 backups.","type":"boolean","default":false},"endpointURL":{"description":"S3 endpoint URL for uploads.","type":"string","default":"http://minio-gateway-service:9000"},"failedJobsHistoryLimit":{"description":"Number of failed backup jobs to retain.","type":"integer","default":1},"forcePathStyle":{"description":"Use path-style S3 URLs (required for MinIO and most S3-compatible providers).","type":"boolean","default":true},"region":{"description":"S3 region.","type":"string","default":""},"s3AccessKey":{"description":"Access key for S3 authentication.","type":"string","default":""},"s3SecretKey":{"description":"Secret key for S3 authentication.","type":"string","default":""},"schedule":{"description":"Cron schedule for automated backups.","type":"string","default":"0 2 * * *"},"successfulJobsHistoryLimit":{"description":"Number of successful backup jobs to retain.","type":"integer","default":3}}},"replicas":{"description":"Number of etcd replicas.","type":"integer","default":3},"resources":{"description":"Resource configuration for etcd.","type":"object","default":{},"properties":{"cpu":{"description":"Number of CPU cores allocated.","default":"1000m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Amount of memory allocated.","default":"512Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"size":{"description":"Persistent Volume size.","default":"4Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"storageClass":{"description":"StorageClass used to store the data.","type":"string","default":""}}} + {"title":"Chart Values","type":"object","properties":{"backup":{"description":"Backup configuration.","type":"object","default":{},"properties":{"destinationPath":{"description":"Destination path for backups (e.g. s3://bucket/path/).","type":"string","default":""},"enabled":{"description":"Enable scheduled S3 backups.","type":"boolean","default":false},"endpointURL":{"description":"S3 endpoint URL for uploads.","type":"string","default":""},"failedJobsHistoryLimit":{"description":"Number of failed backup jobs to retain.","type":"integer","default":1},"forcePathStyle":{"description":"Use path-style S3 URLs (required for MinIO and most S3-compatible providers).","type":"boolean","default":true},"region":{"description":"S3 region.","type":"string","default":""},"s3AccessKey":{"description":"Access key for S3 authentication.","type":"string","default":""},"s3SecretKey":{"description":"Secret key for S3 authentication.","type":"string","default":""},"schedule":{"description":"Cron schedule for automated backups.","type":"string","default":"0 2 * * *"},"successfulJobsHistoryLimit":{"description":"Number of successful backup jobs to retain.","type":"integer","default":3}}},"replicas":{"description":"Number of etcd replicas.","type":"integer","default":3},"resources":{"description":"Resource configuration for etcd.","type":"object","default":{},"properties":{"cpu":{"description":"Number of CPU cores allocated.","default":"1000m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Amount of memory allocated.","default":"512Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"size":{"description":"Persistent Volume size.","default":"4Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"storageClass":{"description":"StorageClass used to store the data.","type":"string","default":""}}} release: prefix: "" labels: From 6deca1b07d9343856aa1ca2a936e751b785d782a Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Mon, 20 Apr 2026 18:59:21 +0300 Subject: [PATCH 08/16] chore(etcd-operator): opt into CreateReplace upgrade policy for CRDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.4.3 introduced new CRDs (EtcdBackup, EtcdBackupSchedule). Flux defaults to Skip on upgrade, so on existing clusters those CRDs never land without a manual kubectl apply. Setting upgradeCRDs: CreateReplace on the etcd-operator component tells PackageReconciler to wire hr.Spec.Upgrade.Crds accordingly. etcd-operator's CRDs evolve additively between versions, so CreateReplace is safe here. Depends on the PackageSource.Spec.Variants[].Components[].Install.UpgradeCRDs field — must ship with a cozystack-operator release that recognizes it. Without that, this field is ignored and the HelmRelease still defaults to Skip. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- packages/core/platform/sources/etcd-operator.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/platform/sources/etcd-operator.yaml b/packages/core/platform/sources/etcd-operator.yaml index 5f42a4a0..a262f94e 100644 --- a/packages/core/platform/sources/etcd-operator.yaml +++ b/packages/core/platform/sources/etcd-operator.yaml @@ -21,3 +21,4 @@ spec: install: namespace: cozy-etcd-operator releaseName: etcd-operator + upgradeCRDs: CreateReplace From 9fc99eed50fbd19dc48c38e96bda0735f41bf1dd Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Tue, 21 Apr 2026 02:30:27 +0300 Subject: [PATCH 09/16] chore(etcd): regenerate values.schema.json and cozyrd manifest Run 'make generate' under packages/extra/etcd to refresh the JSON schema and the corresponding ResourceDefinition YAML after adding backup and resources fields. Matches cozyvalues-gen v1.3.0 output used by the pre-commit CI check. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- packages/extra/etcd/values.schema.json | 118 +++++++++++----------- packages/system/etcd-rd/cozyrds/etcd.yaml | 2 +- 2 files changed, 60 insertions(+), 60 deletions(-) diff --git a/packages/extra/etcd/values.schema.json b/packages/extra/etcd/values.schema.json index fbe35054..cbe259d1 100644 --- a/packages/extra/etcd/values.schema.json +++ b/packages/extra/etcd/values.schema.json @@ -2,6 +2,65 @@ "title": "Chart Values", "type": "object", "properties": { + "size": { + "description": "Persistent Volume size.", + "default": "4Gi", + "pattern": "^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$", + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ], + "x-kubernetes-int-or-string": true + }, + "storageClass": { + "description": "StorageClass used to store the data.", + "type": "string", + "default": "" + }, + "replicas": { + "description": "Number of etcd replicas.", + "type": "integer", + "default": 3 + }, + "resources": { + "description": "Resource configuration for etcd.", + "type": "object", + "default": {}, + "properties": { + "cpu": { + "description": "Number of CPU cores allocated.", + "default": "1000m", + "pattern": "^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$", + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ], + "x-kubernetes-int-or-string": true + }, + "memory": { + "description": "Amount of memory allocated.", + "default": "512Mi", + "pattern": "^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$", + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ], + "x-kubernetes-int-or-string": true + } + } + }, "backup": { "description": "Backup configuration.", "type": "object", @@ -58,65 +117,6 @@ "default": 3 } } - }, - "replicas": { - "description": "Number of etcd replicas.", - "type": "integer", - "default": 3 - }, - "resources": { - "description": "Resource configuration for etcd.", - "type": "object", - "default": {}, - "properties": { - "cpu": { - "description": "Number of CPU cores allocated.", - "default": "1000m", - "pattern": "^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$", - "anyOf": [ - { - "type": "integer" - }, - { - "type": "string" - } - ], - "x-kubernetes-int-or-string": true - }, - "memory": { - "description": "Amount of memory allocated.", - "default": "512Mi", - "pattern": "^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$", - "anyOf": [ - { - "type": "integer" - }, - { - "type": "string" - } - ], - "x-kubernetes-int-or-string": true - } - } - }, - "size": { - "description": "Persistent Volume size.", - "default": "4Gi", - "pattern": "^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$", - "anyOf": [ - { - "type": "integer" - }, - { - "type": "string" - } - ], - "x-kubernetes-int-or-string": true - }, - "storageClass": { - "description": "StorageClass used to store the data.", - "type": "string", - "default": "" } } } diff --git a/packages/system/etcd-rd/cozyrds/etcd.yaml b/packages/system/etcd-rd/cozyrds/etcd.yaml index 4110813a..912b1dc1 100644 --- a/packages/system/etcd-rd/cozyrds/etcd.yaml +++ b/packages/system/etcd-rd/cozyrds/etcd.yaml @@ -8,7 +8,7 @@ spec: plural: etcds singular: etcd openAPISchema: |- - {"title":"Chart Values","type":"object","properties":{"backup":{"description":"Backup configuration.","type":"object","default":{},"properties":{"destinationPath":{"description":"Destination path for backups (e.g. s3://bucket/path/).","type":"string","default":""},"enabled":{"description":"Enable scheduled S3 backups.","type":"boolean","default":false},"endpointURL":{"description":"S3 endpoint URL for uploads.","type":"string","default":""},"failedJobsHistoryLimit":{"description":"Number of failed backup jobs to retain.","type":"integer","default":1},"forcePathStyle":{"description":"Use path-style S3 URLs (required for MinIO and most S3-compatible providers).","type":"boolean","default":true},"region":{"description":"S3 region.","type":"string","default":""},"s3AccessKey":{"description":"Access key for S3 authentication.","type":"string","default":""},"s3SecretKey":{"description":"Secret key for S3 authentication.","type":"string","default":""},"schedule":{"description":"Cron schedule for automated backups.","type":"string","default":"0 2 * * *"},"successfulJobsHistoryLimit":{"description":"Number of successful backup jobs to retain.","type":"integer","default":3}}},"replicas":{"description":"Number of etcd replicas.","type":"integer","default":3},"resources":{"description":"Resource configuration for etcd.","type":"object","default":{},"properties":{"cpu":{"description":"Number of CPU cores allocated.","default":"1000m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Amount of memory allocated.","default":"512Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"size":{"description":"Persistent Volume size.","default":"4Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"storageClass":{"description":"StorageClass used to store the data.","type":"string","default":""}}} + {"title":"Chart Values","type":"object","properties":{"size":{"description":"Persistent Volume size.","default":"4Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"storageClass":{"description":"StorageClass used to store the data.","type":"string","default":""},"replicas":{"description":"Number of etcd replicas.","type":"integer","default":3},"resources":{"description":"Resource configuration for etcd.","type":"object","default":{},"properties":{"cpu":{"description":"Number of CPU cores allocated.","default":"1000m","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Amount of memory allocated.","default":"512Mi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"backup":{"description":"Backup configuration.","type":"object","default":{},"properties":{"destinationPath":{"description":"Destination path for backups (e.g. s3://bucket/path/).","type":"string","default":""},"enabled":{"description":"Enable scheduled S3 backups.","type":"boolean","default":false},"endpointURL":{"description":"S3 endpoint URL for uploads.","type":"string","default":""},"failedJobsHistoryLimit":{"description":"Number of failed backup jobs to retain.","type":"integer","default":1},"forcePathStyle":{"description":"Use path-style S3 URLs (required for MinIO and most S3-compatible providers).","type":"boolean","default":true},"region":{"description":"S3 region.","type":"string","default":""},"s3AccessKey":{"description":"Access key for S3 authentication.","type":"string","default":""},"s3SecretKey":{"description":"Secret key for S3 authentication.","type":"string","default":""},"schedule":{"description":"Cron schedule for automated backups.","type":"string","default":"0 2 * * *"},"successfulJobsHistoryLimit":{"description":"Number of successful backup jobs to retain.","type":"integer","default":3}}}}} release: prefix: "" labels: From 4d7b4bbb33d038b9b140082c23b044e7eef1603d Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Tue, 21 Apr 2026 11:03:27 +0300 Subject: [PATCH 10/16] fix(tests): replace bats run idiom in etcd e2e test cozytest.sh does not implement the bats 'run ' helper and '$status' variable, so the 'Create Etcd with empty backup block (disabled by default)' test died with 'run: not found' on every attempt. Replace the bats idiom with plain shell: use '!' prefix and redirect stderr to assert the resources absent-when-backup-disabled invariant directly. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/e2e-apps/etcd.bats | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/hack/e2e-apps/etcd.bats b/hack/e2e-apps/etcd.bats index 13bd5b5d..cfc146d6 100644 --- a/hack/e2e-apps/etcd.bats +++ b/hack/e2e-apps/etcd.bats @@ -60,10 +60,8 @@ EOF sleep 5 kubectl -n tenant-test wait hr etcd --timeout=60s --for=condition=ready || { dump_diagnostics; false; } # With backup disabled, neither the schedule nor the secret should be created. - run kubectl -n tenant-test get etcdbackupschedule.etcd.aenix.io etcd - [ "$status" -ne 0 ] - run kubectl -n tenant-test get secret etcd-s3-creds - [ "$status" -ne 0 ] + ! kubectl -n tenant-test get etcdbackupschedule.etcd.aenix.io etcd 2>/dev/null + ! kubectl -n tenant-test get secret etcd-s3-creds 2>/dev/null } @test "Create Etcd with backup schedule" { From aaf9dd047c81aaf2fd0e0080616a538887a8e2d8 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Fri, 24 Apr 2026 13:42:17 +0300 Subject: [PATCH 11/16] fix(etcd): cast backup history limits to int in template Address review feedback from gemini-code-assist on packages/extra/etcd/templates/etcd-backup-schedule.yaml:20-21: explicitly coerce successfulJobsHistoryLimit and failedJobsHistoryLimit to int so string values passed via helm --set do not produce a type mismatch in the EtcdBackupSchedule spec. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- packages/extra/etcd/templates/etcd-backup-schedule.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/extra/etcd/templates/etcd-backup-schedule.yaml b/packages/extra/etcd/templates/etcd-backup-schedule.yaml index 58b8f03b..3dce5a9e 100644 --- a/packages/extra/etcd/templates/etcd-backup-schedule.yaml +++ b/packages/extra/etcd/templates/etcd-backup-schedule.yaml @@ -17,8 +17,8 @@ spec: clusterRef: name: etcd schedule: {{ .Values.backup.schedule | quote }} - successfulJobsHistoryLimit: {{ .Values.backup.successfulJobsHistoryLimit }} - failedJobsHistoryLimit: {{ .Values.backup.failedJobsHistoryLimit }} + successfulJobsHistoryLimit: {{ .Values.backup.successfulJobsHistoryLimit | int }} + failedJobsHistoryLimit: {{ .Values.backup.failedJobsHistoryLimit | int }} destination: s3: endpoint: {{ $endpointURL | quote }} From f8f3b294800e7ed636bd95d0a0120c3d917039e8 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Fri, 24 Apr 2026 13:42:37 +0300 Subject: [PATCH 12/16] fix(tests): correct stderr redirection order in etcd diagnostics Address review feedback from coderabbitai on hack/e2e-apps/etcd.bats:19-21: redirect stdout to fd 3 first, then dup stderr onto the new fd 1 so failures from kubectl (NotFound, RBAC, timeouts) reach the test diagnostics stream instead of being swallowed by the bats-captured default stdout. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/e2e-apps/etcd.bats | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hack/e2e-apps/etcd.bats b/hack/e2e-apps/etcd.bats index cfc146d6..7cc9a290 100644 --- a/hack/e2e-apps/etcd.bats +++ b/hack/e2e-apps/etcd.bats @@ -16,9 +16,9 @@ setup() { dump_diagnostics() { echo "# --- diagnostics ---" >&3 - kubectl -n tenant-test get etcdcluster,etcdbackupschedule,cronjob -o wide 2>&1 >&3 || true - kubectl -n tenant-test describe etcdbackupschedule etcd 2>&1 >&3 || true - kubectl -n cozy-etcd-operator logs -l app.kubernetes.io/name=etcd-operator --tail=100 2>&1 >&3 || true + kubectl -n tenant-test get etcdcluster,etcdbackupschedule,cronjob -o wide >&3 2>&1 || true + kubectl -n tenant-test describe etcdbackupschedule etcd >&3 2>&1 || true + kubectl -n cozy-etcd-operator logs -l app.kubernetes.io/name=etcd-operator --tail=100 >&3 2>&1 || true } @test "Create Etcd" { From 040d5d468148a5f0ec95983ac606744eb0c64d88 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Fri, 24 Apr 2026 13:43:05 +0300 Subject: [PATCH 13/16] docs(etcd): use "custom resource" instead of "CRD" for EtcdBackup Address review feedback from coderabbitai on packages/extra/etcd/README.md:9: CRDs are schema definitions; what users apply to trigger a restore or one-shot backup is a custom resource instance of the EtcdBackup kind, not the CRD itself. Reword accordingly. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- packages/extra/etcd/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/extra/etcd/README.md b/packages/extra/etcd/README.md index 71125347..fb146c59 100644 --- a/packages/extra/etcd/README.md +++ b/packages/extra/etcd/README.md @@ -6,7 +6,7 @@ When `backup.enabled` is set to `true`, the chart renders an `EtcdBackupSchedule Enabling backup requires the following fields to be explicitly set (defaults are empty strings so that missing values fail fast at template render time): `backup.s3AccessKey`, `backup.s3SecretKey`, `backup.destinationPath` (must start with `s3://` and have no `//` segments), and `backup.endpointURL`. S3 credentials passed through plain values end up in the HelmRelease manifest — for production deployments prefer an external secret management tool (ESO, Sealed Secrets, etc.) over committing the keys to Git. -**Restore** (`EtcdCluster.spec.bootstrap`) and the one-shot `EtcdBackup` CRD shipped upstream in v0.4.3 are not yet exposed through this chart. Restoring from a snapshot or taking an ad-hoc backup currently requires hand-applying the corresponding CRD. +**Restore** (`EtcdCluster.spec.bootstrap`) and the one-shot `EtcdBackup` custom resource shipped upstream in v0.4.3 are not yet exposed through this chart. Restoring from a snapshot or taking an ad-hoc backup currently requires hand-applying the corresponding custom resource manifest. ## Parameters From 86a93af2831f2f1837aec0cf2d6cc6feb909ec08 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Fri, 24 Apr 2026 13:43:23 +0300 Subject: [PATCH 14/16] fix(tests): stop masking etcd e2e cleanup failures Address review feedback from coderabbitai on hack/e2e-apps/etcd.bats:9-14: drop the '|| true' guards in setup() so real teardown failures (RBAC errors, stuck finalizers, wait timeouts) fail the test setup instead of letting the next test run against stale HelmRelease, Secret, or EtcdBackupSchedule state. --ignore-not-found still covers the empty-namespace case on a clean run. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/e2e-apps/etcd.bats | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/hack/e2e-apps/etcd.bats b/hack/e2e-apps/etcd.bats index 7cc9a290..b20fe918 100644 --- a/hack/e2e-apps/etcd.bats +++ b/hack/e2e-apps/etcd.bats @@ -6,12 +6,12 @@ # prior run so tests remain independent despite the singleton name. setup() { - kubectl -n tenant-test delete etcd.apps.cozystack.io --all --ignore-not-found --timeout=2m || true + kubectl -n tenant-test delete etcd.apps.cozystack.io --all --ignore-not-found --timeout=2m # HelmRelease teardown is async relative to the CR deletion above; wait for # downstream resources so the next test starts from a clean state. - kubectl -n tenant-test wait hr/etcd --for=delete --timeout=2m --ignore-not-found || true - kubectl -n tenant-test wait secret/etcd-s3-creds --for=delete --timeout=1m --ignore-not-found || true - kubectl -n tenant-test wait etcdbackupschedule.etcd.aenix.io/etcd --for=delete --timeout=1m --ignore-not-found || true + kubectl -n tenant-test wait hr/etcd --for=delete --timeout=2m --ignore-not-found + kubectl -n tenant-test wait secret/etcd-s3-creds --for=delete --timeout=1m --ignore-not-found + kubectl -n tenant-test wait etcdbackupschedule.etcd.aenix.io/etcd --for=delete --timeout=1m --ignore-not-found } dump_diagnostics() { From 97eef022e55a2f58454ca339873d7fdc9b48a68e Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Fri, 24 Apr 2026 13:44:02 +0300 Subject: [PATCH 15/16] fix(tests): poll for etcd HelmRelease existence before waiting on readiness Address review feedback from coderabbitai on hack/e2e-apps/etcd.bats:39-40: replace the sleep-then-wait pattern with a wait_etcd_hr_ready helper that polls hr/etcd until the Cozystack API reconciles the Etcd CR and only then waits for condition=ready. kubectl wait exits immediately when the resource does not yet exist, which caused spurious failures when HelmRelease creation took longer than 5s. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/e2e-apps/etcd.bats | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/hack/e2e-apps/etcd.bats b/hack/e2e-apps/etcd.bats index b20fe918..7b940c8f 100644 --- a/hack/e2e-apps/etcd.bats +++ b/hack/e2e-apps/etcd.bats @@ -21,6 +21,14 @@ dump_diagnostics() { kubectl -n cozy-etcd-operator logs -l app.kubernetes.io/name=etcd-operator --tail=100 >&3 2>&1 || true } +# Wait until the etcd HelmRelease is reconciled by Flux and its +# condition=ready is set. kubectl wait fails immediately on a missing +# resource, so poll for existence first. +wait_etcd_hr_ready() { + timeout 60 sh -ec 'until kubectl -n tenant-test get hr/etcd >/dev/null 2>&1; do sleep 2; done' + kubectl -n tenant-test wait hr/etcd --timeout=60s --for=condition=ready +} + @test "Create Etcd" { kubectl apply -f- </dev/null ! kubectl -n tenant-test get secret etcd-s3-creds 2>/dev/null @@ -94,8 +100,7 @@ spec: successfulJobsHistoryLimit: 1 failedJobsHistoryLimit: 1 EOF - sleep 5 - kubectl -n tenant-test wait hr etcd --timeout=60s --for=condition=ready || { dump_diagnostics; false; } + wait_etcd_hr_ready || { dump_diagnostics; false; } kubectl -n tenant-test wait etcdcluster.etcd.aenix.io etcd --timeout=180s --for=jsonpath='{.status.conditions[?(@.type=="Ready")].status}'=True || { dump_diagnostics; false; } kubectl -n tenant-test get etcdbackupschedule.etcd.aenix.io etcd || { dump_diagnostics; false; } # Verify the region field propagated to the EtcdBackupSchedule. From cc7d09b458c0286ea93f821ac0d6f1eb908d571e Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Fri, 24 Apr 2026 13:44:21 +0300 Subject: [PATCH 16/16] fix(tests): use far-future cron in etcd backup e2e to avoid job churn Address review feedback from coderabbitai on hack/e2e-apps/etcd.bats:89: the test verifies that the chart renders EtcdBackupSchedule and that the etcd-operator materializes a CronJob, not that backups fire. Use "0 0 1 1 *" so no Jobs run against the intentionally invalid endpoint during the ~6-minute test window. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/e2e-apps/etcd.bats | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/hack/e2e-apps/etcd.bats b/hack/e2e-apps/etcd.bats index 7b940c8f..abc290db 100644 --- a/hack/e2e-apps/etcd.bats +++ b/hack/e2e-apps/etcd.bats @@ -86,11 +86,12 @@ spec: memory: 128Mi backup: enabled: true - schedule: "*/1 * * * *" - # This test verifies that the chart renders EtcdBackupSchedule/Secret and - # that the etcd-operator materializes a CronJob from the schedule — it does - # NOT verify that backups reach S3. The endpoint below intentionally - # resolves nowhere to keep the test self-contained. + # Schedule is chosen far in the future so no Jobs fire during the + # ~6-minute test window; this test only checks that the chart renders + # EtcdBackupSchedule/Secret and that the etcd-operator materializes a + # CronJob — it does NOT verify that backups reach S3. The endpoint + # below intentionally resolves nowhere to keep the test self-contained. + schedule: "0 0 1 1 *" destinationPath: "s3://test-bucket/etcd-backups/" endpointURL: "http://no-such-endpoint.invalid:9000" region: "us-east-1"