diff --git a/scripts/git-incremental-deploy.ps1 b/scripts/git-incremental-deploy.ps1 index 6c21d4f..8181165 100644 --- a/scripts/git-incremental-deploy.ps1 +++ b/scripts/git-incremental-deploy.ps1 @@ -2628,7 +2628,12 @@ restore_direct_previous_container() { local previous_image="$2" local previous_ip="" - docker rm -f "$name" >/dev/null 2>&1 || return 1 + # Creation can fail before Docker allocates the replacement name. Absence + # must not prevent recreation of the previous immutable image. + [[ "$previous_image" =~ ^sha256:[0-9a-f]{64}$ ]] || return 1 + if docker container inspect "$name" >/dev/null 2>&1; then + docker rm -f "$name" >/dev/null 2>&1 || return 1 + fi create_container "$name" "$previous_image" 0 || return 1 verify_container_uploads_mount_metadata "$name" || return 1 verify_container_upload_dir_config "$name" 1 || return 1 @@ -2639,6 +2644,8 @@ restore_direct_previous_container() { switch_proxy || return 1 verify_proxy_target "$name" || return 1 write_current_container "$name" || return 1 + resume_video_transcode "$name" || return 1 + resume_task_detail_upload "$name" || return 1 } fail_direct_replace_with_restore() { @@ -2681,8 +2688,8 @@ direct_replace_active_container() { *) fail "Direct replacement requires one active material slot" ;; esac - previous_image="$(docker inspect -f '{{.Config.Image}}' "$name" 2>/dev/null || true)" - [ -n "$previous_image" ] || fail "Active container image could not be determined: $name" + previous_image="$(docker inspect -f '{{.Image}}' "$name" 2>/dev/null || true)" + [[ "$previous_image" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "Active container immutable image could not be determined: $name" verify_container_uploads_storage "$name" || fail "Active container uploads storage could not be verified before direct replacement" verify_image_revision_label "$REGISTRY_COMMIT" "$COMMIT_SHA" || fail "Direct replacement image label does not match the exact Git commit" prepare_worker_runtime_directories || fail "Worker runtime directories could not be prepared" diff --git a/scripts/git-incremental-deploy.test.mjs b/scripts/git-incremental-deploy.test.mjs index 2cb8967..5a34367 100644 --- a/scripts/git-incremental-deploy.test.mjs +++ b/scripts/git-incremental-deploy.test.mjs @@ -124,6 +124,61 @@ function shellFunction(name) { return source.slice(start, end + 3) } +test('direct rollback restores an absent or partial replacement from the immutable image', () => { + const rollback = shellFunction('restore_direct_previous_container') + const image = `sha256:${'c'.repeat(64)}` + for (const scenario of [ + { present: 0, removeFails: 0, status: 0, removed: false }, + { present: 1, removeFails: 0, status: 0, removed: true }, + { present: 1, removeFails: 1, status: 1, removed: true }, + ]) { + const input = `set -u +present=${scenario.present} +remove_fails=${scenario.removeFails} +removed=0 +docker() { + case "$1" in + container) [ "$present" = 1 ] ;; + rm) removed=1; [ "$remove_fails" = 0 ] ;; + start) return 0 ;; + *) return 90 ;; + esac +} +create_container() { printf 'created:%s:%s\\n' "$1" "$2"; } +verify_container_uploads_mount_metadata() { return 0; } +verify_container_upload_dir_config() { return 0; } +verify_container_uploads_storage() { return 0; } +wait_for_container_health() { printf '172.18.0.9\\n'; } +write_proxy_config() { printf 'proxy:%s\\n' "$1"; } +switch_proxy() { return 0; } +verify_proxy_target() { printf 'proxy-verified\\n'; } +write_current_container() { return 0; } +resume_video_transcode() { printf 'video-resumed\\n'; } +resume_task_detail_upload() { printf 'upload-resumed\\n'; } +${rollback} +restore_direct_previous_container material-blue '${image}' +status=$? +printf 'removed:%s\\n' "$removed" >&2 +exit "$status" +` + const result = spawnSync(bash, ['-s'], { input, encoding: 'utf8' }) + assert.equal(result.status, scenario.status, result.stderr || result.stdout) + assert.equal(result.stderr.includes('removed:1'), scenario.removed) + if (scenario.status === 0) { + assert.match(result.stdout, new RegExp(`created:material-blue:${image}`)) + assert.match(result.stdout, /proxy:172\.18\.0\.9:3000/) + assert.ok(result.stdout.indexOf('proxy-verified') < result.stdout.indexOf('video-resumed')) + assert.ok(result.stdout.indexOf('proxy-verified') < result.stdout.indexOf('upload-resumed')) + } else assert.doesNotMatch(result.stdout, /created:|proxy:/) + } + const invalid = spawnSync(bash, ['-s'], { input: `docker() { echo docker-was-called; }\n${rollback}\nrestore_direct_previous_container material-blue mutable:latest\n`, encoding: 'utf8' }) + assert.equal(invalid.status, 1) + assert.doesNotMatch(invalid.stdout, /docker-was-called/) + const direct = shellFunction('direct_replace_active_container') + assert.match(direct, /previous_image="\$\(docker inspect -f '\{\{\.Image\}\}'/) + assert.doesNotMatch(direct, /\.Config\.Image/) +}) + test('detail beta configuration validates enabled key/base without exposing provider secrets', () => { const source = shellFunction('prepare_detail_generator_release') const code = source.match(/<<'NODE'\n([\s\S]*?)\nNODE/)[1]