|
| 1 | +#!/usr/bin/env bats |
| 2 | +# Behavioural tests for the shipped Altinity (ClickHouse) backup/restore |
| 3 | +# strategy script, extracted from the rendered cozy-default-altinity Strategy |
| 4 | +# CR and EXECUTED against a stubbed clickhouse-backup HTTP API, with the real |
| 5 | +# jq doing the archive selection. Which archive a restore reads back is |
| 6 | +# decided by that selection over list/remote, so it is exercised against |
| 7 | +# several archives rather than string-matched. |
| 8 | +# |
| 9 | +# The script runs with `bash`, not `sh`, for the reason given in |
| 10 | +# hack/redis-backup-strategy.bats: it opens `set -euo pipefail`, which the |
| 11 | +# production image's busybox ash supports and the CI runner's dash rejects. |
| 12 | + |
| 13 | +load test_helper |
| 14 | + |
| 15 | +CHART=packages/system/backupstrategy-controller |
| 16 | + |
| 17 | +# Render the shipped strategy's in-Pod script into $1 and substitute the |
| 18 | +# controller-render placeholders the way the driver fills them: $2 mode, $3 |
| 19 | +# app (release and source application), $4 the archive name recorded on the |
| 20 | +# Backup (empty for a Backup that records none), $5 the Backup name, $6 its |
| 21 | +# takenAt in the archive-name layout. An empty render fails early, as does a |
| 22 | +# placeholder left unsubstituted. |
| 23 | +render_script() { |
| 24 | + out=$1; mode=$2; app=$3; archive=$4; backup=$5; takenat=$6 |
| 25 | + helm template t "$CHART" -n cozy-backup-controller \ |
| 26 | + --set backupStorage.bucketNameOverride=test-bucket 2>/dev/null \ |
| 27 | + | yq eval 'select(.kind == "Altinity") | .spec.template.spec.containers[0].args[0]' - \ |
| 28 | + | sed -e "s#{{ .Mode }}#${mode}#g" \ |
| 29 | + -e "s#{{ .Release.Name }}#${app}#g" \ |
| 30 | + -e "s#{{ .Release.Namespace }}#tenant-x#g" \ |
| 31 | + -e "s#{{ .Backup.ApplicationRef.Name }}#${app}#g" \ |
| 32 | + -e "s#{{ .Backup.Name }}#${backup}#g" \ |
| 33 | + -e "s#{{ .ArchiveName | default \"\" }}#${archive}#g" \ |
| 34 | + -e "s#{{ .Backup.TakenAt | default \"\" }}#${takenat}#g" > "$out" |
| 35 | + [ -s "$out" ] || return 1 |
| 36 | + if grep -q '{{' "$out"; then |
| 37 | + echo "rendered script still holds a controller placeholder" >&2 |
| 38 | + return 1 |
| 39 | + fi |
| 40 | +} |
| 41 | + |
| 42 | +# Stub the clickhouse-backup API behind curl, first on PATH. list/remote |
| 43 | +# serves $state/remote; actions serves $state/actions plus one success row per |
| 44 | +# operation the script has issued, and after its first read swaps in |
| 45 | +# $state/actions.next when a test seeds one (an operation that finishes while |
| 46 | +# the script waits). Every request is logged to $state/curl.log. sleep is |
| 47 | +# stubbed to fail after a few calls, so a script that never sees its |
| 48 | +# operation finish fails the test instead of hanging it. |
| 49 | +make_stubs() { |
| 50 | + bin=$1; state=$2 |
| 51 | + mkdir -p "$bin" |
| 52 | + : > "$state/remote" |
| 53 | + : > "$state/actions" |
| 54 | + cat > "$bin/curl" <<STUB |
| 55 | +#!/usr/bin/env bash |
| 56 | +set -eu |
| 57 | +method=GET; url="" |
| 58 | +for a in "\$@"; do |
| 59 | + case "\$a" in POST) method=POST ;; http://*) url="\$a" ;; esac |
| 60 | +done |
| 61 | +echo "\$method \$url" >> "$state/curl.log" |
| 62 | +case "\$method \$url" in |
| 63 | + "GET "*/backup/status) echo '{}' ;; |
| 64 | + "GET "*/backup/list/remote) cat "$state/remote" ;; |
| 65 | + "GET "*/backup/actions) |
| 66 | + cat "$state/actions" |
| 67 | + if [ -f "$state/actions.done" ]; then cat "$state/actions.done"; fi |
| 68 | + if [ -f "$state/actions.next" ]; then mv "$state/actions.next" "$state/actions"; fi |
| 69 | + ;; |
| 70 | + "POST "*/backup/create_remote*) |
| 71 | + name=\${url#*name=}; name=\${name%%&*} |
| 72 | + printf '{"command":"create_remote --resume %s","status":"success","start":"2099-01-01 00:00:00"}\n' "\$name" >> "$state/actions.done" |
| 73 | + ;; |
| 74 | + "POST "*/backup/restore_remote/*) |
| 75 | + printf '{"command":"restore_remote %s","status":"success","start":"2099-01-01 00:00:00"}\n' "\${url##*/}" >> "$state/actions.done" |
| 76 | + ;; |
| 77 | + *) echo "unexpected request: \$method \$url" >&2; exit 22 ;; |
| 78 | +esac |
| 79 | +STUB |
| 80 | + cat > "$bin/sleep" <<STUB |
| 81 | +#!/usr/bin/env bash |
| 82 | +n=\$(cat "$state/sleeps" 2>/dev/null || echo 0) |
| 83 | +echo \$((n + 1)) > "$state/sleeps" |
| 84 | +if [ "\$n" -ge 3 ]; then echo "sleep budget spent: the operation never finished" >&2; exit 1; fi |
| 85 | +STUB |
| 86 | + chmod 0755 "$bin/curl" "$bin/sleep" |
| 87 | + export API_USERNAME=u API_PASSWORD=p |
| 88 | +} |
| 89 | + |
| 90 | +# One list/remote row per argument, "name" or "name|desc"; created mirrors |
| 91 | +# the name's timestamp, which is the order the pre-fix selection sorted by. |
| 92 | +remote_rows() { |
| 93 | + for row in "$@"; do |
| 94 | + name=${row%%|*}; desc="tar, regular" |
| 95 | + if [ "$row" != "$name" ]; then desc=${row#*|}; fi |
| 96 | + ts=${name##*-} |
| 97 | + printf '{"name":"%s","created":"%s","location":"remote","required":"","desc":"%s"}\n' "$name" "$ts" "$desc" |
| 98 | + done |
| 99 | +} |
| 100 | + |
| 101 | +@test "an empty render fails the fixture instead of passing as a no-op" { |
| 102 | + tmp=$(mktemp -d) |
| 103 | + CHART=$tmp/no-such-chart |
| 104 | + if render_script "$tmp/s.sh" restore myapp "" bkp 20260304T050622Z 2>/dev/null; then |
| 105 | + echo "FAIL: render_script reported success with no chart" |
| 106 | + false |
| 107 | + fi |
| 108 | +} |
| 109 | + |
| 110 | +@test "restore reads back the archive the Backup recorded, not the newest one" { |
| 111 | + tmp=$(mktemp -d) |
| 112 | + make_stubs "$tmp/bin" "$tmp" |
| 113 | + render_script "$tmp/s.sh" restore myapp clickhouse-myapp-20260304T050607Z bkp-a 20260304T050622Z |
| 114 | + remote_rows clickhouse-myapp-20260304T040000Z clickhouse-myapp-20260304T050607Z \ |
| 115 | + clickhouse-myapp-20260304T060000Z clickhouse-myapp-20260304T070000Z > "$tmp/remote" |
| 116 | + PATH="$tmp/bin:$PATH" bash "$tmp/s.sh" |
| 117 | + grep -qxF 'POST http://chi-clickhouse-myapp-clickhouse-0-0.tenant-x.svc:7171/backup/restore_remote/clickhouse-myapp-20260304T050607Z' "$tmp/curl.log" |
| 118 | + [ "$(grep -c '/backup/restore_remote/' "$tmp/curl.log")" -eq 1 ] |
| 119 | +} |
| 120 | + |
| 121 | +@test "restore fails naming the recorded archive when remote storage lacks it" { |
| 122 | + tmp=$(mktemp -d) |
| 123 | + make_stubs "$tmp/bin" "$tmp" |
| 124 | + render_script "$tmp/s.sh" restore myapp clickhouse-myapp-20260304T050607Z bkp-a 20260304T050622Z |
| 125 | + remote_rows clickhouse-myapp-20260304T040000Z clickhouse-myapp-20260304T060000Z > "$tmp/remote" |
| 126 | + out="" |
| 127 | + if out=$(PATH="$tmp/bin:$PATH" bash "$tmp/s.sh" 2>&1); then |
| 128 | + echo "FAIL: restore succeeded without the recorded archive: $out" |
| 129 | + false |
| 130 | + fi |
| 131 | + printf '%s\n' "$out" | grep -qF 'archive clickhouse-myapp-20260304T050607Z recorded on Backup bkp-a is not in remote storage' |
| 132 | + if grep -qF '/backup/restore_remote/' "$tmp/curl.log"; then |
| 133 | + echo "FAIL: restored another archive: $(cat "$tmp/curl.log")" |
| 134 | + false |
| 135 | + fi |
| 136 | +} |
| 137 | + |
| 138 | +@test "restore of a Backup without a recorded archive takes the newest one stamped by its takenAt, and warns" { |
| 139 | + tmp=$(mktemp -d) |
| 140 | + make_stubs "$tmp/bin" "$tmp" |
| 141 | + render_script "$tmp/s.sh" restore myapp "" bkp-a 20260304T050800Z |
| 142 | + # Each decoy sorts after the expected archive and is ruled out by one |
| 143 | + # filter: broken, not a bare timestamp after the prefix, or stamped after |
| 144 | + # takenAt. |
| 145 | + remote_rows clickhouse-myapp-20260304T040000Z clickhouse-myapp-20260304T050607Z \ |
| 146 | + "clickhouse-myapp-20260304T050700Z|broken (can't stat metadata.json)" \ |
| 147 | + clickhouse-myapp-20260304T050710Z-copy \ |
| 148 | + clickhouse-myapp-20260304T060000Z > "$tmp/remote" |
| 149 | + out=$(PATH="$tmp/bin:$PATH" bash "$tmp/s.sh" 2>&1) |
| 150 | + grep -qxF 'POST http://chi-clickhouse-myapp-clickhouse-0-0.tenant-x.svc:7171/backup/restore_remote/clickhouse-myapp-20260304T050607Z' "$tmp/curl.log" |
| 151 | + [ "$(grep -c '/backup/restore_remote/' "$tmp/curl.log")" -eq 1 ] |
| 152 | + printf '%s\n' "$out" | grep -qF 'WARNING: Backup bkp-a records no archive name' |
| 153 | +} |
| 154 | + |
| 155 | +@test "restore of a Backup without a recorded archive fails when no archive is stamped by its takenAt" { |
| 156 | + tmp=$(mktemp -d) |
| 157 | + make_stubs "$tmp/bin" "$tmp" |
| 158 | + render_script "$tmp/s.sh" restore myapp "" bkp-a 20260304T050800Z |
| 159 | + remote_rows clickhouse-myapp-20260304T060000Z clickhouse-myapp-20260304T070000Z > "$tmp/remote" |
| 160 | + out="" |
| 161 | + if out=$(PATH="$tmp/bin:$PATH" bash "$tmp/s.sh" 2>&1); then |
| 162 | + echo "FAIL: restore succeeded with every archive newer than takenAt: $out" |
| 163 | + false |
| 164 | + fi |
| 165 | + printf '%s\n' "$out" | grep -qF 'no archive of myapp stamped no later than 20260304T050800Z' |
| 166 | + if grep -qF '/backup/restore_remote/' "$tmp/curl.log"; then |
| 167 | + echo "FAIL: restored an archive newer than takenAt: $(cat "$tmp/curl.log")" |
| 168 | + false |
| 169 | + fi |
| 170 | +} |
| 171 | + |
| 172 | +@test "backup writes the archive under the name the controller chose" { |
| 173 | + tmp=$(mktemp -d) |
| 174 | + make_stubs "$tmp/bin" "$tmp" |
| 175 | + render_script "$tmp/s.sh" backup myapp clickhouse-myapp-20260304T050607Z "" "" |
| 176 | + PATH="$tmp/bin:$PATH" bash "$tmp/s.sh" |
| 177 | + grep -qxF 'POST http://chi-clickhouse-myapp-clickhouse-0-0.tenant-x.svc:7171/backup/create_remote?name=clickhouse-myapp-20260304T050607Z&resume=true' "$tmp/curl.log" |
| 178 | +} |
| 179 | + |
| 180 | +@test "a retried backup waits on the attempt the sidecar is still running" { |
| 181 | + tmp=$(mktemp -d) |
| 182 | + make_stubs "$tmp/bin" "$tmp" |
| 183 | + render_script "$tmp/s.sh" backup myapp clickhouse-myapp-20260304T050607Z "" "" |
| 184 | + printf '{"command":"create_remote --resume clickhouse-myapp-20260304T050607Z","status":"in progress","start":"2026-03-04 05:06:08"}\n' > "$tmp/actions" |
| 185 | + printf '{"command":"create_remote --resume clickhouse-myapp-20260304T050607Z","status":"success","start":"2026-03-04 05:06:08"}\n' > "$tmp/actions.next" |
| 186 | + PATH="$tmp/bin:$PATH" bash "$tmp/s.sh" |
| 187 | + # The sidecar answers a second operation with 423 while one runs. |
| 188 | + if grep -qF 'POST ' "$tmp/curl.log"; then |
| 189 | + echo "FAIL: started another operation over the running one: $(cat "$tmp/curl.log")" |
| 190 | + false |
| 191 | + fi |
| 192 | +} |
| 193 | + |
| 194 | +@test "a retried backup after a failed attempt resumes the same archive" { |
| 195 | + tmp=$(mktemp -d) |
| 196 | + make_stubs "$tmp/bin" "$tmp" |
| 197 | + render_script "$tmp/s.sh" backup myapp clickhouse-myapp-20260304T050607Z "" "" |
| 198 | + printf '{"command":"create_remote clickhouse-myapp-20260304T050607Z","status":"error","start":"2026-03-04 05:06:08","error":"S3 PutObject 403"}\n' > "$tmp/actions" |
| 199 | + PATH="$tmp/bin:$PATH" bash "$tmp/s.sh" |
| 200 | + grep -qxF 'POST http://chi-clickhouse-myapp-clickhouse-0-0.tenant-x.svc:7171/backup/create_remote?name=clickhouse-myapp-20260304T050607Z&resume=true' "$tmp/curl.log" |
| 201 | +} |
| 202 | + |
| 203 | +@test "backup falls back to a timestamped name when the controller supplies none" { |
| 204 | + tmp=$(mktemp -d) |
| 205 | + make_stubs "$tmp/bin" "$tmp" |
| 206 | + render_script "$tmp/s.sh" backup myapp "" "" "" |
| 207 | + PATH="$tmp/bin:$PATH" bash "$tmp/s.sh" |
| 208 | + grep -qE '^POST http://[^ ]*/backup/create_remote\?name=clickhouse-myapp-[0-9]{8}T[0-9]{6}Z&resume=true$' "$tmp/curl.log" |
| 209 | +} |
0 commit comments