fix: validate Gitea workflow source identity
This commit is contained in:
1 parent
74aa167d16
commit
f8483badcf
8 files changed
+129
-39
No files matched your search
@@ -19,23 +19,27 @@ jobs:
|
||||
ref: main
|
||||
- name: Verify source release identity before artifact lookup
|
||||
env:
|
||||
SOURCE_WORKFLOW: ${{ gitea.event.workflow_run.name }}
|
||||
SOURCE_WORKFLOW: ${{ gitea.event.workflow.name }}
|
||||
SOURCE_WORKFLOW_PATH: ${{ gitea.event.workflow_run.path }}
|
||||
SOURCE_EVENT: ${{ gitea.event.workflow_run.event }}
|
||||
SOURCE_CONCLUSION: ${{ gitea.event.workflow_run.conclusion }}
|
||||
SOURCE_BRANCH: ${{ gitea.event.workflow_run.head_branch }}
|
||||
SOURCE_RUN_ID: ${{ gitea.event.workflow_run.id }}
|
||||
SOURCE_SHA: ${{ gitea.event.workflow_run.head_sha }}
|
||||
SOURCE_REPOSITORY: ${{ gitea.event.repository.full_name }}
|
||||
run: python3 infra/deploy/release_handoff.py validate-handoff-source
|
||||
- name: Read artifact metadata with the installed Gitea helper when available
|
||||
env:
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
GITEA_TOKEN: ${{ gitea.token }}
|
||||
SOURCE_RUN_ID: ${{ gitea.event.workflow_run.id }}
|
||||
SOURCE_WORKFLOW: ${{ gitea.event.workflow_run.name }}
|
||||
SOURCE_WORKFLOW: ${{ gitea.event.workflow.name }}
|
||||
SOURCE_WORKFLOW_PATH: ${{ gitea.event.workflow_run.path }}
|
||||
SOURCE_EVENT: ${{ gitea.event.workflow_run.event }}
|
||||
SOURCE_CONCLUSION: ${{ gitea.event.workflow_run.conclusion }}
|
||||
SOURCE_BRANCH: ${{ gitea.event.workflow_run.head_branch }}
|
||||
SOURCE_SHA: ${{ gitea.event.workflow_run.head_sha }}
|
||||
SOURCE_REPOSITORY: ${{ gitea.event.repository.full_name }}
|
||||
run: |
|
||||
if ! command -v gitea-api >/dev/null 2>&1; then
|
||||
echo 'DRY-RUN only: gitea-api is unavailable in this job; artifact metadata and host handoff are not verified.'
|
||||
|
||||
@@ -19,11 +19,14 @@ jobs:
|
||||
ref: ${{ gitea.event.workflow_run.head_sha }}
|
||||
- name: Verify successful CI source before using registry credentials
|
||||
env:
|
||||
SOURCE_WORKFLOW: ${{ gitea.event.workflow_run.name }}
|
||||
SOURCE_WORKFLOW: ${{ gitea.event.workflow.name }}
|
||||
SOURCE_WORKFLOW_PATH: ${{ gitea.event.workflow_run.path }}
|
||||
SOURCE_EVENT: ${{ gitea.event.workflow_run.event }}
|
||||
SOURCE_CONCLUSION: ${{ gitea.event.workflow_run.conclusion }}
|
||||
SOURCE_BRANCH: ${{ gitea.event.workflow_run.head_branch }}
|
||||
SOURCE_RUN_ID: ${{ gitea.event.workflow_run.id }}
|
||||
SOURCE_SHA: ${{ gitea.event.workflow_run.head_sha }}
|
||||
SOURCE_REPOSITORY: ${{ gitea.event.repository.full_name }}
|
||||
run: python3 infra/deploy/release_handoff.py validate-ci-source
|
||||
- name: Require explicitly configured Gitea registry credentials
|
||||
env:
|
||||
|
||||
+16
-11
@@ -4,7 +4,13 @@
|
||||
|
||||
No production data or backup existed during the 2026-10-11 audit. The repository
|
||||
now contains `infra/backup/archive.py`; it is not installed or run on the VPS,
|
||||
and `/var/backups/akyldash` remains a planned root-only location.
|
||||
and `/var/backups/akyldash` was only a planned root-only location. The owner has
|
||||
since prohibited keeping a complete archive on the VPS, including temporarily.
|
||||
The local archive and full stop-create-verify-restart procedures below are
|
||||
superseded and **not authorized on the VPS**. The selected destination is
|
||||
externally managed Windows-controlled storage; its design and implementation
|
||||
are pending, so there is no verified backup path yet. Do not treat this plan or
|
||||
the uninstalled archive tool as a valid production backup.
|
||||
|
||||
## Required release policy
|
||||
|
||||
@@ -21,7 +27,7 @@ and `/var/backups/akyldash` remains a planned root-only location.
|
||||
- Test restore into an isolated target before relying on the backup. Do not
|
||||
restore over production as a test.
|
||||
|
||||
## Clean first deployment
|
||||
## Clean first deployment — superseded, not authorized on the VPS
|
||||
|
||||
No backup is needed only when Compose has no containers and all four
|
||||
persistent mounts are confirmed empty. For a first install with a preloaded
|
||||
@@ -47,7 +53,7 @@ sudo python3 infra/backup/archive.py create \
|
||||
sudo python3 infra/backup/archive.py verify /var/backups/akyldash/akyldash-initial-corpus-YYYYMMDDTHHMMSSZ.tar.gz
|
||||
```
|
||||
|
||||
## Cold backup procedure
|
||||
## Cold backup procedure — superseded, not authorized on the VPS
|
||||
|
||||
The script requires every Compose container to be stopped. Schedule downtime,
|
||||
then run from the approved checkout on the VPS:
|
||||
@@ -80,7 +86,7 @@ cd /opt/akyldash/current
|
||||
sudo docker compose --env-file /etc/akyldash/production.env -f deploy/production/compose.yaml up -d
|
||||
```
|
||||
|
||||
## Initial post-setup baseline
|
||||
## Initial post-setup baseline — superseded, not authorized on the VPS
|
||||
|
||||
After the first index has been loaded and validated, the initial `admin` account
|
||||
has been created, and `/review` responds over HTTPS, create a second backup.
|
||||
@@ -104,13 +110,12 @@ production setup as complete until this baseline archive verifies. Keep the
|
||||
pre-start corpus archive as well until the baseline is verified and copied to
|
||||
separately managed encrypted storage.
|
||||
|
||||
Keep the local archive root-only, then copy it to separately managed encrypted
|
||||
storage. A local archive alone does not protect against loss of the VPS disk.
|
||||
Restore extracts only to a new `akyldash-restore-*` directory under `/tmp` or
|
||||
`/var/tmp` and validates the checksum and SQLite databases. Applying the staged
|
||||
contents requires the separate recovery-target tool; it refuses the inventoried
|
||||
production host, existing data/volumes, and an unmarked host. Example isolated
|
||||
restore:
|
||||
The historical procedure below assumes a local VPS archive and is superseded.
|
||||
Do not create or stage a full archive on the VPS, even temporarily. A restore
|
||||
procedure for the selected external Windows-controlled copy is pending its
|
||||
design and implementation. Until an external copy and separate-target restore
|
||||
are verified, the production backup gate is unmet. The old local-archive restore
|
||||
example is not authorized for production data:
|
||||
|
||||
```bash
|
||||
sudo python3 infra/backup/archive.py restore /var/backups/akyldash/backup.tar.gz \
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Infrastructure current state
|
||||
|
||||
Owner decision recorded 2026-10-26: a complete backup must not be stored on
|
||||
the VPS, including temporarily. The local archive/full cold-backup commands in
|
||||
`BACKUP.md` are superseded and unauthorized. The selected externally managed
|
||||
Windows-controlled backup destination is still pending design and
|
||||
implementation; no verified external backup or restore exists, so production
|
||||
deployment remains blocked by the backup gate.
|
||||
|
||||
Read-only deployment recheck: 2026-10-25. Source: SSH as `codex`, direct-origin
|
||||
and public HTTPS probes, local `main` checkout, and Gitea API. No VPS files,
|
||||
services, firewall, containers, volumes, production data, or credentials were
|
||||
|
||||
@@ -50,21 +50,14 @@ backup/restore have been tested.
|
||||
must identify its purpose. Do not change UFW as part of this procedure. The
|
||||
Compose Caddy service obtains and renews HTTPS certificates automatically;
|
||||
Cloudflare Full (strict) still needs end-to-end verification.
|
||||
5. For a clean first deployment, skip backup only when Compose confirms there
|
||||
are no containers and all persistent mounts are empty. If any data exists,
|
||||
create and verify a backup before starting a new version. For a preloaded
|
||||
corpus with absent named volumes, use `--initial-deployment`; it is limited
|
||||
to a container-free host whose `DATA_ROOT` contains only
|
||||
`minjust-normalized/`. Normal backups reject absent volumes. An uncertain
|
||||
or partial state stops deployment. Then start services, load and validate
|
||||
the OpenSearch index, create the initial admin interactively, verify
|
||||
`/review` over HTTPS, and create the required post-setup baseline backup
|
||||
described in `BACKUP.md`. Record the backend image ID for drift checks:
|
||||
|
||||
```bash
|
||||
sudo sh -c 'docker compose --env-file /etc/akyldash/production.env -f /opt/akyldash/current/deploy/production/compose.yaml ps --quiet backend | xargs -r docker inspect --format "{{.Image}}" > /opt/akyldash/deployed-image.txt'
|
||||
sudo chmod 0600 /opt/akyldash/deployed-image.txt
|
||||
```
|
||||
5. **Deployment is blocked by the backup gate.** The owner prohibits keeping a
|
||||
complete archive on the VPS even temporarily. The local archive and
|
||||
stop-create-verify-restart commands in `BACKUP.md` are superseded and not
|
||||
authorized on the VPS. The selected externally managed Windows-controlled
|
||||
backup destination is pending design and implementation; it is not yet a
|
||||
verified backup. Do not start services or perform a production cutover until
|
||||
that external copy and a separate-target restore have been verified. The
|
||||
existing archive tool does not implement that external flow.
|
||||
|
||||
Application deployment remains owner-run. The repository-scoped `act_runner`
|
||||
`0.2.13` service is active under `akyldash-runner` and uses rootless Docker on a
|
||||
|
||||
@@ -49,10 +49,13 @@ or raw process environments.
|
||||
Compose service names, image references and image IDs; running/health state;
|
||||
persistent mount paths and read-only/read-write flags; protected env/secret
|
||||
owner, group, and mode only; listener/firewall summary. Exclude all env values.
|
||||
- Confirm an archive created by the documented cold-backup procedure passes
|
||||
checksum and archive verification and records the current backend/OpenSearch
|
||||
configured refs and image IDs. Confirm a restore was tested on a separate
|
||||
target. Do not restore over production as part of this review.
|
||||
- The owner prohibits keeping a complete archive on the VPS, even temporarily;
|
||||
the local cold-backup and full stop-create-verify-restart procedure in
|
||||
`BACKUP.md` is superseded and must not be run on the VPS. The selected
|
||||
externally managed Windows-controlled backup destination is pending design
|
||||
and implementation. Before cutover, verify an external copy, its checksum,
|
||||
the recorded backend/OpenSearch refs and image IDs, and a restore on a
|
||||
separate target. No backup implementation is included in this CD change.
|
||||
- Confirm whether current data/corpus files are mounted read-only and provide
|
||||
owner/mode metadata before proposing any UID, ownership, or mount changes.
|
||||
- After the PR is reviewed, the owner must run the existing root-only
|
||||
|
||||
@@ -30,17 +30,19 @@ def latest_main_sha() -> str:
|
||||
|
||||
def validate_source(
|
||||
*, workflow: str, event: str, conclusion: str, branch: str, run_id: str, head_sha: str,
|
||||
stage: str, current_main: str,
|
||||
workflow_path: str, repository: str, stage: str, current_main: str,
|
||||
) -> None:
|
||||
expected = {
|
||||
"release": ("CI", "push"),
|
||||
"handoff": ("Release production images", "workflow_run"),
|
||||
"release": ("CI", "ci.yaml@refs/heads/main", "push"),
|
||||
"handoff": ("Release production images", "release-images.yaml@refs/heads/main", "workflow_run"),
|
||||
}
|
||||
if stage not in expected:
|
||||
raise ValueError("unknown release handoff stage")
|
||||
expected_workflow, expected_event = expected[stage]
|
||||
if workflow != expected_workflow or event != expected_event:
|
||||
expected_workflow, expected_path, expected_event = expected[stage]
|
||||
if workflow != expected_workflow or workflow_path != expected_path or event != expected_event:
|
||||
raise ValueError("source workflow or event is not approved for this stage")
|
||||
if repository != "admin/akyldash":
|
||||
raise ValueError("source repository is not admin/akyldash")
|
||||
if conclusion != "success" or branch != "main":
|
||||
raise ValueError("only successful source runs on main may continue")
|
||||
if not RUN_ID_RE.fullmatch(run_id) or not SHA_RE.fullmatch(head_sha):
|
||||
@@ -79,11 +81,13 @@ def descriptor_request(artifact_response: object, *, run_id: str, head_sha: str)
|
||||
def _source_from_environment() -> dict[str, str]:
|
||||
return {
|
||||
"workflow": os.environ.get("SOURCE_WORKFLOW", ""),
|
||||
"workflow_path": os.environ.get("SOURCE_WORKFLOW_PATH", ""),
|
||||
"event": os.environ.get("SOURCE_EVENT", ""),
|
||||
"conclusion": os.environ.get("SOURCE_CONCLUSION", ""),
|
||||
"branch": os.environ.get("SOURCE_BRANCH", ""),
|
||||
"run_id": os.environ.get("SOURCE_RUN_ID", ""),
|
||||
"head_sha": os.environ.get("SOURCE_SHA", ""),
|
||||
"repository": os.environ.get("SOURCE_REPOSITORY", ""),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -16,6 +17,31 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
GITEA_UPLOAD_ARTIFACT = "https://gitea.com/actions/gitea-upload-artifact@055b8b3f04a4a7ed853f2c6ab04256f83e4874dc"
|
||||
|
||||
|
||||
def workflow_step_env(path, step_name, payload):
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
marker = f" - name: {step_name}"
|
||||
start = lines.index(marker)
|
||||
block = []
|
||||
for line in lines[start + 1 :]:
|
||||
if line.startswith(" - "):
|
||||
break
|
||||
block.append(line)
|
||||
env_start = block.index(" env:") + 1
|
||||
env = {}
|
||||
for line in block[env_start:]:
|
||||
match = re.fullmatch(r" ([A-Z_]+): \$\{\{ (gitea(?:\.[a-z_]+)+) \}\}", line)
|
||||
if not match:
|
||||
if line.startswith(" ") and not line.startswith(" "):
|
||||
break
|
||||
continue
|
||||
key, expression = match.groups()
|
||||
value = payload
|
||||
for component in expression.split("."):
|
||||
value = value[component]
|
||||
env[key] = str(value)
|
||||
return env
|
||||
|
||||
|
||||
class ReleaseWorkflowTests(unittest.TestCase):
|
||||
def test_publisher_runs_only_after_successful_main_ci_and_binds_source_sha(self):
|
||||
workflow = (ROOT / ".gitea/workflows/release-images.yaml").read_text(encoding="utf-8")
|
||||
@@ -45,6 +71,46 @@ class ReleaseWorkflowTests(unittest.TestCase):
|
||||
registry_secret = workflow.index("Require explicitly configured Gitea registry credentials")
|
||||
self.assertLess(guard, registry_secret)
|
||||
|
||||
def test_workflow_yaml_maps_gitea_125_payload_into_both_source_guards(self):
|
||||
head_sha = "a" * 40
|
||||
cases = (
|
||||
("release-images.yaml", "Verify successful CI source before using registry credentials",
|
||||
"validate-ci-source", "CI", "ci.yaml@refs/heads/main", "push", "583"),
|
||||
("deploy-handoff.yaml", "Verify source release identity before artifact lookup",
|
||||
"validate-handoff-source", "Release production images",
|
||||
"release-images.yaml@refs/heads/main", "workflow_run", "584"),
|
||||
)
|
||||
for filename, step, command, name, path, event, run_id in cases:
|
||||
payload = {
|
||||
"action": "completed",
|
||||
"workflow": {"id": "workflow-id", "name": name, "path": path.split("@")[0], "state": "active"},
|
||||
"workflow_run": {
|
||||
"id": int(run_id), "path": path, "event": event, "conclusion": "success",
|
||||
"head_branch": "main", "head_sha": head_sha,
|
||||
},
|
||||
"repository": {"full_name": "admin/akyldash"},
|
||||
}
|
||||
workflow_path = ROOT / ".gitea/workflows" / filename
|
||||
source_env = workflow_step_env(workflow_path, step, {"gitea": {"event": payload}})
|
||||
self.assertEqual(set(source_env), {
|
||||
"SOURCE_WORKFLOW", "SOURCE_WORKFLOW_PATH", "SOURCE_EVENT", "SOURCE_CONCLUSION",
|
||||
"SOURCE_BRANCH", "SOURCE_RUN_ID", "SOURCE_SHA", "SOURCE_REPOSITORY",
|
||||
})
|
||||
self.assertIn(f"release_handoff.py {command}", workflow_path.read_text(encoding="utf-8"))
|
||||
with (
|
||||
patch.dict(os.environ, source_env),
|
||||
patch.object(sys, "argv", ["release_handoff.py", command]),
|
||||
patch.object(release_handoff, "latest_main_sha", return_value=head_sha),
|
||||
):
|
||||
self.assertEqual(release_handoff.main(), 0)
|
||||
|
||||
with self.subTest(filename=filename, invalid="repository"), patch.dict(
|
||||
os.environ, source_env | {"SOURCE_REPOSITORY": "attacker/akyldash"}
|
||||
), patch.object(sys, "argv", ["release_handoff.py", command]), patch.object(
|
||||
release_handoff, "latest_main_sha", return_value=head_sha
|
||||
):
|
||||
self.assertEqual(release_handoff.main(), 1)
|
||||
|
||||
def test_deploy_handoff_is_dry_and_uses_the_source_release_run_id(self):
|
||||
workflow = (ROOT / ".gitea/workflows/deploy-handoff.yaml").read_text(encoding="utf-8")
|
||||
self.assertIn('workflows: ["Release production images"]', workflow)
|
||||
@@ -63,7 +129,8 @@ class ReleaseWorkflowTests(unittest.TestCase):
|
||||
|
||||
def test_source_guards_reject_wrong_event_or_stale_main(self):
|
||||
valid = dict(
|
||||
workflow="CI", event="push", conclusion="success", branch="main",
|
||||
workflow="CI", workflow_path="ci.yaml@refs/heads/main", repository="admin/akyldash",
|
||||
event="push", conclusion="success", branch="main",
|
||||
run_id="301", head_sha="a" * 40, stage="release", current_main="a" * 40,
|
||||
)
|
||||
validate_source(**valid)
|
||||
@@ -72,6 +139,8 @@ class ReleaseWorkflowTests(unittest.TestCase):
|
||||
{"branch": "feature/test"},
|
||||
{"conclusion": "failure"},
|
||||
{"workflow": "untrusted workflow"},
|
||||
{"workflow_path": "untrusted.yaml@refs/heads/main"},
|
||||
{"repository": "untrusted/repository"},
|
||||
{"head_sha": "A" * 40},
|
||||
{"run_id": "3/../1"},
|
||||
{"current_main": "b" * 40},
|
||||
@@ -126,11 +195,13 @@ class ReleaseWorkflowTests(unittest.TestCase):
|
||||
metadata.write_text(json.dumps(artifacts), encoding="utf-8")
|
||||
environment = {
|
||||
"SOURCE_WORKFLOW": "Release production images",
|
||||
"SOURCE_WORKFLOW_PATH": "release-images.yaml@refs/heads/main",
|
||||
"SOURCE_EVENT": "workflow_run",
|
||||
"SOURCE_CONCLUSION": "success",
|
||||
"SOURCE_BRANCH": "main",
|
||||
"SOURCE_RUN_ID": run_id,
|
||||
"SOURCE_SHA": head_sha,
|
||||
"SOURCE_REPOSITORY": "admin/akyldash",
|
||||
}
|
||||
output = io.StringIO()
|
||||
with (
|
||||
|
||||
Reference in new issue
Block a user