This commit is contained in:
ahmza committed 2026-10-31 20:41:04 +00:00
1 parent 0af896ab02
commit 6752379d80
7 files changed
+300

No files matched your search

+26
View File
@@ -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
+78
View File
@@ -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
+27
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
# Isolate the blog's lifecycle and access controls from unrelated applications.
apiVersion: v1
kind: Namespace
metadata:
name: blog-ahmza-com
+15
View File
@@ -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
+139
View File
@@ -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.
+10
View File
@@ -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