From 63813c196f5b4c15a1a9dba499d70d6177774d85 Mon Sep 17 00:00:00 2001 From: Hamza Ahmed Date: Sat, 31 Oct 2026 20:51:08 +0000 Subject: [PATCH] update to argo deploy method --- .gitea/scripts/update_gitops_image.py | 51 +++++++ .gitea/tests/test_update_gitops_image.py | 83 ++++++++++++ .gitea/workflows/build-docker-images.yml | 164 +++++++++-------------- .gitignore | 4 + README.md | 38 +++++- 5 files changed, 239 insertions(+), 101 deletions(-) create mode 100644 .gitea/scripts/update_gitops_image.py create mode 100644 .gitea/tests/test_update_gitops_image.py diff --git a/.gitea/scripts/update_gitops_image.py b/.gitea/scripts/update_gitops_image.py new file mode 100644 index 0000000..4bd7ed5 --- /dev/null +++ b/.gitea/scripts/update_gitops_image.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Record a built blog image digest without requiring a YAML package in CI.""" + +import re +import sys +from pathlib import Path + +REPOSITORY = "tea.ahmza.com/ahmza/blog-ahmza-com" +DIGEST_IMAGE = re.compile(re.escape(REPOSITORY) + r"@sha256:[0-9a-f]{64}\Z") +IMAGE_LINE = re.compile( + r"(?m)^(?P +image: )" + + re.escape(REPOSITORY) + + r"(?::[A-Za-z0-9_][A-Za-z0-9_.-]*|@sha256:[0-9a-f]{64})" + + r"(?P[ \t]*(?:#[^\n]*)?)$" +) + + +def update_image(manifest: Path, image: str) -> bool: + """Replace exactly one blog image; reject mutable or unexpected image input.""" + if not DIGEST_IMAGE.fullmatch(image): + raise ValueError("expected the blog repository with a sha256 image digest") + + original = manifest.read_text() + matches = list(IMAGE_LINE.finditer(original)) + if len(matches) != 1: + raise ValueError(f"expected exactly one blog image line, found {len(matches)}") + + updated = IMAGE_LINE.sub( + lambda match: match["prefix"] + image + match["suffix"], original + ) + if updated == original: + return False + manifest.write_text(updated) + return True + + +def main() -> int: + if len(sys.argv) != 3: + print("usage: update_gitops_image.py MANIFEST IMAGE@sha256:DIGEST", file=sys.stderr) + return 2 + try: + changed = update_image(Path(sys.argv[1]), sys.argv[2]) + except (OSError, ValueError) as error: + print(f"Cannot update GitOps image: {error}", file=sys.stderr) + return 1 + print("GitOps image updated" if changed else "GitOps image already current") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.gitea/tests/test_update_gitops_image.py b/.gitea/tests/test_update_gitops_image.py new file mode 100644 index 0000000..8500b97 --- /dev/null +++ b/.gitea/tests/test_update_gitops_image.py @@ -0,0 +1,83 @@ +"""Exercise the exact image update helper used by the publishing workflow.""" + +import importlib.util +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "update_gitops_image.py" +SPEC = importlib.util.spec_from_file_location("update_gitops_image", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) +REPOSITORY = "tea.ahmza.com/ahmza/blog-ahmza-com" +IMAGE = REPOSITORY + "@sha256:" + "a" * 64 + + +class UpdateImageTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.manifest = Path(self.directory.name) / "deployment.yaml" + + def test_replaces_only_blog_image_and_preserves_other_fields(self): + original = ( + "spec:\n" + " containers:\n" + " - name: blog\n" + f" image: {REPOSITORY}:latest\n" + " - name: other\n" + " image: example.org/other:latest\n" + ) + self.manifest.write_text(original) + self.assertTrue(MODULE.update_image(self.manifest, IMAGE)) + self.assertEqual( + self.manifest.read_text(), original.replace(REPOSITORY + ":latest", IMAGE) + ) + + def test_existing_digest_update_preserves_comment(self): + self.manifest.write_text( + " image: " + REPOSITORY + "@sha256:" + "b" * 64 + " # keep\n" + ) + self.assertTrue(MODULE.update_image(self.manifest, IMAGE)) + self.assertEqual(self.manifest.read_text(), " image: " + IMAGE + " # keep\n") + + def test_same_digest_is_noop(self): + text = " image: " + IMAGE + "\n" + self.manifest.write_text(text) + self.assertFalse(MODULE.update_image(self.manifest, IMAGE)) + self.assertEqual(self.manifest.read_text(), text) + + def test_missing_or_duplicate_image_fails_without_modification(self): + for text in ["spec: {}\n", (" image: " + IMAGE + "\n") * 2]: + with self.subTest(text=text): + self.manifest.write_text(text) + with self.assertRaises(ValueError): + MODULE.update_image(self.manifest, IMAGE) + self.assertEqual(self.manifest.read_text(), text) + + def test_rejects_unexpected_or_mutable_image_input(self): + text = " image: " + REPOSITORY + ":latest\n" + self.manifest.write_text(text) + for image in [ + REPOSITORY + ":latest", + REPOSITORY + "@sha256:short", + "example.org/untrusted@sha256:" + "a" * 64, + IMAGE + "\nimage: injected", + REPOSITORY + "@sha256:" + "A" * 64, + ]: + with self.subTest(image=image): + with self.assertRaises(ValueError): + MODULE.update_image(self.manifest, image) + self.assertEqual(self.manifest.read_text(), text) + + def test_refuses_partial_repository_match(self): + text = " image: " + REPOSITORY + "-other:latest\n" + self.manifest.write_text(text) + with self.assertRaises(ValueError): + MODULE.update_image(self.manifest, IMAGE) + self.assertEqual(self.manifest.read_text(), text) + + +if __name__ == "__main__": + unittest.main() diff --git a/.gitea/workflows/build-docker-images.yml b/.gitea/workflows/build-docker-images.yml index cfe4ed5..3fe18d0 100644 --- a/.gitea/workflows/build-docker-images.yml +++ b/.gitea/workflows/build-docker-images.yml @@ -2,134 +2,98 @@ name: Build Hugo on: push: - branches: [ "main" ] + branches: ["main"] + +# Serialize blog publishes so builds cannot update its desired image at once. +# Other apps may still push to GitOps, so the push step also handles that race. +concurrency: + group: blog-publish + cancel-in-progress: false env: REGISTRY: tea.ahmza.com - IMAGE_NAME: ${{ gitea.repository }} + IMAGE: tea.ahmza.com/ahmza/blog-ahmza-com jobs: build: runs-on: ubuntu-latest - permissions: contents: read packages: write - attestations: write - id-token: write steps: - name: Checkout Repo - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Check GitOps update helper + run: python3 -m unittest discover -s .gitea/tests -v - name: Login to Gitea Container Registry - uses: docker/login-action@v3 + # Pin the v3 release ref so an upstream tag move cannot change CI code. + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 with: registry: ${{ env.REGISTRY }} username: ${{ gitea.repository_owner }} password: ${{ secrets.GITEATOKEN }} - # QEMU deliberately omitted. - # - # Hugo runs on BUILDPLATFORM in the Dockerfile, so the static site is - # built natively on the runner. The final amd64/arm64 stages only copy - # the resulting static files into the target-platform SWS image. + # Hugo runs on BUILDPLATFORM; runtime stages only copy static files. + # Both amd64 and arm64 images therefore build without QEMU emulation. - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + # v3, pinned to an immutable commit. + # yamllint disable-line rule:line-length + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f - name: Build and push - uses: docker/build-push-action@v6 + id: build + # v6, pinned to an immutable commit. + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 with: context: . - platforms: linux/arm64, linux/amd64 + platforms: linux/arm64,linux/amd64 push: true tags: | - ${{ env.REGISTRY }}/${{ gitea.repository_owner }}/blog-ahmza-com:latest - ${{ env.REGISTRY }}/${{ gitea.repository_owner }}/blog-ahmza-com:${{ gitea.event.head_commit.id }} - cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ gitea.repository_owner }}/blog-ahmza-com:buildcache - cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ gitea.repository_owner }}/blog-ahmza-com:buildcache,mode=max - deploy: - runs-on: ubuntu-latest - needs: build + ${{ env.IMAGE }}:latest + ${{ env.IMAGE }}:${{ gitea.sha }} + cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache + cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max - steps: - - name: Pre-flight - verify VPN prerequisites - # NetBird brings up a WireGuard interface, which needs a TUN device and - # NET_ADMIN inside the job container. Without them, `netbird up` hangs - # ~50s and dies with a cryptic "DeadlineExceeded". Check up front so the - # failure is obvious and points at the fix (runner config.yaml). + - name: Check out cluster GitOps repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: ahmza.com/k8s-starter-pack + ref: main + # This credential writes only GitOps, separately from registry access. + token: ${{ secrets.GITOPS_TOKEN }} + path: _gitops + + - name: Record the published image in Git + env: + BUILD_DIGEST: ${{ steps.build.outputs.digest }} + SOURCE_SHA: ${{ gitea.sha }} run: | - echo "::group::NetBird prerequisites" - if [ -e /dev/net/tun ]; then - echo "OK: /dev/net/tun present" - else - echo "::error title=Missing TUN device::/dev/net/tun not found. Add --device=/dev/net/tun to the runner's config.yaml container.options." - exit 1 + set -eu + deploy_image="${IMAGE}@${BUILD_DIGEST}" + python3 .gitea/scripts/update_gitops_image.py \ + _gitops/apps/blog-ahmza-com/deployment.yaml "$deploy_image" + + cd _gitops + if git diff --quiet -- apps/blog-ahmza-com/deployment.yaml; then + echo "GitOps already references $deploy_image" + exit 0 fi - if command -v capsh >/dev/null 2>&1; then - capsh --print | grep -q 'cap_net_admin' \ - && echo "OK: NET_ADMIN present" \ - || echo "::warning title=NET_ADMIN missing::netbird up will likely time out (DeadlineExceeded) configuring wt0. Add --cap-add=NET_ADMIN to the runner's config.yaml container.options." - else - echo "note: capsh unavailable, skipping NET_ADMIN check" - fi - echo "::endgroup::" + git config user.name "gitea-actions[bot]" + git config user.email "gitea-actions[bot]@tea.ahmza.com" + git add apps/blog-ahmza-com/deployment.yaml + git commit -m "Deploy blog ${SOURCE_SHA}" - - name: Netbird Connect - id: netbird - timeout-minutes: 3 - uses: Alemiz112/netbird-connect@v1 - with: - setup-key: ${{ secrets.NETBIRD_SETUP_KEY }} - hostname: 'gitea-actions-blog' - management-url: ${{ secrets.NETBIRD_URL }} - - - name: NetBird status - # Surface the actual peer/route state in the log and hard-assert that - # the management connection is up before attempting any deploys. - run: | - echo "::group::netbird status" - sudo netbird status --detail || true - echo "::endgroup::" - sudo netbird status | grep -q 'Management: Connected' \ - && echo "NetBird management connected." \ - || { echo "::error title=NetBird not connected::Management not connected after netbird up."; exit 1; } - - - name: Deploy image to talos-prod - uses: actions-hub/kubectl@v1.36.2 - env: - KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }} - with: - args: >- - -n blog-ahmza-com - set image deployment/blog - blog=${{ env.REGISTRY }}/${{ gitea.repository_owner }}/blog-ahmza-com:${{ gitea.event.head_commit.id }} - - - name: Wait for rollout - uses: actions-hub/kubectl@v1.36.2 - env: - KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }} - with: - args: >- - -n blog-ahmza-com - rollout status deployment/blog - --timeout=5m - - - name: Show deployed pods - uses: actions-hub/kubectl@v1.36.2 - env: - KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }} - with: - args: >- - -n blog-ahmza-com - get pods -o wide - - - name: NetBird diagnostics & disconnect - # Runs even on failure: captures final state for debugging and tears the - # peer down so stale 'gitea-actions-blog' peers don't pile up. - if: always() - run: | - echo "::group::final netbird status" - sudo netbird status --detail || echo "netbird unavailable" - echo "::endgroup::" - sudo netbird down || true + # Rebase unrelated app updates; do not force-push or ignore conflicts. + for attempt in 1 2 3; do + if git push origin HEAD:main; then + exit 0 + fi + if [ "$attempt" -eq 3 ]; then + echo "GitOps push failed after three attempts" >&2 + exit 1 + fi + git pull --rebase origin main + done diff --git a/.gitignore b/.gitignore index 4ff5143..f0befcd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ node_modules public/ resources/ themes/ + +# CI's separate GitOps checkout and Python helper test artifacts are not source. +_gitops/ +__pycache__/ diff --git a/README.md b/README.md index 49a5577..ef56eb8 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,45 @@ sacrificing performance. ## Implementation - **Hugo**: Converts Markdown files into static HTML/CSS/JS files. -- **Gitea Actions**: Automates the build process and creates Docker images. +- **Gitea Actions**: Builds amd64/arm64 images and records the exact image digest + in the separate GitOps repository. +- **Argo CD**: Watches that Git commit and reconciles the Kubernetes deployment. - **Kubernetes Cluster**: Serves the static files to users. +## GitOps deployment + +The workflow in [`.gitea/workflows/build-docker-images.yml`](.gitea/workflows/build-docker-images.yml) +keeps building and publishing `tea.ahmza.com/ahmza/blog-ahmza-com`. Instead of +connecting to NetBird and invoking `kubectl`, it commits the published image's +immutable digest to `apps/blog-ahmza-com/deployment.yaml` in +[`ahmza.com/k8s-starter-pack`](https://tea.ahmza.com/ahmza.com/k8s-starter-pack). +Argo CD then performs the rollout; CI success alone is not confirmation that the +cluster has finished deploying. + +Configure these Gitea Actions secrets in this source repository: + +- `GITEATOKEN`: the existing registry-push credential. +- `GITOPS_TOKEN`: a separate least-privilege bot PAT with read/write access to + `ahmza.com/k8s-starter-pack`, including permission to push to `main`. + +Commit/push the GitOps app manifests **before** enabling this workflow: the +Deployment file must already exist remotely. Once the migration is verified, +remove the old `KUBE_CONFIG`, `NETBIRD_SETUP_KEY`, and `NETBIRD_URL` secrets only +if no other workflows use them. No Kubernetes credentials are needed by this +workflow. The runner still needs HTTPS access to Gitea and the registry. + +The full first-sync, routing, certificate, and rollback checklist is in +[`docs/13-blog-gitops.md`](https://tea.ahmza.com/ahmza.com/k8s-starter-pack/src/branch/main/docs/13-blog-gitops.md) +in the GitOps repository. The older [Caddy examples](docs/caddy-reverse-proxy.md) +are standalone deployment references, not extra components to deploy alongside +the new ingress-nginx manifests. + +Test the standard-library-only image updater locally with: + +```sh +python3 -m unittest discover -s .gitea/tests -v +``` + ## To-Do List - [x] Migrate to Kubernetes - [x] Migrate to Blowfish theme