diff --git a/.gitea/actions/kaniko-push/Dockerfile b/.gitea/actions/kaniko-push/Dockerfile index 204005e..f43c97c 100644 --- a/.gitea/actions/kaniko-push/Dockerfile +++ b/.gitea/actions/kaniko-push/Dockerfile @@ -1,3 +1,4 @@ FROM ghcr.io/osscontainertools/kaniko:v1.28.5-debug@sha256:c73ca67a3b494bce79a8fb6090c4f73c18997ab272d80bffc460a62343aeb412 COPY release-build.sh /usr/local/bin/release-build +COPY validate-destination.sh /usr/local/bin/validate-destination.sh ENTRYPOINT ["/busybox/sh", "/usr/local/bin/release-build"] diff --git a/.gitea/actions/kaniko-push/release-build.sh b/.gitea/actions/kaniko-push/release-build.sh index dd1265c..bc56aeb 100644 --- a/.gitea/actions/kaniko-push/release-build.sh +++ b/.gitea/actions/kaniko-push/release-build.sh @@ -7,20 +7,7 @@ destination=$3 digest_file=$4 : "${REGISTRY_USERNAME:?registry username secret is required}" : "${REGISTRY_PASSWORD:?registry password secret is required}" - -case "$dockerfile" in - deploy/production/Dockerfile.backend) - printf '%s\n' "$destination" | grep -Eq '^git\.vakanaut\.ru/admin/akyldash/backend:[0-9a-f]{40}-[0-9]+-[1-9][0-9]*$' || { - echo 'ERROR: backend destination must be a commit/run/attempt tag in the approved registry namespace' >&2; exit 2; - } - ;; - deploy/production/Dockerfile.opensearch) - printf '%s\n' "$destination" | grep -Eq '^git\.vakanaut\.ru/admin/akyldash/opensearch:[0-9a-f]{40}-[0-9]+-[1-9][0-9]*$' || { - echo 'ERROR: OpenSearch destination must be a commit/run/attempt tag in the approved registry namespace' >&2; exit 2; - } - ;; - *) echo 'ERROR: Dockerfile is outside the approved production image set' >&2; exit 2 ;; -esac +/busybox/sh /usr/local/bin/validate-destination.sh "$dockerfile" "$destination" mkdir -p /kaniko/.docker "$(dirname "$digest_file")" auth=$(printf '%s:%s' "$REGISTRY_USERNAME" "$REGISTRY_PASSWORD" | base64 | tr -d '\n') diff --git a/.gitea/actions/kaniko-push/validate-destination.sh b/.gitea/actions/kaniko-push/validate-destination.sh new file mode 100644 index 0000000..8650f41 --- /dev/null +++ b/.gitea/actions/kaniko-push/validate-destination.sh @@ -0,0 +1,49 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 2 ]; then + echo 'ERROR: expected Dockerfile path and image destination' >&2 + exit 2 +fi + +dockerfile=$1 +destination=$2 +case "$dockerfile" in + deploy/production/Dockerfile.backend) + repository=git.vakanaut.ru/admin/akyldash/backend + ;; + deploy/production/Dockerfile.opensearch) + repository=git.vakanaut.ru/admin/akyldash/opensearch + ;; + *) + echo 'ERROR: Dockerfile is outside the approved production image set' >&2 + exit 2 + ;; +esac + +case "$destination" in + "$repository":*) tag=${destination#"$repository":} ;; + *) + echo 'ERROR: destination is outside the approved registry repository' >&2 + exit 2 + ;; +esac + +commit=${tag%%-*} +run_id=${tag#*-} +case "$commit" in + ''|*[!0123456789abcdef]*) + echo 'ERROR: destination commit must be a lowercase Git SHA-1' >&2 + exit 2 + ;; +esac +if [ "${#commit}" -ne 40 ] || [ "$run_id" = "$tag" ]; then + echo 'ERROR: destination tag must contain a 40-character commit and run ID' >&2 + exit 2 +fi +case "$run_id" in + ''|*[!0123456789]*) + echo 'ERROR: destination run ID must be numeric' >&2 + exit 2 + ;; +esac diff --git a/deploy/production/README.md b/deploy/production/README.md index c47c7b8..a651174 100644 --- a/deploy/production/README.md +++ b/deploy/production/README.md @@ -122,7 +122,7 @@ Gitea account/token with write access to the two repository packages and no unneeded administration rights. Do not reuse a runner or production host credential. The workflow uses the Gitea host and fixed package namespace `git.vakanaut.ru/admin/akyldash/{backend,opensearch}`, pushes unique -commit/run/attempt tags, captures registry digests, validates the release +commit/run-ID tags, captures registry digests, validates the release descriptor, and uploads the JSON plus SHA-256 sidecar as a Gitea Actions artifact using a full-SHA-pinned Gitea-compatible v3 action. PR CI also uploads a tiny synthetic marker with one-day retention to verify the runner's runtime @@ -132,7 +132,7 @@ artifact compatibility is based on the runtime API supported by act_runner artifact retention must preserve release digests and descriptors for the full rollback window; the owner must confirm both policies. -The descriptor records source repository/ref/commit, workflow run and attempt, +The schema-v3 descriptor records source repository/ref/commit, workflow run ID, pinned Kaniko builder and both image digests. It is an unsigned trace record, not a cryptographic attestation; deployment relies on retrieving it from the authenticated Gitea run artifact and verifying its checksum and repository diff --git a/docs/operations/CURRENT_STATE.md b/docs/operations/CURRENT_STATE.md index 8e04982..31a0280 100644 --- a/docs/operations/CURRENT_STATE.md +++ b/docs/operations/CURRENT_STATE.md @@ -158,11 +158,10 @@ Docker state, UFW rule details, and protected file metadata were not verified. - The manual image release workflow is restricted to workflow dispatch from protected `main`; PR image builds remain no-push. It requires owner-configured `REGISTRY_USERNAME` and `REGISTRY_PASSWORD`, validates published digests - against an explicit repository allowlist, and prints a commit/run/attempt - descriptor in the authenticated run log (runner artifact/cache support stays - disabled). The descriptor is unsigned trace metadata, not cryptographic - attestation. Registry write rights and digest retention are not configured - or verified. + against an explicit repository allowlist, and uploads a schema-v3 + commit/run-ID descriptor with SHA-256 as an Actions artifact. The descriptor + is unsigned trace metadata, not cryptographic attestation. Registry write + rights and digest retention are not configured or verified. - A repository-scoped official `act_runner` `0.2.13` installation is configured in `infra/runner/`: rootless Docker under `akyldash-runner`, 15 GiB ext4 loop-backed image/workspace storage, capacity 1, per-job limits (2 CPU, diff --git a/infra/deploy/test_release_workflow.py b/infra/deploy/test_release_workflow.py index bcafc73..f68c845 100644 --- a/infra/deploy/test_release_workflow.py +++ b/infra/deploy/test_release_workflow.py @@ -1,3 +1,5 @@ +import os +import subprocess import unittest from pathlib import Path @@ -44,6 +46,67 @@ class ReleaseWorkflowTests(unittest.TestCase): self.assertNotIn("DOCKER_HOST", push_action) self.assertIn("--digest-file=", push_action) + def test_release_validator_accepts_workflow_tags_without_credentials_or_kaniko(self): + workflow = (ROOT / ".gitea/workflows/release-images.yaml").read_text(encoding="utf-8") + dockerfile = ROOT / ".gitea/actions/kaniko-push/Dockerfile" + release_builder = (ROOT / ".gitea/actions/kaniko-push/release-build.sh").read_text(encoding="utf-8") + validator = ROOT / ".gitea/actions/kaniko-push/validate-destination.sh" + self.assertIn( + "COPY validate-destination.sh /usr/local/bin/validate-destination.sh", + dockerfile.read_text(encoding="utf-8"), + ) + self.assertIn( + '/busybox/sh /usr/local/bin/validate-destination.sh "$dockerfile" "$destination"', + release_builder, + ) + self.assertIn( + "destination: git.vakanaut.ru/admin/akyldash/backend:${{ github.sha }}-${{ github.run_id }}", + workflow, + ) + self.assertIn( + "destination: git.vakanaut.ru/admin/akyldash/opensearch:${{ github.sha }}-${{ github.run_id }}", + workflow, + ) + examples = ( + ("deploy/production/Dockerfile.backend", "git.vakanaut.ru/admin/akyldash/backend"), + ("deploy/production/Dockerfile.opensearch", "git.vakanaut.ru/admin/akyldash/opensearch"), + ) + environment = {"PATH": os.environ.get("PATH", "/usr/bin:/bin")} + for dockerfile_path, repository in examples: + destination = f"{repository}:{'a' * 40}-567" + with self.subTest(repository=repository): + accepted = subprocess.run( + ["sh", str(validator), dockerfile_path, destination], + text=True, + capture_output=True, + env=environment, + ) + self.assertEqual(accepted.returncode, 0, accepted.stderr) + + for invalid in ( + destination + "-1", + destination.replace(repository, "registry.invalid/other/image"), + f"{repository}:{'A' * 40}-567", + f"{repository}:{'a' * 39}-567", + f"{repository}:{'a' * 40}-", + f"{repository}:{'a' * 40}-not-a-run-id", + ): + rejected = subprocess.run( + ["sh", str(validator), dockerfile_path, invalid], + text=True, + capture_output=True, + env=environment, + ) + self.assertNotEqual(rejected.returncode, 0, invalid) + + rejected_dockerfile = subprocess.run( + ["sh", str(validator), "deploy/production/Dockerfile.unapproved", destination], + text=True, + capture_output=True, + env=environment, + ) + self.assertNotEqual(rejected_dockerfile.returncode, 0) + if __name__ == "__main__": unittest.main()