diff --git a/.cruft.json b/.cruft.json index d3bca23..64eb967 100644 --- a/.cruft.json +++ b/.cruft.json @@ -7,7 +7,7 @@ "name": "solution-backup", "slug": "solution-backup", "parameter_key": "solution_backup", - "test_cases": "defaults", + "test_cases": "defaults override-schedules override-labels", "add_lib": "n", "add_pp": "n", "add_golden": "y", diff --git a/.github/changelog-configuration.json b/.github/changelog-configuration.json index 40dc985..2d9bd3d 100644 --- a/.github/changelog-configuration.json +++ b/.github/changelog-configuration.json @@ -29,4 +29,3 @@ ], "template": "${{CATEGORIZED_COUNT}} changes since ${{FROM_TAG}}\n\n${{CHANGELOG}}" } - diff --git a/.github/workflows/espejote.yaml b/.github/workflows/espejote.yaml new file mode 100644 index 0000000..85df869 --- /dev/null +++ b/.github/workflows/espejote.yaml @@ -0,0 +1,20 @@ +name: Espejote render +on: + pull_request: + push: + +env: + ESPEJOTE_VERSION: 0.19.0 + +jobs: + espejote: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Install espejote ${{ env.ESPEJOTE_VERSION }} + run: | + set -euo pipefail + curl -sL "https://github.com/vshn/espejote/releases/download/v${ESPEJOTE_VERSION}/espejote_${ESPEJOTE_VERSION}_linux_amd64.tar.gz" | tar xz espejote + sudo install -m 0755 espejote /usr/local/bin/espejote + - name: Render and diff expected files + run: make test-espejote diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 2cd0c47..e14822b 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -33,6 +33,8 @@ jobs: matrix: instance: - defaults + - override-schedules + - override-labels defaults: run: working-directory: ${{ env.COMPONENT_NAME }} @@ -48,6 +50,8 @@ jobs: matrix: instance: - defaults + - override-schedules + - override-labels defaults: run: working-directory: ${{ env.COMPONENT_NAME }} diff --git a/Makefile b/Makefile index 02f0524..9ce5ea8 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ SHELL := bash .SUFFIXES: include Makefile.vars.mk +-include Makefile.custom.mk .PHONY: help help: ## Show this help diff --git a/Makefile.custom.mk b/Makefile.custom.mk new file mode 100644 index 0000000..9bef4ec --- /dev/null +++ b/Makefile.custom.mk @@ -0,0 +1,11 @@ +# Configure instance alias for commodore component compile +commodore_args += --alias $(instance) + +.PHONY: test-espejote +test-espejote: + bash tests/render/run.sh + git diff --exit-code -- tests/render/expected/ + +.PHONY: gen-espejote +gen-espejote: + bash tests/render/run.sh diff --git a/Makefile.vars.mk b/Makefile.vars.mk index c5796aa..6fb934e 100644 --- a/Makefile.vars.mk +++ b/Makefile.vars.mk @@ -50,4 +50,4 @@ KUBENT_IMAGE ?= ghcr.io/doitintl/kube-no-trouble:latest KUBENT_DOCKER ?= $(DOCKER_CMD) $(DOCKER_ARGS) $(root_volume) --entrypoint=/app/kubent $(KUBENT_IMAGE) instance ?= defaults -test_instances = tests/defaults.yml +test_instances = tests/defaults.yml tests/override-schedules.yml tests/override-labels.yml diff --git a/class/defaults.yml b/class/defaults.yml index 91a5a50..12a0afc 100644 --- a/class/defaults.yml +++ b/class/defaults.yml @@ -2,4 +2,33 @@ parameters: solution_backup: =_metadata: multi_tenant: true - namespace: syn-solution-backup + multi_version: true + multi_instance: true + namespace: ${_instance} + + createNamespace: true + namespaceAnnotations: {} + namespaceLabels: {} + + backup_namespaces: + label: vshn.ch/solution-base-backup + value: "true" + + backup_encryption_secret: + label: vshn.ch/solution-base-backup-encryption-secret + + s3: + endpoint: https://objects.%(region)s.cloudscale.ch + region: rma + + objectbucket: + name_prefix: pvc-backup- + + schedule: + backup: "0 1 * * *" + check: "0 3 * * *" + prune: "0 4 * * *" + retention: + keepDaily: 3 + keepWeekly: 2 + keepMonthly: 1 diff --git a/component/app.jsonnet b/component/app.jsonnet index 9ed913d..a22d149 100644 --- a/component/app.jsonnet +++ b/component/app.jsonnet @@ -3,12 +3,21 @@ local inv = kap.inventory(); local params = inv.parameters.solution_backup; local argocd = import 'lib/argocd.libjsonnet'; -local app = argocd.App('solution-backup', params.namespace); +local instance = inv.parameters._instance; +local app = argocd.App(instance, params.namespace, secrets=true, base='solution_backup') { + spec+: { + syncPolicy+: { + syncOptions+: [ + 'ServerSideApply=true', + ], + }, + }, +}; local appPath = local project = std.get(std.get(app, 'spec', {}), 'project', 'syn'); if project == 'syn' then 'apps' else 'apps-%s' % project; { - ['%s/solution-backup' % appPath]: app, + ['%s/%s' % [ appPath, instance ]]: app, } diff --git a/component/main.jsonnet b/component/main.jsonnet index 1570c28..d2e92e7 100644 --- a/component/main.jsonnet +++ b/component/main.jsonnet @@ -1,10 +1,212 @@ -// main template for solution-backup local kap = import 'lib/kapitan.libjsonnet'; local kube = import 'lib/kube.libjsonnet'; + local inv = kap.inventory(); -// The hiera parameters for the component local params = inv.parameters.solution_backup; +local instance = inv.parameters._instance; +local slug = std.substr(std.sha256(instance), 0, 8); + +local nsName = params.namespace; +local objectPrefix = 'pvc-backup-' + slug; +local saName = 'solution-backup-manager'; + +local backupNs = params.backup_namespaces; +local encryption = params.backup_encryption_secret; + +local s3 = params.s3; +local ob = params.objectbucket; +local sched = params.schedule; +local endpointResolved = s3.endpoint % { region: s3.region }; + +local namespace = (if params.createNamespace then kube.Namespace(nsName) else null) + { + metadata+: { + labels+: params.namespaceLabels, + annotations+: params.namespaceAnnotations, + }, +}; + +local serviceAccount = kube.ServiceAccount(saName) { + metadata+: { namespace: nsName }, +}; +local clusterRole = { + apiVersion: 'rbac.authorization.k8s.io/v1', + kind: 'ClusterRole', + metadata: { + name: nsName + ':' + saName, + }, + rules: [ + { + apiGroups: [ '' ], + resources: [ 'namespaces' ], + verbs: [ 'get', 'list', 'watch' ], + }, + { + apiGroups: [ 'appcat.vshn.io' ], + resources: [ 'objectbuckets' ], + verbs: [ '*' ], + }, + { + apiGroups: [ 'k8up.io' ], + resources: [ 'schedules' ], + verbs: [ '*' ], + }, + ], +}; +local clusterRoleBinding = { + apiVersion: 'rbac.authorization.k8s.io/v1', + kind: 'ClusterRoleBinding', + metadata: { + name: nsName + ':' + saName, + }, + roleRef: { + apiGroup: 'rbac.authorization.k8s.io', + kind: 'ClusterRole', + name: clusterRole.metadata.name, + }, + subjects: [ + { + kind: 'ServiceAccount', + name: saName, + namespace: nsName, + }, + ], +}; + +local rawTemplate = ||| + local esp = import 'espejote.libsonnet'; + + local inDelete(obj) = std.get(obj.metadata, 'deletionTimestamp', '') != ''; + local wantsBackup(ns) = + local labels = std.get(ns.metadata, 'labels', {}); + !inDelete(ns) + && std.get(labels, '%(optInLabel)s', 'false') == '%(optInValue)s' + && std.objectHas(labels, '%(encryptionSecretNameLabel)s'); + + local managedBy = { 'espejote.io/managed-by': '%(managedBy)s' }; + local credsSecretName = '%(credsSecretName)s'; + local bucketNamePrefix = '%(bucketNamePrefix)s'; + + local encryptionSecretName(ns) = + std.get(ns.metadata, 'labels', {})['%(encryptionSecretNameLabel)s']; + + local nsSlug(ns) = std.substr(std.sha256(ns.metadata.name), 0, 8); + + local makeObjectBucket(ns) = { + apiVersion: 'appcat.vshn.io/v1', + kind: 'ObjectBucket', + metadata: { + name: '%(objectPrefix)s', + namespace: ns.metadata.name, + labels: managedBy + }, + spec: { + parameters: { + bucketName: bucketNamePrefix + nsSlug(ns), + region: '%(region)s' + }, + writeConnectionSecretToRef: { + name: credsSecretName + }, + }, + }; + + local makeSchedule(ns) = { + apiVersion: 'k8up.io/v1', + kind: 'Schedule', + metadata: { + name: '%(objectPrefix)s', + namespace: ns.metadata.name, + labels: managedBy + }, + spec: { + backend: { + repoPasswordSecretRef: { + name: encryptionSecretName(ns), + key: 'password' + }, + s3: { + endpoint: '%(endpoint)s', + bucket: bucketNamePrefix + nsSlug(ns), + accessKeyIDSecretRef: { + name: credsSecretName, + key: 'AWS_ACCESS_KEY_ID' + }, + secretAccessKeySecretRef: { + name: credsSecretName, + key: 'AWS_SECRET_ACCESS_KEY' + }, + }, + }, + backup: { schedule: '%(backupSchedule)s' }, + check: { schedule: '%(checkSchedule)s' }, + prune: { schedule: '%(pruneSchedule)s', retention: %(retention)s }, + }, + }; + + local manageNs(ns) = [ makeObjectBucket(ns), makeSchedule(ns) ]; + + local tn = esp.triggerName(); + if tn == 'namespace' then ( + local ns = esp.triggerData().resource; + if ns != null && wantsBackup(ns) then manageNs(ns) else [] + ) else [ + obj + for ns in esp.context().backup_namespaces + if wantsBackup(ns) + for obj in manageNs(ns) + ] +|||; +local template = rawTemplate % { + optInLabel: backupNs.label, + optInValue: backupNs.value, + managedBy: nsName + '.' + objectPrefix, + objectPrefix: objectPrefix, + credsSecretName: 'backup-bucket-creds-' + slug, + bucketNamePrefix: ob.name_prefix + slug + '-', + encryptionSecretNameLabel: encryption.label, + region: s3.region, + endpoint: endpointResolved, + backupSchedule: sched.backup, + checkSchedule: sched.check, + pruneSchedule: sched.prune, + retention: std.manifestJsonEx(sched.retention, ' '), +}; + +local managedResource = { + apiVersion: 'espejote.io/v1alpha1', + kind: 'ManagedResource', + metadata: { + name: objectPrefix, + namespace: nsName, + }, + spec: { + serviceAccountRef: { name: saName }, + context: [ + { + name: 'backup_namespaces', + resource: { + apiVersion: 'v1', + kind: 'Namespace', + namespace: '', + labelSelector: { + matchLabels: { + [backupNs.label]: backupNs.value, + }, + }, + }, + }, + ], + triggers: [ + { name: 'namespace', watchContextResource: { name: 'backup_namespaces' } }, + ], + template: template, + }, +}; -// Define outputs below { + [if params.createNamespace then 'namespace']: namespace, + serviceAccount: serviceAccount, + clusterRole: clusterRole, + clusterRoleBinding: clusterRoleBinding, + managedResource: managedResource, } diff --git a/renovate.json b/renovate.json index f2e1d67..50cab48 100644 --- a/renovate.json +++ b/renovate.json @@ -5,7 +5,9 @@ ":disableDependencyDashboard" ], "ignorePaths": [ - ".github/**" + ".github/workflows/auto-release.yaml", + ".github/workflows/release.yaml", + ".github/workflows/test.yaml" ], "labels": [ "dependency" @@ -23,6 +25,19 @@ "suppressNotifications": [ "artifactErrors" ], + "customManagers": [ + { + "customType": "regex", + "fileMatch": [ + "^\\.github/workflows/espejote\\.ya?ml$" + ], + "matchStrings": [ + "ESPEJOTE_VERSION:\\s*(?v?[0-9.]+)" + ], + "datasourceTemplate": "github-releases", + "depNameTemplate": "vshn/espejote" + } + ], "packageRules": [ { "matchUpdateTypes": [ diff --git a/tests/golden/defaults/defaults/apps/defaults.yaml b/tests/golden/defaults/defaults/apps/defaults.yaml new file mode 100644 index 0000000..6825b97 --- /dev/null +++ b/tests/golden/defaults/defaults/apps/defaults.yaml @@ -0,0 +1,4 @@ +spec: + syncPolicy: + syncOptions: + - ServerSideApply=true diff --git a/tests/golden/defaults/defaults/solution-backup/clusterRole.yaml b/tests/golden/defaults/defaults/solution-backup/clusterRole.yaml new file mode 100644 index 0000000..c9cfe61 --- /dev/null +++ b/tests/golden/defaults/defaults/solution-backup/clusterRole.yaml @@ -0,0 +1,25 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: defaults:solution-backup-manager +rules: + - apiGroups: + - '' + resources: + - namespaces + verbs: + - get + - list + - watch + - apiGroups: + - appcat.vshn.io + resources: + - objectbuckets + verbs: + - '*' + - apiGroups: + - k8up.io + resources: + - schedules + verbs: + - '*' diff --git a/tests/golden/defaults/defaults/solution-backup/clusterRoleBinding.yaml b/tests/golden/defaults/defaults/solution-backup/clusterRoleBinding.yaml new file mode 100644 index 0000000..3c8aa02 --- /dev/null +++ b/tests/golden/defaults/defaults/solution-backup/clusterRoleBinding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: defaults:solution-backup-manager +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: defaults:solution-backup-manager +subjects: + - kind: ServiceAccount + name: solution-backup-manager + namespace: defaults diff --git a/tests/golden/defaults/defaults/solution-backup/managedResource.yaml b/tests/golden/defaults/defaults/solution-backup/managedResource.yaml new file mode 100644 index 0000000..61281dd --- /dev/null +++ b/tests/golden/defaults/defaults/solution-backup/managedResource.yaml @@ -0,0 +1,108 @@ +apiVersion: espejote.io/v1alpha1 +kind: ManagedResource +metadata: + name: pvc-backup-0f37e81d + namespace: defaults +spec: + context: + - name: backup_namespaces + resource: + apiVersion: v1 + kind: Namespace + labelSelector: + matchLabels: + vshn.ch/solution-base-backup: 'true' + namespace: '' + serviceAccountRef: + name: solution-backup-manager + template: | + local esp = import 'espejote.libsonnet'; + + local inDelete(obj) = std.get(obj.metadata, 'deletionTimestamp', '') != ''; + local wantsBackup(ns) = + local labels = std.get(ns.metadata, 'labels', {}); + !inDelete(ns) + && std.get(labels, 'vshn.ch/solution-base-backup', 'false') == 'true' + && std.objectHas(labels, 'vshn.ch/solution-base-backup-encryption-secret'); + + local managedBy = { 'espejote.io/managed-by': 'defaults.pvc-backup-0f37e81d' }; + local credsSecretName = 'backup-bucket-creds-0f37e81d'; + local bucketNamePrefix = 'pvc-backup-0f37e81d-'; + + local encryptionSecretName(ns) = + std.get(ns.metadata, 'labels', {})['vshn.ch/solution-base-backup-encryption-secret']; + + local nsSlug(ns) = std.substr(std.sha256(ns.metadata.name), 0, 8); + + local makeObjectBucket(ns) = { + apiVersion: 'appcat.vshn.io/v1', + kind: 'ObjectBucket', + metadata: { + name: 'pvc-backup-0f37e81d', + namespace: ns.metadata.name, + labels: managedBy + }, + spec: { + parameters: { + bucketName: bucketNamePrefix + nsSlug(ns), + region: 'rma' + }, + writeConnectionSecretToRef: { + name: credsSecretName + }, + }, + }; + + local makeSchedule(ns) = { + apiVersion: 'k8up.io/v1', + kind: 'Schedule', + metadata: { + name: 'pvc-backup-0f37e81d', + namespace: ns.metadata.name, + labels: managedBy + }, + spec: { + backend: { + repoPasswordSecretRef: { + name: encryptionSecretName(ns), + key: 'password' + }, + s3: { + endpoint: 'https://objects.rma.cloudscale.ch', + bucket: bucketNamePrefix + nsSlug(ns), + accessKeyIDSecretRef: { + name: credsSecretName, + key: 'AWS_ACCESS_KEY_ID' + }, + secretAccessKeySecretRef: { + name: credsSecretName, + key: 'AWS_SECRET_ACCESS_KEY' + }, + }, + }, + backup: { schedule: '0 1 * * *' }, + check: { schedule: '0 3 * * *' }, + prune: { schedule: '0 4 * * *', retention: { + "keepDaily": 3, + "keepMonthly": 1, + "keepWeekly": 2 + } }, + }, + }; + + local manageNs(ns) = [ makeObjectBucket(ns), makeSchedule(ns) ]; + + local tn = esp.triggerName(); + if tn == 'namespace' then ( + local ns = esp.triggerData().resource; + if ns != null && wantsBackup(ns) then manageNs(ns) else [] + ) else [ + obj + for ns in esp.context().backup_namespaces + if wantsBackup(ns) + for obj in manageNs(ns) + ] + triggers: + - name: namespace + watchContextResource: + name: backup_namespaces diff --git a/tests/golden/defaults/defaults/solution-backup/namespace.yaml b/tests/golden/defaults/defaults/solution-backup/namespace.yaml new file mode 100644 index 0000000..be02cdb --- /dev/null +++ b/tests/golden/defaults/defaults/solution-backup/namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + annotations: {} + labels: + name: defaults + name: defaults diff --git a/tests/golden/defaults/defaults/solution-backup/serviceAccount.yaml b/tests/golden/defaults/defaults/solution-backup/serviceAccount.yaml new file mode 100644 index 0000000..99b30e9 --- /dev/null +++ b/tests/golden/defaults/defaults/solution-backup/serviceAccount.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + annotations: {} + labels: + name: solution-backup-manager + name: solution-backup-manager + namespace: defaults diff --git a/tests/golden/defaults/solution-backup/apps/solution-backup.yaml b/tests/golden/defaults/solution-backup/apps/solution-backup.yaml deleted file mode 100644 index e69de29..0000000 diff --git a/tests/golden/override-labels/override-labels/apps/override-labels.yaml b/tests/golden/override-labels/override-labels/apps/override-labels.yaml new file mode 100644 index 0000000..6825b97 --- /dev/null +++ b/tests/golden/override-labels/override-labels/apps/override-labels.yaml @@ -0,0 +1,4 @@ +spec: + syncPolicy: + syncOptions: + - ServerSideApply=true diff --git a/tests/golden/override-labels/override-labels/solution-backup/clusterRole.yaml b/tests/golden/override-labels/override-labels/solution-backup/clusterRole.yaml new file mode 100644 index 0000000..c6e0b9d --- /dev/null +++ b/tests/golden/override-labels/override-labels/solution-backup/clusterRole.yaml @@ -0,0 +1,25 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: override-labels:solution-backup-manager +rules: + - apiGroups: + - '' + resources: + - namespaces + verbs: + - get + - list + - watch + - apiGroups: + - appcat.vshn.io + resources: + - objectbuckets + verbs: + - '*' + - apiGroups: + - k8up.io + resources: + - schedules + verbs: + - '*' diff --git a/tests/golden/override-labels/override-labels/solution-backup/clusterRoleBinding.yaml b/tests/golden/override-labels/override-labels/solution-backup/clusterRoleBinding.yaml new file mode 100644 index 0000000..ad57464 --- /dev/null +++ b/tests/golden/override-labels/override-labels/solution-backup/clusterRoleBinding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: override-labels:solution-backup-manager +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: override-labels:solution-backup-manager +subjects: + - kind: ServiceAccount + name: solution-backup-manager + namespace: override-labels diff --git a/tests/golden/override-labels/override-labels/solution-backup/managedResource.yaml b/tests/golden/override-labels/override-labels/solution-backup/managedResource.yaml new file mode 100644 index 0000000..38ce0f8 --- /dev/null +++ b/tests/golden/override-labels/override-labels/solution-backup/managedResource.yaml @@ -0,0 +1,108 @@ +apiVersion: espejote.io/v1alpha1 +kind: ManagedResource +metadata: + name: pvc-backup-e5344851 + namespace: override-labels +spec: + context: + - name: backup_namespaces + resource: + apiVersion: v1 + kind: Namespace + labelSelector: + matchLabels: + acme.io/backup-enabled: 'yes' + namespace: '' + serviceAccountRef: + name: solution-backup-manager + template: | + local esp = import 'espejote.libsonnet'; + + local inDelete(obj) = std.get(obj.metadata, 'deletionTimestamp', '') != ''; + local wantsBackup(ns) = + local labels = std.get(ns.metadata, 'labels', {}); + !inDelete(ns) + && std.get(labels, 'acme.io/backup-enabled', 'false') == 'yes' + && std.objectHas(labels, 'acme.io/backup-encryption-secret'); + + local managedBy = { 'espejote.io/managed-by': 'override-labels.pvc-backup-e5344851' }; + local credsSecretName = 'backup-bucket-creds-e5344851'; + local bucketNamePrefix = 'pvc-backup-e5344851-'; + + local encryptionSecretName(ns) = + std.get(ns.metadata, 'labels', {})['acme.io/backup-encryption-secret']; + + local nsSlug(ns) = std.substr(std.sha256(ns.metadata.name), 0, 8); + + local makeObjectBucket(ns) = { + apiVersion: 'appcat.vshn.io/v1', + kind: 'ObjectBucket', + metadata: { + name: 'pvc-backup-e5344851', + namespace: ns.metadata.name, + labels: managedBy + }, + spec: { + parameters: { + bucketName: bucketNamePrefix + nsSlug(ns), + region: 'ch-dk-2' + }, + writeConnectionSecretToRef: { + name: credsSecretName + }, + }, + }; + + local makeSchedule(ns) = { + apiVersion: 'k8up.io/v1', + kind: 'Schedule', + metadata: { + name: 'pvc-backup-e5344851', + namespace: ns.metadata.name, + labels: managedBy + }, + spec: { + backend: { + repoPasswordSecretRef: { + name: encryptionSecretName(ns), + key: 'password' + }, + s3: { + endpoint: 'https://sos-ch-dk-2.exo.io', + bucket: bucketNamePrefix + nsSlug(ns), + accessKeyIDSecretRef: { + name: credsSecretName, + key: 'AWS_ACCESS_KEY_ID' + }, + secretAccessKeySecretRef: { + name: credsSecretName, + key: 'AWS_SECRET_ACCESS_KEY' + }, + }, + }, + backup: { schedule: '0 1 * * *' }, + check: { schedule: '0 3 * * *' }, + prune: { schedule: '0 4 * * *', retention: { + "keepDaily": 3, + "keepMonthly": 1, + "keepWeekly": 2 + } }, + }, + }; + + local manageNs(ns) = [ makeObjectBucket(ns), makeSchedule(ns) ]; + + local tn = esp.triggerName(); + if tn == 'namespace' then ( + local ns = esp.triggerData().resource; + if ns != null && wantsBackup(ns) then manageNs(ns) else [] + ) else [ + obj + for ns in esp.context().backup_namespaces + if wantsBackup(ns) + for obj in manageNs(ns) + ] + triggers: + - name: namespace + watchContextResource: + name: backup_namespaces diff --git a/tests/golden/override-labels/override-labels/solution-backup/namespace.yaml b/tests/golden/override-labels/override-labels/solution-backup/namespace.yaml new file mode 100644 index 0000000..d8147f7 --- /dev/null +++ b/tests/golden/override-labels/override-labels/solution-backup/namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + annotations: {} + labels: + name: override-labels + name: override-labels diff --git a/tests/golden/override-labels/override-labels/solution-backup/serviceAccount.yaml b/tests/golden/override-labels/override-labels/solution-backup/serviceAccount.yaml new file mode 100644 index 0000000..17f3ce1 --- /dev/null +++ b/tests/golden/override-labels/override-labels/solution-backup/serviceAccount.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + annotations: {} + labels: + name: solution-backup-manager + name: solution-backup-manager + namespace: override-labels diff --git a/tests/golden/override-schedules/override-schedules/apps/override-schedules.yaml b/tests/golden/override-schedules/override-schedules/apps/override-schedules.yaml new file mode 100644 index 0000000..6825b97 --- /dev/null +++ b/tests/golden/override-schedules/override-schedules/apps/override-schedules.yaml @@ -0,0 +1,4 @@ +spec: + syncPolicy: + syncOptions: + - ServerSideApply=true diff --git a/tests/golden/override-schedules/override-schedules/solution-backup/clusterRole.yaml b/tests/golden/override-schedules/override-schedules/solution-backup/clusterRole.yaml new file mode 100644 index 0000000..3f899d6 --- /dev/null +++ b/tests/golden/override-schedules/override-schedules/solution-backup/clusterRole.yaml @@ -0,0 +1,25 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: override-schedules:solution-backup-manager +rules: + - apiGroups: + - '' + resources: + - namespaces + verbs: + - get + - list + - watch + - apiGroups: + - appcat.vshn.io + resources: + - objectbuckets + verbs: + - '*' + - apiGroups: + - k8up.io + resources: + - schedules + verbs: + - '*' diff --git a/tests/golden/override-schedules/override-schedules/solution-backup/clusterRoleBinding.yaml b/tests/golden/override-schedules/override-schedules/solution-backup/clusterRoleBinding.yaml new file mode 100644 index 0000000..d786a87 --- /dev/null +++ b/tests/golden/override-schedules/override-schedules/solution-backup/clusterRoleBinding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: override-schedules:solution-backup-manager +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: override-schedules:solution-backup-manager +subjects: + - kind: ServiceAccount + name: solution-backup-manager + namespace: override-schedules diff --git a/tests/golden/override-schedules/override-schedules/solution-backup/managedResource.yaml b/tests/golden/override-schedules/override-schedules/solution-backup/managedResource.yaml new file mode 100644 index 0000000..338810c --- /dev/null +++ b/tests/golden/override-schedules/override-schedules/solution-backup/managedResource.yaml @@ -0,0 +1,109 @@ +apiVersion: espejote.io/v1alpha1 +kind: ManagedResource +metadata: + name: pvc-backup-fcde2823 + namespace: override-schedules +spec: + context: + - name: backup_namespaces + resource: + apiVersion: v1 + kind: Namespace + labelSelector: + matchLabels: + vshn.ch/solution-base-backup: 'true' + namespace: '' + serviceAccountRef: + name: solution-backup-manager + template: | + local esp = import 'espejote.libsonnet'; + + local inDelete(obj) = std.get(obj.metadata, 'deletionTimestamp', '') != ''; + local wantsBackup(ns) = + local labels = std.get(ns.metadata, 'labels', {}); + !inDelete(ns) + && std.get(labels, 'vshn.ch/solution-base-backup', 'false') == 'true' + && std.objectHas(labels, 'vshn.ch/solution-base-backup-encryption-secret'); + + local managedBy = { 'espejote.io/managed-by': 'override-schedules.pvc-backup-fcde2823' }; + local credsSecretName = 'backup-bucket-creds-fcde2823'; + local bucketNamePrefix = 'pvc-backup-fcde2823-'; + + local encryptionSecretName(ns) = + std.get(ns.metadata, 'labels', {})['vshn.ch/solution-base-backup-encryption-secret']; + + local nsSlug(ns) = std.substr(std.sha256(ns.metadata.name), 0, 8); + + local makeObjectBucket(ns) = { + apiVersion: 'appcat.vshn.io/v1', + kind: 'ObjectBucket', + metadata: { + name: 'pvc-backup-fcde2823', + namespace: ns.metadata.name, + labels: managedBy + }, + spec: { + parameters: { + bucketName: bucketNamePrefix + nsSlug(ns), + region: 'rma' + }, + writeConnectionSecretToRef: { + name: credsSecretName + }, + }, + }; + + local makeSchedule(ns) = { + apiVersion: 'k8up.io/v1', + kind: 'Schedule', + metadata: { + name: 'pvc-backup-fcde2823', + namespace: ns.metadata.name, + labels: managedBy + }, + spec: { + backend: { + repoPasswordSecretRef: { + name: encryptionSecretName(ns), + key: 'password' + }, + s3: { + endpoint: 'https://objects.rma.cloudscale.ch', + bucket: bucketNamePrefix + nsSlug(ns), + accessKeyIDSecretRef: { + name: credsSecretName, + key: 'AWS_ACCESS_KEY_ID' + }, + secretAccessKeySecretRef: { + name: credsSecretName, + key: 'AWS_SECRET_ACCESS_KEY' + }, + }, + }, + backup: { schedule: '0 2 * * *' }, + check: { schedule: '0 4 * * 6' }, + prune: { schedule: '0 6 * * 0', retention: { + "keepDaily": 7, + "keepLast": 14, + "keepMonthly": 1, + "keepWeekly": 2 + } }, + }, + }; + + local manageNs(ns) = [ makeObjectBucket(ns), makeSchedule(ns) ]; + + local tn = esp.triggerName(); + if tn == 'namespace' then ( + local ns = esp.triggerData().resource; + if ns != null && wantsBackup(ns) then manageNs(ns) else [] + ) else [ + obj + for ns in esp.context().backup_namespaces + if wantsBackup(ns) + for obj in manageNs(ns) + ] + triggers: + - name: namespace + watchContextResource: + name: backup_namespaces diff --git a/tests/golden/override-schedules/override-schedules/solution-backup/namespace.yaml b/tests/golden/override-schedules/override-schedules/solution-backup/namespace.yaml new file mode 100644 index 0000000..fc4e151 --- /dev/null +++ b/tests/golden/override-schedules/override-schedules/solution-backup/namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + annotations: {} + labels: + name: override-schedules + name: override-schedules diff --git a/tests/golden/override-schedules/override-schedules/solution-backup/serviceAccount.yaml b/tests/golden/override-schedules/override-schedules/solution-backup/serviceAccount.yaml new file mode 100644 index 0000000..27f5e74 --- /dev/null +++ b/tests/golden/override-schedules/override-schedules/solution-backup/serviceAccount.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + annotations: {} + labels: + name: solution-backup-manager + name: solution-backup-manager + namespace: override-schedules diff --git a/tests/override-labels.yml b/tests/override-labels.yml new file mode 100644 index 0000000..991c6f2 --- /dev/null +++ b/tests/override-labels.yml @@ -0,0 +1,10 @@ +parameters: + solution_backup: + backup_namespaces: + label: acme.io/backup-enabled + value: "yes" + backup_encryption_secret: + label: acme.io/backup-encryption-secret + s3: + endpoint: https://sos-%(region)s.exo.io + region: ch-dk-2 diff --git a/tests/override-schedules.yml b/tests/override-schedules.yml new file mode 100644 index 0000000..41008d4 --- /dev/null +++ b/tests/override-schedules.yml @@ -0,0 +1,9 @@ +parameters: + solution_backup: + schedule: + backup: "0 2 * * *" + check: "0 4 * * 6" + prune: "0 6 * * 0" + retention: + keepLast: 14 + keepDaily: 7 diff --git a/tests/render/expected/labeled-general.rendered.yaml b/tests/render/expected/labeled-general.rendered.yaml new file mode 100644 index 0000000..9419437 --- /dev/null +++ b/tests/render/expected/labeled-general.rendered.yaml @@ -0,0 +1,45 @@ +--- +- apiVersion: appcat.vshn.io/v1 + kind: ObjectBucket + metadata: + labels: + espejote.io/managed-by: defaults.pvc-backup-0f37e81d + name: pvc-backup-0f37e81d + namespace: test-app + spec: + parameters: + bucketName: pvc-backup-0f37e81d-b58b0cb4 + region: rma + writeConnectionSecretToRef: + name: backup-bucket-creds-0f37e81d +- apiVersion: k8up.io/v1 + kind: Schedule + metadata: + labels: + espejote.io/managed-by: defaults.pvc-backup-0f37e81d + name: pvc-backup-0f37e81d + namespace: test-app + spec: + backend: + repoPasswordSecretRef: + key: password + name: backup-encryption + s3: + accessKeyIDSecretRef: + key: AWS_ACCESS_KEY_ID + name: backup-bucket-creds-0f37e81d + bucket: pvc-backup-0f37e81d-b58b0cb4 + endpoint: https://objects.rma.cloudscale.ch + secretAccessKeySecretRef: + key: AWS_SECRET_ACCESS_KEY + name: backup-bucket-creds-0f37e81d + backup: + schedule: 0 1 * * * + check: + schedule: 0 3 * * * + prune: + retention: + keepDaily: 3 + keepMonthly: 1 + keepWeekly: 2 + schedule: 0 4 * * * diff --git a/tests/render/expected/labeled-trigger-namespace.rendered.yaml b/tests/render/expected/labeled-trigger-namespace.rendered.yaml new file mode 100644 index 0000000..72193af --- /dev/null +++ b/tests/render/expected/labeled-trigger-namespace.rendered.yaml @@ -0,0 +1,46 @@ +--- +# Trigger: {"TriggerName":"namespace","WatchResource":{"apiVersion":"v1","kind":"Namespace","name":"test-app"}} +- apiVersion: appcat.vshn.io/v1 + kind: ObjectBucket + metadata: + labels: + espejote.io/managed-by: defaults.pvc-backup-0f37e81d + name: pvc-backup-0f37e81d + namespace: test-app + spec: + parameters: + bucketName: pvc-backup-0f37e81d-b58b0cb4 + region: rma + writeConnectionSecretToRef: + name: backup-bucket-creds-0f37e81d +- apiVersion: k8up.io/v1 + kind: Schedule + metadata: + labels: + espejote.io/managed-by: defaults.pvc-backup-0f37e81d + name: pvc-backup-0f37e81d + namespace: test-app + spec: + backend: + repoPasswordSecretRef: + key: password + name: backup-encryption + s3: + accessKeyIDSecretRef: + key: AWS_ACCESS_KEY_ID + name: backup-bucket-creds-0f37e81d + bucket: pvc-backup-0f37e81d-b58b0cb4 + endpoint: https://objects.rma.cloudscale.ch + secretAccessKeySecretRef: + key: AWS_SECRET_ACCESS_KEY + name: backup-bucket-creds-0f37e81d + backup: + schedule: 0 1 * * * + check: + schedule: 0 3 * * * + prune: + retention: + keepDaily: 3 + keepMonthly: 1 + keepWeekly: 2 + schedule: 0 4 * * * diff --git a/tests/render/expected/labeled-with-encryption-secret-label.rendered.yaml b/tests/render/expected/labeled-with-encryption-secret-label.rendered.yaml new file mode 100644 index 0000000..b44aee4 --- /dev/null +++ b/tests/render/expected/labeled-with-encryption-secret-label.rendered.yaml @@ -0,0 +1,45 @@ +--- +- apiVersion: appcat.vshn.io/v1 + kind: ObjectBucket + metadata: + labels: + espejote.io/managed-by: defaults.pvc-backup-0f37e81d + name: pvc-backup-0f37e81d + namespace: test-app + spec: + parameters: + bucketName: pvc-backup-0f37e81d-b58b0cb4 + region: rma + writeConnectionSecretToRef: + name: backup-bucket-creds-0f37e81d +- apiVersion: k8up.io/v1 + kind: Schedule + metadata: + labels: + espejote.io/managed-by: defaults.pvc-backup-0f37e81d + name: pvc-backup-0f37e81d + namespace: test-app + spec: + backend: + repoPasswordSecretRef: + key: password + name: my-custom-encryption + s3: + accessKeyIDSecretRef: + key: AWS_ACCESS_KEY_ID + name: backup-bucket-creds-0f37e81d + bucket: pvc-backup-0f37e81d-b58b0cb4 + endpoint: https://objects.rma.cloudscale.ch + secretAccessKeySecretRef: + key: AWS_SECRET_ACCESS_KEY + name: backup-bucket-creds-0f37e81d + backup: + schedule: 0 1 * * * + check: + schedule: 0 3 * * * + prune: + retention: + keepDaily: 3 + keepMonthly: 1 + keepWeekly: 2 + schedule: 0 4 * * * diff --git a/tests/render/expected/labeled-without-encryption-label.rendered.yaml b/tests/render/expected/labeled-without-encryption-label.rendered.yaml new file mode 100644 index 0000000..fe92594 --- /dev/null +++ b/tests/render/expected/labeled-without-encryption-label.rendered.yaml @@ -0,0 +1,2 @@ +--- +[] diff --git a/tests/render/expected/trigger-namespace-unlabeled.rendered.yaml b/tests/render/expected/trigger-namespace-unlabeled.rendered.yaml new file mode 100644 index 0000000..39d9db0 --- /dev/null +++ b/tests/render/expected/trigger-namespace-unlabeled.rendered.yaml @@ -0,0 +1,3 @@ +--- +# Trigger: {"TriggerName":"namespace","WatchResource":{"apiVersion":"v1","kind":"Namespace","name":"test-app"}} +[] diff --git a/tests/render/expected/unlabeled.rendered.yaml b/tests/render/expected/unlabeled.rendered.yaml new file mode 100644 index 0000000..fe92594 --- /dev/null +++ b/tests/render/expected/unlabeled.rendered.yaml @@ -0,0 +1,2 @@ +--- +[] diff --git a/tests/render/inputs/README.md b/tests/render/inputs/README.md new file mode 100644 index 0000000..a0e893a --- /dev/null +++ b/tests/render/inputs/README.md @@ -0,0 +1,33 @@ +# Test inputs + +These YAML files simulate in-cluster state offline and feed `espejote render +--input`. Each file is a single document with `triggers`, `context`, and +`libraries`; the `context.resources` arrays are pre-filtered to match what a +real cluster's selectors would return (offline, `espejote` does not re-apply +the ManagedResource's labelSelector against `context[].resources`). + +Opt-in label (defaults instance): `vshn.ch/solution-base-backup: "true"`. +Encryption-secret label (defaults instance): +`vshn.ch/solution-base-backup-encryption-secret`, value = the name of the Secret +holding the restic repo password under key `password`. There is NO default — +a namespace without this label is not backed up. + +- `labeled-general.yaml` — general reconciliation, one opted-in namespace + `test-app` with both the opt-in + encryption-secret labels. Renders the + AppCat `ObjectBucket` claim and the k8up `Schedule`. +- `labeled-trigger-namespace.yaml` — `namespace` watch trigger fires for the + opted-in namespace (both labels); context also lists it. Renders the pair + for that ns only. +- `labeled-with-encryption-secret-label.yaml` — general reconciliation, opted + in AND carrying the encryption-secret label with a custom Secret name. The + Schedule's `repoPasswordSecretRef.name` resolves to that custom name + (`my-custom-encryption`). +- `labeled-without-encryption-label.yaml` — opted in via the opt-in label but + WITHOUT the encryption-secret label. There is no default, so nothing renders. +- `unlabeled.yaml` — general reconciliation, no namespace opted in (empty + `backup_namespaces` context). Renders nothing. +- `trigger-namespace-unlabeled.yaml` — `namespace` watch trigger fires for a + namespace without the opt-in label; context empty. Renders nothing (the + `wantsBackup(ns)` guard returns false). + +No cluster, no kubectl: only `espejote` (from PATH, not vendored) reads them. diff --git a/tests/render/inputs/labeled-general.yaml b/tests/render/inputs/labeled-general.yaml new file mode 100644 index 0000000..5ea44a7 --- /dev/null +++ b/tests/render/inputs/labeled-general.yaml @@ -0,0 +1,20 @@ +# Scenario labeled-general — general reconciliation, one namespace `test-app` +# opted in via `vshn.ch/solution-base-backup=true` AND carrying the encryption +# secret label `vshn.ch/solution-base-backup-encryption-secret: backup-encryption` +# (the name of the restic repo-password Secret the user created in that ns). +# Renders ObjectBucket + Schedule. +triggers: + - {} +context: + - name: backup_namespaces + resources: + - apiVersion: v1 + kind: Namespace + metadata: + name: test-app + labels: + vshn.ch/solution-base-backup: "true" + vshn.ch/solution-base-backup-encryption-secret: backup-encryption + status: + phase: Active +libraries: {} diff --git a/tests/render/inputs/labeled-trigger-namespace.yaml b/tests/render/inputs/labeled-trigger-namespace.yaml new file mode 100644 index 0000000..3992480 --- /dev/null +++ b/tests/render/inputs/labeled-trigger-namespace.yaml @@ -0,0 +1,29 @@ +# Scenario labeled-trigger-namespace — `namespace` watch trigger fires for the +# opted-in namespace `test-app` (opt-in + encryption-secret labels both present), +# and the `backup_namespaces` context also lists it. Renders ObjectBucket + +# Schedule for that namespace only. +triggers: + - name: namespace + watchResource: + apiVersion: v1 + kind: Namespace + metadata: + name: test-app + labels: + vshn.ch/solution-base-backup: "true" + vshn.ch/solution-base-backup-encryption-secret: backup-encryption + status: + phase: Active +context: + - name: backup_namespaces + resources: + - apiVersion: v1 + kind: Namespace + metadata: + name: test-app + labels: + vshn.ch/solution-base-backup: "true" + vshn.ch/solution-base-backup-encryption-secret: backup-encryption + status: + phase: Active +libraries: {} diff --git a/tests/render/inputs/labeled-with-encryption-secret-label.yaml b/tests/render/inputs/labeled-with-encryption-secret-label.yaml new file mode 100644 index 0000000..a88c7f3 --- /dev/null +++ b/tests/render/inputs/labeled-with-encryption-secret-label.yaml @@ -0,0 +1,19 @@ +# Scenario labeled-with-encryption-secret-label — general reconciliation, +# namespace `test-app` opted in (opt-in label) and carrying the encryption +# secret label pointing at a custom Secret name `my-custom-encryption`. The +# Schedule's repoPasswordSecretRef.name must resolve to `my-custom-encryption`. +triggers: + - {} +context: + - name: backup_namespaces + resources: + - apiVersion: v1 + kind: Namespace + metadata: + name: test-app + labels: + vshn.ch/solution-base-backup: "true" + vshn.ch/solution-base-backup-encryption-secret: my-custom-encryption + status: + phase: Active +libraries: {} diff --git a/tests/render/inputs/labeled-without-encryption-label.yaml b/tests/render/inputs/labeled-without-encryption-label.yaml new file mode 100644 index 0000000..98d80d1 --- /dev/null +++ b/tests/render/inputs/labeled-without-encryption-label.yaml @@ -0,0 +1,18 @@ +# Scenario labeled-without-encryption-label — general reconciliation, namespace +# `test-app` opted in via the opt-in label but WITHOUT the encryption-secret +# label. There is no default encryption-secret name, so `wantsBackup(ns)` +# returns false and nothing is rendered for that namespace. +triggers: + - {} +context: + - name: backup_namespaces + resources: + - apiVersion: v1 + kind: Namespace + metadata: + name: test-app + labels: + vshn.ch/solution-base-backup: "true" + status: + phase: Active +libraries: {} diff --git a/tests/render/inputs/trigger-namespace-unlabeled.yaml b/tests/render/inputs/trigger-namespace-unlabeled.yaml new file mode 100644 index 0000000..2a9e533 --- /dev/null +++ b/tests/render/inputs/trigger-namespace-unlabeled.yaml @@ -0,0 +1,17 @@ +# Scenario trigger-namespace-unlabeled — `namespace` watch trigger fires for a +# namespace WITHOUT the opt-in label; backup_namespaces context is empty. The +# `wantsBackup(ns)` guard returns false, so nothing is rendered. +triggers: + - name: namespace + watchResource: + apiVersion: v1 + kind: Namespace + metadata: + name: test-app + labels: {} + status: + phase: Active +context: + - name: backup_namespaces + resources: [] +libraries: {} diff --git a/tests/render/inputs/unlabeled.yaml b/tests/render/inputs/unlabeled.yaml new file mode 100644 index 0000000..21025d9 --- /dev/null +++ b/tests/render/inputs/unlabeled.yaml @@ -0,0 +1,8 @@ +# Scenario unlabeled — general reconciliation, no namespace opted in +# (empty backup_namespaces context). Renders nothing. +triggers: + - {} +context: + - name: backup_namespaces + resources: [] +libraries: {} diff --git a/tests/render/run.sh b/tests/render/run.sh new file mode 100644 index 0000000..bf7468c --- /dev/null +++ b/tests/render/run.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# ponytail: render every input through the compiled defaults ManagedResource and +# write the output over its committed expected file. The caller checks for drift +# with `git diff --exit-code tests/render/expected/` — same golden-file model as +# `make golden-diff`, no hand-rolled assertions. +# +# Run `bash tests/render/run.sh && git diff --exit-code tests/render/expected/` +# locally; CI does the same. Regenerate the expected files after an intentional +# template change with `bash tests/render/run.sh` then commit them. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +MANIFEST="$ROOT/compiled/defaults/solution-backup/managedResource.yaml" +INPUTS="$ROOT/tests/render/inputs" +EXPECTED="$ROOT/tests/render/expected" + +if ! command -v espejote >/dev/null 2>&1; then + echo "espejote not found on PATH — install it from https://github.com/vshn/espejote releases (not vendored in this repo)" >&2 + exit 77 +fi + +if [ ! -f "$MANIFEST" ]; then + make test -e instance=defaults >/dev/null +fi +if [ ! -f "$MANIFEST" ]; then + echo "FAIL: compiled ManagedResource not found at $MANIFEST" >&2 + exit 1 +fi + +mkdir -p "$EXPECTED" +for input in "$INPUTS"/*.yaml; do + name="$(basename "$input" .yaml)" + espejote render "$MANIFEST" --input "$input" >"$EXPECTED/$name.rendered.yaml" +done +echo "rendered $(ls -1 "$INPUTS"/*.yaml | wc -l) scenarios into $EXPECTED"