Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4019569ab7 | |||
| 1c8761b188 | |||
| e1c8e84502 | |||
| 3cca441874 | |||
| 55431b5ad0 | |||
| a39ac84729 |
+34
-8
@@ -407,8 +407,11 @@ verify_backup_file_shape() {
|
|||||||
report_backup() {
|
report_backup() {
|
||||||
local target="$1" size
|
local target="$1" size
|
||||||
if ! chmod 600 "$target" 2>/dev/null; then
|
if ! chmod 600 "$target" 2>/dev/null; then
|
||||||
docker run --rm -v "$BACKUP_DIR":/backup -e BACKUP_FILE="$(basename "$target")" \
|
if ! docker run --rm -v "$BACKUP_DIR":/backup -e BACKUP_FILE="$(basename "$target")" \
|
||||||
alpine:3 sh -c 'chmod 600 "/backup/$BACKUP_FILE"'
|
alpine:3 sh -c 'chmod 600 "/backup/$BACKUP_FILE"'; then
|
||||||
|
echo "Could not restrict backup permissions for ${target}. Aborting deploy."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
size="$(du -h "$target" | cut -f1)"
|
size="$(du -h "$target" | cut -f1)"
|
||||||
echo "Backup verified: ${target} (${size})"
|
echo "Backup verified: ${target} (${size})"
|
||||||
@@ -559,14 +562,29 @@ apply_backup_retention() {
|
|||||||
echo "Monthly recovery set retained: ${monthly_dir}"
|
echo "Monthly recovery set retained: ${monthly_dir}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
find "$BACKUP_DIR" -maxdepth 1 -type f -name 'jobtracker-*' -mtime "+$daily_days" -delete
|
if ! find "$BACKUP_DIR" -maxdepth 1 -type f -name 'jobtracker-*' -mtime "+$daily_days" -delete; then
|
||||||
|
echo "Daily backup retention failed. Aborting deploy."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
local monthly_dirs=()
|
local monthly_dirs=() monthly_listing
|
||||||
mapfile -t monthly_dirs < <(find "$monthly_root" -mindepth 1 -maxdepth 1 -type d -name '20??-??' | sort)
|
# Check the pipeline directly; mapfile cannot report process-substitution failures.
|
||||||
|
if ! monthly_listing="$(find "$monthly_root" -mindepth 1 -maxdepth 1 -type d -name '20??-??' | sort)"; then
|
||||||
|
echo "Could not enumerate monthly backups. Aborting deploy."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ -n "$monthly_listing" ]; then
|
||||||
|
mapfile -t monthly_dirs <<< "$monthly_listing"
|
||||||
|
fi
|
||||||
while [ "${#monthly_dirs[@]}" -gt "$monthly_count" ]; do
|
while [ "${#monthly_dirs[@]}" -gt "$monthly_count" ]; do
|
||||||
local oldest="${monthly_dirs[0]}"
|
local oldest="${monthly_dirs[0]}"
|
||||||
case "$oldest" in
|
case "$oldest" in
|
||||||
"$monthly_root"/20??-??) rm -rf -- "$oldest" ;;
|
"$monthly_root"/20??-??)
|
||||||
|
if ! rm -rf -- "$oldest"; then
|
||||||
|
echo "Monthly backup retention failed. Aborting deploy."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
*) echo "Refusing to prune unexpected monthly backup path: $oldest"; return 1 ;;
|
*) echo "Refusing to prune unexpected monthly backup path: $oldest"; return 1 ;;
|
||||||
esac
|
esac
|
||||||
monthly_dirs=("${monthly_dirs[@]:1}")
|
monthly_dirs=("${monthly_dirs[@]:1}")
|
||||||
@@ -660,10 +678,18 @@ finish_deploy() {
|
|||||||
|
|
||||||
if [ "$status" -ne 0 ] && [ "$replacement_started" = true ]; then
|
if [ "$status" -ne 0 ] && [ "$replacement_started" = true ]; then
|
||||||
if [ -n "$previous_backend_ref" ] && [ -n "$previous_frontend_ref" ]; then
|
if [ -n "$previous_backend_ref" ] && [ -n "$previous_frontend_ref" ]; then
|
||||||
echo "Deployment failed after core replacement; restoring previous healthy images."
|
echo "Deployment failed after core replacement; restarting previous core images."
|
||||||
if BACKEND_IMAGE="$previous_backend_ref" FRONTEND_IMAGE="$previous_frontend_ref" \
|
if BACKEND_IMAGE="$previous_backend_ref" FRONTEND_IMAGE="$previous_frontend_ref" \
|
||||||
compose up -d --force-recreate --remove-orphans backend frontend; then
|
compose up -d --force-recreate --remove-orphans backend frontend; then
|
||||||
echo "Previous core release restored. Deployment remains failed; inspect the logs above."
|
if curl -fsS --connect-timeout 5 --max-time 5 --retry 5 --retry-delay 2 --retry-connrefused --retry-max-time 30 "${APP_PUBLIC_BASE_URL%/}/" >/dev/null &&
|
||||||
|
curl -fsS --connect-timeout 5 --max-time 5 --retry 5 --retry-delay 2 --retry-connrefused --retry-max-time 30 "${APP_PUBLIC_BASE_URL%/}/health" >/dev/null &&
|
||||||
|
curl -fsS --connect-timeout 5 --max-time 5 --retry 5 --retry-delay 2 --retry-connrefused --retry-max-time 30 "${APP_PUBLIC_BASE_URL%/}/ready" >/dev/null; then
|
||||||
|
echo "Previous core images restarted and public core checks passed. Deployment remains failed; inspect the logs above."
|
||||||
|
else
|
||||||
|
echo "Previous core images restarted, but rollback health verification failed. Manual recovery is required."
|
||||||
|
compose ps || true
|
||||||
|
compose logs --tail=200 backend frontend || true
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "Automatic core rollback failed. Manual recovery is required."
|
echo "Automatic core rollback failed. Manual recovery is required."
|
||||||
compose ps || true
|
compose ps || true
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# First production deployment runbook
|
# First production deployment runbook
|
||||||
|
|
||||||
|
> Historical July 2026 baseline. Current release preparation follows
|
||||||
|
> [the release qualification matrix](../docs/release-readiness/14_RELEASE_ACCEPTANCE_MATRIX.md) and
|
||||||
|
> [recovery specification](../docs/release-readiness/11_BACKUP_RESTORE_ROLLBACK.md).
|
||||||
|
> Reconcile the examples below with the exact candidate, migration history and isolated rehearsal
|
||||||
|
> before operational use. Current rollback health checks are source-tested offline.
|
||||||
|
|
||||||
> Written 2026-07-19 against the actual implementation, not against the other docs. Every command and
|
> Written 2026-07-19 against the actual implementation, not against the other docs. Every command and
|
||||||
> failure mode below was exercised locally against MariaDB 11 containers. No production data was used.
|
> failure mode below was exercised locally against MariaDB 11 containers. No production data was used.
|
||||||
>
|
>
|
||||||
@@ -220,9 +226,11 @@ everything written since the dump — including anything users did during and af
|
|||||||
**Restore the database only if the data itself is wrong.** If the backend simply will not start, the
|
**Restore the database only if the data itself is wrong.** If the backend simply will not start, the
|
||||||
code rollback is almost certainly the whole fix.
|
code rollback is almost certainly the whole fix.
|
||||||
|
|
||||||
Why a code rollback is safe here: every Phase 4/5 migration is a no-op — the reconciler owns those
|
The July 2026 baseline used no-op Phase 4/5 migrations around reconciler-owned tables. Current
|
||||||
tables — so reverting code never leaves migration history ahead of the schema. The reconciler is
|
migrations also perform schema changes, including `AddJobRevision` adding the application Version
|
||||||
additive and never drops a table holding rows, so the older code simply ignores the newer tables.
|
column and `AddUserOperationEvents` creating its table and indexes. Qualify the exact old/new image
|
||||||
|
pair against the current migration history and representative post-upgrade writes. The recovery
|
||||||
|
specification above includes ownership, revision, document and restore checks for that rehearsal.
|
||||||
|
|
||||||
## Procedure
|
## Procedure
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
| `deploy/deploy.sh:backup_database` | Requires explicit provider; SQL dump for MariaDB, volume archive for SQLite; rejects missing/empty/malformed/trailerless dumps | Format checks do not restore or compare records; emergency skip exists and is not approved here |
|
| `deploy/deploy.sh:backup_database` | Requires explicit provider; SQL dump for MariaDB, volume archive for SQLite; rejects missing/empty/malformed/trailerless dumps | Format checks do not restore or compare records; emergency skip exists and is not approved here |
|
||||||
| `backup_recovery_volumes` | MariaDB additionally archives required application data volume; independently archives tombstones when present | Consistency between dump/files/tombstones and current key availability still needs isolated rehearsal |
|
| `backup_recovery_volumes` | MariaDB additionally archives required application data volume; independently archives tombstones when present | Consistency between dump/files/tombstones and current key availability still needs isolated rehearsal |
|
||||||
| Retention/off-host copy | Daily/monthly retention and optional rclone copy | Source message calls remote encrypted but source does not establish actual remote encryption configuration; off-host configuration and recovery remain unverified |
|
| Retention/off-host copy | Daily/monthly retention and optional rclone copy | Source message calls remote encrypted but source does not establish actual remote encryption configuration; off-host configuration and recovery remain unverified |
|
||||||
| `finish_deploy` | On failure after replacement, restarts previous backend/frontend image references when both exist; retains failed exit | No database reversal, no previous AI image restoration, and no post-rollback health check in this handler |
|
| `finish_deploy` | On failure after replacement, restarts previous backend/frontend references, checks public frontend/liveness/readiness with bounded retries and retains failed exit | Offline stubs verify control flow. Actual recovery, image identity, database reversal and previous AI image restoration remain unqualified |
|
||||||
| Tombstone store and replay | Validates ledger records; Program invokes `StageRestoredAccountsAsync` at startup; deleted identities are staged again | Test restored data with latest independent tombstones; corrupt ledger must fail closed. Existing AccountDeletionTests include restore replay, not a complete operational restore |
|
| Tombstone store and replay | Validates ledger records; Program invokes `StageRestoredAccountsAsync` at startup; deleted identities are staged again | Test restored data with latest independent tombstones; corrupt ledger must fail closed. Existing AccountDeletionTests include restore replay, not a complete operational restore |
|
||||||
|
|
||||||
Build retry paths prune Docker build/system cache and deployment may warm an Ollama model. Therefore even a proposed "local deploy smoke" must not run this script against the shared workstation. No such action was taken.
|
Build retry paths prune Docker build/system cache and deployment may warm an Ollama model. Therefore even a proposed "local deploy smoke" must not run this script against the shared workstation. No such action was taken.
|
||||||
@@ -71,3 +71,39 @@ The fix explicitly checks preparation and copy. It writes the stamp to a tempora
|
|||||||
- Red run: **2 failures and 1 passing control** against the pre-fix function. The partial-copy case directly reproduced false completion; the marker-publication case required an atomic rename the old function did not perform.
|
- Red run: **2 failures and 1 passing control** against the pre-fix function. The partial-copy case directly reproduced false completion; the marker-publication case required an atomic rename the old function did not perform.
|
||||||
- Green run: **12/12 backup tests** including four monthly cases (successful copy and stamp, partial copy, failed marker rename, existing completed month). Rollback handler and hosted quarantine checks passed **5/5** and **4/4**. Bash syntax validation passed.
|
- Green run: **12/12 backup tests** including four monthly cases (successful copy and stamp, partial copy, failed marker rename, existing completed month). Rollback handler and hosted quarantine checks passed **5/5** and **4/4**. Bash syntax validation passed.
|
||||||
- Only temporary synthetic files were created or copied. Pruning and upload were stubbed. Approved rehearsal still needs independent checks of artifacts for the marker stamp, encryption, retrieval and actual restore before any release decision.
|
- Only temporary synthetic files were created or copied. Pruning and upload were stubbed. Approved rehearsal still needs independent checks of artifacts for the marker stamp, encryption, retrieval and actual restore before any release decision.
|
||||||
|
|
||||||
|
## RR-12 post-rollback core health checks
|
||||||
|
|
||||||
|
11 November 2026. Source `a39ac847298e1fbe7667cf42671e7ec265500fcd` on `codex/release-rollback-health`, based on `24a62997`. After a successful previous-image restart, the handler now checks the configured public frontend, `/health` and `/ready`. Each curl request has connection/request timeouts and bounded retries for startup failures. A failed check reports that manual recovery is required and collects status/logs. The deployment's original nonzero status is retained even if diagnostics fail. The success message is limited to restarted core images and passing public core checks.
|
||||||
|
|
||||||
|
The red test run executed six methods and reported four assertion failures because the old handler performed no probes. The green run passed six methods, including failed-probe subcases for all three endpoints, Compose failure, incomplete references, pre-replacement failure and successful deployment. Probe failures and Compose failure also use failing diagnostic stubs to verify original-status preservation. Twelve backup tests and shell syntax validation passed separately. Earlier five-test receipts remain historical and overlap this run.
|
||||||
|
|
||||||
|
Only the extracted exit handler/trap ran with shell functions substituting Compose, Docker, curl and cleanup. The full deployment script was never sourced or executed, and no network/containers/models were used. Raw red/green/backup/syntax receipts are in `evidence/rollback-health/`. Actual startup timing, proxy routing, exact image identity, post-migration writes and database/files/keys/tombstone recovery still need the authorised isolated rehearsal. Passing public probes alone does not establish recovery correctness.
|
||||||
|
|
||||||
|
## RR-13 retention failure propagation
|
||||||
|
|
||||||
|
11 November 2026. Based on `3cca441874abfab6ad581c5a07913a6d66622423` on `codex/release-rollback-health`. Bounded offline remediation under prompt 11, sections 13 and 16. Engineering priority P2: retention failures could falsely report success and permit upload/deployment, leaving retention incomplete; no actual data loss or production impact was observed. No security severity or release-risk acceptance is implied.
|
||||||
|
|
||||||
|
The real conditional deployment caller suppresses Bash errexit within `apply_backup_retention`. Previously a failed daily `find -delete` or monthly `rm` was masked by the final success message. Monthly `find | sort` failures were also hidden by process substitution feeding `mapfile`. Seven retention test methods reproduced four failing assertions: daily prune, monthly enumeration, sorting and monthly prune all returned success and reached the mocked upload/deployment boundary.
|
||||||
|
|
||||||
|
Daily pruning and each monthly removal now explicitly return failure. Monthly enumeration is captured in a checked command substitution under the script's existing `pipefail`, then split into the same ordered array; an empty listing produces an empty array. Failure prevents later monthly removals, upload and deployment, and suppresses the final retention-success message. Existing daily age/count defaults, monthly path guard, copy/atomic-marker behavior, archive/dump/upload functions and backend error handling remain unchanged. Pruning can already have removed some eligible old files before a command fails; this change does not undo prior removals or prove recoverability.
|
||||||
|
|
||||||
|
Fresh evidence, Windows / Python 3.12.10 / Git Bash:
|
||||||
|
|
||||||
|
- `python scripts/test-deploy-backup.py BackupRetentionTests -v`: red run **7 methods, 4 assertion failures** (monthly enumeration has two failing subcases). Four existing methods passed. An additional empty-list control was added afterward.
|
||||||
|
- `python scripts/test-deploy-backup.py -v`: final **16/16 methods PASSED**, including 8 retention, 5 connection-parser and 3 upload methods. Healthy retention requests removal of the two oldest synthetic months in order, preserves current copied bytes/stamp and an existing completed snapshot, and reaches upload/deployment. Daily failure prevents enumeration; either pipeline failure prevents monthly pruning; first monthly removal failure prevents further removals. Empty-list control makes no removal request. The red and earlier green totals overlap; do not add them.
|
||||||
|
- `python scripts/test-deploy-rollback.py -v`: **6/6 PASSED** independently; original failure statuses, rollback diagnostics and health-check gates retained.
|
||||||
|
- Git Bash `bash -n deploy/deploy.sh`: **PASSED**, syntax only. Raw receipts and SHA-256 source identities: [retention-failures evidence](evidence/retention-failures/source-identity.json), alongside `red.txt`, `green-backup.txt`, `rollback.txt`, and `syntax.txt`.
|
||||||
|
- `graphify update .`: AST-only update completed, 18,539 nodes / 45,993 edges. Optional SQL extractor missing and unsupported/no-symbol files reported; no dependency installed or semantic/model extraction run. Generated graph remains untracked local navigation material.
|
||||||
|
|
||||||
|
Tests execute only extracted functions/caller with synthetic temporary files. Every pruning `find`, `rm` and upload operation is a shell stub; sorting uses only mocked listing text. The full deployment script was never executed or sourced. No real backup, deletion, network transfer, containers, database, migration, providers, model, infrastructure repair or production operation was performed. Product/browser/full MariaDB/hosted/security suites were not rerun for this shell-only package. Actual Linux-host pruning, encrypted retrieval/decryption, consistent restore, post-write rollback and agreed RTO/RPO remain unqualified. RR-09/RR-12/RR-13 recovery gates and production NO-GO remain open. Reverting this bounded commit restores the prior failure-masking behavior; no schema or user-data reversal is needed.
|
||||||
|
|
||||||
|
## RR-13 backup permission gate
|
||||||
|
|
||||||
|
Source1c8761b1 follows retention checkpointe1c8e845. report_backup previously ignored failure of both host chmod and its Docker fallback while running under conditional backup gates. Extracted real verification functions and the actual database/recovery conditional gate reproduced continued verification/promotion in both failing permission scenarios.
|
||||||
|
|
||||||
|
The fallback now returns failure explicitly, retaining the artifact and suppressing Backup verified and subsequent deployment gates. Host-success and fallback-success paths retain their behaviour. No Docker command, chmod, archive operation or deployment was executed against real data:permissions/container/size/content verification are stubbed around a synthetic temporary artifact.
|
||||||
|
|
||||||
|
RED:three methods/six scenarios, two failing subtests. GREEN:19 backup methods passed, including three permission methods/six scenarios; Bash syntax passed. The prior six rollback methods were not rerun because the handler is unchanged. Native logs/source bindings: evidence/backup-permissions. Root continued after delegated worker usage limits; test temp/output uses E because C capacity is constrained.
|
||||||
|
|
||||||
|
Actual Linux permissions, encrypted retrieval, full restore, post-write rollback, RPO/RTO and final release qualification remain open. No real permission/prune/upload/deploy/recovery occurred. Revert1c8761b1 and rerun backup checks to roll back the code change; no data migration is involved.
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ Discovery only, 2026-11-02. These are qualification gaps unless a concrete defec
|
|||||||
| RR-09 | Backup/restore and rollback | Existing procedures and historical seeded rehearsal are not a current restore. Need isolated recovery-set completeness, decryption, tombstone behavior, post-write rollback compatibility and agreed RPO/RTO |
|
| RR-09 | Backup/restore and rollback | Existing procedures and historical seeded rehearsal are not a current restore. Need isolated recovery-set completeness, decryption, tombstone behavior, post-write rollback compatibility and agreed RPO/RTO |
|
||||||
| RR-10 | AI quality/activation | Deterministic Docling fixtures are distinct from real-model semantic accuracy. Optional unqualified adapter remains disabled; no AI invocation authorised here |
|
| RR-10 | AI quality/activation | Deterministic Docling fixtures are distinct from real-model semantic accuracy. Optional unqualified adapter remains disabled; no AI invocation authorised here |
|
||||||
| RR-11 | Backup host parsing rejected supported `Host=` alias and uppercase spelling | FIXED AND OFFLINE-TESTED. See [bounded remediation](RR-11_IMPLEMENTATION.md). Real dump/restore qualification remains open |
|
| RR-11 | Backup host parsing rejected supported `Host=` alias and uppercase spelling | FIXED AND OFFLINE-TESTED. See [bounded remediation](RR-11_IMPLEMENTATION.md). Real dump/restore qualification remains open |
|
||||||
| RR-12 | Rollback recreates core images without post-rollback health qualification; no schema/data/AI reversal | OFFLINE HANDLER CHECKS PASSED 5/5. See [failure-injection evidence](11_BACKUP_RESTORE_ROLLBACK.md#rr-12-offline-failure-injection). Actual image recovery and health/readiness after migration/writes still require isolated rehearsal |
|
| RR-12 | Previous handler omitted post-rollback health checks; schema/data/AI recovery remains separate | Health probes FIXED in `a39ac847` and six offline handler methods passed, including failed endpoints and diagnostics. See [health follow-up](11_BACKUP_RESTORE_ROLLBACK.md#rr-12-post-rollback-core-health-checks). Actual image identity, startup timing and recovery after migration/writes still require isolated rehearsal |
|
||||||
| RR-13 | Off-host encryption and recovery-set consistency need independent proof. Failed transfer and partial monthly copy previously let deployment continue | Transfer and monthly marker failure gates FIXED AND OFFLINE-TESTED. Actual encryption, retrieval/decryption and database/files/key/tombstone consistency require approved isolation |
|
| RR-13 | Off-host encryption and recovery-set consistency need independent proof. Failed transfer, partial monthly copy and retention errors previously let deployment continue | Transfer/marker gates and explicit daily prune, monthly enumeration/sort and removal failure propagation FIXED AND OFFLINE-TESTED. Retention suite16/16 preceded permission protection follow-up1c8761b1; final backup suite19/19 methods. Four retention failure assertions and two permission failure subtests reproduced first. See [retention follow-up](11_BACKUP_RESTORE_ROLLBACK.md#rr-13-retention-failure-propagation). See also [permission follow-up](11_BACKUP_RESTORE_ROLLBACK.md#rr-13-backup-permission-gate). Actual encryption, retrieval/decryption and database/files/key/tombstone consistency require approved isolation |
|
||||||
|
| RR-14 | July first-deploy runbook inferred rollback safety from its no-op migration baseline, while current migration Up methods add schema | Historical scope and current qualification links clarified. `AddJobRevision` adds Version and `AddUserOperationEvents` creates a table/indexes. Exact old/new image compatibility after migration/writes remains an isolated rehearsal gate |
|
||||||
|
|
||||||
Continue evidence work against the [source endpoint and environment census](03_ENDPOINT_ENVIRONMENT_CENSUS.md). Actual qualification requires an accepted candidate and isolated environment. Every fix needs minimal scope, targeted verification and the applicable stage approval.
|
Continue evidence work against the [source endpoint and environment census](03_ENDPOINT_ENVIRONMENT_CENSUS.md). Actual qualification requires an accepted candidate and isolated environment. Every fix needs minimal scope, targeted verification and the applicable stage approval.
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# Release discovery handoff
|
# Release discovery handoff
|
||||||
|
|
||||||
|
11 November. Branch `codex/release-rollback-health`, baseline `24a62997`, source `a39ac847` adds bounded post-rollback frontend/liveness/readiness probes and failing-diagnostics coverage while preserving the original exit status. Six extracted-handler tests, twelve backup tests and shell syntax validation passed. Full deploy, Docker, network, models and real restore were not executed. [Evidence and remaining qualification](11_BACKUP_RESTORE_ROLLBACK.md#rr-12-post-rollback-core-health-checks).
|
||||||
|
|
||||||
|
RR-14 clarifies that the first-production runbook is a July baseline. Current Up migrations add the Version column and operation-event schema. Its old no-op argument cannot qualify a current old/new image pair. Current candidate, migration/write and recovery rehearsal requirements are now linked from that runbook. This was source/document review only.
|
||||||
|
|
||||||
RR-12 offline failure injection passed 5/5 against the unchanged deploy exit handler. The evidence covers synthetic prior-image recreation, failed rollback diagnostics, original exit status and conditions that skip rollback. [Recovery report](11_BACKUP_RESTORE_ROLLBACK.md#rr-12-offline-failure-injection) distinguishes this from a real post-migration/post-write rehearsal. RR-13 off-host retrieval/decryption and consistent restore remain open. No production qualification.
|
RR-12 offline failure injection passed 5/5 against the unchanged deploy exit handler. The evidence covers synthetic prior-image recreation, failed rollback diagnostics, original exit status and conditions that skip rollback. [Recovery report](11_BACKUP_RESTORE_ROLLBACK.md#rr-12-offline-failure-injection) distinguishes this from a real post-migration/post-write rehearsal. RR-13 off-host retrieval/decryption and consistent restore remain open. No production qualification.
|
||||||
|
|
||||||
RR-13 transfer guard [reproduced and fixed offline](11_BACKUP_RESTORE_ROLLBACK.md#rr-13-off-host-upload-failure-handling). Daily and monthly copy errors stop deployment, and the success log makes no encryption claim. Actual remote encryption and restore remain open.
|
RR-13 transfer guard [reproduced and fixed offline](11_BACKUP_RESTORE_ROLLBACK.md#rr-13-off-host-upload-failure-handling). Daily and monthly copy errors stop deployment, and the success log makes no encryption claim. Actual remote encryption and restore remain open.
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"baseline": "e1c8e84502e08144a32f496af8b03cb26f0ec815",
|
||||||
|
"sourceCommit": "1c8761b1880279902dc17ab2ce0b58d44018562b",
|
||||||
|
"sourcesLfSha256": {
|
||||||
|
"deploy/deploy.sh": "723ace6d66b74d6578f3a941b8a73a451ec5ceabd4d5a1c9d49c1e0a40496ce6",
|
||||||
|
"scripts/test-deploy-backup.py": "62063c97b743e17a15f58166329cffe4ebbcc326f060c3e20f39e9d81cddd6ff"
|
||||||
|
},
|
||||||
|
"red": "Three methods/six permission scenarios; two failed subtests.",
|
||||||
|
"green": "19 backup methods passed, including three permission methods/six scenarios.",
|
||||||
|
"limits": "Extracted functions and actual conditional gates; chmod/Docker/du/gzip/archive shape are synthetic stubs. No real permissions, deployment, deletion or recovery."
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
test_empty_password_is_preserved (__main__.BackupConnectionTests.test_empty_password_is_preserved) ... ok
|
||||||
|
test_invalid_or_ambiguous_input_never_invokes_dump_or_exposes_input (__main__.BackupConnectionTests.test_invalid_or_ambiguous_input_never_invokes_dump_or_exposes_input) ... ok
|
||||||
|
test_quoted_separators_and_shell_characters_remain_data (__main__.BackupConnectionTests.test_quoted_separators_and_shell_characters_remain_data) ... ok
|
||||||
|
test_single_quoted_unicode_password_and_database_alias_are_preserved (__main__.BackupConnectionTests.test_single_quoted_unicode_password_and_database_alias_are_preserved) ... ok
|
||||||
|
test_supported_host_alias_and_case_reach_dump_unchanged (__main__.BackupConnectionTests.test_supported_host_alias_and_case_reach_dump_unchanged) ... ok
|
||||||
|
test_both_permission_failures_block_verification_and_next_gate (__main__.BackupPermissionTests.test_both_permission_failures_block_verification_and_next_gate) ... ok
|
||||||
|
test_fallback_permission_success_preserves_verification (__main__.BackupPermissionTests.test_fallback_permission_success_preserves_verification) ... ok
|
||||||
|
test_host_permission_success_does_not_need_fallback (__main__.BackupPermissionTests.test_host_permission_success_does_not_need_fallback) ... ok
|
||||||
|
test_completed_monthly_snapshot_contains_both_artifacts_and_stamp (__main__.BackupRetentionTests.test_completed_monthly_snapshot_contains_both_artifacts_and_stamp) ... ok
|
||||||
|
test_daily_prune_failure_blocks_monthly_pruning_upload_and_deploy (__main__.BackupRetentionTests.test_daily_prune_failure_blocks_monthly_pruning_upload_and_deploy) ... ok
|
||||||
|
test_empty_monthly_enumeration_does_not_request_removal (__main__.BackupRetentionTests.test_empty_monthly_enumeration_does_not_request_removal) ... ok
|
||||||
|
test_marker_publish_failure_does_not_expose_complete_marker_or_proceed (__main__.BackupRetentionTests.test_marker_publish_failure_does_not_expose_complete_marker_or_proceed) ... ok
|
||||||
|
test_monthly_enumeration_failure_blocks_pruning_upload_and_deploy (__main__.BackupRetentionTests.test_monthly_enumeration_failure_blocks_pruning_upload_and_deploy) ... ok
|
||||||
|
test_monthly_prune_failure_stops_before_remaining_months_upload_and_deploy (__main__.BackupRetentionTests.test_monthly_prune_failure_stops_before_remaining_months_upload_and_deploy) ... ok
|
||||||
|
test_partial_monthly_copy_cannot_mark_snapshot_complete_or_proceed (__main__.BackupRetentionTests.test_partial_monthly_copy_cannot_mark_snapshot_complete_or_proceed) ... ok
|
||||||
|
test_preserves_the_existing_completed_monthly_snapshot (__main__.BackupRetentionTests.test_preserves_the_existing_completed_monthly_snapshot) ... ok
|
||||||
|
test_daily_copy_failure_stops_before_remaining_files_and_monthly_copy (__main__.BackupUploadTests.test_daily_copy_failure_stops_before_remaining_files_and_monthly_copy) ... ok
|
||||||
|
test_monthly_copy_failure_blocks_deploy (__main__.BackupUploadTests.test_monthly_copy_failure_blocks_deploy) ... ok
|
||||||
|
test_uploads_only_current_daily_files_then_monthly_snapshot (__main__.BackupUploadTests.test_uploads_only_current_daily_files_then_monthly_snapshot) ... ok
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Ran 19 tests in 11.903s
|
||||||
|
|
||||||
|
OK
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
test_both_permission_failures_block_verification_and_next_gate (__main__.BackupPermissionTests.test_both_permission_failures_block_verification_and_next_gate) ...
|
||||||
|
test_both_permission_failures_block_verification_and_next_gate (__main__.BackupPermissionTests.test_both_permission_failures_block_verification_and_next_gate) (kind='database') ... FAIL
|
||||||
|
test_both_permission_failures_block_verification_and_next_gate (__main__.BackupPermissionTests.test_both_permission_failures_block_verification_and_next_gate) (kind='recovery') ... FAIL
|
||||||
|
test_fallback_permission_success_preserves_verification (__main__.BackupPermissionTests.test_fallback_permission_success_preserves_verification) ... ok
|
||||||
|
test_host_permission_success_does_not_need_fallback (__main__.BackupPermissionTests.test_host_permission_success_does_not_need_fallback) ... ok
|
||||||
|
|
||||||
|
======================================================================
|
||||||
|
FAIL: test_both_permission_failures_block_verification_and_next_gate (__main__.BackupPermissionTests.test_both_permission_failures_block_verification_and_next_gate) (kind='database')
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "C:\Users\Connor\.codex\worktrees\release-readiness-discovery\Job tracker\scripts\test-deploy-backup.py", line 355, in test_both_permission_failures_block_verification_and_next_gate
|
||||||
|
self.assertNotEqual(0, result.returncode, result.stdout + result.stderr)
|
||||||
|
AssertionError: 0 == 0 : HOST PERMISSIONS
|
||||||
|
FALLBACK PERMISSIONS
|
||||||
|
Backup verified: E:/Storage/Projects/Active/temp docs/jobjakt-local-build/frozen-document-integrity/temp/backup-permission-fg2554w_/synthetic-backup.gz (1K)
|
||||||
|
NEXT DEPLOY GATE WOULD START
|
||||||
|
|
||||||
|
|
||||||
|
======================================================================
|
||||||
|
FAIL: test_both_permission_failures_block_verification_and_next_gate (__main__.BackupPermissionTests.test_both_permission_failures_block_verification_and_next_gate) (kind='recovery')
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "C:\Users\Connor\.codex\worktrees\release-readiness-discovery\Job tracker\scripts\test-deploy-backup.py", line 355, in test_both_permission_failures_block_verification_and_next_gate
|
||||||
|
self.assertNotEqual(0, result.returncode, result.stdout + result.stderr)
|
||||||
|
AssertionError: 0 == 0 : HOST PERMISSIONS
|
||||||
|
FALLBACK PERMISSIONS
|
||||||
|
Backup verified: E:/Storage/Projects/Active/temp docs/jobjakt-local-build/frozen-document-integrity/temp/backup-permission-ri_ir06o/synthetic-backup.gz (1K)
|
||||||
|
NEXT DEPLOY GATE WOULD START
|
||||||
|
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Ran 3 tests in 2.133s
|
||||||
|
|
||||||
|
FAILED (failures=2)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PASS: C:/Program Files/Git/bin/bash.exe -n deploy/deploy.sh; exit0
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
test_empty_password_is_preserved (__main__.BackupConnectionTests.test_empty_password_is_preserved) ... ok
|
||||||
|
test_invalid_or_ambiguous_input_never_invokes_dump_or_exposes_input (__main__.BackupConnectionTests.test_invalid_or_ambiguous_input_never_invokes_dump_or_exposes_input) ... ok
|
||||||
|
test_quoted_separators_and_shell_characters_remain_data (__main__.BackupConnectionTests.test_quoted_separators_and_shell_characters_remain_data) ... ok
|
||||||
|
test_single_quoted_unicode_password_and_database_alias_are_preserved (__main__.BackupConnectionTests.test_single_quoted_unicode_password_and_database_alias_are_preserved) ... ok
|
||||||
|
test_supported_host_alias_and_case_reach_dump_unchanged (__main__.BackupConnectionTests.test_supported_host_alias_and_case_reach_dump_unchanged) ... ok
|
||||||
|
test_completed_monthly_snapshot_contains_both_artifacts_and_stamp (__main__.BackupRetentionTests.test_completed_monthly_snapshot_contains_both_artifacts_and_stamp) ... ok
|
||||||
|
test_daily_prune_failure_blocks_monthly_pruning_upload_and_deploy (__main__.BackupRetentionTests.test_daily_prune_failure_blocks_monthly_pruning_upload_and_deploy) ... ok
|
||||||
|
test_empty_monthly_enumeration_does_not_request_removal (__main__.BackupRetentionTests.test_empty_monthly_enumeration_does_not_request_removal) ... ok
|
||||||
|
test_marker_publish_failure_does_not_expose_complete_marker_or_proceed (__main__.BackupRetentionTests.test_marker_publish_failure_does_not_expose_complete_marker_or_proceed) ... ok
|
||||||
|
test_monthly_enumeration_failure_blocks_pruning_upload_and_deploy (__main__.BackupRetentionTests.test_monthly_enumeration_failure_blocks_pruning_upload_and_deploy) ... ok
|
||||||
|
test_monthly_prune_failure_stops_before_remaining_months_upload_and_deploy (__main__.BackupRetentionTests.test_monthly_prune_failure_stops_before_remaining_months_upload_and_deploy) ... ok
|
||||||
|
test_partial_monthly_copy_cannot_mark_snapshot_complete_or_proceed (__main__.BackupRetentionTests.test_partial_monthly_copy_cannot_mark_snapshot_complete_or_proceed) ... ok
|
||||||
|
test_preserves_the_existing_completed_monthly_snapshot (__main__.BackupRetentionTests.test_preserves_the_existing_completed_monthly_snapshot) ... ok
|
||||||
|
test_daily_copy_failure_stops_before_remaining_files_and_monthly_copy (__main__.BackupUploadTests.test_daily_copy_failure_stops_before_remaining_files_and_monthly_copy) ... ok
|
||||||
|
test_monthly_copy_failure_blocks_deploy (__main__.BackupUploadTests.test_monthly_copy_failure_blocks_deploy) ... ok
|
||||||
|
test_uploads_only_current_daily_files_then_monthly_snapshot (__main__.BackupUploadTests.test_uploads_only_current_daily_files_then_monthly_snapshot) ... ok
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Ran 16 tests in 6.842s
|
||||||
|
|
||||||
|
OK
|
||||||
|
|
||||||
|
PROCESS_EXIT=0
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
test_completed_monthly_snapshot_contains_both_artifacts_and_stamp (__main__.BackupRetentionTests.test_completed_monthly_snapshot_contains_both_artifacts_and_stamp) ... ok
|
||||||
|
test_daily_prune_failure_blocks_monthly_pruning_upload_and_deploy (__main__.BackupRetentionTests.test_daily_prune_failure_blocks_monthly_pruning_upload_and_deploy) ... FAIL
|
||||||
|
test_marker_publish_failure_does_not_expose_complete_marker_or_proceed (__main__.BackupRetentionTests.test_marker_publish_failure_does_not_expose_complete_marker_or_proceed) ... ok
|
||||||
|
test_monthly_enumeration_failure_blocks_pruning_upload_and_deploy (__main__.BackupRetentionTests.test_monthly_enumeration_failure_blocks_pruning_upload_and_deploy) ...
|
||||||
|
test_monthly_enumeration_failure_blocks_pruning_upload_and_deploy (__main__.BackupRetentionTests.test_monthly_enumeration_failure_blocks_pruning_upload_and_deploy) (operation='monthly-find') ... FAIL
|
||||||
|
test_monthly_enumeration_failure_blocks_pruning_upload_and_deploy (__main__.BackupRetentionTests.test_monthly_enumeration_failure_blocks_pruning_upload_and_deploy) (operation='sort') ... FAIL
|
||||||
|
test_monthly_prune_failure_stops_before_remaining_months_upload_and_deploy (__main__.BackupRetentionTests.test_monthly_prune_failure_stops_before_remaining_months_upload_and_deploy) ... FAIL
|
||||||
|
test_partial_monthly_copy_cannot_mark_snapshot_complete_or_proceed (__main__.BackupRetentionTests.test_partial_monthly_copy_cannot_mark_snapshot_complete_or_proceed) ... ok
|
||||||
|
test_preserves_the_existing_completed_monthly_snapshot (__main__.BackupRetentionTests.test_preserves_the_existing_completed_monthly_snapshot) ... ok
|
||||||
|
|
||||||
|
======================================================================
|
||||||
|
FAIL: test_daily_prune_failure_blocks_monthly_pruning_upload_and_deploy (__main__.BackupRetentionTests.test_daily_prune_failure_blocks_monthly_pruning_upload_and_deploy)
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "C:\Users\Connor\.codex\worktrees\release-readiness-discovery\Job tracker\scripts\test-deploy-backup.py", line 258, in test_daily_prune_failure_blocks_monthly_pruning_upload_and_deploy
|
||||||
|
self.assertEqual(1, result.returncode, result.stdout + result.stderr)
|
||||||
|
AssertionError: 1 != 0 : Monthly recovery set retained: C:/Users/Connor/AppData/Local/Temp/tmp_6t6unbl/monthly/2099-01
|
||||||
|
PRUNING WOULD START
|
||||||
|
CLEANUP REQUEST -rf --
|
||||||
|
MONTHLY PRUNE 2098-11
|
||||||
|
CLEANUP REQUEST -rf --
|
||||||
|
MONTHLY PRUNE 2098-12
|
||||||
|
Backup retention applied: 30 days daily, 1 monthly sets.
|
||||||
|
UPLOAD WOULD START
|
||||||
|
DEPLOY WOULD CONTINUE
|
||||||
|
MONTHLY ENUMERATION
|
||||||
|
|
||||||
|
|
||||||
|
======================================================================
|
||||||
|
FAIL: test_monthly_enumeration_failure_blocks_pruning_upload_and_deploy (__main__.BackupRetentionTests.test_monthly_enumeration_failure_blocks_pruning_upload_and_deploy) (operation='monthly-find')
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "C:\Users\Connor\.codex\worktrees\release-readiness-discovery\Job tracker\scripts\test-deploy-backup.py", line 270, in test_monthly_enumeration_failure_blocks_pruning_upload_and_deploy
|
||||||
|
self.assertEqual(1, result.returncode, result.stdout + result.stderr)
|
||||||
|
AssertionError: 1 != 0 : Monthly recovery set retained: C:/Users/Connor/AppData/Local/Temp/tmpk3z7klep/monthly/2099-01
|
||||||
|
PRUNING WOULD START
|
||||||
|
CLEANUP REQUEST -rf --
|
||||||
|
MONTHLY PRUNE 2098-11
|
||||||
|
CLEANUP REQUEST -rf --
|
||||||
|
MONTHLY PRUNE 2098-12
|
||||||
|
Backup retention applied: 30 days daily, 1 monthly sets.
|
||||||
|
UPLOAD WOULD START
|
||||||
|
DEPLOY WOULD CONTINUE
|
||||||
|
MONTHLY ENUMERATION
|
||||||
|
|
||||||
|
|
||||||
|
======================================================================
|
||||||
|
FAIL: test_monthly_enumeration_failure_blocks_pruning_upload_and_deploy (__main__.BackupRetentionTests.test_monthly_enumeration_failure_blocks_pruning_upload_and_deploy) (operation='sort')
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "C:\Users\Connor\.codex\worktrees\release-readiness-discovery\Job tracker\scripts\test-deploy-backup.py", line 270, in test_monthly_enumeration_failure_blocks_pruning_upload_and_deploy
|
||||||
|
self.assertEqual(1, result.returncode, result.stdout + result.stderr)
|
||||||
|
AssertionError: 1 != 0 : Monthly recovery set retained: C:/Users/Connor/AppData/Local/Temp/tmp6bu45jcv/monthly/2099-01
|
||||||
|
PRUNING WOULD START
|
||||||
|
Backup retention applied: 30 days daily, 1 monthly sets.
|
||||||
|
UPLOAD WOULD START
|
||||||
|
DEPLOY WOULD CONTINUE
|
||||||
|
MONTHLY ENUMERATION
|
||||||
|
|
||||||
|
|
||||||
|
======================================================================
|
||||||
|
FAIL: test_monthly_prune_failure_stops_before_remaining_months_upload_and_deploy (__main__.BackupRetentionTests.test_monthly_prune_failure_stops_before_remaining_months_upload_and_deploy)
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "C:\Users\Connor\.codex\worktrees\release-readiness-discovery\Job tracker\scripts\test-deploy-backup.py", line 279, in test_monthly_prune_failure_stops_before_remaining_months_upload_and_deploy
|
||||||
|
self.assertEqual(1, result.returncode, result.stdout + result.stderr)
|
||||||
|
AssertionError: 1 != 0 : Monthly recovery set retained: C:/Users/Connor/AppData/Local/Temp/tmpesmkwjd9/monthly/2099-01
|
||||||
|
PRUNING WOULD START
|
||||||
|
CLEANUP REQUEST -rf --
|
||||||
|
MONTHLY PRUNE 2098-11
|
||||||
|
CLEANUP REQUEST -rf --
|
||||||
|
MONTHLY PRUNE 2098-12
|
||||||
|
Backup retention applied: 30 days daily, 1 monthly sets.
|
||||||
|
UPLOAD WOULD START
|
||||||
|
DEPLOY WOULD CONTINUE
|
||||||
|
MONTHLY ENUMERATION
|
||||||
|
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Ran 7 tests in 1.787s
|
||||||
|
|
||||||
|
FAILED (failures=4)
|
||||||
|
|
||||||
|
PROCESS_EXIT=1
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
test_each_failed_health_probe_preserves_failure_and_requires_manual_recovery (__main__.DeployRollbackTests.test_each_failed_health_probe_preserves_failure_and_requires_manual_recovery) ... ok
|
||||||
|
test_failed_replacement_recreates_only_prior_core_images (__main__.DeployRollbackTests.test_failed_replacement_recreates_only_prior_core_images) ... ok
|
||||||
|
test_failure_before_replacement_does_not_recreate_any_service (__main__.DeployRollbackTests.test_failure_before_replacement_does_not_recreate_any_service) ... ok
|
||||||
|
test_incomplete_previous_release_does_not_recreate_any_service (__main__.DeployRollbackTests.test_incomplete_previous_release_does_not_recreate_any_service) ... ok
|
||||||
|
test_rollback_failure_retains_original_status_and_collects_diagnostics (__main__.DeployRollbackTests.test_rollback_failure_retains_original_status_and_collects_diagnostics) ... ok
|
||||||
|
test_success_does_not_recreate_any_service (__main__.DeployRollbackTests.test_success_does_not_recreate_any_service) ... ok
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Ran 6 tests in 0.369s
|
||||||
|
|
||||||
|
OK
|
||||||
|
|
||||||
|
PROCESS_EXIT=0
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"baseline": "3cca441874abfab6ad581c5a07913a6d66622423",
|
||||||
|
"python": "3.12.10 (tags/v3.12.10:0cc8128, Apr 8 2025, 12:21:36) [MSC v.1943 64 bit (AMD64)]",
|
||||||
|
"files": {
|
||||||
|
"deploy/deploy.sh": "f25723f68aa93f2e1ad6afd467facba5d0fd97004122f02db3f77f967237f334",
|
||||||
|
"scripts/test-deploy-backup.py": "a9d3a69214888c76407b9a47c9e5394144f4a5f01dba134717d0ea9ff8f40ef1",
|
||||||
|
"scripts/test-deploy-rollback.py": "4d3ec3074ca7582fda52960285909e5f4fbad1e497343b9dbe54a8d8acc90fc4"
|
||||||
|
},
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "green-backup",
|
||||||
|
"command": [
|
||||||
|
"python",
|
||||||
|
"scripts/test-deploy-backup.py",
|
||||||
|
"-v"
|
||||||
|
],
|
||||||
|
"exit": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rollback",
|
||||||
|
"command": [
|
||||||
|
"python",
|
||||||
|
"scripts/test-deploy-rollback.py",
|
||||||
|
"-v"
|
||||||
|
],
|
||||||
|
"exit": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "syntax",
|
||||||
|
"command": [
|
||||||
|
"C:/Program Files/Git/bin/bash.exe",
|
||||||
|
"-n",
|
||||||
|
"deploy/deploy.sh"
|
||||||
|
],
|
||||||
|
"exit": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"red_deploy_sha256": "e5290bf766627517701c269c866398d6466843c05d1dcb832a866815c228291d",
|
||||||
|
"red_note": "Seven retention methods against baseline deploy source; empty-list control added afterward. Raw worktree SHA-256 identities preserve exact tested bytes, including line endings."
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
PROCESS_EXIT=0
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
............
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Ran 12 tests in 5.884s
|
||||||
|
|
||||||
|
OK
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
FFFF....
|
||||||
|
======================================================================
|
||||||
|
FAIL: test_each_failed_health_probe_preserves_failure_and_requires_manual_recovery (__main__.DeployRollbackTests.test_each_failed_health_probe_preserves_failure_and_requires_manual_recovery) (path='/')
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "C:\Users\Connor\.codex\worktrees\release-readiness-discovery\Job tracker\scripts\test-deploy-rollback.py", line 72, in test_each_failed_health_probe_preserves_failure_and_requires_manual_recovery
|
||||||
|
self.assertEqual(self.probes()[:index + 1], calls[1:-2])
|
||||||
|
AssertionError: Lists differ: ['CALL curl -fsS --connect-timeout 5 --max[97 chars]id/'] != []
|
||||||
|
|
||||||
|
First list contains 1 additional elements.
|
||||||
|
First extra element 0:
|
||||||
|
'CALL curl -fsS --connect-timeout 5 --max-time 5 --retry 5 --retry-delay 2 --retry-connrefused --retry-max-time 30 https://synthetic.invalid/'
|
||||||
|
|
||||||
|
+ []
|
||||||
|
- ['CALL curl -fsS --connect-timeout 5 --max-time 5 --retry 5 --retry-delay 2 '
|
||||||
|
- '--retry-connrefused --retry-max-time 30 https://synthetic.invalid/']
|
||||||
|
|
||||||
|
======================================================================
|
||||||
|
FAIL: test_each_failed_health_probe_preserves_failure_and_requires_manual_recovery (__main__.DeployRollbackTests.test_each_failed_health_probe_preserves_failure_and_requires_manual_recovery) (path='/health')
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "C:\Users\Connor\.codex\worktrees\release-readiness-discovery\Job tracker\scripts\test-deploy-rollback.py", line 72, in test_each_failed_health_probe_preserves_failure_and_requires_manual_recovery
|
||||||
|
self.assertEqual(self.probes()[:index + 1], calls[1:-2])
|
||||||
|
AssertionError: Lists differ: ['CALL curl -fsS --connect-timeout 5 --max[247 chars]lth'] != []
|
||||||
|
|
||||||
|
First list contains 2 additional elements.
|
||||||
|
First extra element 0:
|
||||||
|
'CALL curl -fsS --connect-timeout 5 --max-time 5 --retry 5 --retry-delay 2 --retry-connrefused --retry-max-time 30 https://synthetic.invalid/'
|
||||||
|
|
||||||
|
+ []
|
||||||
|
- ['CALL curl -fsS --connect-timeout 5 --max-time 5 --retry 5 --retry-delay 2 '
|
||||||
|
- '--retry-connrefused --retry-max-time 30 https://synthetic.invalid/',
|
||||||
|
- 'CALL curl -fsS --connect-timeout 5 --max-time 5 --retry 5 --retry-delay 2 '
|
||||||
|
- '--retry-connrefused --retry-max-time 30 https://synthetic.invalid/health']
|
||||||
|
|
||||||
|
======================================================================
|
||||||
|
FAIL: test_each_failed_health_probe_preserves_failure_and_requires_manual_recovery (__main__.DeployRollbackTests.test_each_failed_health_probe_preserves_failure_and_requires_manual_recovery) (path='/ready')
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "C:\Users\Connor\.codex\worktrees\release-readiness-discovery\Job tracker\scripts\test-deploy-rollback.py", line 72, in test_each_failed_health_probe_preserves_failure_and_requires_manual_recovery
|
||||||
|
self.assertEqual(self.probes()[:index + 1], calls[1:-2])
|
||||||
|
AssertionError: Lists differ: ['CALL curl -fsS --connect-timeout 5 --max[396 chars]ady'] != []
|
||||||
|
|
||||||
|
First list contains 3 additional elements.
|
||||||
|
First extra element 0:
|
||||||
|
'CALL curl -fsS --connect-timeout 5 --max-time 5 --retry 5 --retry-delay 2 --retry-connrefused --retry-max-time 30 https://synthetic.invalid/'
|
||||||
|
|
||||||
|
+ []
|
||||||
|
- ['CALL curl -fsS --connect-timeout 5 --max-time 5 --retry 5 --retry-delay 2 '
|
||||||
|
- '--retry-connrefused --retry-max-time 30 https://synthetic.invalid/',
|
||||||
|
- 'CALL curl -fsS --connect-timeout 5 --max-time 5 --retry 5 --retry-delay 2 '
|
||||||
|
- '--retry-connrefused --retry-max-time 30 https://synthetic.invalid/health',
|
||||||
|
- 'CALL curl -fsS --connect-timeout 5 --max-time 5 --retry 5 --retry-delay 2 '
|
||||||
|
- '--retry-connrefused --retry-max-time 30 https://synthetic.invalid/ready']
|
||||||
|
|
||||||
|
======================================================================
|
||||||
|
FAIL: test_failed_replacement_recreates_only_prior_core_images (__main__.DeployRollbackTests.test_failed_replacement_recreates_only_prior_core_images)
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "C:\Users\Connor\.codex\worktrees\release-readiness-discovery\Job tracker\scripts\test-deploy-rollback.py", line 55, in test_failed_replacement_recreates_only_prior_core_images
|
||||||
|
self.assertEqual([
|
||||||
|
AssertionError: Lists differ: ['CAL[121 chars]ous]', 'CALL curl -fsS --connect-timeout 5 --m[398 chars]ady'] != ['CAL[121 chars]ous]']
|
||||||
|
|
||||||
|
First list contains 3 additional elements.
|
||||||
|
First extra element 1:
|
||||||
|
'CALL curl -fsS --connect-timeout 5 --max-time 5 --retry 5 --retry-delay 2 --retry-connrefused --retry-max-time 30 https://synthetic.invalid/'
|
||||||
|
|
||||||
|
Diff is 808 characters long. Set self.maxDiff to None to see it.
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Ran 6 tests in 0.420s
|
||||||
|
|
||||||
|
FAILED (failures=4)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Git Bash bash -n deploy/deploy.sh exited 0. Full script was not executed.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
......
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Ran 6 tests in 0.374s
|
||||||
|
|
||||||
|
OK
|
||||||
@@ -175,11 +175,29 @@ class BackupRetentionTests(unittest.TestCase):
|
|||||||
'set -euo pipefail',
|
'set -euo pipefail',
|
||||||
'BACKUP_DIR=' + shlex.quote(folder.as_posix()),
|
'BACKUP_DIR=' + shlex.quote(folder.as_posix()),
|
||||||
'BACKUP_STAMP=synthetic',
|
'BACKUP_STAMP=synthetic',
|
||||||
|
'BACKUP_DAILY_RETENTION_DAYS=30',
|
||||||
|
'BACKUP_MONTHLY_RETENTION_COUNT=1',
|
||||||
'FAIL_OPERATION=' + shlex.quote(fail_operation),
|
'FAIL_OPERATION=' + shlex.quote(fail_operation),
|
||||||
'date() { echo 2099-01; }',
|
'date() { echo 2099-01; }',
|
||||||
'upload_current_backup_set() { echo "UPLOAD WOULD START"; }',
|
'upload_current_backup_set() { echo "UPLOAD WOULD START"; }',
|
||||||
'find() { if [[ "$*" == *-delete* ]]; then echo "PRUNING WOULD START"; fi; }',
|
'find() {',
|
||||||
'rm() { echo "CLEANUP REQUEST $1 $2"; }',
|
' if [[ "$*" == *-delete* ]]; then',
|
||||||
|
' echo "PRUNING WOULD START";',
|
||||||
|
' [ "$FAIL_OPERATION" != daily-find ]; return;',
|
||||||
|
' fi;',
|
||||||
|
' echo "MONTHLY ENUMERATION" >&2;',
|
||||||
|
' if [ "$FAIL_OPERATION" = empty-enumeration ]; then return 0; fi;',
|
||||||
|
' printf "%s/monthly/%s\\n" "$BACKUP_DIR" 2098-12 "$BACKUP_DIR" 2099-01 "$BACKUP_DIR" 2098-11;',
|
||||||
|
' [ "$FAIL_OPERATION" != monthly-find ];',
|
||||||
|
'}',
|
||||||
|
'sort() { if [ "$FAIL_OPERATION" = sort ]; then return 25; fi; command sort "$@"; }',
|
||||||
|
'rm() {',
|
||||||
|
' echo "CLEANUP REQUEST $1 $2";',
|
||||||
|
' if [ "$1" = -rf ]; then',
|
||||||
|
' echo "MONTHLY PRUNE ${3##*/}";',
|
||||||
|
' [ "$FAIL_OPERATION" != monthly-rm ];',
|
||||||
|
' fi;',
|
||||||
|
'}',
|
||||||
'cp() {',
|
'cp() {',
|
||||||
' if [ "$FAIL_OPERATION" = cp ]; then',
|
' if [ "$FAIL_OPERATION" = cp ]; then',
|
||||||
' printf partial > "${@: -1}/partial"; return 23;',
|
' printf partial > "${@: -1}/partial"; return 23;',
|
||||||
@@ -206,6 +224,8 @@ class BackupRetentionTests(unittest.TestCase):
|
|||||||
'jobtracker-files-synthetic.tar.gz': b'files fixture',
|
'jobtracker-files-synthetic.tar.gz': b'files fixture',
|
||||||
'.complete': b'synthetic\n'}, artifacts)
|
'.complete': b'synthetic\n'}, artifacts)
|
||||||
self.assertIn('PRUNING WOULD START', result.stdout)
|
self.assertIn('PRUNING WOULD START', result.stdout)
|
||||||
|
self.assertEqual(['MONTHLY PRUNE 2098-11', 'MONTHLY PRUNE 2098-12'],
|
||||||
|
[line for line in result.stdout.splitlines() if line.startswith('MONTHLY PRUNE ')])
|
||||||
self.assertIn('UPLOAD WOULD START', result.stdout)
|
self.assertIn('UPLOAD WOULD START', result.stdout)
|
||||||
self.assertIn('DEPLOY WOULD CONTINUE', result.stdout)
|
self.assertIn('DEPLOY WOULD CONTINUE', result.stdout)
|
||||||
|
|
||||||
@@ -234,6 +254,108 @@ class BackupRetentionTests(unittest.TestCase):
|
|||||||
self.assertEqual({'jobtracker-db-earlier.sql.gz': b'earlier fixture', '.complete': b'earlier\n'}, artifacts)
|
self.assertEqual({'jobtracker-db-earlier.sql.gz': b'earlier fixture', '.complete': b'earlier\n'}, artifacts)
|
||||||
self.assertIn('DEPLOY WOULD CONTINUE', result.stdout)
|
self.assertIn('DEPLOY WOULD CONTINUE', result.stdout)
|
||||||
|
|
||||||
|
def test_daily_prune_failure_blocks_monthly_pruning_upload_and_deploy(self):
|
||||||
|
result, _ = self.retain('daily-find')
|
||||||
|
self.assertEqual(1, result.returncode, result.stdout + result.stderr)
|
||||||
|
self.assertIn('Daily backup retention failed', result.stdout)
|
||||||
|
self.assertNotIn('MONTHLY ENUMERATION', result.stderr)
|
||||||
|
self.assertNotIn('MONTHLY PRUNE', result.stdout)
|
||||||
|
self.assertNotIn('UPLOAD WOULD START', result.stdout)
|
||||||
|
self.assertNotIn('DEPLOY WOULD CONTINUE', result.stdout)
|
||||||
|
self.assertNotIn('Backup retention applied', result.stdout)
|
||||||
|
|
||||||
|
def test_monthly_enumeration_failure_blocks_pruning_upload_and_deploy(self):
|
||||||
|
for operation in ['monthly-find', 'sort']:
|
||||||
|
with self.subTest(operation=operation):
|
||||||
|
result, _ = self.retain(operation)
|
||||||
|
self.assertEqual(1, result.returncode, result.stdout + result.stderr)
|
||||||
|
self.assertIn('Could not enumerate monthly backups', result.stdout)
|
||||||
|
self.assertNotIn('MONTHLY PRUNE', result.stdout)
|
||||||
|
self.assertNotIn('UPLOAD WOULD START', result.stdout)
|
||||||
|
self.assertNotIn('DEPLOY WOULD CONTINUE', result.stdout)
|
||||||
|
self.assertNotIn('Backup retention applied', result.stdout)
|
||||||
|
|
||||||
|
def test_monthly_prune_failure_stops_before_remaining_months_upload_and_deploy(self):
|
||||||
|
result, _ = self.retain('monthly-rm')
|
||||||
|
self.assertEqual(1, result.returncode, result.stdout + result.stderr)
|
||||||
|
self.assertIn('Monthly backup retention failed', result.stdout)
|
||||||
|
self.assertIn('MONTHLY PRUNE 2098-11', result.stdout)
|
||||||
|
self.assertNotIn('MONTHLY PRUNE 2098-12', result.stdout)
|
||||||
|
self.assertNotIn('UPLOAD WOULD START', result.stdout)
|
||||||
|
self.assertNotIn('DEPLOY WOULD CONTINUE', result.stdout)
|
||||||
|
self.assertNotIn('Backup retention applied', result.stdout)
|
||||||
|
|
||||||
|
def test_empty_monthly_enumeration_does_not_request_removal(self):
|
||||||
|
result, _ = self.retain('empty-enumeration')
|
||||||
|
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
||||||
|
self.assertNotIn('MONTHLY PRUNE', result.stdout)
|
||||||
|
self.assertIn('Backup retention applied', result.stdout)
|
||||||
|
self.assertIn('UPLOAD WOULD START', result.stdout)
|
||||||
|
self.assertIn('DEPLOY WOULD CONTINUE', result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
class BackupPermissionTests(unittest.TestCase):
|
||||||
|
def permission_gate(self, kind, host_status, fallback_status):
|
||||||
|
source = (ROOT / 'deploy/deploy.sh').read_text(encoding='utf-8')
|
||||||
|
functions = []
|
||||||
|
for name in ['report_backup', 'verify_sql_backup', 'verify_volume_backup']:
|
||||||
|
start = source.index(name + '() {')
|
||||||
|
functions.append(source[start:source.index('\n}\n', start) + 3])
|
||||||
|
caller = 'backup_database' if kind == 'database' else 'backup_recovery_volumes'
|
||||||
|
start = source.index('if ! ' + caller + '; then')
|
||||||
|
gate = source[start:source.index('\nfi\n', start) + 4]
|
||||||
|
with tempfile.TemporaryDirectory(prefix='backup-permission-') as directory:
|
||||||
|
target = Path(directory) / 'synthetic-backup.gz'
|
||||||
|
payload = b'synthetic permission fixture'
|
||||||
|
target.write_bytes(payload)
|
||||||
|
script = '\n'.join([
|
||||||
|
'set -euo pipefail',
|
||||||
|
'BACKUP_DIR=' + shlex.quote(Path(directory).as_posix()),
|
||||||
|
'TARGET=' + shlex.quote(target.as_posix()),
|
||||||
|
'chmod() { echo "HOST PERMISSIONS"; return ' + str(host_status) + '; }',
|
||||||
|
'docker() { if [[ "${@: -1}" == *chmod* ]]; then echo "FALLBACK PERMISSIONS"; return ' + str(fallback_status) + '; fi; return 0; }',
|
||||||
|
'du() { printf "1K\\tsynthetic\\n"; }',
|
||||||
|
'rm() { echo "UNEXPECTED REMOVAL"; return 99; }',
|
||||||
|
'gzip() { printf "CREATE TABLE synthetic (id int);\\n-- Dump completed\\n"; }',
|
||||||
|
'verify_backup_file_shape() { [ -s "$1" ]; }',
|
||||||
|
*functions,
|
||||||
|
'backup_database() { verify_sql_backup "$TARGET"; }',
|
||||||
|
'backup_recovery_volumes() { verify_volume_backup "$TARGET"; }',
|
||||||
|
gate,
|
||||||
|
'echo "NEXT DEPLOY GATE WOULD START"',
|
||||||
|
])
|
||||||
|
result = subprocess.run([BASH, '--noprofile', '--norc'], input=script, text=True,
|
||||||
|
encoding='utf-8', capture_output=True, cwd=ROOT)
|
||||||
|
self.assertEqual(payload, target.read_bytes(), 'Permission failure must not remove the backup artifact')
|
||||||
|
self.assertNotIn('UNEXPECTED REMOVAL', result.stdout)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def test_host_permission_success_does_not_need_fallback(self):
|
||||||
|
for kind in ['database', 'recovery']:
|
||||||
|
with self.subTest(kind=kind):
|
||||||
|
result = self.permission_gate(kind, 0, 23)
|
||||||
|
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
||||||
|
self.assertNotIn('FALLBACK PERMISSIONS', result.stdout)
|
||||||
|
self.assertIn('Backup verified', result.stdout)
|
||||||
|
self.assertIn('NEXT DEPLOY GATE WOULD START', result.stdout)
|
||||||
|
|
||||||
|
def test_fallback_permission_success_preserves_verification(self):
|
||||||
|
for kind in ['database', 'recovery']:
|
||||||
|
with self.subTest(kind=kind):
|
||||||
|
result = self.permission_gate(kind, 1, 0)
|
||||||
|
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
||||||
|
self.assertIn('FALLBACK PERMISSIONS', result.stdout)
|
||||||
|
self.assertIn('Backup verified', result.stdout)
|
||||||
|
self.assertIn('NEXT DEPLOY GATE WOULD START', result.stdout)
|
||||||
|
|
||||||
|
def test_both_permission_failures_block_verification_and_next_gate(self):
|
||||||
|
for kind in ['database', 'recovery']:
|
||||||
|
with self.subTest(kind=kind):
|
||||||
|
result = self.permission_gate(kind, 1, 23)
|
||||||
|
self.assertNotEqual(0, result.returncode, result.stdout + result.stderr)
|
||||||
|
self.assertNotIn('Backup verified', result.stdout)
|
||||||
|
self.assertNotIn('NEXT DEPLOY GATE WOULD START', result.stdout)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ BASH = 'C:/Program Files/Git/bin/bash.exe' if os.name == 'nt' else shutil.which(
|
|||||||
|
|
||||||
|
|
||||||
class DeployRollbackTests(unittest.TestCase):
|
class DeployRollbackTests(unittest.TestCase):
|
||||||
def exit_deploy(self, status, *, replaced=True, backend=True, frontend=True, compose_status=0):
|
def exit_deploy(self, status, *, replaced=True, backend=True, frontend=True, compose_status=0, failed_probe='', diagnostics_status=0):
|
||||||
source = (ROOT / 'deploy/deploy.sh').read_text(encoding='utf-8')
|
source = (ROOT / 'deploy/deploy.sh').read_text(encoding='utf-8')
|
||||||
start = source.index('finish_deploy() {')
|
start = source.index('finish_deploy() {')
|
||||||
handler = source[start:source.index('\ntrap finish_deploy EXIT\n', start) + len('\ntrap finish_deploy EXIT\n')]
|
handler = source[start:source.index('\ntrap finish_deploy EXIT\n', start) + len('\ntrap finish_deploy EXIT\n')]
|
||||||
script = '\n'.join([
|
script = '\n'.join([
|
||||||
'set -euo pipefail',
|
'set -euo pipefail',
|
||||||
|
'exec 3>&1',
|
||||||
|
'APP_PUBLIC_BASE_URL=https://synthetic.invalid/',
|
||||||
f'replacement_started={str(replaced).lower()}',
|
f'replacement_started={str(replaced).lower()}',
|
||||||
'previous_backend_ref=' + ('synthetic/backend:previous' if backend else ''),
|
'previous_backend_ref=' + ('synthetic/backend:previous' if backend else ''),
|
||||||
'previous_frontend_ref=' + ('synthetic/frontend:previous' if frontend else ''),
|
'previous_frontend_ref=' + ('synthetic/frontend:previous' if frontend else ''),
|
||||||
@@ -23,11 +25,15 @@ class DeployRollbackTests(unittest.TestCase):
|
|||||||
'auth_config_headers_file=synthetic-headers',
|
'auth_config_headers_file=synthetic-headers',
|
||||||
'rm() { printf "CALL rm %s\\n" "$*"; }',
|
'rm() { printf "CALL rm %s\\n" "$*"; }',
|
||||||
'docker() { echo "CALL unexpected docker"; return 99; }',
|
'docker() { echo "CALL unexpected docker"; return 99; }',
|
||||||
'curl() { echo "CALL unexpected curl"; return 99; }',
|
'curl() {',
|
||||||
|
' printf "CALL curl %s\\n" "$*" >&3;',
|
||||||
|
f' if [ "${{@: -1}}" = "https://synthetic.invalid{failed_probe}" ]; then return 22; fi;',
|
||||||
|
' return 0;',
|
||||||
|
'}',
|
||||||
'compose() {',
|
'compose() {',
|
||||||
' printf "CALL compose %s [%s] [%s]\\n" "$*" "${BACKEND_IMAGE:-}" "${FRONTEND_IMAGE:-}";',
|
' printf "CALL compose %s [%s] [%s]\\n" "$*" "${BACKEND_IMAGE:-}" "${FRONTEND_IMAGE:-}";',
|
||||||
f' if [ "$1" = up ]; then return {compose_status}; fi;',
|
f' if [ "$1" = up ]; then return {compose_status}; fi;',
|
||||||
' return 0;',
|
f' return {diagnostics_status};',
|
||||||
'}',
|
'}',
|
||||||
handler,
|
handler,
|
||||||
f'exit {status}',
|
f'exit {status}',
|
||||||
@@ -49,11 +55,28 @@ class DeployRollbackTests(unittest.TestCase):
|
|||||||
self.assertEqual([
|
self.assertEqual([
|
||||||
'CALL compose up -d --force-recreate --remove-orphans backend frontend '
|
'CALL compose up -d --force-recreate --remove-orphans backend frontend '
|
||||||
'[synthetic/backend:previous] [synthetic/frontend:previous]',
|
'[synthetic/backend:previous] [synthetic/frontend:previous]',
|
||||||
|
*self.probes(),
|
||||||
], calls)
|
], calls)
|
||||||
|
self.assertIn('public core checks passed', stdout)
|
||||||
self.assertIn('Deployment remains failed', stdout)
|
self.assertIn('Deployment remains failed', stdout)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def probes():
|
||||||
|
flags = '-fsS --connect-timeout 5 --max-time 5 --retry 5 --retry-delay 2 --retry-connrefused --retry-max-time 30'
|
||||||
|
return [f'CALL curl {flags} https://synthetic.invalid{path}' for path in ['/', '/health', '/ready']]
|
||||||
|
|
||||||
|
def test_each_failed_health_probe_preserves_failure_and_requires_manual_recovery(self):
|
||||||
|
for index, path in enumerate(['/', '/health', '/ready']):
|
||||||
|
with self.subTest(path=path):
|
||||||
|
stdout, calls = self.exit_deploy(41, failed_probe=path, diagnostics_status=17)
|
||||||
|
self.assertEqual(self.probes()[:index + 1], calls[1:-2])
|
||||||
|
self.assertEqual(['CALL compose ps [] []', 'CALL compose logs --tail=200 backend frontend [] []'], calls[-2:])
|
||||||
|
self.assertIn('rollback health verification failed', stdout)
|
||||||
|
self.assertIn('Manual recovery is required', stdout)
|
||||||
|
self.assertNotIn('public core checks passed', stdout)
|
||||||
|
|
||||||
def test_rollback_failure_retains_original_status_and_collects_diagnostics(self):
|
def test_rollback_failure_retains_original_status_and_collects_diagnostics(self):
|
||||||
stdout, calls = self.exit_deploy(41, compose_status=19)
|
stdout, calls = self.exit_deploy(41, compose_status=19, diagnostics_status=17)
|
||||||
self.assertEqual([
|
self.assertEqual([
|
||||||
'CALL compose up -d --force-recreate --remove-orphans backend frontend '
|
'CALL compose up -d --force-recreate --remove-orphans backend frontend '
|
||||||
'[synthetic/backend:previous] [synthetic/frontend:previous]',
|
'[synthetic/backend:previous] [synthetic/frontend:previous]',
|
||||||
|
|||||||
Reference in New Issue
Block a user