fix(staging): deploy gate-attested controllers
This commit is contained in:
@@ -262,8 +262,12 @@ jobs:
|
||||
[[ "$web_digest" =~ ^sha256:[0-9a-f]{64}$ ]]
|
||||
install -d -m 700 artifacts/staging-images
|
||||
umask 077
|
||||
printf 'git_sha=%s\napi_digest=%s\nweb_digest=%s\n' \
|
||||
"$GITEA_SHA" "$api_digest" "$web_digest" \
|
||||
git archive --format=tar --output artifacts/staging-images/controller.tar \
|
||||
"$GITEA_SHA" deploy frontend/scripts/staging-image-manifest.mjs
|
||||
controller_sha256="$(sha256sum artifacts/staging-images/controller.tar | awk '{print $1}')"
|
||||
[[ "$controller_sha256" =~ ^[0-9a-f]{64}$ ]]
|
||||
printf 'git_sha=%s\napi_digest=%s\nweb_digest=%s\ncontroller_sha256=%s\n' \
|
||||
"$GITEA_SHA" "$api_digest" "$web_digest" "$controller_sha256" \
|
||||
> artifacts/staging-images/manifest.env
|
||||
node frontend/scripts/staging-image-manifest.mjs \
|
||||
artifacts/staging-images/manifest.env "$GITEA_SHA" "$IMAGE_REPOSITORY" >/dev/null
|
||||
@@ -282,7 +286,7 @@ jobs:
|
||||
--workdir "$workdir" \
|
||||
--env HOME=/tmp \
|
||||
--env "INPUT_NAME=staging-image-manifest-$GITEA_SHA-$GITEA_RUN_ATTEMPT" \
|
||||
--env INPUT_PATH=artifacts/staging-images/manifest.env \
|
||||
--env INPUT_PATH=artifacts/staging-images/ \
|
||||
--env INPUT_OVERWRITE=false \
|
||||
--env ACTIONS_RUNTIME_TOKEN \
|
||||
--env ACTIONS_RESULTS_URL \
|
||||
|
||||
@@ -76,42 +76,64 @@ jobs:
|
||||
fi
|
||||
[[ "$gate_run_id" =~ ^[0-9]+$ ]] || { echo "no successful exact-SHA staging quality gate run found" >&2; exit 1; }
|
||||
|
||||
staging_head="$(git ls-remote https://git.copse.top/root/Jyotisha.git refs/heads/staging | awk '{print $1}')"
|
||||
[[ "$staging_head" =~ ^[0-9a-f]{40}$ ]]
|
||||
read_ref_sha() {
|
||||
local branch="$1"
|
||||
curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/$branch" |
|
||||
jq -er --arg ref "refs/heads/$branch" '
|
||||
select(type == "array" and length == 1) |
|
||||
.[0] | select(.ref == $ref) | .object.sha |
|
||||
select(test("^[0-9a-f]{40}$"))
|
||||
'
|
||||
}
|
||||
staging_head="$(read_ref_sha staging)"
|
||||
controller_sha="$(read_ref_sha main)"
|
||||
[[ "$controller_sha" == "$staging_head" ]] || { echo "reviewed main and staging controller heads differ" >&2; exit 1; }
|
||||
if [[ "$allow_rollback" == false && "$REQUESTED_SHA" != "$staging_head" ]]; then
|
||||
echo "stale staging revision refused; use explicit manual rollback only when intended" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$allow_rollback" == true && "$REQUESTED_SHA" != "$controller_sha" ]]; then
|
||||
comparison="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/compare/$REQUESTED_SHA...$controller_sha")"
|
||||
jq -e --arg base "$REQUESTED_SHA" --arg head "$controller_sha" '
|
||||
(.commits // []) as $commits |
|
||||
def parents($sha): [$commits[] | select(.sha == $sha) | (.parents // [])[] | .sha];
|
||||
def reaches($sha; $seen):
|
||||
if $sha == $base then true
|
||||
elif ($seen | index($sha)) != null then false
|
||||
else any(parents($sha)[]; . as $parent | reaches($parent; $seen + [$sha])) end;
|
||||
(.total_commits | type) == "number" and
|
||||
.total_commits == ($commits | length) and ($commits | length) > 0 and
|
||||
([$commits[].sha] | length == (unique | length)) and reaches($head; [])
|
||||
' <<<"$comparison" >/dev/null || { echo "rollback revision is not in reviewed main history" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
controller_gate_run_id="$gate_run_id"
|
||||
if [[ "$controller_sha" != "$REQUESTED_SHA" ]]; then
|
||||
controller_runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$controller_sha&branch=staging&event=push&status=success&limit=100")"
|
||||
controller_run="$(jq -cer --arg sha "$controller_sha" '
|
||||
[.workflow_runs[] | select(
|
||||
(.path | split("@")[0] | endswith("backend-quality-gate.yml")) and
|
||||
.head_sha == $sha and .head_branch == "staging" and
|
||||
.event == "push" and .conclusion == "success"
|
||||
)] | sort_by(.id) | reverse | first
|
||||
' <<<"$controller_runs")"
|
||||
controller_gate_run_id="$(jq -er '.id' <<<"$controller_run")"
|
||||
fi
|
||||
[[ "$controller_gate_run_id" =~ ^[0-9]+$ ]]
|
||||
{
|
||||
echo "sha=$REQUESTED_SHA"
|
||||
echo "gate_run_id=$gate_run_id"
|
||||
echo "controller_sha=$controller_sha"
|
||||
echo "controller_gate_run_id=$controller_gate_run_id"
|
||||
echo "allow_rollback=$allow_rollback"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout trusted main controller
|
||||
env:
|
||||
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init .
|
||||
git remote remove origin 2>/dev/null || true
|
||||
git remote add origin https://git.copse.top/root/Jyotisha.git
|
||||
fetch_succeeded=false
|
||||
for attempt in 1 2 3; do
|
||||
if timeout 120 git fetch --no-tags origin main "$DEPLOY_SHA"; then
|
||||
fetch_succeeded=true
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "trusted main fetch failed after $attempt bounded attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
[[ "$fetch_succeeded" == true ]]
|
||||
git checkout --detach --force origin/main
|
||||
git merge-base --is-ancestor "$DEPLOY_SHA" HEAD || { echo "staging revision is not in trusted main history" >&2; exit 1; }
|
||||
|
||||
- name: Prepare pinned Node tooling
|
||||
env:
|
||||
NODE_TOOL_SOURCE_IMAGE: swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/node:22-bookworm-slim@sha256:ef343465b6a14bbdf2ab52f6e100ec0659a792464fcf72c462370d88b3df909c
|
||||
@@ -152,44 +174,103 @@ jobs:
|
||||
node --version
|
||||
npm --version
|
||||
|
||||
- name: Download gate-produced image manifest
|
||||
- name: Download target and controller gate artifacts
|
||||
env:
|
||||
GATE_RUN_ID: ${{ steps.revision.outputs.gate_run_id }}
|
||||
TARGET_GATE_RUN_ID: ${{ steps.revision.outputs.gate_run_id }}
|
||||
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
|
||||
CONTROLLER_GATE_RUN_ID: ${{ steps.revision.outputs.controller_gate_run_id }}
|
||||
CONTROLLER_SHA: ${{ steps.revision.outputs.controller_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
artifact_prefix="staging-image-manifest-$DEPLOY_SHA-"
|
||||
artifacts="$(curl --fail --silent --show-error \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs/$GATE_RUN_ID/artifacts?limit=100")"
|
||||
selected_artifact="$(jq -cer --arg prefix "$artifact_prefix" '
|
||||
[(.artifacts // [])[]
|
||||
| select(.expired == false and (.name | startswith($prefix)))
|
||||
| . + {attempt: ((.name | ltrimstr($prefix)) | tonumber?)}
|
||||
| select(.attempt != null and .attempt >= 1)
|
||||
] | sort_by(.attempt, .id) | reverse | first
|
||||
' <<<"$artifacts")"
|
||||
artifact_name="$(jq -er '.name' <<<"$selected_artifact")"
|
||||
artifact_id="$(jq -er '.id' <<<"$selected_artifact")"
|
||||
artifact_attempt="${artifact_name#"$artifact_prefix"}"
|
||||
[[ "$artifact_name" == "$artifact_prefix"* ]]
|
||||
[[ "$artifact_attempt" =~ ^[1-9][0-9]*$ ]]
|
||||
[[ "$artifact_id" =~ ^[0-9]+$ ]]
|
||||
install -d -m 700 artifacts/staging-image
|
||||
curl --fail --silent --show-error --location \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/artifacts/$artifact_id/zip" \
|
||||
--output "${RUNNER_TEMP}/staging-image-manifest.zip"
|
||||
unzip -q "${RUNNER_TEMP}/staging-image-manifest.zip" -d artifacts/staging-image
|
||||
[[ -f artifacts/staging-image/manifest.env ]]
|
||||
download_bundle() {
|
||||
local run_id="$1" sha="$2" destination="$3" zip_path="$4"
|
||||
local prefix artifacts selected name id attempt
|
||||
prefix="staging-image-manifest-$sha-"
|
||||
artifacts="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs/$run_id/artifacts?limit=100")"
|
||||
selected="$(jq -cer --arg prefix "$prefix" '
|
||||
[(.artifacts // [])[]
|
||||
| select(.expired == false and (.name | startswith($prefix)))
|
||||
| . + {attempt: ((.name | ltrimstr($prefix)) | tonumber?)}
|
||||
| select(.attempt != null and .attempt >= 1)
|
||||
] | sort_by(.attempt, .id) | reverse | first
|
||||
' <<<"$artifacts")"
|
||||
name="$(jq -er '.name' <<<"$selected")"
|
||||
id="$(jq -er '.id' <<<"$selected")"
|
||||
attempt="${name#"$prefix"}"
|
||||
[[ "$name" == "$prefix"* && "$attempt" =~ ^[1-9][0-9]*$ && "$id" =~ ^[0-9]+$ ]]
|
||||
install -d -m 700 "$destination"
|
||||
curl --fail --silent --show-error --location --connect-timeout 15 --max-time 120 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/artifacts/$id/zip" \
|
||||
--output "$zip_path"
|
||||
python3 - "$zip_path" "$destination" <<'PY'
|
||||
import pathlib, stat, sys, zipfile
|
||||
archive = pathlib.Path(sys.argv[1])
|
||||
destination = pathlib.Path(sys.argv[2])
|
||||
allowed = {"manifest.env", "controller.tar"}
|
||||
with zipfile.ZipFile(archive) as bundle:
|
||||
entries = bundle.infolist()
|
||||
names = [entry.filename for entry in entries]
|
||||
if len(names) != len(set(names)) or not names or not set(names).issubset(allowed):
|
||||
raise SystemExit("invalid staging artifact bundle")
|
||||
if sum(entry.file_size for entry in entries) > 3 * 1024 * 1024:
|
||||
raise SystemExit("staging artifact bundle is too large")
|
||||
for entry in entries:
|
||||
path = pathlib.PurePosixPath(entry.filename)
|
||||
mode = entry.external_attr >> 16
|
||||
if path.is_absolute() or ".." in path.parts or path.name != entry.filename:
|
||||
raise SystemExit("unsafe staging artifact path")
|
||||
if mode and not stat.S_ISREG(mode):
|
||||
raise SystemExit("unsafe staging artifact type")
|
||||
target = destination / entry.filename
|
||||
with bundle.open(entry) as source, target.open("xb") as output:
|
||||
output.write(source.read())
|
||||
PY
|
||||
[[ -f "$destination/manifest.env" ]]
|
||||
}
|
||||
rm -rf artifacts/staging-image artifacts/controller
|
||||
download_bundle "$TARGET_GATE_RUN_ID" "$DEPLOY_SHA" artifacts/staging-image "${RUNNER_TEMP}/staging-target.zip"
|
||||
download_bundle "$CONTROLLER_GATE_RUN_ID" "$CONTROLLER_SHA" artifacts/controller "${RUNNER_TEMP}/staging-controller.zip"
|
||||
|
||||
- name: Validate immutable image manifest
|
||||
- name: Validate gate-attested controller and immutable image manifest
|
||||
id: images
|
||||
env:
|
||||
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
|
||||
CONTROLLER_SHA: ${{ steps.revision.outputs.controller_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node frontend/scripts/staging-image-manifest.mjs \
|
||||
controller_manifest=artifacts/controller/manifest.env
|
||||
controller_tar=artifacts/controller/controller.tar
|
||||
[[ -f "$controller_tar" ]]
|
||||
[[ "$(wc -l < "$controller_manifest" | tr -d ' ')" == 4 ]]
|
||||
manifest_controller_sha="$(awk -F= '$1 == "git_sha" {print $2}' "$controller_manifest")"
|
||||
expected_controller_digest="$(awk -F= '$1 == "controller_sha256" {print $2}' "$controller_manifest")"
|
||||
[[ "$manifest_controller_sha" == "$CONTROLLER_SHA" ]]
|
||||
[[ "$expected_controller_digest" =~ ^[0-9a-f]{64}$ ]]
|
||||
printf '%s %s\n' "$expected_controller_digest" "$controller_tar" | sha256sum --check --status
|
||||
python3 - "$controller_tar" <<'PY'
|
||||
import pathlib, sys, tarfile
|
||||
archive = pathlib.Path(sys.argv[1])
|
||||
required = {"deploy/run-staging-deploy.sh", "frontend/scripts/staging-image-manifest.mjs"}
|
||||
with tarfile.open(archive, "r:") as bundle:
|
||||
members = bundle.getmembers()
|
||||
names = [member.name for member in members]
|
||||
if len(names) != len(set(names)) or not required.issubset(names):
|
||||
raise SystemExit("invalid staging controller bundle")
|
||||
if sum(member.size for member in members) > 2 * 1024 * 1024:
|
||||
raise SystemExit("staging controller bundle is too large")
|
||||
for member in members:
|
||||
path = pathlib.PurePosixPath(member.name)
|
||||
if path.is_absolute() or ".." in path.parts or not (member.isdir() or member.isfile()):
|
||||
raise SystemExit("unsafe staging controller bundle")
|
||||
PY
|
||||
install -d -m 700 artifacts/controller/extracted
|
||||
tar -xf "$controller_tar" -C artifacts/controller/extracted
|
||||
node artifacts/controller/extracted/frontend/scripts/staging-image-manifest.mjs \
|
||||
"$controller_manifest" "$CONTROLLER_SHA" "$IMAGE_REPOSITORY" >/dev/null
|
||||
node artifacts/controller/extracted/frontend/scripts/staging-image-manifest.mjs \
|
||||
artifacts/staging-image/manifest.env "$DEPLOY_SHA" "$IMAGE_REPOSITORY" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Deploy exact image digests under pinned SSH identity
|
||||
@@ -217,7 +298,12 @@ jobs:
|
||||
remote="$DEPLOY_USER@$DEPLOY_HOST"
|
||||
require_current_staging_head() {
|
||||
[[ "$ALLOW_ROLLBACK" == true ]] && return
|
||||
current_head="$(git ls-remote https://git.copse.top/root/Jyotisha.git refs/heads/staging | awk '{print $1}')"
|
||||
current_head="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/staging" |
|
||||
jq -er 'select(type == "array" and length == 1) | .[0] |
|
||||
select(.ref == "refs/heads/staging") | .object.sha |
|
||||
select(test("^[0-9a-f]{40}$"))')"
|
||||
[[ "$current_head" == "$DEPLOY_SHA" ]] || { echo "staging advanced during deployment; refusing stale mutation" >&2; exit 1; }
|
||||
}
|
||||
cleanup() {
|
||||
@@ -230,15 +316,26 @@ jobs:
|
||||
incoming="$(ssh "${ssh_options[@]}" "$remote" "mktemp -d /tmp/jyotisha-staging.XXXXXXXXXX")"
|
||||
[[ "$incoming" == /tmp/jyotisha-staging.* ]]
|
||||
ssh "${ssh_options[@]}" "$remote" "install -d -m 700 '$incoming/.docker'"
|
||||
tar -cf "${RUNNER_TEMP}/deploy.tar" deploy
|
||||
scp -i "$key_path" -P "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path" "${RUNNER_TEMP}/deploy.tar" "$remote:$incoming/deploy.tar"
|
||||
ssh "${ssh_options[@]}" "$remote" "tar -xf '$incoming/deploy.tar' -C '$incoming' && rm -f -- '$incoming/deploy.tar'"
|
||||
scp -i "$key_path" -P "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path" artifacts/controller/controller.tar "$remote:$incoming/controller.tar"
|
||||
ssh "${ssh_options[@]}" "$remote" "tar -xf '$incoming/controller.tar' -C '$incoming' && rm -f -- '$incoming/controller.tar'"
|
||||
previous_sha="$(ssh "${ssh_options[@]}" "$remote" "state='$DEPLOY_PATH/.state/deployed-revision'; if [ -f \"\$state\" ]; then cat \"\$state\"; else id=\$(sudo -n docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=web' | head -n 1); if [ -n \"\$id\" ]; then sudo -n docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"\$id\" | sed -n 's/^GITHUB_SHA=//p' | head -n 1; else printf not-deployed; fi; fi")"
|
||||
[[ "$previous_sha" == not-deployed || "$previous_sha" =~ ^[0-9a-f]{40}$ ]] || exit 1
|
||||
forward_verified=false
|
||||
if [[ "$previous_sha" != not-deployed && "$previous_sha" != "$DEPLOY_SHA" && "$ALLOW_ROLLBACK" != true ]]; then
|
||||
git cat-file -e "${previous_sha}^{commit}" 2>/dev/null || git fetch origin "$previous_sha"
|
||||
git merge-base --is-ancestor "$previous_sha" "$DEPLOY_SHA" || { echo "automatic staging rollback or divergent deploy refused" >&2; exit 1; }
|
||||
comparison="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/compare/$previous_sha...$DEPLOY_SHA")"
|
||||
jq -e --arg base "$previous_sha" --arg head "$DEPLOY_SHA" '
|
||||
(.commits // []) as $commits |
|
||||
def parents($sha): [$commits[] | select(.sha == $sha) | (.parents // [])[] | .sha];
|
||||
def reaches($sha; $seen):
|
||||
if $sha == $base then true
|
||||
elif ($seen | index($sha)) != null then false
|
||||
else any(parents($sha)[]; . as $parent | reaches($parent; $seen + [$sha])) end;
|
||||
(.total_commits | type) == "number" and
|
||||
.total_commits == ($commits | length) and ($commits | length) > 0 and
|
||||
([$commits[].sha] | length == (unique | length)) and reaches($head; [])
|
||||
' <<<"$comparison" >/dev/null || { echo "automatic staging rollback or divergent deploy refused" >&2; exit 1; }
|
||||
forward_verified=true
|
||||
fi
|
||||
require_current_staging_head
|
||||
|
||||
@@ -41,8 +41,21 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "deploy_sha must be a lowercase full commit SHA" >&2; exit 1; }
|
||||
staging_head="$(git ls-remote https://git.copse.top/root/Jyotisha.git refs/heads/staging | awk '{print $1}')"
|
||||
read_ref_sha() {
|
||||
local branch="$1"
|
||||
curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/$branch" |
|
||||
jq -er --arg ref "refs/heads/$branch" '
|
||||
select(type == "array" and length == 1) |
|
||||
.[0] | select(.ref == $ref) | .object.sha |
|
||||
select(test("^[0-9a-f]{40}$"))
|
||||
'
|
||||
}
|
||||
staging_head="$(read_ref_sha staging)"
|
||||
main_head="$(read_ref_sha main)"
|
||||
[[ "$staging_head" == "$DEPLOY_SHA" ]] || { echo "migration requires current staging head" >&2; exit 1; }
|
||||
[[ "$main_head" == "$DEPLOY_SHA" ]] || { echo "staging migration revision must equal reviewed main head" >&2; exit 1; }
|
||||
runs="$(curl --fail --silent --show-error \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$DEPLOY_SHA&branch=staging&event=push&status=success&limit=100")"
|
||||
@@ -60,30 +73,6 @@ jobs:
|
||||
echo "gate_run_id=$gate_run_id"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout trusted main controller
|
||||
env:
|
||||
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init .
|
||||
git remote remove origin 2>/dev/null || true
|
||||
git remote add origin https://git.copse.top/root/Jyotisha.git
|
||||
fetch_succeeded=false
|
||||
for attempt in 1 2 3; do
|
||||
if timeout 120 git fetch --no-tags origin main "$DEPLOY_SHA"; then
|
||||
fetch_succeeded=true
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "trusted main fetch failed after $attempt bounded attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
[[ "$fetch_succeeded" == true ]]
|
||||
git checkout --detach --force origin/main
|
||||
git merge-base --is-ancestor "$DEPLOY_SHA" HEAD || { echo "staging revision is not in trusted main history" >&2; exit 1; }
|
||||
|
||||
- name: Prepare pinned Node tooling
|
||||
env:
|
||||
NODE_TOOL_SOURCE_IMAGE: swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/node:22-bookworm-slim@sha256:ef343465b6a14bbdf2ab52f6e100ec0659a792464fcf72c462370d88b3df909c
|
||||
@@ -152,17 +141,65 @@ jobs:
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/artifacts/$artifact_id/zip" \
|
||||
--output "${RUNNER_TEMP}/staging-image-manifest.zip"
|
||||
unzip -q "${RUNNER_TEMP}/staging-image-manifest.zip" -d artifacts/staging-image
|
||||
python3 - "${RUNNER_TEMP}/staging-image-manifest.zip" artifacts/staging-image <<'PY'
|
||||
import pathlib, stat, sys, zipfile
|
||||
archive = pathlib.Path(sys.argv[1])
|
||||
destination = pathlib.Path(sys.argv[2])
|
||||
allowed = {"manifest.env", "controller.tar"}
|
||||
with zipfile.ZipFile(archive) as bundle:
|
||||
entries = bundle.infolist()
|
||||
names = [entry.filename for entry in entries]
|
||||
if len(names) != len(set(names)) or set(names) != allowed:
|
||||
raise SystemExit("invalid staging artifact bundle")
|
||||
if sum(entry.file_size for entry in entries) > 3 * 1024 * 1024:
|
||||
raise SystemExit("staging artifact bundle is too large")
|
||||
for entry in entries:
|
||||
path = pathlib.PurePosixPath(entry.filename)
|
||||
mode = entry.external_attr >> 16
|
||||
if path.is_absolute() or ".." in path.parts or path.name != entry.filename:
|
||||
raise SystemExit("unsafe staging artifact path")
|
||||
if mode and not stat.S_ISREG(mode):
|
||||
raise SystemExit("unsafe staging artifact type")
|
||||
target = destination / entry.filename
|
||||
with bundle.open(entry) as source, target.open("xb") as output:
|
||||
output.write(source.read())
|
||||
PY
|
||||
[[ -f artifacts/staging-image/manifest.env ]]
|
||||
[[ -f artifacts/staging-image/controller.tar ]]
|
||||
|
||||
- name: Validate digest-pinned migration image
|
||||
- name: Validate gate-attested controller and digest-pinned migration image
|
||||
id: image
|
||||
env:
|
||||
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node frontend/scripts/staging-image-manifest.mjs \
|
||||
artifacts/staging-image/manifest.env "$DEPLOY_SHA" "$IMAGE_REPOSITORY" >>"$GITHUB_OUTPUT"
|
||||
manifest=artifacts/staging-image/manifest.env
|
||||
controller_tar=artifacts/staging-image/controller.tar
|
||||
[[ "$(wc -l < "$manifest" | tr -d ' ')" == 4 ]]
|
||||
manifest_sha="$(awk -F= '$1 == "git_sha" {print $2}' "$manifest")"
|
||||
expected_controller_digest="$(awk -F= '$1 == "controller_sha256" {print $2}' "$manifest")"
|
||||
[[ "$manifest_sha" == "$DEPLOY_SHA" && "$expected_controller_digest" =~ ^[0-9a-f]{64}$ ]]
|
||||
printf '%s %s\n' "$expected_controller_digest" "$controller_tar" | sha256sum --check --status
|
||||
python3 - "$controller_tar" <<'PY'
|
||||
import pathlib, sys, tarfile
|
||||
archive = pathlib.Path(sys.argv[1])
|
||||
required = {"deploy/run-staging-migration.sh", "frontend/scripts/staging-image-manifest.mjs"}
|
||||
with tarfile.open(archive, "r:") as bundle:
|
||||
members = bundle.getmembers()
|
||||
names = [member.name for member in members]
|
||||
if len(names) != len(set(names)) or not required.issubset(names):
|
||||
raise SystemExit("invalid staging controller bundle")
|
||||
if sum(member.size for member in members) > 2 * 1024 * 1024:
|
||||
raise SystemExit("staging controller bundle is too large")
|
||||
for member in members:
|
||||
path = pathlib.PurePosixPath(member.name)
|
||||
if path.is_absolute() or ".." in path.parts or not (member.isdir() or member.isfile()):
|
||||
raise SystemExit("unsafe staging controller bundle")
|
||||
PY
|
||||
install -d -m 700 artifacts/staging-image/extracted
|
||||
tar -xf "$controller_tar" -C artifacts/staging-image/extracted
|
||||
node artifacts/staging-image/extracted/frontend/scripts/staging-image-manifest.mjs \
|
||||
"$manifest" "$DEPLOY_SHA" "$IMAGE_REPOSITORY" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Apply digest-pinned migration under host lock
|
||||
env:
|
||||
@@ -186,7 +223,12 @@ jobs:
|
||||
ssh_options=(-i "$key_path" -p "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path")
|
||||
remote="$DEPLOY_USER@$DEPLOY_HOST"
|
||||
require_current_staging_head() {
|
||||
current_head="$(git ls-remote https://git.copse.top/root/Jyotisha.git refs/heads/staging | awk '{print $1}')"
|
||||
current_head="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/staging" |
|
||||
jq -er 'select(type == "array" and length == 1) | .[0] |
|
||||
select(.ref == "refs/heads/staging") | .object.sha |
|
||||
select(test("^[0-9a-f]{40}$"))')"
|
||||
[[ "$current_head" == "$DEPLOY_SHA" ]] || { echo "staging advanced during migration; refusing stale mutation" >&2; exit 1; }
|
||||
}
|
||||
cleanup() {
|
||||
@@ -199,15 +241,26 @@ jobs:
|
||||
incoming="$(ssh "${ssh_options[@]}" "$remote" "mktemp -d /tmp/jyotisha-staging.XXXXXXXXXX")"
|
||||
[[ "$incoming" == /tmp/jyotisha-staging.* ]]
|
||||
ssh "${ssh_options[@]}" "$remote" "install -d -m 700 '$incoming/.docker'"
|
||||
tar -cf "${RUNNER_TEMP}/deploy.tar" deploy
|
||||
scp -i "$key_path" -P "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path" "${RUNNER_TEMP}/deploy.tar" "$remote:$incoming/deploy.tar"
|
||||
ssh "${ssh_options[@]}" "$remote" "tar -xf '$incoming/deploy.tar' -C '$incoming' && rm -f -- '$incoming/deploy.tar'"
|
||||
scp -i "$key_path" -P "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path" artifacts/staging-image/controller.tar "$remote:$incoming/controller.tar"
|
||||
ssh "${ssh_options[@]}" "$remote" "tar -xf '$incoming/controller.tar' -C '$incoming' && rm -f -- '$incoming/controller.tar'"
|
||||
previous_sha="$(ssh "${ssh_options[@]}" "$remote" "state='$DEPLOY_PATH/.state/deployed-revision'; if [ -f \"\$state\" ]; then cat \"\$state\"; else id=\$(sudo -n docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=web' | head -n 1); if [ -n \"\$id\" ]; then sudo -n docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"\$id\" | sed -n 's/^GITHUB_SHA=//p' | head -n 1; else printf not-deployed; fi; fi")"
|
||||
[[ "$previous_sha" == not-deployed || "$previous_sha" =~ ^[0-9a-f]{40}$ ]] || exit 1
|
||||
forward_verified=false
|
||||
if [[ "$previous_sha" != not-deployed && "$previous_sha" != "$DEPLOY_SHA" ]]; then
|
||||
git cat-file -e "${previous_sha}^{commit}" 2>/dev/null || git fetch origin "$previous_sha"
|
||||
git merge-base --is-ancestor "$previous_sha" "$DEPLOY_SHA" || { echo "migration rollback or divergence refused" >&2; exit 1; }
|
||||
comparison="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
|
||||
--header "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/compare/$previous_sha...$DEPLOY_SHA")"
|
||||
jq -e --arg base "$previous_sha" --arg head "$DEPLOY_SHA" '
|
||||
(.commits // []) as $commits |
|
||||
def parents($sha): [$commits[] | select(.sha == $sha) | (.parents // [])[] | .sha];
|
||||
def reaches($sha; $seen):
|
||||
if $sha == $base then true
|
||||
elif ($seen | index($sha)) != null then false
|
||||
else any(parents($sha)[]; . as $parent | reaches($parent; $seen + [$sha])) end;
|
||||
(.total_commits | type) == "number" and
|
||||
.total_commits == ($commits | length) and ($commits | length) > 0 and
|
||||
([$commits[].sha] | length == (unique | length)) and reaches($head; [])
|
||||
' <<<"$comparison" >/dev/null || { echo "migration rollback or divergence refused" >&2; exit 1; }
|
||||
forward_verified=true
|
||||
fi
|
||||
require_current_staging_head
|
||||
|
||||
Reference in New Issue
Block a user