From 6752379d808c124320d5b634d584649b77f85a6f Mon Sep 17 00:00:00 2001 From: Hamza Ahmed Date: Sat, 31 Oct 2026 20:41:04 +0000 Subject: [PATCH] add blog --- apps/blog-ahmza-com.yaml | 26 ++++++ apps/blog-ahmza-com/deployment.yaml | 78 ++++++++++++++++ apps/blog-ahmza-com/ingress.yaml | 27 ++++++ apps/blog-ahmza-com/namespace.yaml | 5 + apps/blog-ahmza-com/service.yaml | 15 +++ docs/13-blog-gitops.md | 139 ++++++++++++++++++++++++++++ docs/network.md | 10 ++ 7 files changed, 300 insertions(+) create mode 100644 apps/blog-ahmza-com.yaml create mode 100644 apps/blog-ahmza-com/deployment.yaml create mode 100644 apps/blog-ahmza-com/ingress.yaml create mode 100644 apps/blog-ahmza-com/namespace.yaml create mode 100644 apps/blog-ahmza-com/service.yaml create mode 100644 docs/13-blog-gitops.md diff --git a/apps/blog-ahmza-com.yaml b/apps/blog-ahmza-com.yaml new file mode 100644 index 0000000..2c77651 --- /dev/null +++ b/apps/blog-ahmza-com.yaml @@ -0,0 +1,26 @@ +# CI publishes the static site image; this Application makes Git, rather than +# a runner's kubeconfig, the source of truth for which image Kubernetes runs. +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: blog-ahmza-com + namespace: argocd + # No cascading-deletion finalizer during initial migration: removing this + # Application must not unexpectedly delete the public site's resources. +spec: + project: default + source: + repoURL: https://tea.ahmza.com/ahmza.com/k8s-starter-pack.git + targetRevision: main + path: apps/blog-ahmza-com + destination: + server: https://kubernetes.default.svc + namespace: blog-ahmza-com + syncPolicy: + automated: + # Review the initial sync before allowing Git deletions to remove live + # resources. Enabling prune later is separate from cascading app deletion. + prune: false + selfHeal: true + syncOptions: + - CreateNamespace=true diff --git a/apps/blog-ahmza-com/deployment.yaml b/apps/blog-ahmza-com/deployment.yaml new file mode 100644 index 0000000..c759304 --- /dev/null +++ b/apps/blog-ahmza-com/deployment.yaml @@ -0,0 +1,78 @@ +# Hugo generates the complete site at image-build time. SWS serves only those +# immutable files, with no writable content volume or Kubernetes API access. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: blog + namespace: blog-ahmza-com +spec: + replicas: 2 + strategy: + type: RollingUpdate + rollingUpdate: + # Keep the existing site available while replacement Pods become ready. + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + app: blog + template: + metadata: + labels: + app: blog + spec: + automountServiceAccountToken: false + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + # Ignore tainted control-plane nodes when balancing the two workers; + # otherwise a surge Pod could be blocked by an unusable third node. + nodeTaintsPolicy: Honor + whenUnsatisfiable: DoNotSchedule + labelSelector: + matchLabels: + app: blog + securityContext: + seccompProfile: + type: RuntimeDefault + containers: + - name: blog + # Bootstrap with the verified published amd64/arm64 index. CI updates + # this line to each build's digest, never a mutable latest tag. + # yamllint disable-line rule:line-length + image: tea.ahmza.com/ahmza/blog-ahmza-com@sha256:25dce7a45c8869879bbb6f056b3e4d18971c7da542255867ad83f1db26ffefc3 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 80 + protocol: TCP + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + # SWS does not declare USER in the image. An explicit unprivileged + # identity lets Kubernetes enforce runAsNonRoot safely. + runAsUser: 10001 + runAsGroup: 10001 + capabilities: + drop: + - ALL + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 2 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 30 + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 250m + memory: 128Mi diff --git a/apps/blog-ahmza-com/ingress.yaml b/apps/blog-ahmza-com/ingress.yaml new file mode 100644 index 0000000..7215651 --- /dev/null +++ b/apps/blog-ahmza-com/ingress.yaml @@ -0,0 +1,27 @@ +# Share ingress-nginx at 10.10.60.12. DNS and any external Caddy/NetBird +# hostname routing must also be pointed at this ingress before public cutover. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: blog + namespace: blog-ahmza-com + annotations: + # Reuse the production DNS-01 issuer already used by the cluster's sites. + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + ingressClassName: nginx + tls: + - hosts: + - blog.ahmza.com + secretName: blog-tls + rules: + - host: blog.ahmza.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: blog + port: + name: http diff --git a/apps/blog-ahmza-com/namespace.yaml b/apps/blog-ahmza-com/namespace.yaml new file mode 100644 index 0000000..f9c7671 --- /dev/null +++ b/apps/blog-ahmza-com/namespace.yaml @@ -0,0 +1,5 @@ +# Isolate the blog's lifecycle and access controls from unrelated applications. +apiVersion: v1 +kind: Namespace +metadata: + name: blog-ahmza-com diff --git a/apps/blog-ahmza-com/service.yaml b/apps/blog-ahmza-com/service.yaml new file mode 100644 index 0000000..41cfa9c --- /dev/null +++ b/apps/blog-ahmza-com/service.yaml @@ -0,0 +1,15 @@ +# A stable in-cluster address for ingress-nginx; no extra MetalLB IP is needed. +apiVersion: v1 +kind: Service +metadata: + name: blog + namespace: blog-ahmza-com +spec: + type: ClusterIP + selector: + app: blog + ports: + - name: http + port: 80 + targetPort: http + protocol: TCP diff --git a/docs/13-blog-gitops.md b/docs/13-blog-gitops.md new file mode 100644 index 0000000..7c0efaa --- /dev/null +++ b/docs/13-blog-gitops.md @@ -0,0 +1,139 @@ +# 13 — Deploy the Hugo blog through Argo CD + +The blog source lives in `ahmza/blog-ahmza-com`. This GitOps repository owns +**desired state**: the Kubernetes resources and exact image that should run. +Argo CD compares those files with the cluster and applies changes. CI no longer +needs a kubeconfig, Kubernetes credentials, or a per-job NetBird connection. +The runner still needs working HTTPS access to Gitea and its container registry. + +## What owns what + +```text +blog source push to main + -> Gitea Actions builds amd64 + arm64 images + -> registry returns the multi-architecture image digest + -> CI commits that digest to this repository + -> app-of-apps discovers apps/blog-ahmza-com.yaml + -> Argo CD applies apps/blog-ahmza-com/ to the cluster +``` + +A **digest** (`sha256:...`) identifies exact image contents, unlike the mutable +`latest` tag. Both architecture variants are referenced by the same index digest, +so the Pi and x86 workers select their matching image automatically. + +The new files are: + +- `apps/blog-ahmza-com.yaml`: the Argo CD Application, meaning which Git path to + watch and where to apply it. +- `apps/blog-ahmza-com/namespace.yaml`: the namespace, an isolation boundary for + this application's resources. +- `apps/blog-ahmza-com/deployment.yaml`: two Pods (running container instances), + with zero-unavailable rolling updates and the SWS `/health` probes. +- `apps/blog-ahmza-com/service.yaml`: a ClusterIP Service, a stable internal + destination for those replaceable Pods. +- `apps/blog-ahmza-com/ingress.yaml`: routes HTTPS `blog.ahmza.com` to that Service + through the already-running ingress-nginx at `10.10.60.12`. + +There is no PVC or extra Caddy Deployment. The image includes the full generated +site, and TLS is handled by the existing ingress/cert-manager infrastructure. +The Application starts with automatic pruning disabled and no cascading-delete +finalizer. Review deletion behavior before changing either: removing the +Application initially leaves its workloads running rather than deleting them. + +## Prepare credentials before enabling the new workflow + +In the **blog source repository**, configure these Gitea Actions secrets: + +| Secret | Purpose | +|---|---| +| `GITEATOKEN` | Existing credential that can push `ahmza/blog-ahmza-com` registry packages | +| `GITOPS_TOKEN` | Separate bot PAT permitted to read/write `ahmza.com/k8s-starter-pack` | + +The bot must be allowed to push to the GitOps repository's `main` branch. Scope +its token and membership as narrowly as Gitea supports; it does not need +Kubernetes credentials or administrator access. Do not commit either token. + +At preparation time, the published blog image was readable with an anonymous +registry bearer-token exchange. Therefore no image pull secret is shipped in +these manifests. If package visibility is changed to private, manage a read-only +registry credential in this namespace through the existing SOPS mechanism +(`docs/10-secrets.md`) and reference it using `imagePullSecrets`. The source +repository and registry package have separate visibility settings. + +## First deployment: GitOps first, workflow second + +1. Review and commit the Application, manifests, and documentation in this + repository, then push to `main`. They must exist remotely before the new CI + workflow can update the Deployment file. Nothing local is visible to Argo CD. + +2. The existing app-of-apps watches `apps/` non-recursively. It should create the + child Application without another manual `kubectl apply`. Initial deployment + uses the already-published image index pinned in the manifest. Check: + + ```sh + kubectl -n argocd get application blog-ahmza-com + kubectl -n blog-ahmza-com get deployment,pods,service,ingress,certificate + kubectl -n blog-ahmza-com rollout status deployment/blog --timeout=5m + ``` + + These commands are diagnostics, not the deployment mechanism. If the child + Application does not appear, inspect `app-of-apps` sync status and conditions. + Do not solve this by letting CI apply resources directly again. + +3. Verify the hostname without changing public routing first: + + ```sh + curl --fail --show-error --resolve blog.ahmza.com:443:10.10.60.12 \ + https://blog.ahmza.com/ + ``` + + TLS verification remains enabled. If certificate issuance is still pending, + inspect `certificate/blog-tls` and cert-manager events; do not bypass TLS. + Reuse of the ready production DNS-01 issuer follows the other cluster sites. + +4. Update external routing only after the new site is healthy. For the existing + NetBird -> ingress1 Caddy -> Kubernetes topology, add `blog.ahmza.com` to the + Caddy matcher forwarding to `10.10.60.12:443`, preserving the hostname for TLS + SNI and HTTP Host. Check that the public NetBird service sends that hostname + to Caddy. Set the LAN's Technitium override to `10.10.60.12` if LAN clients + should use the internal ingress directly. These settings are outside this + repository and have not been changed by adding an Ingress manifest. + + Keep public TLS termination, compression and security headers owned by the + intended proxy layer; SWS deliberately has compression/security headers off. + Cloudflare Full/Full (strict), if used, requires HTTPS to Cloudflare's actual + origin. An encrypted NetBird hop later in the path is not a substitute for + that TLS connection. + +5. With `GITOPS_TOKEN` configured, commit/push the updated workflow and helper + files in the blog source repository. That push builds the site and updates + only `apps/blog-ahmza-com/deployment.yaml` here to the new digest. + +6. Check both the Gitea job and Argo CD. A successful CI job means the image and + desired-state commit were published, NOT that Kubernetes is already healthy. + Argo CD applies the commit asynchronously; inspect its sync/health status and + the Deployment's rollout before declaring the release successful. + +7. After a successful cutover, remove obsolete blog repository secrets + `KUBE_CONFIG`, `NETBIRD_SETUP_KEY`, and `NETBIRD_URL` if no other workflow uses + them. If the kubeconfig had broad cluster access, revoke/rotate its credential + separately; deleting a CI secret does not revoke the underlying credential. + Remove no account-wide NetBird policies/keys without checking other users. + +## Concurrent publishing and rollback + +The blog workflow serializes its publishes. Other applications can still push to +this GitOps repository, so it retries non-fast-forward pushes with a rebase. +Conflicting changes fail visibly instead of force-pushing over someone else's +work. The digest updater refuses missing/duplicate image lines or a different +image repository. + +To roll back, revert the GitOps commit changing the blog's image digest, then +push that revert. Keep the old digest in the registry so it remains pullable. +Do not use `kubectl set image`: Argo CD's self-healing would put the Git version +back. A future source build will deliberately publish another desired-state +commit. + +> Checkpoint: if an image build succeeds but the GitOps push fails, should Argo +> deploy that image? No: the desired-state commit did not land, so it should keep +> the previous image. This separation is why CI no longer needs cluster access. diff --git a/docs/network.md b/docs/network.md index 778cbcf..a2f6da7 100644 --- a/docs/network.md +++ b/docs/network.md @@ -94,6 +94,16 @@ Add a row here every time you point a new hostname at this cluster (see [`docs/11-ingress.md`](11-ingress.md)). The DNS record itself lives in Cloudflare, not in this repo — this table is just the log of what's real. +## Planned hostname migrations (not yet verified live) + +- `https://blog.ahmza.com` → the `blog` Service in `blog-ahmza-com`, through + the existing ingress-nginx address `https://10.10.60.12`. Defined in + [`apps/blog-ahmza-com/ingress.yaml`](../apps/blog-ahmza-com/ingress.yaml). + Uses `letsencrypt-prod`; verify certificate issuance and update internal + DNS/external Caddy routing before cutover. No new MetalLB address is allocated. + Move this entry into the live hostname table only after verification. See + [`docs/13-blog-gitops.md`](13-blog-gitops.md) for the migration checklist. + ## Free in the pool Addresses from the range above that aren't spoken for yet. Update this