diff --git a/CLAUDE.md b/CLAUDE.md index dbc3655..13a0c84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -182,6 +182,16 @@ No NetworkPolicy changes needed (see Platform Shared Services above). This is set globally in `values/common.yaml` and should not be overridden with requests in env or tenant files. +## Optionele componenten (`tenant.components`) + +Een component onder `tenant.components` heeft een eigen ApplicationSet met een +post-selector, zodat de bestaande tenant-Applications niet veranderen. +Vandaag is er een: `demoReset` (`charts/tenant-demo-reset`, ApplicationSet +`nextcloud-tenant-demo-reset`). Die zet een demo-tenant elke nacht terug naar +een gouden momentopname en wist daarbij alles wat bezoekers maakten. Alleen +voor demo-tenants; `confirmWipe` moet de tenantnaam herhalen. Zie +`docs/DEMO-RESET.md`. Tests: `nextcloud-platform/tests/run-demo-reset-tests.sh`. + ## Conduction Apps The apps in `tenant.apps.enabled` (default: opencatalogi, openconnector, openregister) are auto-installed in Nextcloud via one hook script in `values/common.yaml` (`before-starting`, with `post-installation` as a YAML anchor to the same text). It runs idempotently using a state file at `/var/www/html/data/.conduction-apps-state`. Installation logs are at `/var/www/html/data/conduction-apps.log`. diff --git a/changelog.d/tenant-demo-reset.md b/changelog.d/tenant-demo-reset.md new file mode 100644 index 0000000..3beb5cc --- /dev/null +++ b/changelog.d/tenant-demo-reset.md @@ -0,0 +1,40 @@ +### Toegevoegd: 2026-10-14 (nachtelijke demo-reset, uit tenzij aangezet) + +Een openbare demo-tenant vult zich met wat bezoekers maken. Nieuwe opt-in +component `tenant.components.demoReset` zet zo'n tenant elke nacht terug naar +een gouden momentopname en laadt daarna de voorbeeldsets opnieuw. Aanleiding: +de demogemeente Zuiddrecht (`zuiddrecht-prod`). + +Nieuwe lokale chart `charts/tenant-demo-reset` en een aparte ApplicationSet +`nextcloud-tenant-demo-reset` met een post-selector op +`tenant.components.demoReset.enabled`. Zelfde afweging als +`nextcloud-tenant-routes`: geen vierde source in `nextcloud-tenants`, dus de +bestaande tenant-Applications veranderen niet. + +De chart levert een ServiceAccount met alleen `pods` get/list en `pods/exec`, +het script als ConfigMap, een nachtelijke CronJob en een CronJob +`demo-reset-golden` die een mens afvuurt. Alles loopt via `kubectl exec` in de +bestaande pods. De momentopname is een template-database in de eigen Postgres +plus een kopie van de datamap op dezelfde PVC. + +Veiligheid: `confirmWipe` moet de tenantnaam herhalen (validator, chart en +script). De reset stopt voordat er iets gewist is bij een S3 primary +objectstore, een andere instanceid, of een database en map van verschillende +momenten. Faalt hij daarna, dan blijft de onderhoudsmodus aan. Redis wordt +alleen voor deze instantie geleegd (`occ memcache:distributed:clear`). + +Validator: vijftien nieuwe cases. Nieuwe suite +`nextcloud-platform/tests/run-demo-reset-tests.sh`, gedraaid door +`scripts/verify.sh`. Docs: `docs/DEMO-RESET.md`. + +Optioneel `limitApps` (`apps` + `exceptGroups`): na de voorbeeldsets zet de +reset elke app uit `apps` aan voor alle groepen behalve `exceptGroups`, per app +apart; een app die zich niet tot groepen laat beperken gaat aan voor iedereen, +met een WARN. Nodig omdat een set groepen aanmaakt die in de momentopname nog +niet bestaan, en omdat de install-hook bij een pin-bump een groepsbeperking +laat vervallen. Eerste gebruik: werkplek-apps alleen voor medewerkers in +Zuiddrecht, inwoners zien het portaal. + +**Openstaand:** de ApplicationSet komt pas na een handmatige sync van +`nextcloud-platform-bootstrap`. Het image `alpine/k8s:1.29.2` is op tag +gepind, niet op digest. Nooit op een cluster gedraaid. diff --git a/charts/tenant-demo-reset/Chart.yaml b/charts/tenant-demo-reset/Chart.yaml new file mode 100644 index 0000000..a8106cc --- /dev/null +++ b/charts/tenant-demo-reset/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: tenant-demo-reset +description: Nachtelijke reset van een demo-tenant naar een gouden momentopname (database en datamap), daarna de voorbeeldsets opnieuw laden. Rendert alleen voor tenants die tenant.components.demoReset aanzetten. +type: application +version: 1.0.0 diff --git a/charts/tenant-demo-reset/files/demo-reset.sh b/charts/tenant-demo-reset/files/demo-reset.sh new file mode 100644 index 0000000..9b73768 --- /dev/null +++ b/charts/tenant-demo-reset/files/demo-reset.sh @@ -0,0 +1,485 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: EUPL-1.2 +# role: tool +# +# demo-reset.sh: zet een demo-tenant terug naar een gouden momentopname. +# +# demo-reset.sh golden neem de momentopname (door een mens gestart) +# demo-reset.sh reset zet hem terug en laad de voorbeeldsets opnieuw +# (elke nacht, vanuit de CronJob) +# +# Draait in een Job in de namespace van de tenant en doet ALLES via +# `kubectl exec` in de pods die er al staan: occ in de Nextcloud-container, +# psql in de Postgres-container. Daardoor draaien occ en psql altijd in exact +# de versie die de tenant zelf draait, en heeft deze Job geen image-tag, geen +# databasewachtwoord en geen netwerkpad naar Postgres nodig. Het enige recht +# is `pods/exec` in de eigen namespace (zie templates/rbac.yaml). +# +# Wat de momentopname is, en waar hij staat: +# +# database een kopie van de tenant-database binnen dezelfde Postgres, +# `_demo_golden`, als template gemarkeerd en zonder +# connecties. Terugzetten is CREATE DATABASE ... TEMPLATE: een +# bestandskopie op de server, geen dump en geen restore. +# bestanden een kopie van de datamap (/var/www/html/data) naar +# /var/www/demo-reset/golden op dezelfde PVC. /var/www/ is de +# subPath `root` van de chart en valt buiten de codeboom die de +# image-entrypoint bij een upgrade met rsync overschrijft. +# +# Wat NIET wordt teruggezet, met opzet: +# +# config/ instanceid, passwordsalt en secret blijven die van de +# draaiende tenant. De momentopname weigert terug te zetten als +# de instanceid niet gelijk is aan die in de momentopname. +# custom_apps/ de app-code is van de install-hook, net als zijn state-bestand +# data/.conduction-apps-state. Een app die na de momentopname +# nieuwer is geworden, krijgt na het terugzetten `occ upgrade`. +# logs nextcloud.log*, audit.log en conduction-apps.log blijven staan. +# +# Gedrag bij falen: +# +# - Vóór de eerste onomkeerbare stap: onderhoudsmodus weer uit (als deze Job +# hem aanzette), de tenant draait door zoals hij was. +# - Ná de eerste onomkeerbare stap: onderhoudsmodus BLIJFT AAN. Een half +# teruggezette tenant hoort niet te serveren. De momentopname zelf is niet +# aangeraakt, dus de Job opnieuw starten maakt het af. +# - Faalt alleen het laden van de voorbeeldsets, dan is de tenant al terug op +# de momentopname en blijft hij in de lucht; de Job faalt wel zichtbaar. +# +# Writes: de tenant-database, de datamap en /var/www/demo-reset van de tenant +# Idempotent: ja; een herhaalde run zet opnieuw dezelfde momentopname terug +# Requires: bash, kubectl, jq, curl (alpine/k8s heeft ze alle drie) + +set -euo pipefail + +MODE="${1:-}" +case "${MODE}" in + golden | reset) ;; + *) + echo "gebruik: demo-reset.sh golden|reset" >&2 + exit 2 + ;; +esac + +KUBECTL="${KUBECTL:-kubectl}" +CURL="${CURL:-curl}" +WAIT_STEP="${WAIT_STEP:-5}" +CRON_WAIT_TRIES="${CRON_WAIT_TRIES:-180}" + +readonly STORE="/var/www/demo-reset" +readonly DATA="/var/www/html/data" +readonly OCC="php /var/www/html/occ" + +log() { printf '%s demo-reset %s: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${MODE}" "$*"; } +die() { + log "FOUT: $*" >&2 + exit 1 +} + +# Een lege waarde is een fout, nooit een default. Zie docs/DEMO-RESET.md. +require() { + local name="$1" + [ -n "${!name:-}" ] || die "${name} is leeg. De chart hoort hem te zetten; zonder hem doet deze Job niets." +} + +require POD_NAMESPACE +require TENANT_NAME +require TENANT_HOST +require CONFIRM_WIPE + +# De bevestiging staat in het tenantbestand en moet de tenantnaam letterlijk +# herhalen. De validator en de chart toetsen dit ook; hier nog een keer, omdat +# dit de laatste plek is vóór het wissen. +[ "${CONFIRM_WIPE}" = "${TENANT_NAME}" ] || + die "confirmWipe '${CONFIRM_WIPE}' is niet de tenantnaam '${TENANT_NAME}'. Er wordt niets gewist." + +readonly NS="${POD_NAMESPACE}" +readonly STATUS_URL="${STATUS_URL:-http://nextcloud.${NS}.svc.cluster.local:8080/status.php}" +EXAMPLE_SETS="${EXAMPLE_SETS:-}" +EXAMPLE_SET_MODE="${EXAMPLE_SET_MODE:-install}" +PASSWORD_FILE="${PASSWORD_FILE:-}" +LIMIT_APPS="${LIMIT_APPS:-}" +LIMIT_EXCEPT_GROUPS="${LIMIT_EXCEPT_GROUPS:-}" + +case "${EXAMPLE_SET_MODE}" in + install | reinstall) ;; + *) die "EXAMPLE_SET_MODE '${EXAMPLE_SET_MODE}' is onbekend; verwacht install of reinstall." ;; +esac +for set_id in ${EXAMPLE_SETS}; do + [[ "${set_id}" =~ ^[a-z0-9][a-z0-9-]{0,62}$ ]] || die "voorbeeldset '${set_id}' heeft een ongeldige naam." +done +if [ -n "${PASSWORD_FILE}" ] && [ ! -s "${PASSWORD_FILE}" ]; then + die "PASSWORD_FILE ${PASSWORD_FILE} is leeg of bestaat niet. Er is niets gewist." +fi +for app in ${LIMIT_APPS}; do + [[ "${app}" =~ ^[a-z][a-z0-9_]{0,63}$ ]] || die "app '${app}' in LIMIT_APPS is geen app-id. Er is niets gewist." +done +for group in ${LIMIT_EXCEPT_GROUPS}; do + [[ "${group}" =~ ^[A-Za-z0-9._@-]{1,64}$ ]] || die "groep '${group}' in LIMIT_EXCEPT_GROUPS heeft een ongeldige naam. Er is niets gewist." +done +if [ -n "${LIMIT_APPS}" ] && [ -z "${LIMIT_EXCEPT_GROUPS}" ]; then + die "LIMIT_APPS is gezet zonder LIMIT_EXCEPT_GROUPS; dan krijgt elke groep de apps en beperkt de stap niets. Er is niets gewist." +fi + +# --------------------------------------------------------------------------- +# Pods en exec +# --------------------------------------------------------------------------- + +one_pod() { + local selector="$1" what="$2" names + local -a pods + names="$("${KUBECTL}" get pods -n "${NS}" -l "${selector}" \ + --field-selector=status.phase=Running -o jsonpath='{.items[*].metadata.name}')" + read -r -a pods <<<"${names}" + [ "${#pods[@]}" -eq 1 ] || + die "verwacht precies één draaiende ${what}-pod in ${NS}, gevonden: ${#pods[@]} (${names:-geen})." + printf '%s' "${pods[0]}" +} + +nc() { "${KUBECTL}" exec -n "${NS}" "${NC_POD}" -c nextcloud -- "$@"; } +nc_in() { "${KUBECTL}" exec -i -n "${NS}" "${NC_POD}" -c nextcloud -- "$@"; } + +# occ als www-data, op dezelfde manier als de entrypoint van de image het doet +# (`su -p` houdt de omgeving van de container, dus ook REDIS_HOST en de S3-env). +# Alleen vaste woorden en gevalideerde set-namen komen hier doorheen. +occ() { nc su -p www-data -s /bin/sh -c "${OCC} $*"; } + +pg() { "${KUBECTL}" exec -n "${NS}" "${PG_POD}" -c postgresql -- "$@"; } + +# Eén SQL-statement als de postgres-superuser, via 127.0.0.1 in de pod zelf. +# Het wachtwoord leest de pod uit zijn eigen secret-bestand (Bitnami +# usePasswordFiles) of env; het komt nooit in deze Job. +sql() { + # shellcheck disable=SC2016 # $-variabelen horen in de Postgres-pod uitgewerkt te worden + pg sh -c 'PGPASSWORD="${POSTGRES_POSTGRES_PASSWORD:-$(cat "${POSTGRES_POSTGRES_PASSWORD_FILE}")}" exec psql -X -q -v ON_ERROR_STOP=1 -h 127.0.0.1 -U postgres -d postgres -At -c "$1"' sql "$1" +} + +db_exists() { [ "$(sql "SELECT 1 FROM pg_database WHERE datname = '$1'")" = "1" ]; } + +terminate() { + sql "SELECT count(pg_terminate_backend(pid)) FROM pg_stat_activity WHERE datname = '$1' AND pid <> pg_backend_pid()" >/dev/null +} + +# CREATE DATABASE ... TEMPLATE en RENAME weigeren zolang er nog een connectie +# op de bron staat. In onderhoudsmodus komen er geen nieuwe bij, maar een +# lopend verzoek kan net nog bezig zijn: kort opnieuw proberen. +with_retry() { + local i + for i in 1 2 3 4 5; do + "$@" && return 0 + log "nog connecties op de database, poging ${i} van 5" + sleep "${WAIT_STEP}" + done + return 1 +} + +clone_db() { # [eigenaar] + terminate "$1" + sql "CREATE DATABASE \"$2\" TEMPLATE \"$1\"${3:+ OWNER \"$3\"}" +} + +rename_db() { # + terminate "$1" + sql "ALTER DATABASE \"$1\" RENAME TO \"$2\"" +} + +drop_db() { # ; een template moet eerst geen template meer zijn + if db_exists "$1"; then + sql "ALTER DATABASE \"$1\" WITH IS_TEMPLATE false" + sql "DROP DATABASE \"$1\" WITH (FORCE)" + fi +} + +# --------------------------------------------------------------------------- +# Onderhoudsmodus en cron +# --------------------------------------------------------------------------- + +MAINT_BY_US=0 +DESTRUCTIVE=0 + +maint_on() { + occ maintenance:mode --on >/dev/null + MAINT_BY_US=1 + log "onderhoudsmodus aan" +} + +maint_off() { + occ maintenance:mode --off >/dev/null + MAINT_BY_US=0 + log "onderhoudsmodus uit" +} + +# cron.php stopt zelf in onderhoudsmodus, maar een run die al liep loopt door. +# Wacht tot er geen meer is. Het patroon `cron[.]php` matcht niet zichzelf. +wait_for_cron() { + local containers i n + containers="$("${KUBECTL}" get pod -n "${NS}" "${NC_POD}" -o jsonpath='{.spec.containers[*].name}')" + if [[ " ${containers} " != *" nextcloud-cron "* ]]; then + log "geen nextcloud-cron-container in de pod, niets om op te wachten" + return 0 + fi + for ((i = 1; i <= CRON_WAIT_TRIES; i++)); do + n="$("${KUBECTL}" exec -n "${NS}" "${NC_POD}" -c nextcloud-cron -- \ + sh -c 'grep -l "cron[.]php" /proc/[0-9]*/cmdline 2>/dev/null | wc -l')" + if [ "${n//[[:space:]]/}" = "0" ]; then + return 0 + fi + [ "${i}" -eq 1 ] && log "cron.php loopt nog, wachten" + sleep "${WAIT_STEP}" + done + die "cron.php loopt na ${CRON_WAIT_TRIES} pogingen nog steeds." +} + +on_exit() { + local rc=$? + if [ "${rc}" -ne 0 ]; then + if [ "${DESTRUCTIVE}" -eq 1 ]; then + log "FOUT na de eerste onomkeerbare stap: de onderhoudsmodus BLIJFT AAN." >&2 + log "De momentopname is niet aangeraakt. Start de Job opnieuw (docs/DEMO-RESET.md)." >&2 + elif [ "${MAINT_BY_US}" -eq 1 ]; then + log "fout vóór het wissen: onderhoudsmodus gaat weer uit, de tenant draait door zoals hij was" >&2 + maint_off || log "onderhoudsmodus uitzetten mislukte ook; zet hem met de hand uit" >&2 + fi + fi + exit "${rc}" +} +trap on_exit EXIT + +# --------------------------------------------------------------------------- +# Gedeelde voorcontrole +# --------------------------------------------------------------------------- + +# De gouden en de nachtelijke Job mogen nooit tegelijk lopen. concurrencyPolicy +# dekt alleen runs van dezelfde CronJob; deze check dekt ze samen. +OWN_RUNS="$("${KUBECTL}" get pods -n "${NS}" -l app.kubernetes.io/name=tenant-demo-reset \ + --field-selector=status.phase=Running -o jsonpath='{.items[*].metadata.name}')" +read -r -a own_runs <<<"${OWN_RUNS}" +[ "${#own_runs[@]}" -le 1 ] || + die "er loopt al een andere demo-reset-Job (${OWN_RUNS}). Er is niets veranderd." + +NC_POD="$(one_pod 'app.kubernetes.io/name=nextcloud,app.kubernetes.io/component=app' nextcloud)" +PG_POD="$(one_pod 'app.kubernetes.io/name=postgresql,app.kubernetes.io/component=primary' postgresql)" +log "nextcloud-pod ${NC_POD}, postgres-pod ${PG_POD}" + +LIVE_DB="$(pg printenv POSTGRES_DATABASE || true)" +DB_OWNER="$(pg printenv POSTGRES_USER || true)" +for v in LIVE_DB DB_OWNER; do + [[ "${!v}" =~ ^[a-z_][a-z0-9_]{0,40}$ ]] || + die "${v} uit de Postgres-pod is leeg of geen gewone naam ('${!v}'). Is dit een tenant met dbType postgres?" +done +readonly GOLDEN_DB="${LIVE_DB}_demo_golden" +readonly INCOMING_DB="${LIVE_DB}_demo_incoming" +readonly PREVIOUS_DB="${LIVE_DB}_demo_previous" + +STATUS="$(occ status --output=json)" +jq -e '.installed == true' >/dev/null <<<"${STATUS}" || die "Nextcloud is niet geïnstalleerd volgens occ status." + +INSTANCEID="$(occ config:system:get instanceid || true)" +[ -n "${INSTANCEID}" ] || die "instanceid is leeg in config.php." + +# Deze component zet alleen een lokale datamap terug. Staan de bestanden in S3 +# (primary objectstore), dan zou een teruggezette database naar objecten +# wijzen die er niet meer zijn. Dan stoppen in plaats van half werk. +if occ config:system:get objectstore >/dev/null 2>&1; then + die "deze tenant heeft een S3 primary objectstore. demo-reset zet alleen een lokale datamap terug; er is niets gewist." +fi + +# --------------------------------------------------------------------------- +# golden: neem de momentopname +# --------------------------------------------------------------------------- + +take_golden() { + jq -e '.maintenance == false' >/dev/null <<<"${STATUS}" || + die "de tenant staat al in onderhoudsmodus. Is iemand anders bezig? Er is niets veranderd." + + local apps id + apps="$(occ app:list --output=json)" + id="$(date -u +%Y%m%dT%H%M%SZ)" + + maint_on + wait_for_cron + + log "database ${LIVE_DB} kopiëren naar ${INCOMING_DB}" + drop_db "${INCOMING_DB}" + with_retry clone_db "${LIVE_DB}" "${INCOMING_DB}" + + log "datamap kopiëren naar ${STORE}/incoming" + nc sh -c "rm -rf '${STORE}/incoming' && mkdir -p '${STORE}/incoming/data' && cd '${DATA}' && \ +tar -cf - --exclude=./nextcloud.log --exclude='./nextcloud.log.*' --exclude=./audit.log \ +--exclude=./conduction-apps.log --exclude=./.conduction-apps-state . | tar -xpf - -C '${STORE}/incoming/data'" + + # De live staat is vanaf hier niet meer nodig: zo kort mogelijk offline. + maint_off + + jq -n --arg id "${id}" --arg instanceid "${INSTANCEID}" --arg tenant "${TENANT_NAME}" \ + --arg database "${LIVE_DB}" --argjson status "${STATUS}" --argjson apps "${apps}" \ + '{id: $id, tenant: $tenant, instanceid: $instanceid, database: $database, + nextcloud: $status.versionstring, apps: $apps.enabled}' | + nc_in sh -c "cat > '${STORE}/incoming/manifest.json'" + + # Wisselen. Eerst de oude momentopname ongeldig maken, dan pas de database en + # de map vervangen: een run die halverwege stopt laat zo nooit een + # database en een map achter die van verschillende momenten zijn maar + # geldig lijken. + log "momentopname ${id} wordt de gouden" + nc rm -f "${STORE}/golden/COMPLETE" + drop_db "${GOLDEN_DB}" + with_retry rename_db "${INCOMING_DB}" "${GOLDEN_DB}" + sql "COMMENT ON DATABASE \"${GOLDEN_DB}\" IS 'demo-reset ${id}'" + sql "ALTER DATABASE \"${GOLDEN_DB}\" WITH IS_TEMPLATE true ALLOW_CONNECTIONS false" + nc sh -c "rm -rf '${STORE}/golden' && mv '${STORE}/incoming' '${STORE}/golden' && echo '${id}' > '${STORE}/golden/COMPLETE'" + + log "klaar: momentopname ${id}, database $(sql "SELECT pg_size_pretty(pg_database_size('${GOLDEN_DB}'))"), bestanden $(nc du -sh "${STORE}/golden" | cut -f1)" +} + +# --------------------------------------------------------------------------- +# reset: zet de momentopname terug +# --------------------------------------------------------------------------- + +load_example_sets() { + local set_id args + for set_id in ${EXAMPLE_SETS}; do + if [ "${EXAMPLE_SET_MODE}" = "reinstall" ]; then + log "voorbeeldset ${set_id} verwijderen" + occ openregister:example-set:remove "${set_id}" + fi + log "voorbeeldset ${set_id} laden" + if [ -n "${PASSWORD_FILE}" ]; then + # Het wachtwoord gaat over stdin, niet als argument: dan staat het niet in + # de exec-aanroep en dus niet in een audit-log van de API-server. + args="openregister:example-set:install ${set_id} --password-from-env" + # shellcheck disable=SC2016 # OC_PASS hoort in de Nextcloud-container gelezen te worden + nc_in sh -c 'IFS= read -r OC_PASS; export OC_PASS; exec su -p www-data -s /bin/sh -c "$1"' sh "${OCC} ${args}" <"${PASSWORD_FILE}" + else + occ openregister:example-set:install "${set_id}" + fi + done +} + +# Zet elke app uit LIMIT_APPS aan voor alle groepen behalve LIMIT_EXCEPT_GROUPS. +# Draait na de voorbeeldsets, omdat een set groepen aanmaakt die er in de +# momentopname nog niet waren. Een app die Nextcloud niet tot groepen laat +# beperken (bv. spreed, photos, activity) gaat aan voor iedereen, met een WARN. +# Per app apart: één weigering in een gezamenlijke aanroep liet de rest uit. +limit_apps_to_groups() { + local groups_json apps_json app group g except + local -a groups=() group_args=() + [ -n "${LIMIT_APPS}" ] || return 0 + + groups_json="$(occ group:list --output=json)" + while IFS= read -r group; do + except=0 + for g in ${LIMIT_EXCEPT_GROUPS}; do [ "${group}" = "${g}" ] && except=1; done + [ "${except}" = 1 ] && continue + [[ "${group}" =~ ^[A-Za-z0-9._@-]{1,64}$ ]] || + die "groep '${group}' heeft een naam die niet veilig door occ kan; apps niet beperkt." + groups+=("${group}") + done < <(jq -r 'keys[]' <<<"${groups_json}") + [ "${#groups[@]}" -gt 0 ] || + die "geen groepen over na het weglaten van '${LIMIT_EXCEPT_GROUPS}'; apps niet beperkt." + for group in "${groups[@]}"; do group_args+=("--groups=${group}"); done + + apps_json="$(occ app:list --output=json)" + for app in ${LIMIT_APPS}; do + if ! jq -e --arg a "${app}" '(.enabled // {}) + (.disabled // {}) | has($a)' >/dev/null <<<"${apps_json}"; then + log "app ${app} is niet geïnstalleerd; overgeslagen" + continue + fi + if occ app:enable "${group_args[@]}" "${app}" >/dev/null; then + log "app ${app} beperkt tot ${#groups[@]} groepen" + else + log "WARN: app ${app} laat zich niet tot groepen beperken; aan voor iedereen" >&2 + occ app:enable "${app}" >/dev/null + fi + done +} + +reset_to_golden() { + local golden_id manifest golden_comment + + golden_id="$(nc cat "${STORE}/golden/COMPLETE" 2>/dev/null || true)" + [ -n "${golden_id}" ] || + die "geen geldige momentopname (${STORE}/golden/COMPLETE ontbreekt). Neem er eerst een: docs/DEMO-RESET.md. Er is niets gewist." + manifest="$(nc cat "${STORE}/golden/manifest.json")" + [ "$(jq -r .id <<<"${manifest}")" = "${golden_id}" ] || + die "manifest.json hoort niet bij COMPLETE (${golden_id}). Er is niets gewist." + [ "$(jq -r .instanceid <<<"${manifest}")" = "${INSTANCEID}" ] || + die "de momentopname is van een andere instantie (instanceid verschilt). Er is niets gewist." + db_exists "${GOLDEN_DB}" || die "database ${GOLDEN_DB} ontbreekt. Er is niets gewist." + golden_comment="$(sql "SELECT shobj_description(oid, 'pg_database') FROM pg_database WHERE datname = '${GOLDEN_DB}'")" + [ "${golden_comment}" = "demo-reset ${golden_id}" ] || + die "database ${GOLDEN_DB} ('${golden_comment}') hoort niet bij momentopname ${golden_id}. Er is niets gewist." + log "momentopname ${golden_id} terugzetten" + + if jq -e '.maintenance == true' >/dev/null <<<"${STATUS}"; then + log "de tenant stond al in onderhoudsmodus (vorige run gefaald?); doorgaan, en hij gaat aan het eind uit" + else + maint_on + fi + wait_for_cron + + drop_db "${INCOMING_DB}" + clone_db "${GOLDEN_DB}" "${INCOMING_DB}" "${DB_OWNER}" + + # Vanaf hier is de bezoekersstaat weg zodra het lukt, en half als het faalt. + DESTRUCTIVE=1 + drop_db "${PREVIOUS_DB}" + if db_exists "${LIVE_DB}"; then + with_retry rename_db "${LIVE_DB}" "${PREVIOUS_DB}" + else + log "database ${LIVE_DB} bestaat niet (vorige run stopte tijdens de wissel); doorgaan" + fi + rename_db "${INCOMING_DB}" "${LIVE_DB}" + drop_db "${PREVIOUS_DB}" + log "database teruggezet" + + nc sh -c "find '${DATA}' -mindepth 1 -maxdepth 1 ! -name nextcloud.log ! -name 'nextcloud.log.*' \ +! -name audit.log ! -name conduction-apps.log ! -name .conduction-apps-state -exec rm -rf {} + && \ +cp -a '${STORE}/golden/data/.' '${DATA}/'" + log "datamap teruggezet" + + # APCu (memcache.local) leeft in het geheugen van de php-fpm-master. Een + # reload (USR2) begint met een leeg geheugen. Faalt dat, dan verlopen die + # waarden vanzelf; geen reden om de tenant offline te houden. + # shellcheck disable=SC2016 # /proc/1/comm hoort in de Nextcloud-container gelezen te worden + if nc sh -c 'case "$(cat /proc/1/comm)" in php-fpm*) exit 0 ;; esac; exit 1'; then + if nc kill -USR2 1; then + log "php-fpm herladen (lokale cache leeg)" + else + log "php-fpm herladen mislukte; ga door" + fi + else + log "PID 1 is geen php-fpm; lokale cache niet geleegd" + fi + + if occ status --output=json | jq -e '.needsDbUpgrade == true' >/dev/null; then + log "de app-code is nieuwer dan de momentopname: occ upgrade" + occ upgrade + fi + + maint_off + # Gedeelde Redis: alleen de sleutels van deze instantie (prefix = instanceid + + # app-versies), nooit FLUSHALL. Moet NA maintenance:mode --off, want in + # onderhoudsmodus rekent Nextcloud met een andere prefix. + occ memcache:distributed:clear >/dev/null + DESTRUCTIVE=0 + log "tenant staat op momentopname ${golden_id}" + + load_example_sets + limit_apps_to_groups + + STATUS="$(occ status --output=json)" + jq -e '.installed == true and .maintenance == false and .needsDbUpgrade == false' >/dev/null <<<"${STATUS}" || + die "occ status na afloop klopt niet: ${STATUS}" + "${CURL}" -fsS --max-time 30 -H "Host: ${TENANT_HOST}" "${STATUS_URL}" | + jq -e '.installed == true and .maintenance == false' >/dev/null || + die "status.php via de service geeft geen gezonde tenant." + + nc sh -c "echo '$(date -u +%Y-%m-%dT%H:%M:%SZ) ${golden_id}' > '${STORE}/last-reset'" + log "klaar" +} + +case "${MODE}" in + golden) take_golden ;; + reset) reset_to_golden ;; +esac diff --git a/charts/tenant-demo-reset/templates/_helpers.tpl b/charts/tenant-demo-reset/templates/_helpers.tpl new file mode 100644 index 0000000..689aa1d --- /dev/null +++ b/charts/tenant-demo-reset/templates/_helpers.tpl @@ -0,0 +1,114 @@ +{{/* +Weiger te renderen als de bevestiging niet klopt. Wordt aangeroepen vanuit elk +template, zodat er nooit een half gerenderde set ontstaat. +*/}} +{{- define "demo-reset.guard" -}} +{{- if not .Values.tenant.name -}} +{{- fail "tenant.name is leeg: de ApplicationSet hoort hem te zetten." -}} +{{- end -}} +{{- if not .Values.tenant.host -}} +{{- fail "tenant.host is leeg: de ApplicationSet hoort hem te zetten." -}} +{{- end -}} +{{- if ne .Values.demoReset.confirmWipe .Values.tenant.name -}} +{{- fail (printf "tenant.components.demoReset.confirmWipe moet letterlijk de tenantnaam zijn (%q), maar is %q. Deze component wist elke nacht de tenant; er wordt niets gerenderd." .Values.tenant.name .Values.demoReset.confirmWipe) -}} +{{- end -}} +{{- if not (has .Values.demoReset.exampleSetMode (list "install" "reinstall")) -}} +{{- fail (printf "demoReset.exampleSetMode %q is onbekend; verwacht install of reinstall." .Values.demoReset.exampleSetMode) -}} +{{- end -}} +{{- end -}} + +{{- define "demo-reset.labels" -}} +app.kubernetes.io/name: tenant-demo-reset +app.kubernetes.io/part-of: nextcloud-platform +nextcloud.platform/tenant: {{ .Values.tenant.name | quote }} +{{- end -}} + +{{/* +Pod-template voor beide CronJobs. Argument: dict "root" . "mode" "reset|golden". +De Job raakt zelf geen volume van de tenant aan; alles gaat via kubectl exec. +Daarom geen nodeSelector en geen affinity: hij mag overal draaien. +*/}} +{{- define "demo-reset.pod" -}} +{{- $root := .root -}} +metadata: + labels: + {{- include "demo-reset.labels" $root | nindent 4 }} + nextcloud.platform/demo-reset-mode: {{ .mode }} +spec: + serviceAccountName: demo-reset + automountServiceAccountToken: true + restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 65534 + runAsGroup: 65534 + seccompProfile: + type: RuntimeDefault + containers: + - name: demo-reset + image: "{{ $root.Values.image.repository }}:{{ $root.Values.image.tag }}" + imagePullPolicy: {{ $root.Values.image.pullPolicy }} + command: ["bash", "/scripts/demo-reset.sh", {{ .mode | quote }}] + env: + - name: HOME + value: /tmp + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: TENANT_NAME + value: {{ $root.Values.tenant.name | quote }} + - name: TENANT_HOST + value: {{ $root.Values.tenant.host | quote }} + - name: CONFIRM_WIPE + value: {{ $root.Values.demoReset.confirmWipe | quote }} + - name: EXAMPLE_SETS + value: {{ join " " $root.Values.demoReset.exampleSets | quote }} + - name: EXAMPLE_SET_MODE + value: {{ $root.Values.demoReset.exampleSetMode | quote }} + {{- with $root.Values.demoReset.limitApps }} + {{- if .apps }} + - name: LIMIT_APPS + value: {{ join " " .apps | quote }} + - name: LIMIT_EXCEPT_GROUPS + value: {{ join " " (default (list) .exceptGroups) | quote }} + {{- end }} + {{- end }} + {{- if $root.Values.demoReset.exampleSetPasswordSecret }} + - name: PASSWORD_FILE + value: /secrets/demo-password + {{- end }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + resources: + {{- toYaml $root.Values.resources | nindent 8 }} + volumeMounts: + - name: script + mountPath: /scripts + readOnly: true + - name: tmp + mountPath: /tmp + {{- if $root.Values.demoReset.exampleSetPasswordSecret }} + - name: demo-password + mountPath: /secrets + readOnly: true + {{- end }} + volumes: + - name: script + configMap: + name: demo-reset-script + defaultMode: 0555 + - name: tmp + emptyDir: {} + {{- if $root.Values.demoReset.exampleSetPasswordSecret }} + - name: demo-password + secret: + secretName: {{ $root.Values.demoReset.exampleSetPasswordSecret | quote }} + items: + - key: demo-password + path: demo-password + {{- end }} +{{- end -}} diff --git a/charts/tenant-demo-reset/templates/configmap.yaml b/charts/tenant-demo-reset/templates/configmap.yaml new file mode 100644 index 0000000..656e744 --- /dev/null +++ b/charts/tenant-demo-reset/templates/configmap.yaml @@ -0,0 +1,15 @@ +{{- if .Values.demoReset.enabled }} +{{- include "demo-reset.guard" . }} +# Het script staat als los bestand in files/, zodat het buiten de chart te +# lezen, te linten en te testen is (nextcloud-platform/tests/run-demo-reset-tests.sh). +apiVersion: v1 +kind: ConfigMap +metadata: + name: demo-reset-script + namespace: {{ .Release.Namespace }} + labels: + {{- include "demo-reset.labels" . | nindent 4 }} +data: + demo-reset.sh: | + {{- .Files.Get "files/demo-reset.sh" | nindent 4 }} +{{- end }} diff --git a/charts/tenant-demo-reset/templates/cronjobs.yaml b/charts/tenant-demo-reset/templates/cronjobs.yaml new file mode 100644 index 0000000..f388bdf --- /dev/null +++ b/charts/tenant-demo-reset/templates/cronjobs.yaml @@ -0,0 +1,65 @@ +{{- if .Values.demoReset.enabled }} +{{- include "demo-reset.guard" . }} +# De nachtelijke reset. `paused: true` in het tenantbestand zet hem stil. +# +# backoffLimit 0: geen automatische herhaling. Een tweede poging direct na een +# fout raakt dezelfde fout, en een run die na de eerste onomkeerbare stap faalt +# laat de onderhoudsmodus bewust aan. Herstarten is een menselijk besluit +# (docs/DEMO-RESET.md); de volgende nacht probeert hij het vanzelf opnieuw. +apiVersion: batch/v1 +kind: CronJob +metadata: + name: demo-reset + namespace: {{ .Release.Namespace }} + labels: + {{- include "demo-reset.labels" . | nindent 4 }} +spec: + schedule: {{ .Values.demoReset.schedule | quote }} + timeZone: {{ .Values.demoReset.timeZone | quote }} + suspend: {{ .Values.demoReset.paused }} + concurrencyPolicy: Forbid + startingDeadlineSeconds: 3600 + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + metadata: + labels: + {{- include "demo-reset.labels" . | nindent 8 }} + spec: + backoffLimit: 0 + activeDeadlineSeconds: {{ .Values.job.activeDeadlineSeconds }} + template: + {{- include "demo-reset.pod" (dict "root" . "mode" "reset") | nindent 8 }} +--- +# De gouden momentopname. Draait NOOIT vanzelf: suspend staat vast op true en +# het schema (31 februari) bestaat niet. Een mens start hem, na een +# gecontroleerde stand van de demo: +# +# kubectl create job -n {{ .Release.Namespace }} --from=cronjob/demo-reset-golden demo-reset-golden-$(date +%Y%m%d%H%M) +# +# Het is een CronJob en geen losse Job omdat een Job in git door Argo bij elke +# sync opnieuw zou worden aangemaakt of als OutOfSync blijft staan; een CronJob +# is een sjabloon dat je met de hand afvuurt. +apiVersion: batch/v1 +kind: CronJob +metadata: + name: demo-reset-golden + namespace: {{ .Release.Namespace }} + labels: + {{- include "demo-reset.labels" . | nindent 4 }} +spec: + schedule: "0 0 31 2 *" + suspend: true + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + metadata: + labels: + {{- include "demo-reset.labels" . | nindent 8 }} + spec: + backoffLimit: 0 + activeDeadlineSeconds: {{ .Values.job.activeDeadlineSeconds }} + template: + {{- include "demo-reset.pod" (dict "root" . "mode" "golden") | nindent 8 }} +{{- end }} diff --git a/charts/tenant-demo-reset/templates/rbac.yaml b/charts/tenant-demo-reset/templates/rbac.yaml new file mode 100644 index 0000000..fb720a8 --- /dev/null +++ b/charts/tenant-demo-reset/templates/rbac.yaml @@ -0,0 +1,49 @@ +{{- if .Values.demoReset.enabled }} +{{- include "demo-reset.guard" . }} +# Het enige recht van de reset: pods in deze namespace vinden en er een +# commando in starten. Geen secrets, geen deployments, geen PVC's, niets buiten +# de namespace. occ en psql draaien in de pods van de tenant zelf. +# +# pods/exec heeft `create` (SPDY, kubectl tot 1.29) en `get` (WebSocket, +# kubectl vanaf 1.30) nodig; beide staan erin zodat een image-upgrade de Job +# niet stil breekt. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: demo-reset + namespace: {{ .Release.Namespace }} + labels: + {{- include "demo-reset.labels" . | nindent 4 }} +automountServiceAccountToken: false +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: demo-reset + namespace: {{ .Release.Namespace }} + labels: + {{- include "demo-reset.labels" . | nindent 4 }} +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] + - apiGroups: [""] + resources: ["pods/exec"] + verbs: ["create", "get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: demo-reset + namespace: {{ .Release.Namespace }} + labels: + {{- include "demo-reset.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: demo-reset +subjects: + - kind: ServiceAccount + name: demo-reset + namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/charts/tenant-demo-reset/values.yaml b/charts/tenant-demo-reset/values.yaml new file mode 100644 index 0000000..d45fd26 --- /dev/null +++ b/charts/tenant-demo-reset/values.yaml @@ -0,0 +1,69 @@ +# Default values, overschreven via de inline values van de ApplicationSet +# nextcloud-tenant-demo-reset. Rendert NIETS tenzij demoReset.enabled true is. +# +# Deze component WIST elke nacht alles wat bezoekers in de tenant maakten. +# Hij hoort alleen op een demo-tenant. Zie docs/DEMO-RESET.md. + +tenant: + name: "" # tenant.name, ook de namespace + host: "" # primaire hostnaam, voor de eindcontrole op status.php + +demoReset: + enabled: false + + # Moet letterlijk tenant.name zijn. Een tenantbestand dat dit blok van een + # ander bestand overneemt, rendert dan niet in plaats van een gemeente te + # wissen. + confirmWipe: "" + + # Kubernetes CronJob-syntax, in timeZone. 04:15 valt na het onderhoudsvenster + # van Nextcloud (maintenance_window_start 3 UTC in values/common.yaml) en ruim + # voor de ochtend. + schedule: "15 4 * * *" + timeZone: Europe/Amsterdam + + # true = de nachtelijke CronJob staat stil (suspend). Zo zet je de reset uit + # zonder de momentopname of de rechten weg te halen. + paused: false + + # Voorbeeldsets die na het terugzetten geladen worden met + # `occ openregister:example-set:install `. + exampleSets: [] + + # install alleen install. Wat in de momentopname al staat, blijft zoals + # het was ("kept"), inclusief de datums. + # reinstall eerst `example-set:remove`, dan install: alles opnieuw, met + # relatieve datums vanaf vandaag. + exampleSetMode: install + + # Optioneel: naam van een Secret in de namespace met sleutel `demo-password`. + # Dan krijgt install `--password-from-env`, met het wachtwoord via stdin. + exampleSetPasswordSecret: "" + + # Optioneel: na de voorbeeldsets elke app uit `apps` aanzetten voor alle + # groepen behalve `exceptGroups` (`occ app:enable --groups=...`). Zo blijven + # werkplek-apps voor medewerkers en ziet een inwoner alleen het portaal. Een + # app die Nextcloud niet tot groepen laat beperken, gaat aan voor iedereen. + limitApps: + apps: [] + exceptGroups: [] + +image: + # Hetzelfde image als de credential-refresh CronJob in cluster-infra: kubectl, + # jq en curl in één. + repository: alpine/k8s + tag: "1.29.2" + pullPolicy: IfNotPresent + +job: + # Een reset van een demo-tenant duurt minuten; een uur is ruim. Wat langer + # loopt, hangt. + activeDeadlineSeconds: 3600 + +# Geen requests, alleen limits (Resource Policy in CLAUDE.md): een nachtelijke +# Job hoeft geen capaciteit te reserveren. Het zware werk gebeurt in de pods +# van de tenant zelf, via exec. +resources: + limits: + cpu: 200m + memory: 128Mi diff --git a/docs/DEMO-RESET.md b/docs/DEMO-RESET.md new file mode 100644 index 0000000..9d6bd58 --- /dev/null +++ b/docs/DEMO-RESET.md @@ -0,0 +1,245 @@ +--- +last_reviewed: 2026-10-14 +owner: info@conduction.nl +--- + +# Demo-reset: een demo-tenant elke nacht terug naar een vaste stand + +Een openbare demo vult zich met wat bezoekers maken: accounts, verzoeken, +dossiers, uploads. Met `tenant.components.demoReset` zet je zo'n tenant elke +nacht terug naar een gouden momentopname. Daarna laadt hij de voorbeeldsets +opnieuw, zodat relatieve datums weer vanaf vandaag tellen. + +> **Dit wist elke nacht alles wat bezoekers maakten.** Zet het alleen aan op een +> demo-tenant. De validator en de chart eisen daarom dat +> `confirmWipe` letterlijk de tenantnaam herhaalt. + +## Hoe het werkt + +Een eigen ApplicationSet, `nextcloud-tenant-demo-reset`, rendert +`charts/tenant-demo-reset` in de namespace van de tenant. Dat gebeurt alleen +voor een tenant met `components.demoReset.enabled: true`. Voor de rest van de +vloot levert hij niets op. De bestaande `nc-`-Applications veranderen +niet; zie de toelichting in de ApplicationSet voor waarom dit geen vierde +source is. + +De chart levert vijf dingen: + +| Resource | Wat | +|---|---| +| ServiceAccount, Role, RoleBinding `demo-reset` | Pods vinden en er een commando in starten (`pods` get/list, `pods/exec`). Niets anders, niets buiten de namespace. | +| ConfigMap `demo-reset-script` | Het script, `charts/tenant-demo-reset/files/demo-reset.sh`. | +| CronJob `demo-reset` | De nachtelijke reset. Standaard 04:15 Europe/Amsterdam. | +| CronJob `demo-reset-golden` | De momentopname. Draait nooit vanzelf; een mens vuurt hem af. | + +De Job doet alles via `kubectl exec` in de pods die er al staan. occ draait in +de Nextcloud-container, psql in de Postgres-container. Zo draaien ze altijd in +de versie die de tenant zelf draait. De Job heeft geen databasewachtwoord en +geen netwerkpad naar Postgres nodig. + +### Wat de momentopname is + +| Deel | Waar | Hoe terug | +|---|---|---| +| Database | `nextcloud_demo_golden` in de eigen Postgres, als template, zonder connecties | `CREATE DATABASE ... TEMPLATE`, dan twee renames. Een bestandskopie op de server. | +| Datamap | `/var/www/demo-reset/golden/data` op de PVC `nextcloud-nextcloud` | Live-inhoud weg, kopie terug. | + +`/var/www/` is de subPath `root` van de Nextcloud-chart. Die valt buiten de +codeboom die de image bij een upgrade met rsync overschrijft. + +### Wat niet wordt teruggezet + +| Wat | Waarom | +|---|---| +| `config/` | instanceid, passwordsalt en secret blijven van de draaiende tenant. De reset weigert een momentopname met een andere instanceid. | +| `custom_apps/` en `data/.conduction-apps-state` | Die zijn van de install-hook. Is een app na de momentopname nieuwer, dan volgt `occ upgrade`. | +| `nextcloud.log*`, `audit.log`, `conduction-apps.log` | Logs horen bij de draaiende tenant, niet bij de demo-stand. | + +### Redis + +De tenant gebruikt de gedeelde platform-Redis. Een `FLUSHALL` zou elke tenant +raken. De reset draait daarom `occ memcache:distributed:clear`. Dat wist alleen +de sleutels met de prefix van deze instantie (instanceid plus app-versies). Het +moet na `maintenance:mode --off`, want in onderhoudsmodus rekent Nextcloud met +een andere prefix. + +De lokale cache (APCu) leeft in de php-fpm-master. De reset herlaadt php-fpm +(`kill -USR2 1`) zodat die leeg begint. + +## Aanzetten + +In het tenantbestand: + +```yaml +tenant: + name: zuiddrecht-prod + dbType: postgres + components: + demoReset: + enabled: true + confirmWipe: zuiddrecht-prod + exampleSets: + - zuiddrecht +``` + +| Sleutel | Verplicht | Default | Betekenis | +|---|---|---|---| +| `enabled` | ja | | `true` of `false`, zonder aanhalingstekens. | +| `confirmWipe` | als `enabled: true` | | Letterlijk `tenant.name`. | +| `schedule` | nee | `15 4 * * *` | Cron met vijf velden, in Europe/Amsterdam. | +| `paused` | nee | `false` | `true` zet de nachtelijke CronJob stil. | +| `exampleSets` | nee | leeg | Sets voor `occ openregister:example-set:install`. Vraagt `openregister` in `apps.enabled`. | +| `exampleSetMode` | nee | `install` | `install` of `reinstall` (eerst `example-set:remove`). | +| `exampleSetPasswordSecret` | nee | | Naam van een Secret in de namespace met sleutel `demo-password`. Dan krijgt install `--password-from-env`; het wachtwoord gaat via stdin. | +| `limitApps.apps` | nee | leeg | Apps die na de sets alleen aan staan voor alle groepen behalve `limitApps.exceptGroups`. Vraagt `exceptGroups`. | +| `limitApps.exceptGroups` | als `limitApps.apps` gezet is | leeg | Groepen die de apps níet krijgen, bv. `inwoners`. | + +De validator weigert: een onbekende sleutel, `enabled` als tekst, een andere +`confirmWipe`, `dbType` anders dan postgres, `tenant.canary: true` (S3), een +ongeldig schema, sets zonder openregister, en `limitApps.apps` zonder +`exceptGroups`. + +### Apps alleen voor medewerkers (`limitApps`) + +Op een demo met inwoners-accounts horen werkplek-apps alleen bij medewerkers. +De groepen van medewerkers komen uit de voorbeeldset, dus de beperking moet na +het laden van de set opnieuw gezet worden: een set kan groepen aanmaken die in +de momentopname nog niet bestonden. Ook zet de install-hook bij een pin-bump een +app uit en weer aan, en dan vervalt een groepsbeperking. + +```yaml + limitApps: + apps: [dashboard, mail, dossiq, pipelinq] + exceptGroups: [inwoners] +``` + +Faalt deze stap (geen groep over, of een groepsnaam die niet veilig door `occ` +kan), dan faalt de Job zichtbaar. De tenant staat dan al op de momentopname en +blijft in de lucht. + +### install of reinstall + +`example-set:install` laat objecten die er al staan met rust ("kept"). Hun +datums schuiven dan niet op. Kies dus een van twee: + +- **Momentopname vóór het laden, `install`.** De momentopname bevat de tenant + met apps en instellingen, zonder voorbeeldset. Elke nacht laadt de reset de + set vers, met datums vanaf vandaag. De eerste handmatige reset is de + gecontroleerde lading. +- **Momentopname na het laden, `reinstall`.** De reset verwijdert de set en + laadt hem opnieuw. Dit leunt op `example-set:remove`. + +## De gouden momentopname nemen + +Dit doet een mens, na een stand die klopt. + +```bash +NS=zuiddrecht-prod +kubectl create job -n "$NS" --from=cronjob/demo-reset-golden "demo-reset-golden-$(date +%Y%m%d%H%M)" +kubectl logs -n "$NS" -f job/demo-reset-golden- +``` + +De tenant staat een paar minuten in onderhoudsmodus. De Job weigert als de +tenant al in onderhoud staat. Een nieuwe momentopname vervangt de vorige pas als +hij compleet is. + +## Wat er 's nachts gebeurt + +1. Controles, voordat er iets verandert: precies één Nextcloud- en één + Postgres-pod, geen andere demo-reset-Job, een complete momentopname, dezelfde + instanceid, database en map van hetzelfde moment, geen S3 primary storage. +2. Onderhoudsmodus aan. Wachten tot een lopende `cron.php` klaar is. +3. `nextcloud_demo_incoming` maken uit de gouden database. +4. Vanaf hier is het onomkeerbaar. Live database wordt `..._previous`, + incoming wordt live, previous gaat weg. +5. Datamap leeg (behalve logs en hook-state), kopie terug. +6. php-fpm herladen. `occ upgrade` als de app-code nieuwer is. +7. Onderhoudsmodus uit. `occ memcache:distributed:clear`. +8. Per set `occ openregister:example-set:install `. + Daarna, met `limitApps`, per app `occ app:enable --groups= ... ` voor + elke groep behalve `exceptGroups`. Een app die Nextcloud niet tot groepen + laat beperken (spreed, photos, activity) gaat aan voor iedereen, met een + WARN in de log; een app die niet geïnstalleerd is, wordt overgeslagen. +9. Eindcontrole: `occ status` en `status.php` via de service. + `/var/www/demo-reset/last-reset` krijgt tijd en momentopname-id. + +## Controleren of het werkte + +```bash +NS=zuiddrecht-prod +kubectl get cronjob -n "$NS" demo-reset # LAST SCHEDULE, SUSPEND +kubectl get jobs -n "$NS" -l app.kubernetes.io/name=tenant-demo-reset +kubectl logs -n "$NS" job/ # eindigt met "klaar" +kubectl exec -n "$NS" deploy/nextcloud -c nextcloud -- cat /var/www/demo-reset/last-reset +``` + +## Als het misgaat + +| Waar het faalt | Wat je ziet | Wat je doet | +|---|---|---| +| Bij een controle (stap 1) | Job `Failed`, melding eindigt op "Er is niets gewist." | De oorzaak staat in de melding. De tenant draait door. | +| Vóór stap 4 | "onderhoudsmodus gaat weer uit" | Niets; de tenant draait door zoals hij was. | +| Ná stap 4 | "de onderhoudsmodus BLIJFT AAN" | Start de reset opnieuw, zie hieronder. De momentopname is niet aangeraakt. | +| Bij het laden van een set (stap 8) | Job `Failed`, tenant in de lucht op de momentopname | Lees de occ-uitvoer in de log. Datums staan op de momentopname tot de volgende run. | + +De reset met de hand starten: + +```bash +kubectl create job -n "$NS" --from=cronjob/demo-reset "demo-reset-handmatig-$(date +%Y%m%d%H%M)" +``` + +De Job herhaalt zichzelf niet (`backoffLimit: 0`). Een tweede poging direct na +een fout raakt meestal dezelfde fout. + +## Uitzetten en weghalen + +Uitzetten: `paused: true` in het tenantbestand. De CronJob staat dan stil en de +momentopname blijft. + +Weghalen gaat in drie stappen, want deze ApplicationSet heeft +`preserveResourcesOnDeletion: true`. Haal je alleen het blok weg, dan blijft de +CronJob staan en draait hij door. + +1. Eerst `paused: true`, laten syncen. +2. Dan het blok `components.demoReset` weghalen. +3. Een mens ruimt op: + +```bash +NS=zuiddrecht-prod +kubectl delete cronjob,role,rolebinding,serviceaccount,configmap -n "$NS" -l app.kubernetes.io/name=tenant-demo-reset +kubectl exec -n "$NS" deploy/nextcloud -c nextcloud -- rm -rf /var/www/demo-reset +kubectl exec -n "$NS" nextcloud-postgresql-0 -c postgresql -- sh -c \ + 'PGPASSWORD="$(cat "$POSTGRES_POSTGRES_PASSWORD_FILE")" psql -h 127.0.0.1 -U postgres -d postgres \ + -c "ALTER DATABASE nextcloud_demo_golden WITH IS_TEMPLATE false" -c "DROP DATABASE nextcloud_demo_golden"' +``` + +## Voorwaarden op het cluster + +- **De ApplicationSet moet er komen.** `argo/` heeft geen `automated`; een mens + synct `nextcloud-platform-bootstrap` na de merge. +- **Het image `alpine/k8s:1.29.2`** moet te pullen zijn. Het is hetzelfde image + als de credential-refresh CronJob in cluster-infra. +- **De AppProject `nextcloud-platform`** staat Role en RoleBinding al toe + (sinds 2026-08-07) en CronJob via `batch`. Geen wijziging nodig. +- **Ruimte in Postgres.** Tijdens een reset staan er drie kopieën van de + database: gouden, live en incoming. De PVC is 8Gi. +- **Ruimte op de Nextcloud-PVC.** De datamap staat er twee keer op. + +## Grenzen + +- Alleen `dbType: postgres`. De momentopname is een Postgres-template. +- Alleen een lokale datamap. Een tenant met een S3 primary objectstore + (`canary-overrides.yaml`) stopt de reset voordat er iets gewist is. Met S3 zou + een teruggezette database naar objecten wijzen die er niet meer zijn. +- Eén Nextcloud-pod. `replicaCount: 1` in prod; bij twee stopt de reset. +- Ingelogde bezoekers zijn na de reset uitgelogd. Hun inlogtokens stonden in de + database, en die is teruggezet. + +## Waarom zo, en niet anders + +| Alternatief | Waarom niet | +|---|---| +| CNPG-backup en restore naar een tijdstip | Tenants draaien op de Bitnami-Postgres van de Nextcloud-chart, niet op CNPG. Zie `CNPG-MIGRATIE.md`. | +| `pg_dump` en `pg_restore` van een gouden dump | Werkt, maar trager, en de dump moet ergens staan. Een template-database is een bestandskopie op dezelfde server. | +| VolumeSnapshot van de PVC's | Er is geen werkende VolumeSnapshotClass (`CNPG-MIGRATIE.md` §4). En het terugzetten vraagt de PVC weg te halen, met Argo-drift als gevolg. | +| Een gouden prefix in S3 terugkopiëren | De bestanden van een gewone tenant staan niet in S3 maar op de PVC. | diff --git a/docs/TENANT-OPERATIONS.md b/docs/TENANT-OPERATIONS.md index ce75f82..40d4af1 100644 --- a/docs/TENANT-OPERATIONS.md +++ b/docs/TENANT-OPERATIONS.md @@ -1,5 +1,5 @@ --- -last_reviewed: 2026-08-21 +last_reviewed: 2026-10-14 owner: info@conduction.nl --- @@ -49,6 +49,10 @@ namespace, de prefix en de database niet. ## Tenant Reset (Data wissen) +> Een demo-tenant elke nacht terugzetten naar een vaste stand doe je niet met +> de hand, maar met `tenant.components.demoReset`. Zie +> [DEMO-RESET.md](DEMO-RESET.md). + Reset een tenant naar een schone staat **zonder** de configuratie te verwijderen. ### Alleen PVCs resetten (snelste methode) diff --git a/docs/agents.md b/docs/agents.md index 77ad06d..3a0e629 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -1,5 +1,5 @@ --- -last_reviewed: 2026-07-10 +last_reviewed: 2026-10-14 owner: info@conduction.nl --- @@ -15,6 +15,8 @@ Guardrails voor agents in deze repo, per het handboek-formaat | Tenant-bestand aanmaken (`values/tenants/tenant--.yaml` uit de template) | **voorstel-eerst** (toon het volledige bestand, schrijf pas na akkoord — creatie-regel 2026-07-10) | declaratief: bestand bestaat al → geen tweede aanmaak (check eerst) | `./scripts/verify.sh` (validator + smoke-render) groen | | Frontend-blok (`tenant.frontend:`): nieuw toevoegen **voorstel-eerst**, bestaand wijzigen autonoom | zie creatie-regel | gewenste staat in het bestand; gelijke staat → geen diff | verify hier én react-base `./scripts/verify.sh` (vloot-render) | | Tenant-waarden wijzigen (resources, apps, phpMemoryLimit) | autonoom | idem (declaratief bestand) | verify groen; render-diff tonen bij twijfel | +| `tenant.components.demoReset` aanzetten (wist de tenant elke nacht) | **voorstel-eerst** | declaratief; `confirmWipe` moet de tenantnaam zijn | verify groen; DEMO-RESET.md | +| Gouden momentopname nemen of een demo-reset met de hand starten (`kubectl create job --from=cronjob/...`) | mens-vereist | ja; zelfde momentopname, zelfde uitkomst | agent zet het commando klaar; mens voert uit; DEMO-RESET.md | | Docs bijwerken (zelfde wijziging als de code) | autonoom | tekstueel; front-matter `last_reviewed` bijwerken | docs-contract-gate | | Tenant-bestand verwijderen | mens-vereist | n.v.t. (voorbereiding is een diff) | agent bereidt voor; mens beslist + pusht; REMOVING-TENANT.md | | Secrets aanmaken (`create-tenant-secret.sh`, kubectl create secret) | mens-vereist | scripts zijn idempotent, maar secret-handling blijft mensenwerk | mens draait; agent mag het commando klaarzetten | diff --git a/docs/index.md b/docs/index.md index ac873ec..5e2ca73 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,5 +1,5 @@ --- -last_reviewed: 2026-10-13 +last_reviewed: 2026-10-14 owner: info@conduction.nl --- @@ -48,6 +48,9 @@ hoofdstuk dat je nodig hebt. - `GATEWAY-API.md` - een tenant naast zijn Ingress op de gedeelde Gateway zetten (migratie weg van ingress-nginx), en waarom dat nog niet vloot-breed kan - `TENANT-OPERATIONS.md` - tenant reset, volledig verwijderen, opnieuw opzetten +- `DEMO-RESET.md` - een demo-tenant elke nacht terug naar een gouden + momentopname (`tenant.components.demoReset`): aanzetten, momentopname nemen, + controleren, uitzetten - `STORAGE-OPERATIONS.md` - PVC resizen, S3-databeheer - `DEBUGGING.md` - database-shell/backup, logs, occ-status, one-liners - `EMERGENCY.md` - noodprocedures (crashloops, sync-falen, storage vol, @@ -94,6 +97,13 @@ generieke pins, checksum, mapnaam, links in de tarball, info.xml-id, falen dat de vorige versie laat staan of terugzet, en een afgebroken wissel. `scripts/verify.sh` draait ook deze suite mee. +`nextcloud-platform/tests/run-demo-reset-tests.sh` rendert +`charts/tenant-demo-reset` en het values-blok van de ApplicationSet +`nextcloud-tenant-demo-reset`, en draait het reset-script tegen een +nep-kubectl met echte bestandsoperaties. De cases dekken de momentopname, het +terugzetten, falen voor en na de eerste onomkeerbare stap en een wissel die +halverwege stopte. `scripts/verify.sh` draait ook deze suite mee. + ## Conventies (gelden overal) - **Namespace = kale tenant-naam** (bv. `straatje-accept`). De Argo-*applicatie* diff --git a/nextcloud-platform/argo/applicationsets/nextcloud-tenant-demo-reset.yaml b/nextcloud-platform/argo/applicationsets/nextcloud-tenant-demo-reset.yaml new file mode 100644 index 0000000..a6630bb --- /dev/null +++ b/nextcloud-platform/argo/applicationsets/nextcloud-tenant-demo-reset.yaml @@ -0,0 +1,115 @@ +--- +# Nachtelijke demo-reset per tenant, als APARTE Application naast `nc-`. +# +# Wat het doet: elke nacht de database en de datamap van een demo-tenant +# terugzetten naar een gouden momentopname en daarna de voorbeeldsets opnieuw +# laden. Alles wat bezoekers maakten (accounts, verzoeken, dossiers, uploads) +# is dan weg. Zie docs/DEMO-RESET.md. +# +# Waarom een eigen ApplicationSet en geen vierde source in `nextcloud-tenants`: +# dezelfde afweging als bij `nextcloud-tenant-routes`. Een source erbij +# verandert de spec van álle tenant-Applications en laat ze hersyncen, voor een +# component die op één tenant draait. Deze ApplicationSet levert voor de vloot +# niets op: de post-selector matcht alleen een tenant met +# `components.demoReset.enabled: true`, en een ontbrekende key matcht `In` niet. +# +# Uitzetten gaat met `paused: true` in het tenantbestand, NIET door `enabled` +# weg te halen. Deze ApplicationSet heeft `preserveResourcesOnDeletion: true` +# (zoals de andere twee): verdwijnt de Application, dan blijft de CronJob in de +# namespace staan en draait hij gewoon door. docs/DEMO-RESET.md beschrijft hoe +# je hem helemaal weghaalt. +apiVersion: argoproj.io/v1alpha1 +kind: ApplicationSet +metadata: + name: nextcloud-tenant-demo-reset + namespace: argocd + annotations: + argocd.argoproj.io/compare-options: IgnoreExtraneous +spec: + goTemplate: true + goTemplateOptions: ["missingkey=default"] + + syncPolicy: + preserveResourcesOnDeletion: true + + generators: + - git: + repoURL: "https://github.com/ConductionNL/Nextcloud-base.git" + revision: release + files: + - path: "nextcloud-platform/values/tenants/tenant-*.yaml" + + # Opt-in, zelfde mechaniek als de gateway-selector in + # nextcloud-tenant-routes: de geflattende params met dotted keys. + selector: + matchExpressions: + - key: tenant.components.demoReset.enabled + operator: In + values: + - "true" + + template: + metadata: + name: "nc-{{ .tenant.name }}-demo-reset" + namespace: argocd + labels: + app.kubernetes.io/part-of: nextcloud-platform + nextcloud.platform/tenant: "{{ .tenant.name }}" + nextcloud.platform/environment: "{{ .tenant.environment }}" + annotations: + argocd.argoproj.io/sync-wave: "{{ default \"1\" .tenant.wave }}" + finalizers: + - resources-finalizer.argocd.argoproj.io + spec: + project: nextcloud-platform + + source: + repoURL: "https://github.com/ConductionNL/Nextcloud-base.git" + targetRevision: '{{ if eq (toString .tenant.wave) "0" }}HEAD{{ else if eq .tenant.environment "accept" }}release-accept{{ else }}release{{ end }}' + path: charts/tenant-demo-reset + helm: + releaseName: "nextcloud-demo-reset" + # De host-afleiding is dezelfde als in nextcloud-tenants en + # nextcloud-tenant-routes; wijzigt die, dan moet deze mee. + values: | + {{- $name := .tenant.name -}} + {{- $suffix := regexFind "(accept|test|prod)$" $name -}} + {{- $org := regexReplaceAll "-(accept|test|prod)$" $name "" -}} + {{- $omgeving := default .tenant.environment $suffix -}} + {{- $computedHost := "" -}} + {{- if eq $omgeving "prod" -}} + {{- $computedHost = printf "%s.commonground.nu" $org -}} + {{- else -}} + {{- $computedHost = printf "%s.%s.commonground.nu" $org $omgeving -}} + {{- end -}} + {{- $host := default $computedHost .tenant.hostname -}} + {{- $dr := .tenant.components.demoReset }} + tenant: + name: "{{ $name }}" + host: "{{ $host }}" + demoReset: + enabled: true + confirmWipe: "{{ default "" $dr.confirmWipe }}" + schedule: "{{ default "15 4 * * *" $dr.schedule }}" + paused: {{ default false $dr.paused }} + exampleSets: {{ default (list) $dr.exampleSets | toJson }} + exampleSetMode: "{{ default "install" $dr.exampleSetMode }}" + exampleSetPasswordSecret: "{{ default "" $dr.exampleSetPasswordSecret }}" + {{- $la := default (dict) $dr.limitApps }} + limitApps: + apps: {{ default (list) $la.apps | toJson }} + exceptGroups: {{ default (list) $la.exceptGroups | toJson }} + + destination: + server: https://kubernetes.default.svc + namespace: "{{ default .tenant.name .tenant.namespace }}" + + syncPolicy: + automated: + prune: true + selfHeal: true + allowEmpty: false + syncOptions: + # De namespace bestaat al; die is van de tenant-Application. + - CreateNamespace=false + - PrunePropagationPolicy=foreground diff --git a/nextcloud-platform/scripts/validate-values.sh b/nextcloud-platform/scripts/validate-values.sh index 8ac5216..3b191ff 100644 --- a/nextcloud-platform/scripts/validate-values.sh +++ b/nextcloud-platform/scripts/validate-values.sh @@ -617,6 +617,195 @@ validate_frontend_branding() { return 0 } +# Optionele componenten per tenant (tenant.components.*). Elke sleutel hier +# heeft een eigen ApplicationSet die hem leest; een sleutel die niemand leest +# wordt stil genegeerd, dus die weigeren we. +COMPONENT_KEYS=("demoReset") +DEMO_RESET_KEYS=( + "enabled" "confirmWipe" "schedule" "paused" "exampleSets" + "exampleSetMode" "exampleSetPasswordSecret" "limitApps" +) +DEMO_RESET_LIMIT_APPS_KEYS=("apps" "exceptGroups") + +# Sleutels onder een pad toetsen aan een allowlist (los van _check_keys in +# validate_frontend_keys, dat pas bestaat nadat die functie één keer liep). +check_allowed_keys() { + local file="$1" path="$2" label="$3" + shift 3 + local allowed=("$@") key a found has_error=0 + while IFS= read -r key; do + [ -z "$key" ] && continue + found=0 + for a in "${allowed[@]}"; do + if [ "$key" = "$a" ]; then + found=1 + break + fi + done + if [ $found -eq 0 ]; then + log_error "$file: Onbekende sleutel '$key' onder $label. Niets leest hem, dus hij wordt stil genegeerd. Toegestaan: ${allowed[*]}" + has_error=1 + fi + done < <(yq eval "$path // {} | keys | .[]" "$file" 2>/dev/null) + return $has_error +} + +# Validate tenant.components.demoReset (charts/tenant-demo-reset) +# +# Deze component WIST elke nacht alles wat bezoekers in de tenant maakten. Een +# fout hier kost dus geen render, maar een gemeente. Daarom strenger dan de +# rest: `enabled` moet een echte boolean zijn (de ApplicationSet-selector +# matcht de string "true"; `yes` of `"true"` gedraagt zich anders dan het +# leest), en `confirmWipe` moet letterlijk de tenantnaam herhalen, zodat een +# blok dat van een ander tenantbestand is overgenomen niet stil meegaat. +# Zie docs/DEMO-RESET.md. +validate_demo_reset() { + local file="$1" + local base='.tenant.components.demoReset' + local has_error=0 + + if [ "$(yq eval '.tenant.components | type' "$file" 2>/dev/null)" = "!!null" ]; then + return 0 + fi + check_allowed_keys "$file" '.tenant.components' 'tenant.components' "${COMPONENT_KEYS[@]}" || has_error=1 + + if [ "$(yq eval "$base | type" "$file" 2>/dev/null)" = "!!null" ]; then + return $has_error + fi + check_allowed_keys "$file" "$base" 'tenant.components.demoReset' "${DEMO_RESET_KEYS[@]}" || has_error=1 + + local enabled_type enabled + enabled_type=$(yq eval "$base.enabled | type" "$file" 2>/dev/null) + enabled=$(yq eval "$base.enabled" "$file" 2>/dev/null) + if [ "$enabled_type" != "!!bool" ]; then + log_error "$file: tenant.components.demoReset.enabled moet true of false zijn (een YAML-boolean, zonder aanhalingstekens), niet '$enabled'." + has_error=1 + fi + + local paused_type + paused_type=$(yq eval "$base.paused | type" "$file" 2>/dev/null) + if [ "$paused_type" != "!!null" ] && [ "$paused_type" != "!!bool" ]; then + log_error "$file: tenant.components.demoReset.paused moet true of false zijn (een YAML-boolean)." + has_error=1 + fi + + # Vijf velden, alleen cijfers en * , / -. Geen @daily-macro's of namen: de + # bedoeling moet in het tenantbestand te lezen zijn. + local schedule + schedule=$(yq eval "$base.schedule // \"\"" "$file" 2>/dev/null) + if [ -n "$schedule" ] && ! [[ "$schedule" =~ ^[0-9*/,-]+( [0-9*/,-]+){4}$ ]]; then + log_error "$file: tenant.components.demoReset.schedule '$schedule' is geen cron-schema met vijf velden (bv. \"15 4 * * *\", tijdzone Europe/Amsterdam)." + has_error=1 + fi + + local sets_type set_id set_count=0 + sets_type=$(yq eval "$base.exampleSets | type" "$file" 2>/dev/null) + if [ "$sets_type" != "!!null" ] && [ "$sets_type" != "!!seq" ]; then + log_error "$file: tenant.components.demoReset.exampleSets moet een lijst zijn." + has_error=1 + elif [ "$sets_type" = "!!seq" ]; then + while IFS= read -r set_id; do + set_count=$((set_count + 1)) + if ! [[ "$set_id" =~ ^[a-z0-9][a-z0-9-]{0,62}$ ]]; then + log_error "$file: tenant.components.demoReset.exampleSets bevat '$set_id'; verwacht een set-id zoals 'zuiddrecht' (kleine letters, cijfers, koppeltekens)." + has_error=1 + fi + done < <(yq eval "$base.exampleSets[]" "$file" 2>/dev/null) + fi + + local mode + mode=$(yq eval "$base.exampleSetMode // \"\"" "$file" 2>/dev/null) + if [ -n "$mode" ] && [ "$mode" != "install" ] && [ "$mode" != "reinstall" ]; then + log_error "$file: tenant.components.demoReset.exampleSetMode '$mode' is onbekend. Toegestaan: install, reinstall." + has_error=1 + fi + + local secret + secret=$(yq eval "$base.exampleSetPasswordSecret // \"\"" "$file" 2>/dev/null) + if [ -n "$secret" ] && ! [[ "$secret" =~ ^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$ ]]; then + log_error "$file: tenant.components.demoReset.exampleSetPasswordSecret '$secret' is geen geldige Secret-naam." + has_error=1 + fi + + if [ $set_count -eq 0 ]; then + if [ -n "$mode" ]; then + log_error "$file: tenant.components.demoReset.exampleSetMode is gezet zonder exampleSets. Zonder sets wordt er niets geladen." + has_error=1 + fi + if [ -n "$secret" ]; then + log_error "$file: tenant.components.demoReset.exampleSetPasswordSecret is gezet zonder exampleSets. Het wachtwoord is alleen voor het laden van de sets." + has_error=1 + fi + elif ! yq eval '.tenant.apps.enabled[]' "$file" 2>/dev/null | grep -x 'openregister' >/dev/null; then + log_error "$file: tenant.components.demoReset.exampleSets vraagt openregister (occ openregister:example-set:install), maar openregister staat niet in tenant.apps.enabled." + has_error=1 + fi + + # limitApps: na de sets apps beperken tot alle groepen behalve exceptGroups. + local limit_type + limit_type=$(yq eval "$base.limitApps | type" "$file" 2>/dev/null) + if [ "$limit_type" != "!!null" ] && [ "$limit_type" != "!!map" ]; then + log_error "$file: tenant.components.demoReset.limitApps moet een map zijn met apps en exceptGroups." + has_error=1 + elif [ "$limit_type" = "!!map" ]; then + check_allowed_keys "$file" "$base.limitApps" 'tenant.components.demoReset.limitApps' "${DEMO_RESET_LIMIT_APPS_KEYS[@]}" || has_error=1 + local list_key list_type item app_count=0 group_count=0 + for list_key in apps exceptGroups; do + list_type=$(yq eval "$base.limitApps.$list_key | type" "$file" 2>/dev/null) + if [ "$list_type" != "!!null" ] && [ "$list_type" != "!!seq" ]; then + log_error "$file: tenant.components.demoReset.limitApps.$list_key moet een lijst zijn." + has_error=1 + continue + fi + while IFS= read -r item; do + [ -z "$item" ] && continue + if [ "$list_key" = "apps" ]; then + app_count=$((app_count + 1)) + if ! [[ "$item" =~ ^[a-z][a-z0-9_]{0,63}$ ]]; then + log_error "$file: tenant.components.demoReset.limitApps.apps bevat '$item'; verwacht een app-id zoals 'dossiq'." + has_error=1 + fi + else + group_count=$((group_count + 1)) + if ! [[ "$item" =~ ^[A-Za-z0-9._@-]{1,64}$ ]]; then + log_error "$file: tenant.components.demoReset.limitApps.exceptGroups bevat '$item'; verwacht een groeps-id (letters, cijfers, . _ @ -)." + has_error=1 + fi + fi + done < <(yq eval "$base.limitApps.${list_key}[]" "$file" 2>/dev/null) + done + if [ $app_count -gt 0 ] && [ $group_count -eq 0 ]; then + log_error "$file: tenant.components.demoReset.limitApps.apps is gezet zonder exceptGroups. Dan krijgt elke groep de apps en beperkt de stap niets." + has_error=1 + fi + fi + + # Wat alleen telt als hij echt aan staat. + if [ "$enabled" = "true" ] && [ "$enabled_type" = "!!bool" ]; then + local name confirm db canary + name=$(yq eval '.tenant.name' "$file" 2>/dev/null) + confirm=$(yq eval "$base.confirmWipe // \"\"" "$file" 2>/dev/null) + if [ "$confirm" != "$name" ]; then + log_error "$file: tenant.components.demoReset.confirmWipe moet letterlijk de tenantnaam '$name' zijn (nu: '$confirm'). Deze component wist elke nacht alles wat bezoekers maakten." + has_error=1 + fi + + db=$(yq eval '.tenant.dbType' "$file" 2>/dev/null) + if [ "$db" != "postgres" ]; then + log_error "$file: tenant.components.demoReset werkt alleen met dbType postgres (de momentopname is een Postgres-template); deze tenant heeft '$db'." + has_error=1 + fi + + canary=$(yq eval '.tenant.canary // false' "$file" 2>/dev/null) + if [ "$canary" = "true" ]; then + log_error "$file: tenant.components.demoReset kan niet op een tenant met tenant.canary: true. Die zet de bestanden in S3 (canary-overrides.yaml) en demo-reset zet alleen een lokale datamap terug." + has_error=1 + fi + fi + + return $has_error +} + # Check for potential secrets in file content check_for_secrets() { local file="$1" @@ -679,6 +868,7 @@ validate_tenant_file() { validate_frontend_image "$file" || ((file_errors++)) validate_frontend_keys "$file" || ((file_errors++)) validate_frontend_branding "$file" || ((file_errors++)) + validate_demo_reset "$file" || ((file_errors++)) check_for_secrets "$file" || true # Warnings only if [ $file_errors -eq 0 ]; then diff --git a/nextcloud-platform/tests/cases/tenant-components-onbekende-sleutel.expect b/nextcloud-platform/tests/cases/tenant-components-onbekende-sleutel.expect new file mode 100644 index 0000000..055cad6 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-components-onbekende-sleutel.expect @@ -0,0 +1 @@ +Onbekende sleutel 'keycloak' onder tenant.components diff --git a/nextcloud-platform/tests/cases/tenant-components-onbekende-sleutel.yaml b/nextcloud-platform/tests/cases/tenant-components-onbekende-sleutel.yaml new file mode 100644 index 0000000..18264ca --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-components-onbekende-sleutel.yaml @@ -0,0 +1,13 @@ +--- +tenant: + name: zuiddrecht-prod + environment: prod + wave: "1" + dbType: postgres + apps: + enabled: + - openregister + - opencatalogi + components: + keycloak: + enabled: true diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-bevestiging-andere-tenant.expect b/nextcloud-platform/tests/cases/tenant-demo-reset-bevestiging-andere-tenant.expect new file mode 100644 index 0000000..9ce77f7 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-bevestiging-andere-tenant.expect @@ -0,0 +1 @@ +confirmWipe moet letterlijk de tenantnaam 'zuiddrecht-prod' zijn (nu: 'alkmaar-prod') diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-bevestiging-andere-tenant.yaml b/nextcloud-platform/tests/cases/tenant-demo-reset-bevestiging-andere-tenant.yaml new file mode 100644 index 0000000..f78eb33 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-bevestiging-andere-tenant.yaml @@ -0,0 +1,14 @@ +--- +tenant: + name: zuiddrecht-prod + environment: prod + wave: "1" + dbType: postgres + apps: + enabled: + - openregister + - opencatalogi + components: + demoReset: + enabled: true + confirmWipe: alkmaar-prod diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-bevestiging-ontbreekt.expect b/nextcloud-platform/tests/cases/tenant-demo-reset-bevestiging-ontbreekt.expect new file mode 100644 index 0000000..c979b60 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-bevestiging-ontbreekt.expect @@ -0,0 +1 @@ +confirmWipe moet letterlijk de tenantnaam 'zuiddrecht-prod' zijn (nu: '') diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-bevestiging-ontbreekt.yaml b/nextcloud-platform/tests/cases/tenant-demo-reset-bevestiging-ontbreekt.yaml new file mode 100644 index 0000000..f4af7e7 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-bevestiging-ontbreekt.yaml @@ -0,0 +1,13 @@ +--- +tenant: + name: zuiddrecht-prod + environment: prod + wave: "1" + dbType: postgres + apps: + enabled: + - openregister + - opencatalogi + components: + demoReset: + enabled: true diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-canary.expect b/nextcloud-platform/tests/cases/tenant-demo-reset-canary.expect new file mode 100644 index 0000000..5d2747e --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-canary.expect @@ -0,0 +1 @@ +kan niet op een tenant met tenant.canary: true diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-canary.yaml b/nextcloud-platform/tests/cases/tenant-demo-reset-canary.yaml new file mode 100644 index 0000000..bbfd8ad --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-canary.yaml @@ -0,0 +1,15 @@ +--- +tenant: + name: zuiddrecht-prod + environment: prod + wave: "1" + dbType: postgres + apps: + enabled: + - openregister + - opencatalogi + canary: true + components: + demoReset: + enabled: true + confirmWipe: zuiddrecht-prod diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-enabled-als-tekst.expect b/nextcloud-platform/tests/cases/tenant-demo-reset-enabled-als-tekst.expect new file mode 100644 index 0000000..e893f48 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-enabled-als-tekst.expect @@ -0,0 +1 @@ +demoReset.enabled moet true of false zijn diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-enabled-als-tekst.yaml b/nextcloud-platform/tests/cases/tenant-demo-reset-enabled-als-tekst.yaml new file mode 100644 index 0000000..ff1540f --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-enabled-als-tekst.yaml @@ -0,0 +1,14 @@ +--- +tenant: + name: zuiddrecht-prod + environment: prod + wave: "1" + dbType: postgres + apps: + enabled: + - openregister + - opencatalogi + components: + demoReset: + enabled: "true" + confirmWipe: zuiddrecht-prod diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-geldig.expect b/nextcloud-platform/tests/cases/tenant-demo-reset-geldig.expect new file mode 100644 index 0000000..7ef22e9 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-geldig.expect @@ -0,0 +1 @@ +PASS diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-geldig.yaml b/nextcloud-platform/tests/cases/tenant-demo-reset-geldig.yaml new file mode 100644 index 0000000..a96d0f6 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-geldig.yaml @@ -0,0 +1,19 @@ +--- +tenant: + name: zuiddrecht-prod + environment: prod + wave: "1" + dbType: postgres + apps: + enabled: + - openregister + - opencatalogi + components: + demoReset: + enabled: true + confirmWipe: zuiddrecht-prod + schedule: "15 4 * * *" + exampleSets: + - zuiddrecht + exampleSetMode: reinstall + exampleSetPasswordSecret: demo-reset-password diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-als-lijst.expect b/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-als-lijst.expect new file mode 100644 index 0000000..9ad2cb8 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-als-lijst.expect @@ -0,0 +1 @@ +limitApps moet een map zijn diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-als-lijst.yaml b/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-als-lijst.yaml new file mode 100644 index 0000000..81230cb --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-als-lijst.yaml @@ -0,0 +1,18 @@ +--- +tenant: + name: zuiddrecht-prod + environment: prod + wave: "1" + dbType: postgres + apps: + enabled: + - openregister + - opencatalogi + components: + demoReset: + enabled: true + confirmWipe: zuiddrecht-prod + exampleSets: + - zuiddrecht + limitApps: + - dossiq diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-geldig.expect b/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-geldig.expect new file mode 100644 index 0000000..7ef22e9 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-geldig.expect @@ -0,0 +1 @@ +PASS diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-geldig.yaml b/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-geldig.yaml new file mode 100644 index 0000000..73306b0 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-geldig.yaml @@ -0,0 +1,22 @@ +--- +tenant: + name: zuiddrecht-prod + environment: prod + wave: "1" + dbType: postgres + apps: + enabled: + - openregister + - opencatalogi + components: + demoReset: + enabled: true + confirmWipe: zuiddrecht-prod + exampleSets: + - zuiddrecht + limitApps: + apps: + - dossiq + - mail + exceptGroups: + - inwoners diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-onbekende-sleutel.expect b/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-onbekende-sleutel.expect new file mode 100644 index 0000000..add0984 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-onbekende-sleutel.expect @@ -0,0 +1 @@ +Onbekende sleutel 'groups' onder tenant.components.demoReset.limitApps diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-onbekende-sleutel.yaml b/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-onbekende-sleutel.yaml new file mode 100644 index 0000000..7dcd878 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-onbekende-sleutel.yaml @@ -0,0 +1,23 @@ +--- +tenant: + name: zuiddrecht-prod + environment: prod + wave: "1" + dbType: postgres + apps: + enabled: + - openregister + - opencatalogi + components: + demoReset: + enabled: true + confirmWipe: zuiddrecht-prod + exampleSets: + - zuiddrecht + limitApps: + apps: + - dossiq + exceptGroups: + - inwoners + groups: + - medewerkers diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-ongeldige-app.expect b/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-ongeldige-app.expect new file mode 100644 index 0000000..d4ff95e --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-ongeldige-app.expect @@ -0,0 +1 @@ +limitApps.apps bevat 'dossiq; rm -rf /' diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-ongeldige-app.yaml b/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-ongeldige-app.yaml new file mode 100644 index 0000000..370d04c --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-ongeldige-app.yaml @@ -0,0 +1,21 @@ +--- +tenant: + name: zuiddrecht-prod + environment: prod + wave: "1" + dbType: postgres + apps: + enabled: + - openregister + - opencatalogi + components: + demoReset: + enabled: true + confirmWipe: zuiddrecht-prod + exampleSets: + - zuiddrecht + limitApps: + apps: + - 'dossiq; rm -rf /' + exceptGroups: + - inwoners diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-ongeldige-groep.expect b/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-ongeldige-groep.expect new file mode 100644 index 0000000..ce5445b --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-ongeldige-groep.expect @@ -0,0 +1 @@ +limitApps.exceptGroups bevat diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-ongeldige-groep.yaml b/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-ongeldige-groep.yaml new file mode 100644 index 0000000..11df40b --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-ongeldige-groep.yaml @@ -0,0 +1,21 @@ +--- +tenant: + name: zuiddrecht-prod + environment: prod + wave: "1" + dbType: postgres + apps: + enabled: + - openregister + - opencatalogi + components: + demoReset: + enabled: true + confirmWipe: zuiddrecht-prod + exampleSets: + - zuiddrecht + limitApps: + apps: + - dossiq + exceptGroups: + - 'inwoners $(id)' diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-zonder-uitzondering.expect b/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-zonder-uitzondering.expect new file mode 100644 index 0000000..9f2abdf --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-zonder-uitzondering.expect @@ -0,0 +1 @@ +limitApps.apps is gezet zonder exceptGroups diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-zonder-uitzondering.yaml b/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-zonder-uitzondering.yaml new file mode 100644 index 0000000..424e4b2 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-limit-apps-zonder-uitzondering.yaml @@ -0,0 +1,19 @@ +--- +tenant: + name: zuiddrecht-prod + environment: prod + wave: "1" + dbType: postgres + apps: + enabled: + - openregister + - opencatalogi + components: + demoReset: + enabled: true + confirmWipe: zuiddrecht-prod + exampleSets: + - zuiddrecht + limitApps: + apps: + - dossiq diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-mariadb.expect b/nextcloud-platform/tests/cases/tenant-demo-reset-mariadb.expect new file mode 100644 index 0000000..e42a384 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-mariadb.expect @@ -0,0 +1 @@ +werkt alleen met dbType postgres diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-mariadb.yaml b/nextcloud-platform/tests/cases/tenant-demo-reset-mariadb.yaml new file mode 100644 index 0000000..4514ced --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-mariadb.yaml @@ -0,0 +1,14 @@ +--- +tenant: + name: zuiddrecht-prod + environment: prod + wave: "1" + dbType: mariadb + apps: + enabled: + - openregister + - opencatalogi + components: + demoReset: + enabled: true + confirmWipe: zuiddrecht-prod diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-modus-onbekend.expect b/nextcloud-platform/tests/cases/tenant-demo-reset-modus-onbekend.expect new file mode 100644 index 0000000..92a0829 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-modus-onbekend.expect @@ -0,0 +1 @@ +exampleSetMode 'opnieuw' is onbekend diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-modus-onbekend.yaml b/nextcloud-platform/tests/cases/tenant-demo-reset-modus-onbekend.yaml new file mode 100644 index 0000000..c8332cd --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-modus-onbekend.yaml @@ -0,0 +1,16 @@ +--- +tenant: + name: zuiddrecht-prod + environment: prod + wave: "1" + dbType: postgres + apps: + enabled: + - openregister + - opencatalogi + components: + demoReset: + enabled: true + confirmWipe: zuiddrecht-prod + exampleSets: [zuiddrecht] + exampleSetMode: opnieuw diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-modus-zonder-sets.expect b/nextcloud-platform/tests/cases/tenant-demo-reset-modus-zonder-sets.expect new file mode 100644 index 0000000..2b13fa9 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-modus-zonder-sets.expect @@ -0,0 +1 @@ +exampleSetMode is gezet zonder exampleSets diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-modus-zonder-sets.yaml b/nextcloud-platform/tests/cases/tenant-demo-reset-modus-zonder-sets.yaml new file mode 100644 index 0000000..55dccd5 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-modus-zonder-sets.yaml @@ -0,0 +1,15 @@ +--- +tenant: + name: zuiddrecht-prod + environment: prod + wave: "1" + dbType: postgres + apps: + enabled: + - openregister + - opencatalogi + components: + demoReset: + enabled: true + confirmWipe: zuiddrecht-prod + exampleSetMode: reinstall diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-onbekende-sleutel.expect b/nextcloud-platform/tests/cases/tenant-demo-reset-onbekende-sleutel.expect new file mode 100644 index 0000000..a430565 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-onbekende-sleutel.expect @@ -0,0 +1 @@ +Onbekende sleutel 'goldenSnapshot' onder tenant.components.demoReset diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-onbekende-sleutel.yaml b/nextcloud-platform/tests/cases/tenant-demo-reset-onbekende-sleutel.yaml new file mode 100644 index 0000000..28175f7 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-onbekende-sleutel.yaml @@ -0,0 +1,15 @@ +--- +tenant: + name: zuiddrecht-prod + environment: prod + wave: "1" + dbType: postgres + apps: + enabled: + - openregister + - opencatalogi + components: + demoReset: + enabled: true + confirmWipe: zuiddrecht-prod + goldenSnapshot: golden diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-paused-als-tekst.expect b/nextcloud-platform/tests/cases/tenant-demo-reset-paused-als-tekst.expect new file mode 100644 index 0000000..4b362ae --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-paused-als-tekst.expect @@ -0,0 +1 @@ +demoReset.paused moet true of false zijn diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-paused-als-tekst.yaml b/nextcloud-platform/tests/cases/tenant-demo-reset-paused-als-tekst.yaml new file mode 100644 index 0000000..efce963 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-paused-als-tekst.yaml @@ -0,0 +1,15 @@ +--- +tenant: + name: zuiddrecht-prod + environment: prod + wave: "1" + dbType: postgres + apps: + enabled: + - openregister + - opencatalogi + components: + demoReset: + enabled: true + confirmWipe: zuiddrecht-prod + paused: ja diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-schema-ongeldig.expect b/nextcloud-platform/tests/cases/tenant-demo-reset-schema-ongeldig.expect new file mode 100644 index 0000000..c4e45c3 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-schema-ongeldig.expect @@ -0,0 +1 @@ +schedule 'elke nacht' is geen cron-schema diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-schema-ongeldig.yaml b/nextcloud-platform/tests/cases/tenant-demo-reset-schema-ongeldig.yaml new file mode 100644 index 0000000..3d7e242 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-schema-ongeldig.yaml @@ -0,0 +1,15 @@ +--- +tenant: + name: zuiddrecht-prod + environment: prod + wave: "1" + dbType: postgres + apps: + enabled: + - openregister + - opencatalogi + components: + demoReset: + enabled: true + confirmWipe: zuiddrecht-prod + schedule: elke nacht diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-set-ongeldige-naam.expect b/nextcloud-platform/tests/cases/tenant-demo-reset-set-ongeldige-naam.expect new file mode 100644 index 0000000..8d02fa4 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-set-ongeldige-naam.expect @@ -0,0 +1 @@ +exampleSets bevat 'Zuiddrecht Demo' diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-set-ongeldige-naam.yaml b/nextcloud-platform/tests/cases/tenant-demo-reset-set-ongeldige-naam.yaml new file mode 100644 index 0000000..85a72ee --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-set-ongeldige-naam.yaml @@ -0,0 +1,15 @@ +--- +tenant: + name: zuiddrecht-prod + environment: prod + wave: "1" + dbType: postgres + apps: + enabled: + - openregister + - opencatalogi + components: + demoReset: + enabled: true + confirmWipe: zuiddrecht-prod + exampleSets: ["Zuiddrecht Demo"] diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-set-zonder-openregister.expect b/nextcloud-platform/tests/cases/tenant-demo-reset-set-zonder-openregister.expect new file mode 100644 index 0000000..322faad --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-set-zonder-openregister.expect @@ -0,0 +1 @@ +openregister staat niet in tenant.apps.enabled diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-set-zonder-openregister.yaml b/nextcloud-platform/tests/cases/tenant-demo-reset-set-zonder-openregister.yaml new file mode 100644 index 0000000..d42cb4b --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-set-zonder-openregister.yaml @@ -0,0 +1,15 @@ +--- +tenant: + name: zuiddrecht-prod + environment: prod + wave: "1" + dbType: postgres + apps: + enabled: + - filinq + - opencatalogi + components: + demoReset: + enabled: true + confirmWipe: zuiddrecht-prod + exampleSets: [zuiddrecht] diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-uit-zonder-bevestiging.expect b/nextcloud-platform/tests/cases/tenant-demo-reset-uit-zonder-bevestiging.expect new file mode 100644 index 0000000..7ef22e9 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-uit-zonder-bevestiging.expect @@ -0,0 +1 @@ +PASS diff --git a/nextcloud-platform/tests/cases/tenant-demo-reset-uit-zonder-bevestiging.yaml b/nextcloud-platform/tests/cases/tenant-demo-reset-uit-zonder-bevestiging.yaml new file mode 100644 index 0000000..66e2ae7 --- /dev/null +++ b/nextcloud-platform/tests/cases/tenant-demo-reset-uit-zonder-bevestiging.yaml @@ -0,0 +1,13 @@ +--- +tenant: + name: zuiddrecht-prod + environment: prod + wave: "1" + dbType: postgres + apps: + enabled: + - openregister + - opencatalogi + components: + demoReset: + enabled: false diff --git a/nextcloud-platform/tests/run-demo-reset-tests.sh b/nextcloud-platform/tests/run-demo-reset-tests.sh new file mode 100755 index 0000000..c04acf7 --- /dev/null +++ b/nextcloud-platform/tests/run-demo-reset-tests.sh @@ -0,0 +1,675 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: EUPL-1.2 +# role: tool +# +# nextcloud-platform/tests/run-demo-reset-tests.sh: tests voor de demo-reset +# (charts/tenant-demo-reset, de ApplicationSet nextcloud-tenant-demo-reset en +# het script charts/tenant-demo-reset/files/demo-reset.sh). +# +# Drie delen: +# +# chart helm template: uit = leeg, aan = zes geldige resources +# (kubeconform -strict), een verkeerde confirmWipe rendert niet, +# paused en het wachtwoord-Secret komen goed door, het script in +# de ConfigMap is byte-gelijk aan files/demo-reset.sh. +# appset het `values:`-blok van de ApplicationSet, gerenderd met Helm. +# Helm en Argo gebruiken allebei text/template met Sprig; het +# blok gebruikt overal `default`, dus het verschil in missingkey +# speelt niet. Zo dekt CI het blok zonder Argo. +# script demo-reset.sh tegen een nep-kubectl. Bestandsoperaties in de +# Nextcloud-container draaien ECHT, op een nep-boom onder een +# mktemp-map (paden /var/www en /proc/1/comm worden omgezet). +# Postgres is een map per database. `occ` is een stub die de +# onderhoudsmodus bijhoudt en elke aanroep logt, met de stand van +# de onderhoudsmodus erbij, zodat de volgorde toetsbaar is. +# `kill` wordt nooit uitgevoerd, alleen gelogd. +# +# Writes: alleen in een eigen mktemp-map +# Idempotent: ja +# Requires: bash, helm, kubeconform, jq, tar, find, cp +# +# Usage: +# ./run-demo-reset-tests.sh # alle cases +# VERBOSE=1 ./run-demo-reset-tests.sh # toon de scriptuitvoer bij een falende case + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly REPO_ROOT="${SCRIPT_DIR}/../.." +readonly CHART="${REPO_ROOT}/charts/tenant-demo-reset" +readonly SCRIPT="${CHART}/files/demo-reset.sh" +readonly APPSET="${SCRIPT_DIR}/../argo/applicationsets/nextcloud-tenant-demo-reset.yaml" + +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly NC='\033[0m' + +PASSED=0 +FAILED=0 +CASE="" +CASE_OK=1 + +WORK="$(mktemp -d)" +readonly WORK +trap 'rm -rf "$WORK"' EXIT + +for tool in helm kubeconform jq; do + command -v "$tool" >/dev/null 2>&1 || { echo "error: $tool ontbreekt" >&2; exit 1; } +done + +start() { CASE="$1"; CASE_OK=1; } +not() { ! "$@"; } +check() { # + local what="$1" + shift + if ! "$@"; then + printf "${RED} mis${NC} %s: %s\n" "$CASE" "$what" >&2 + CASE_OK=0 + fi +} +finish() { + if [ "$CASE_OK" -eq 1 ]; then + printf "${GREEN}PASS${NC} %s\n" "$CASE" + PASSED=$((PASSED + 1)) + else + printf "${RED}FAIL${NC} %s\n" "$CASE" >&2 + if [ -n "${VERBOSE:-}" ] && [ -f "$WORK/out" ]; then sed 's/^/ /' "$WORK/out" >&2; fi + FAILED=$((FAILED + 1)) + fi +} + +# --------------------------------------------------------------------------- +# Deel 1: chart +# --------------------------------------------------------------------------- + +ON=(--set demoReset.enabled=true --set tenant.name=zuiddrecht-prod + --set tenant.host=cloud.zuiddrecht.nl --set demoReset.confirmWipe=zuiddrecht-prod + --set 'demoReset.exampleSets={zuiddrecht}') + +start "chart: uit rendert niets" +out="$(helm template t "$CHART" -n zuiddrecht-prod)" +check "lege render" test -z "${out//[[:space:]]/}" +finish + +start "chart: aan rendert zes geldige resources" +helm template t "$CHART" -n zuiddrecht-prod "${ON[@]}" >"$WORK/on.yaml" +check "kubeconform -strict" kubeconform -strict -summary "$WORK/on.yaml" >/dev/null +kinds="$(grep -E '^kind:' "$WORK/on.yaml" | sort | tr '\n' ' ')" +check "kinds ($kinds)" test "$kinds" = "kind: ConfigMap kind: CronJob kind: CronJob kind: Role kind: RoleBinding kind: ServiceAccount " +check "reset-CronJob niet gepauzeerd" test "$(yq 'select(.kind == "CronJob" and .metadata.name == "demo-reset") | .spec.suspend' "$WORK/on.yaml")" = "false" +check "golden-CronJob altijd suspend" test "$(yq 'select(.kind == "CronJob" and .metadata.name == "demo-reset-golden") | .spec.suspend' "$WORK/on.yaml")" = "true" +check "Role alleen pods get/list en pods/exec create/get" test \ + "$(yq -o=json 'select(.kind == "Role") | .rules' "$WORK/on.yaml" | jq -c .)" = \ + '[{"apiGroups":[""],"resources":["pods"],"verbs":["get","list"]},{"apiGroups":[""],"resources":["pods/exec"],"verbs":["create","get"]}]' +# yq zet er één regeleinde achter; $(...) haalt aan beide kanten alle +# afsluitende regeleindes weg, de rest moet byte-gelijk zijn. +check "script in ConfigMap gelijk aan files/demo-reset.sh" test \ + "$(yq 'select(.kind == "ConfigMap") | .data["demo-reset.sh"]' "$WORK/on.yaml")" = "$(cat "$SCRIPT")" +check "geen requests (Resource Policy)" not grep -q 'requests:' "$WORK/on.yaml" +finish + +start "chart: limitApps komt als env door, en ontbreekt als hij leeg is" +reset_env() { yq "select(.metadata.name == \"demo-reset\") | .spec.jobTemplate.spec.template.spec.containers[0].env[] | select(.name == \"$2\") | .value" "$1"; } +check "zonder limitApps geen LIMIT_APPS-env" test -z "$(reset_env "$WORK/on.yaml" LIMIT_APPS)" +helm template t "$CHART" -n zuiddrecht-prod "${ON[@]}" --set 'demoReset.limitApps.apps={dossiq,mail}' \ + --set 'demoReset.limitApps.exceptGroups={inwoners}' >"$WORK/l.yaml" +check "kubeconform" kubeconform -strict "$WORK/l.yaml" >/dev/null +check "LIMIT_APPS" test "$(reset_env "$WORK/l.yaml" LIMIT_APPS)" = "dossiq mail" +check "LIMIT_EXCEPT_GROUPS" test "$(reset_env "$WORK/l.yaml" LIMIT_EXCEPT_GROUPS)" = "inwoners" +finish + +start "chart: verkeerde confirmWipe rendert niet" +set +e +err="$(helm template t "$CHART" -n zuiddrecht-prod "${ON[@]}" --set demoReset.confirmWipe=alkmaar-prod 2>&1)" +rc=$? +set -e +check "exit niet 0" test "$rc" -ne 0 +check "melding noemt confirmWipe" grep -qF 'confirmWipe moet letterlijk de tenantnaam zijn' <<<"$err" +finish + +start "chart: paused en wachtwoord-Secret" +helm template t "$CHART" -n zuiddrecht-prod "${ON[@]}" --set demoReset.paused=true \ + --set demoReset.exampleSetPasswordSecret=demo-reset-password >"$WORK/p.yaml" +check "kubeconform" kubeconform -strict "$WORK/p.yaml" >/dev/null +check "reset-CronJob suspend" test "$(yq 'select(.kind == "CronJob" and .metadata.name == "demo-reset") | .spec.suspend' "$WORK/p.yaml")" = "true" +check "PASSWORD_FILE gezet" grep -qF 'value: /secrets/demo-password' "$WORK/p.yaml" +check "Secret-volume" grep -qF 'secretName: "demo-reset-password"' "$WORK/p.yaml" +finish + +# --------------------------------------------------------------------------- +# Deel 2: het values-blok van de ApplicationSet +# --------------------------------------------------------------------------- + +render_appset_values() { # -> values op stdout + local c="$WORK/appset-chart" + rm -rf "$c" && mkdir -p "$c/templates" + printf 'apiVersion: v2\nname: appset-values\nversion: 0.0.0\n' >"$c/Chart.yaml" + { printf '{{- with .Values }}\n'; yq -r '.spec.template.spec.source.helm.values' "$APPSET"; printf '{{- end }}\n'; } >"$c/templates/values.yaml" + helm template a "$c" -f "$1" --show-only templates/values.yaml | sed '1,/^# Source:/d' +} + +start "appset: volledig blok" +cat >"$WORK/t1.yaml" <<'EOF' +tenant: + name: zuiddrecht-prod + environment: prod + hostname: cloud.zuiddrecht.nl + components: + demoReset: + enabled: true + confirmWipe: zuiddrecht-prod + schedule: "30 2 * * *" + paused: true + exampleSets: [zuiddrecht, extra] + exampleSetMode: reinstall + exampleSetPasswordSecret: demo-reset-password + limitApps: + apps: [dossiq, mail] + exceptGroups: [inwoners] +EOF +render_appset_values "$WORK/t1.yaml" >"$WORK/v1.yaml" +check "host uit tenant.hostname" test "$(yq .tenant.host "$WORK/v1.yaml")" = "cloud.zuiddrecht.nl" +check "schedule" test "$(yq .demoReset.schedule "$WORK/v1.yaml")" = "30 2 * * *" +check "paused" test "$(yq .demoReset.paused "$WORK/v1.yaml")" = "true" +check "exampleSets" test "$(yq -o=json -I=0 .demoReset.exampleSets "$WORK/v1.yaml")" = '["zuiddrecht","extra"]' +check "mode" test "$(yq .demoReset.exampleSetMode "$WORK/v1.yaml")" = "reinstall" +check "limitApps.apps" test "$(yq -o=json -I=0 .demoReset.limitApps.apps "$WORK/v1.yaml")" = '["dossiq","mail"]' +check "limitApps.exceptGroups" test "$(yq -o=json -I=0 .demoReset.limitApps.exceptGroups "$WORK/v1.yaml")" = '["inwoners"]' +helm template t "$CHART" -n zuiddrecht-prod -f "$WORK/v1.yaml" >"$WORK/v1.manifests.yaml" +check "chart rendert met deze values" kubeconform -strict "$WORK/v1.manifests.yaml" >/dev/null +finish + +start "appset: minimaal blok krijgt defaults en afgeleide host" +cat >"$WORK/t2.yaml" <<'EOF' +tenant: + name: demo-accept + environment: accept + components: + demoReset: + enabled: true + confirmWipe: demo-accept +EOF +render_appset_values "$WORK/t2.yaml" >"$WORK/v2.yaml" +check "afgeleide host" test "$(yq .tenant.host "$WORK/v2.yaml")" = "demo.accept.commonground.nu" +check "schedule default" test "$(yq .demoReset.schedule "$WORK/v2.yaml")" = "15 4 * * *" +check "paused default" test "$(yq .demoReset.paused "$WORK/v2.yaml")" = "false" +check "exampleSets leeg" test "$(yq -o=json -I=0 .demoReset.exampleSets "$WORK/v2.yaml")" = '[]' +check "mode default" test "$(yq .demoReset.exampleSetMode "$WORK/v2.yaml")" = "install" +check "limitApps.apps leeg" test "$(yq -o=json -I=0 .demoReset.limitApps.apps "$WORK/v2.yaml")" = '[]' +helm template t "$CHART" -n demo-accept -f "$WORK/v2.yaml" >"$WORK/v2.manifests.yaml" +check "chart rendert met deze values" kubeconform -strict "$WORK/v2.manifests.yaml" >/dev/null +finish + +start "appset: selector matcht alleen enabled true" +check "selector-key" test "$(yq '.spec.generators[0].selector.matchExpressions[0].key' "$APPSET")" = "tenant.components.demoReset.enabled" +check "selector-waarde" test "$(yq -o=json -I=0 '.spec.generators[0].selector.matchExpressions[0].values' "$APPSET")" = '["true"]' +finish + +# --------------------------------------------------------------------------- +# Deel 3: het script tegen een nep-kubectl +# --------------------------------------------------------------------------- + +STUBS="$WORK/stubs" +mkdir -p "$STUBS" + +# kubectl: get pods / get pod / exec. Alles komt in $FAKE_STATE/kubectl.log. +cat >"$STUBS/kubectl" <<'EOF' +#!/usr/bin/env bash +set -uo pipefail +S="$FAKE_STATE" +echo "$*" >>"$S/kubectl.log" +if [ "$1" = "get" ]; then + case "$*" in + *"app.kubernetes.io/name=tenant-demo-reset"*) echo -n "${FAKE_OWN_PODS-demo-reset-job-x}" ;; + *"app.kubernetes.io/name=nextcloud,"*) echo -n "${FAKE_NC_PODS-nextcloud-abc}" ;; + *"app.kubernetes.io/name=postgresql,"*) echo -n "nextcloud-postgresql-0" ;; + *"{.spec.containers[*].name}"*) echo -n "nextcloud nextcloud-nginx nextcloud-cron" ;; + *) echo "nep-kubectl: onbekende get: $*" >&2; exit 1 ;; + esac + exit 0 +fi +[ "$1" = "exec" ] || { echo "nep-kubectl: onbekend: $*" >&2; exit 1; } +shift +container="" +while [ "$#" -gt 0 ] && [ "$1" != "--" ]; do + case "$1" in -c) container="$2"; shift 2 ;; *) shift ;; esac +done +shift +case "$container" in +postgresql) + if [ "$1" = "printenv" ]; then + case "$2" in POSTGRES_DATABASE) echo nextcloud ;; POSTGRES_USER) echo nextcloud ;; *) exit 1 ;; esac + exit 0 + fi + exec "$FAKE_STUBS/fake-sql" "${@: -1}" + ;; +nextcloud-cron) + n="$(cat "$S/cron" 2>/dev/null || echo 0)" + echo "$n" + [ "$n" -gt 0 ] && echo $((n - 1)) >"$S/cron" + exit 0 + ;; +nextcloud) + if [ "$1" = "kill" ]; then echo "kill $*" >>"$S/occ.log"; exit 0; fi + args=() + for a in "$@"; do + a="${a//\/var\/www/$FAKE_ROOT/var/www}" + a="${a//\/proc\/1\/comm/$FAKE_ROOT/proc/1/comm}" + args+=("$a") + done + if [ -n "${FAKE_FAIL_EXEC:-}" ] && [[ "${args[*]}" == *"$FAKE_FAIL_EXEC"* ]]; then + echo "nep-kubectl: faalinjectie op '$FAKE_FAIL_EXEC'" >&2 + exit 1 + fi + PATH="$FAKE_STUBS:$PATH" exec "${args[@]}" + ;; +*) echo "nep-kubectl: onbekende container '$container'" >&2; exit 1 ;; +esac +EOF + +# su -p www-data -s /bin/sh -c "": draai met dezelfde omgeving. +cat >"$STUBS/su" <<'EOF' +#!/usr/bin/env bash +while [ "$#" -gt 0 ] && [ "$1" != "-c" ]; do shift; done +exec sh -c "$2" +EOF + +# php /occ : de occ-stub. Logt "maint=<0|1> ". +cat >"$STUBS/php" <<'EOF' +#!/usr/bin/env bash +S="$FAKE_STATE" +shift +m=0; [ -f "$S/maintenance" ] && m=1 +line="maint=$m $*" +[ -n "${OC_PASS:-}" ] && line="$line OC_PASS=$OC_PASS" +echo "$line" >>"$S/occ.log" +if [ -n "${FAKE_FAIL_OCC:-}" ] && [[ "$*" == *"$FAKE_FAIL_OCC"* ]]; then exit 1; fi +case "$*" in + "status --output=json") + up=false; [ -f "$S/needs-upgrade" ] && up=true + mm=false; [ "$m" = 1 ] && mm=true + echo "{\"installed\":true,\"version\":\"32.0.13.1\",\"versionstring\":\"32.0.13\",\"maintenance\":$mm,\"needsDbUpgrade\":$up}" ;; + "config:system:get instanceid") echo "${FAKE_INSTANCEID-oc1234}" ;; + "config:system:get objectstore") [ -n "${FAKE_OBJECTSTORE:-}" ] && { echo S3; exit 0; }; exit 1 ;; + "maintenance:mode --on") touch "$S/maintenance" ;; + "maintenance:mode --off") rm -f "$S/maintenance" ;; + "app:list --output=json") echo '{"enabled":{"openregister":"2.1.41","dossiq":"0.4.53","spreed":"22.0.0"},"disabled":{"mail":"5.0.0"}}' ;; + "group:list --output=json") echo "${FAKE_GROUPS-{\"admin\":[\"admin\"],\"inwoners\":[],\"medewerkers\":[]}}" ;; + "app:enable --groups="*) + if [ -n "${FAKE_NO_GROUP_LIMIT:-}" ] && [[ "$*" == *" $FAKE_NO_GROUP_LIMIT" ]]; then + echo "$FAKE_NO_GROUP_LIMIT can't be enabled for groups." >&2; exit 1 + fi ;; + "app:enable "*) ;; + "upgrade") rm -f "$S/needs-upgrade" ;; + "memcache:distributed:clear") ;; + openregister:example-set:*) + # App-commando's laden niet in onderhoudsmodus (core Console\Application). + if [ "$m" = 1 ]; then echo "Command not found" >&2; exit 1; fi ;; + *) echo "occ-stub: onbekend: $*" >&2; exit 1 ;; +esac +EOF + +# Postgres als een map per database: $FAKE_STATE/db//{content,template,comment}. +cat >"$STUBS/fake-sql" <<'EOF' +#!/usr/bin/env bash +S="$FAKE_STATE/db" +q="$1" +echo "$q" >>"$FAKE_STATE/sql.log" +if [ -n "${FAKE_FAIL_SQL:-}" ] && [[ "$q" =~ $FAKE_FAIL_SQL ]]; then echo "ERROR: faalinjectie" >&2; exit 1; fi +if [[ "$q" =~ ^SELECT\ 1\ FROM\ pg_database\ WHERE\ datname\ =\ \'([a-z_]+)\'$ ]]; then + [ -d "$S/${BASH_REMATCH[1]}" ] && echo 1; exit 0 +elif [[ "$q" =~ ^SELECT\ count\(pg_terminate_backend ]]; then + echo 0 +elif [[ "$q" =~ ^CREATE\ DATABASE\ \"([a-z_]+)\"\ TEMPLATE\ \"([a-z_]+)\" ]]; then + d="${BASH_REMATCH[1]}"; t="${BASH_REMATCH[2]}" + [ -d "$S/$t" ] && [ ! -d "$S/$d" ] || { echo "ERROR: create $d from $t" >&2; exit 1; } + mkdir "$S/$d" && cp "$S/$t/content" "$S/$d/content" +elif [[ "$q" =~ ^ALTER\ DATABASE\ \"([a-z_]+)\"\ RENAME\ TO\ \"([a-z_]+)\"$ ]]; then + [ -d "$S/${BASH_REMATCH[1]}" ] && [ ! -d "$S/${BASH_REMATCH[2]}" ] || { echo "ERROR: rename" >&2; exit 1; } + mv "$S/${BASH_REMATCH[1]}" "$S/${BASH_REMATCH[2]}" +elif [[ "$q" =~ ^ALTER\ DATABASE\ \"([a-z_]+)\"\ WITH\ IS_TEMPLATE\ (true|false) ]]; then + [ -d "$S/${BASH_REMATCH[1]}" ] || exit 1 + echo "${BASH_REMATCH[2]}" >"$S/${BASH_REMATCH[1]}/template" +elif [[ "$q" =~ ^DROP\ DATABASE\ \"([a-z_]+)\" ]]; then + [ "$(cat "$S/${BASH_REMATCH[1]}/template" 2>/dev/null)" = "true" ] && { echo "ERROR: cannot drop a template database" >&2; exit 1; } + rm -rf "${S:?}/${BASH_REMATCH[1]}" +elif [[ "$q" =~ ^COMMENT\ ON\ DATABASE\ \"([a-z_]+)\"\ IS\ \'(.*)\'$ ]]; then + echo "${BASH_REMATCH[2]}" >"$S/${BASH_REMATCH[1]}/comment" +elif [[ "$q" =~ datname\ =\ \'([a-z_]+)\'$ ]] && [[ "$q" == *shobj_description* ]]; then + cat "$S/${BASH_REMATCH[1]}/comment" 2>/dev/null; exit 0 +elif [[ "$q" == *pg_size_pretty* ]]; then + echo "8 MB" +else + echo "fake-sql: onbekend: $q" >&2; exit 1 +fi +EOF + +# curl -fsS ... -H "Host: x" : status.php uit de stand van de stub. +cat >"$STUBS/curl" <<'EOF' +#!/usr/bin/env bash +echo "$*" >>"$FAKE_STATE/curl.log" +mm=false; [ -f "$FAKE_STATE/maintenance" ] && mm=true +echo "{\"installed\":true,\"maintenance\":$mm}" +EOF +chmod +x "$STUBS"/* + +# Een verse tenant: live database "bezoekers", een datamap met logs, de +# state van de apps-hook en gebruikersbestanden. +new_tenant() { + export FAKE_ROOT="$WORK/root" FAKE_STATE="$WORK/state" FAKE_STUBS="$STUBS" + rm -rf "$FAKE_ROOT" "$FAKE_STATE" + mkdir -p "$FAKE_STATE/db/nextcloud" "$FAKE_ROOT/proc/1" "$FAKE_ROOT/var/www/html/data/sanne/files" \ + "$FAKE_ROOT/var/www/html/data/appdata_oc1234" + echo "php-fpm" >"$FAKE_ROOT/proc/1/comm" + echo "demo" >"$FAKE_STATE/db/nextcloud/content" + local d="$FAKE_ROOT/var/www/html/data" + echo "brief" >"$d/sanne/files/brief.txt" + echo "preview" >"$d/appdata_oc1234/p" + echo "# Nextcloud data directory" >"$d/.ocdata" + echo "log-voor" >"$d/nextcloud.log" + echo "hook-state" >"$d/.conduction-apps-state" + : >"$FAKE_STATE/kubectl.log" + : >"$FAKE_STATE/occ.log" + unset FAKE_FAIL_EXEC FAKE_FAIL_OCC FAKE_FAIL_SQL FAKE_OBJECTSTORE FAKE_INSTANCEID FAKE_OWN_PODS FAKE_NC_PODS \ + FAKE_GROUPS FAKE_NO_GROUP_LIMIT +} + +# Bezoekers doen dingen: database anders, een upload, een verwijderd bestand, +# meer log. +visitors_were_here() { + local d="$FAKE_ROOT/var/www/html/data" + echo "bezoekers" >"$FAKE_STATE/db/nextcloud/content" + mkdir -p "$d/bezoeker1/files" && echo "upload" >"$d/bezoeker1/files/upload.pdf" + rm -f "$d/sanne/files/brief.txt" + echo "log-na" >"$d/nextcloud.log" + echo "hook-state-nieuw" >"$d/.conduction-apps-state" +} + +run_script() { # ; zet RC en schrijft uitvoer naar $WORK/out + set +e + env PATH="$STUBS:$PATH" KUBECTL="$STUBS/kubectl" CURL="$STUBS/curl" WAIT_STEP=0 CRON_WAIT_TRIES=3 \ + POD_NAMESPACE=zuiddrecht-prod TENANT_NAME=zuiddrecht-prod TENANT_HOST=cloud.zuiddrecht.nl \ + CONFIRM_WIPE="${CONFIRM:-zuiddrecht-prod}" EXAMPLE_SETS="${SETS-zuiddrecht}" \ + EXAMPLE_SET_MODE="${SETMODE:-install}" PASSWORD_FILE="${PWFILE:-}" \ + LIMIT_APPS="${LIMITAPPS-}" LIMIT_EXCEPT_GROUPS="${LIMITEXCEPT-}" \ + bash "$SCRIPT" "$1" >"$WORK/out" 2>&1 + RC=$? + set -e +} + +db() { cat "$FAKE_STATE/db/$1/content" 2>/dev/null || echo ""; } +maint_on_now() { [ -f "$FAKE_STATE/maintenance" ]; } +data() { echo "$FAKE_ROOT/var/www/html/data/$1"; } +# Regelnummer van de eerste occ-aanroep die op het patroon past (0 = geen). +occ_line() { grep -n -m1 -F -- "$1" "$FAKE_STATE/occ.log" | cut -d: -f1 || true; } +before() { local a b; a="$(occ_line "$1")"; b="$(occ_line "$2")"; [ -n "$a" ] && [ -n "$b" ] && [ "$a" -lt "$b" ]; } + +golden_ready() { + new_tenant + run_script golden + [ "$RC" -eq 0 ] || { cat "$WORK/out" >&2; echo "golden_ready faalde" >&2; exit 1; } + : >"$FAKE_STATE/occ.log" +} + +start "script: golden neemt database en datamap, zonder logs en hook-state" +new_tenant +echo 2 >"$FAKE_STATE/cron" +run_script golden +check "exit 0" test "$RC" -eq 0 +check "gouden database is kopie" test "$(db nextcloud_demo_golden)" = "demo" +check "gouden database is template" test "$(cat "$FAKE_STATE/db/nextcloud_demo_golden/template")" = "true" +id="$(cat "$FAKE_ROOT/var/www/demo-reset/golden/COMPLETE" 2>/dev/null || true)" +check "COMPLETE heeft een id" test -n "$id" +check "database-commentaar hoort bij COMPLETE" test "$(cat "$FAKE_STATE/db/nextcloud_demo_golden/comment")" = "demo-reset $id" +check "manifest instanceid" test "$(jq -r .instanceid "$FAKE_ROOT/var/www/demo-reset/golden/manifest.json")" = "oc1234" +check "brief.txt in de momentopname" test -f "$FAKE_ROOT/var/www/demo-reset/golden/data/sanne/files/brief.txt" +check "nextcloud.log niet in de momentopname" test ! -e "$FAKE_ROOT/var/www/demo-reset/golden/data/nextcloud.log" +check ".conduction-apps-state niet in de momentopname" test ! -e "$FAKE_ROOT/var/www/demo-reset/golden/data/.conduction-apps-state" +check "geen incoming-database over" test ! -d "$FAKE_STATE/db/nextcloud_demo_incoming" +check "onderhoudsmodus weer uit" not maint_on_now +check "eerst onderhoud, dan kopie" grep -q 'CREATE DATABASE "nextcloud_demo_incoming" TEMPLATE "nextcloud"' "$FAKE_STATE/sql.log" +check "gewacht op cron" test "$(cat "$FAKE_STATE/cron")" = "0" +finish + +start "script: golden weigert als de tenant al in onderhoud staat" +new_tenant +touch "$FAKE_STATE/maintenance" +run_script golden +check "exit 1" test "$RC" -eq 1 +check "geen gouden database" test ! -d "$FAKE_STATE/db/nextcloud_demo_golden" +check "onderhoud blijft zoals het was (aan)" maint_on_now +finish + +start "script: reset zet database en datamap terug, logs en hook-state blijven" +golden_ready +visitors_were_here +run_script reset +check "exit 0" test "$RC" -eq 0 +check "database terug" test "$(db nextcloud)" = "demo" +check "gouden database staat er nog" test "$(db nextcloud_demo_golden)" = "demo" +check "geen previous/incoming over" test ! -d "$FAKE_STATE/db/nextcloud_demo_previous" -a ! -d "$FAKE_STATE/db/nextcloud_demo_incoming" +check "upload van bezoeker weg" test ! -e "$(data bezoeker1)" +check "brief.txt terug" test -f "$(data sanne/files/brief.txt)" +check "appdata terug" test -f "$(data appdata_oc1234/p)" +check "nextcloud.log blijft de live versie" test "$(cat "$(data nextcloud.log)")" = "log-na" +check "hook-state blijft de live versie" test "$(cat "$(data .conduction-apps-state)")" = "hook-state-nieuw" +check "onderhoudsmodus uit" not maint_on_now +check "onderhoud aan vóór de wissel" before "maintenance:mode --on" "maintenance:mode --off" +check "cache legen na onderhoud uit" before "maint=0 memcache:distributed:clear" "openregister:example-set:install" +check "cache legen niet in onderhoud" not grep -q 'maint=1 memcache:distributed:clear' "$FAKE_STATE/occ.log" +check "set laden buiten onderhoud" grep -q '^maint=0 openregister:example-set:install zuiddrecht$' "$FAKE_STATE/occ.log" +check "php-fpm herladen" grep -q '^kill kill -USR2 1$' "$FAKE_STATE/occ.log" +check "status.php met de tenant-host" grep -qF 'Host: cloud.zuiddrecht.nl' "$FAKE_STATE/curl.log" +check "last-reset geschreven" test -s "$FAKE_ROOT/var/www/demo-reset/last-reset" +finish + +start "script: reset twee keer achter elkaar is gelijk" +run_script reset +check "exit 0" test "$RC" -eq 0 +check "database nog steeds terug" test "$(db nextcloud)" = "demo" +check "brief.txt" test -f "$(data sanne/files/brief.txt)" +finish + +start "script: reset zonder momentopname wist niets" +new_tenant +visitors_were_here +run_script reset +check "exit 1" test "$RC" -eq 1 +check "melding" grep -qF 'geen geldige momentopname' "$WORK/out" +check "database onaangeroerd" test "$(db nextcloud)" = "bezoekers" +check "upload staat er nog" test -f "$(data bezoeker1/files/upload.pdf)" +check "nooit onderhoud aangezet" not grep -q 'maintenance:mode --on' "$FAKE_STATE/occ.log" +finish + +start "script: momentopname van een andere instantie wordt geweigerd" +golden_ready +visitors_were_here +FAKE_INSTANCEID=ocANDERS run_script reset +check "exit 1" test "$RC" -eq 1 +check "melding" grep -qF 'andere instantie' "$WORK/out" +check "database onaangeroerd" test "$(db nextcloud)" = "bezoekers" +finish + +start "script: database en map van verschillende momenten worden geweigerd" +golden_ready +visitors_were_here +echo "demo-reset 19990101T000000Z" >"$FAKE_STATE/db/nextcloud_demo_golden/comment" +run_script reset +check "exit 1" test "$RC" -eq 1 +check "melding" grep -qF 'hoort niet bij momentopname' "$WORK/out" +check "database onaangeroerd" test "$(db nextcloud)" = "bezoekers" +finish + +start "script: S3 primary objectstore stopt alles" +golden_ready +visitors_were_here +FAKE_OBJECTSTORE=1 run_script reset +check "exit 1" test "$RC" -eq 1 +check "melding" grep -qF 'S3 primary objectstore' "$WORK/out" +check "database onaangeroerd" test "$(db nextcloud)" = "bezoekers" +check "nooit onderhoud aangezet" not grep -q 'maintenance:mode --on' "$FAKE_STATE/occ.log" +finish + +start "script: fout vóór het wissen zet onderhoud weer uit" +golden_ready +visitors_were_here +FAKE_FAIL_SQL='^CREATE DATABASE "nextcloud_demo_incoming"' run_script reset +check "exit 1" test "$RC" -eq 1 +check "database onaangeroerd" test "$(db nextcloud)" = "bezoekers" +check "onderhoudsmodus weer uit" not maint_on_now +check "melding" grep -qF 'fout vóór het wissen' "$WORK/out" +finish + +start "script: fout ná het wissen laat onderhoud AAN" +golden_ready +visitors_were_here +FAKE_FAIL_EXEC='cp -a' run_script reset +check "exit 1" test "$RC" -eq 1 +check "onderhoudsmodus blijft aan" maint_on_now +check "melding" grep -qF 'BLIJFT AAN' "$WORK/out" +check "momentopname onaangeroerd" test "$(db nextcloud_demo_golden)" = "demo" +check "gouden map onaangeroerd" test -f "$FAKE_ROOT/var/www/demo-reset/golden/data/sanne/files/brief.txt" +# En een tweede run maakt het af, ook al stond onderhoud nog aan. +unset FAKE_FAIL_EXEC +run_script reset +check "herstart: exit 0" test "$RC" -eq 0 +check "herstart: onderhoud uit" not maint_on_now +check "herstart: brief.txt terug" test -f "$(data sanne/files/brief.txt)" +finish + +start "script: herstart na een wissel die halverwege stopte" +golden_ready +visitors_were_here +# Stand na een crash tussen de twee renames: live weg, previous en incoming er. +mv "$FAKE_STATE/db/nextcloud" "$FAKE_STATE/db/nextcloud_demo_previous" +mkdir "$FAKE_STATE/db/nextcloud_demo_incoming" && echo "demo" >"$FAKE_STATE/db/nextcloud_demo_incoming/content" +touch "$FAKE_STATE/maintenance" +run_script reset +check "exit 0" test "$RC" -eq 0 +check "database terug" test "$(db nextcloud)" = "demo" +check "geen previous/incoming over" test ! -d "$FAKE_STATE/db/nextcloud_demo_previous" -a ! -d "$FAKE_STATE/db/nextcloud_demo_incoming" +check "onderhoud uit" not maint_on_now +finish + +start "script: falende voorbeeldset laat de tenant op de momentopname, in de lucht" +golden_ready +visitors_were_here +FAKE_FAIL_OCC='openregister:example-set:install' run_script reset +check "exit 1" test "$RC" -eq 1 +check "database terug" test "$(db nextcloud)" = "demo" +check "onderhoud uit" not maint_on_now +finish + +start "script: reinstall verwijdert eerst, dan install" +golden_ready +SETMODE=reinstall run_script reset +check "exit 0" test "$RC" -eq 0 +check "remove vóór install" before "openregister:example-set:remove zuiddrecht" "openregister:example-set:install zuiddrecht" +finish + +start "script: wachtwoord gaat via stdin, nooit als argument" +golden_ready +printf 'geheim-demo-wachtwoord\n' >"$WORK/pw" +PWFILE="$WORK/pw" run_script reset +check "exit 0" test "$RC" -eq 0 +check "install met --password-from-env en OC_PASS" grep -q 'openregister:example-set:install zuiddrecht --password-from-env OC_PASS=geheim-demo-wachtwoord' "$FAKE_STATE/occ.log" +check "wachtwoord niet in een kubectl-aanroep" not grep -q 'geheim-demo-wachtwoord' "$FAKE_STATE/kubectl.log" +check "wachtwoord niet in de uitvoer" not grep -q 'geheim-demo-wachtwoord' "$WORK/out" +finish + +start "script: nieuwere app-code krijgt occ upgrade vóór onderhoud uit" +golden_ready +touch "$FAKE_STATE/needs-upgrade" +run_script reset +check "exit 0" test "$RC" -eq 0 +check "upgrade in onderhoud" grep -q '^maint=1 upgrade$' "$FAKE_STATE/occ.log" +check "upgrade vóór onderhoud uit" before "maint=1 upgrade" "maintenance:mode --off" +finish + +start "script: zonder voorbeeldsets alleen terugzetten" +golden_ready +SETS="" run_script reset +check "exit 0" test "$RC" -eq 0 +check "geen example-set-aanroep" not grep -q 'example-set' "$FAKE_STATE/occ.log" +finish + +start "script: verkeerde confirmWipe raakt de cluster niet" +golden_ready +: >"$FAKE_STATE/kubectl.log" +CONFIRM=alkmaar-prod run_script reset +check "exit 1" test "$RC" -eq 1 +check "geen enkele kubectl-aanroep" test ! -s "$FAKE_STATE/kubectl.log" +finish + +start "script: een tweede demo-reset-Job tegelijk stopt" +golden_ready +visitors_were_here +FAKE_OWN_PODS="demo-reset-a demo-reset-golden-b" run_script reset +check "exit 1" test "$RC" -eq 1 +check "melding" grep -qF 'er loopt al een andere demo-reset-Job' "$WORK/out" +check "database onaangeroerd" test "$(db nextcloud)" = "bezoekers" +finish + +start "script: twee Nextcloud-pods stopt (lege of dubbele waarde is een fout)" +golden_ready +FAKE_NC_PODS="nextcloud-a nextcloud-b" run_script reset +check "exit 1" test "$RC" -eq 1 +check "melding" grep -qF 'precies één draaiende nextcloud-pod' "$WORK/out" +FAKE_NC_PODS="" run_script reset +check "geen pod: exit 1" test "$RC" -eq 1 +finish + +start "script: limitApps beperkt na de sets, per app, buiten onderhoud" +golden_ready +FAKE_NO_GROUP_LIMIT=spreed LIMITAPPS="dossiq spreed mail kalender" LIMITEXCEPT=inwoners run_script reset +check "exit 0" test "$RC" -eq 0 +check "dossiq voor alle groepen behalve inwoners" grep -q '^maint=0 app:enable --groups=admin --groups=medewerkers dossiq$' "$FAKE_STATE/occ.log" +check "een uitgeschakelde app telt als geïnstalleerd" grep -q '^maint=0 app:enable --groups=admin --groups=medewerkers mail$' "$FAKE_STATE/occ.log" +check "inwoners nooit in --groups" not grep -q -- '--groups=inwoners' "$FAKE_STATE/occ.log" +check "spreed weigert groepen en gaat aan voor iedereen" grep -q '^maint=0 app:enable spreed$' "$FAKE_STATE/occ.log" +check "WARN voor spreed" grep -qF 'WARN: app spreed' "$WORK/out" +check "niet-geïnstalleerde app overgeslagen" grep -qF 'app kalender is niet geïnstalleerd' "$WORK/out" +check "kalender nooit aangezet" not grep -q 'app:enable.*kalender' "$FAKE_STATE/occ.log" +check "na de voorbeeldset" before "openregister:example-set:install zuiddrecht" "app:enable --groups=admin --groups=medewerkers dossiq" +finish + +start "script: zonder limitApps geen groepsstap" +golden_ready +run_script reset +check "exit 0" test "$RC" -eq 0 +check "geen group:list" not grep -q 'group:list' "$FAKE_STATE/occ.log" +check "geen app:enable" not grep -q 'app:enable' "$FAKE_STATE/occ.log" +finish + +start "script: limitApps zonder uitzondering raakt de cluster niet" +golden_ready +: >"$FAKE_STATE/kubectl.log" +LIMITAPPS="dossiq" LIMITEXCEPT="" run_script reset +check "exit 1" test "$RC" -eq 1 +check "melding" grep -qF 'LIMIT_APPS is gezet zonder LIMIT_EXCEPT_GROUPS' "$WORK/out" +check "geen enkele kubectl-aanroep" test ! -s "$FAKE_STATE/kubectl.log" +finish + +start "script: geen groep over faalt zichtbaar, tenant blijft op de momentopname" +golden_ready +visitors_were_here +FAKE_GROUPS='{"inwoners":[]}' LIMITAPPS="dossiq" LIMITEXCEPT=inwoners run_script reset +check "exit 1" test "$RC" -eq 1 +check "melding" grep -qF 'geen groepen over' "$WORK/out" +check "database terug" test "$(db nextcloud)" = "demo" +check "onderhoud uit" not maint_on_now +check "geen app:enable" not grep -q 'app:enable' "$FAKE_STATE/occ.log" +finish + +start "script: groepsnaam die niet veilig door occ kan stopt de stap" +golden_ready +FAKE_GROUPS='{"admin":[],"a b;id":[],"inwoners":[]}' LIMITAPPS="dossiq" LIMITEXCEPT=inwoners run_script reset +check "exit 1" test "$RC" -eq 1 +check "melding" grep -qF 'niet veilig door occ' "$WORK/out" +check "geen app:enable" not grep -q 'app:enable' "$FAKE_STATE/occ.log" +finish + +echo +echo "tests: ${PASSED} geslaagd, ${FAILED} gefaald" +[[ $FAILED -eq 0 ]] diff --git a/scripts/verify.sh b/scripts/verify.sh index 77b9d7f..72f8558 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -144,4 +144,11 @@ echo "validator-tests OK" nextcloud-platform/tests/run-hook-tests.sh >/dev/null echo "hook-tests OK" +# Tests voor de demo-reset (charts/tenant-demo-reset). Die component wist elke +# nacht een tenant; hij draait op een cluster dat hier niet is. De suite rendert +# de chart en het values-blok van de ApplicationSet, en draait het script tegen +# een nep-kubectl, inclusief wat er gebeurt als het halverwege faalt. +nextcloud-platform/tests/run-demo-reset-tests.sh >/dev/null +echo "demo-reset-tests OK" + echo "verify: OK"