Sync the self-hosting stack

This commit is contained in:
2026-08-30 09:58:08 +02:00
parent cd7d807a4f
commit 9b5020dea0
12 changed files with 392 additions and 2 deletions
+5
View File
@@ -1,6 +1,11 @@
apiVersion: v1
kind: Namespace
metadata:
# First, alone. Everything below is namespaced, and a sync that applied them in parallel
# would race the namespace's own creation — including the Pod Security labels above, which
# decide whether any of these pods are admitted at all.
annotations:
argocd.argoproj.io/sync-wave: "-3"
name: jarvis
labels:
# Both images run unprivileged and drop every capability, so the strictest profile applies
+28
View File
@@ -31,12 +31,21 @@ kind: ServiceAccount
metadata:
name: jarvis-secret-minter
namespace: jarvis
# The identity the minting Job below authenticates as, one wave ahead of it. Ordinary
# resources, not hooks: a hook ServiceAccount would be deleted and recreated on every sync,
# invalidating its token for no reason.
annotations:
argocd.argoproj.io/sync-wave: "-2"
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: jarvis-secret-minter
namespace: jarvis
# The permission that identity carries, in the same wave as the identity itself: a Role that
# lands after the Job has already authenticated is a Job that got a 403.
annotations:
argocd.argoproj.io/sync-wave: "-2"
rules:
# `create` AND NOTHING ELSE. No `get`, no `list`, no `update`.
#
@@ -57,6 +66,9 @@ kind: RoleBinding
metadata:
name: jarvis-secret-minter
namespace: jarvis
# And the binding between the two, in the same wave for the same reason.
annotations:
argocd.argoproj.io/sync-wave: "-2"
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
@@ -71,6 +83,22 @@ kind: Job
metadata:
name: jarvis-mint-secrets
namespace: jarvis
annotations:
# A sync hook for the same reason the migration is one — `spec.template` is immutable, so an
# ordinary Job whose image digest moved is a sync that fails on "field is immutable" — plus one
# of its own: `ttlSecondsAfterFinished` deletes this Job an hour after it succeeds. As an
# ordinary tracked resource that is a controller reporting OutOfSync forever, on an object whose
# whole purpose is to be gone.
#
# Running it again is safe BY CONSTRUCTION rather than by care: the identity below can `create`
# and nothing else, so a second run cannot overwrite the secret it wrote the first time. It gets
# a 409 and treats it as success. That is what makes it safe to hand to a controller that will
# reconcile it on a schedule nobody is watching.
argocd.argoproj.io/hook: Sync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
# Before the migration (wave 0), because the migration reads DATABASE_URL out of the secrets;
# after the ServiceAccount and Role above (wave -2), which this Job authenticates as.
argocd.argoproj.io/sync-wave: "-1"
spec:
# Re-running is safe (409 → left untouched), so a retry costs nothing. A high limit would only
# mean a broken RBAC took longer to become visible.
+8
View File
@@ -1,6 +1,11 @@
apiVersion: v1
kind: ConfigMap
metadata:
# Before anything reads it: the migration Job (wave 0) and both Deployments (wave 1) mount
# these as envFrom, and a pod that starts without them starts with the wrong configuration
# rather than with none — envFrom of a missing ConfigMap is a pod that never becomes ready.
annotations:
argocd.argoproj.io/sync-wave: "-2"
name: jarvis-api-config
namespace: jarvis
data:
@@ -65,6 +70,9 @@ data:
apiVersion: v1
kind: ConfigMap
metadata:
# With the ConfigMap above, for the same reason.
annotations:
argocd.argoproj.io/sync-wave: "-2"
name: jarvis-web-config
namespace: jarvis
data:
+22 -2
View File
@@ -10,8 +10,17 @@
# observable status, a bounded number of retries, and a failure that stops the rollout instead of
# letting it proceed against a schema that was never applied.
#
# With Helm this becomes a `pre-install,pre-upgrade` hook; with Argo, a sync-wave. Applied by hand,
# the ordering is yours to keep — see the README.
# UNDER ARGO CD IT IS A SYNC HOOK, and that is not decoration — it is what makes this directory
# reconcilable at all. A Job's `spec.template` is immutable, so an ordinary Job whose image digest
# changed is a patch the API server REFUSES: the sync fails on "field is immutable" and the release
# stops, every time, from the second release onwards. `BeforeHookCreation` deletes the previous Job
# before creating this one, which is the supported way of saying "run it again, with new contents".
#
# It costs one thing worth knowing: the previous run's Job object, and its logs, are gone by the
# time the next one starts. Ship the logs elsewhere if you need them after the fact.
#
# With Helm this is a `pre-install,pre-upgrade` hook instead. Applied by hand, the ordering is yours
# to keep — see the README.
#
# It is safe to run twice: every pass is idempotent and the whole phase is wrapped in a Postgres
# advisory lock, so a second copy waits rather than racing.
@@ -20,6 +29,17 @@ kind: Job
metadata:
name: jarvis-migrate
namespace: jarvis
annotations:
# The SYNC phase, not PreSync. A PreSync hook runs in a phase of its own, before any ordinary
# resource is applied — which on a first sync means before the namespace, the ConfigMap and the
# Secret this Job reads exist. In the Sync phase it is ordered by the same waves as everything
# else, so "after the config, before the api" is one number rather than a second mechanism.
argocd.argoproj.io/hook: Sync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
# After the config and the minting (waves -2 and -1), before any api pod (wave 1). Argo does not
# begin the next wave until this Job reports complete, which is the ordering the README's
# `kubectl wait` keeps by hand.
argocd.argoproj.io/sync-wave: "0"
spec:
# One writer. The lock makes a second one wait rather than corrupt, but there is no reason to
# have one waiting.
+8
View File
@@ -1,6 +1,11 @@
apiVersion: apps/v1
kind: Deployment
metadata:
# After the schema. This is the wave the whole ordering exists for: an api pod that starts
# against a schema the migration has not applied is the failure every other number here is
# arranged to prevent.
annotations:
argocd.argoproj.io/sync-wave: "1"
name: jarvis-api
namespace: jarvis
spec:
@@ -176,6 +181,9 @@ spec:
apiVersion: v1
kind: Service
metadata:
# With the Deployment it fronts.
annotations:
argocd.argoproj.io/sync-wave: "1"
name: jarvis-api
namespace: jarvis
spec:
+16
View File
@@ -1,6 +1,16 @@
apiVersion: apps/v1
kind: Deployment
metadata:
# Beside the api, in the same wave, and NOT because it is independent of it — measured on a real
# cluster, this pod's `/readyz` is nginx proxying the api's `/api/health/live`, so it answers 502
# and stays unready for as long as there is no api behind it.
#
# The same wave is still right. Argo waits for a wave as a whole, so the api coming up is what
# makes this ready and there is nothing for a later wave to add; putting it earlier would only
# mean it sat unready for longer. What it does mean is that a stuck api presents as TWO unhealthy
# Deployments rather than one, and the api is the one to look at.
annotations:
argocd.argoproj.io/sync-wave: "1"
name: jarvis-web
namespace: jarvis
spec:
@@ -112,6 +122,9 @@ spec:
apiVersion: v1
kind: Service
metadata:
# With the Deployment it belongs to.
annotations:
argocd.argoproj.io/sync-wave: "1"
name: jarvis-web
namespace: jarvis
spec:
@@ -134,6 +147,9 @@ spec:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
# With the Deployment it belongs to.
annotations:
argocd.argoproj.io/sync-wave: "1"
name: jarvis-web
namespace: jarvis
spec:
+4
View File
@@ -15,6 +15,10 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
# Last, so that the first traffic admitted from outside meets Services with endpoints behind
# them. An Ingress applied early is a route to a 503.
annotations:
argocd.argoproj.io/sync-wave: "2"
name: jarvis
namespace: jarvis
annotations:
+8
View File
@@ -6,6 +6,11 @@
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
# With the Ingress, after the Deployments they protect. A PodDisruptionBudget naming a
# selector that matches nothing is not an error and not a warning — it is simply not
# protecting anything, which is the one state it must never be in quietly.
annotations:
argocd.argoproj.io/sync-wave: "2"
name: jarvis-api
namespace: jarvis
spec:
@@ -18,6 +23,9 @@ spec:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
# Same wave, same reason.
annotations:
argocd.argoproj.io/sync-wave: "2"
name: jarvis-web
namespace: jarvis
spec:
+26
View File
@@ -23,6 +23,11 @@
apiVersion: v1
kind: ConfigMap
metadata:
# Beside the config, well before the api. The api reaches Redis on its readiness probe, so a
# keyspace that is not there yet is a Deployment that rolls and then reports unready for as
# long as it takes — which Argo would read as a failed sync rather than as a wait.
annotations:
argocd.argoproj.io/sync-wave: "-2"
name: redis-scripts
namespace: jarvis
data:
@@ -79,6 +84,9 @@ data:
apiVersion: v1
kind: ConfigMap
metadata:
# With the rest of Redis, in the wave before anything reads it.
annotations:
argocd.argoproj.io/sync-wave: "-2"
name: redis-config
namespace: jarvis
data:
@@ -112,6 +120,9 @@ data:
apiVersion: v1
kind: Service
metadata:
# With the rest of Redis, in the wave before anything reads it.
annotations:
argocd.argoproj.io/sync-wave: "-2"
name: redis
namespace: jarvis
spec:
@@ -129,6 +140,9 @@ spec:
apiVersion: v1
kind: Service
metadata:
# With the rest of Redis, in the wave before anything reads it.
annotations:
argocd.argoproj.io/sync-wave: "-2"
name: redis-sentinel
namespace: jarvis
spec:
@@ -144,6 +158,9 @@ spec:
apiVersion: apps/v1
kind: StatefulSet
metadata:
# With the rest of Redis, in the wave before anything reads it.
annotations:
argocd.argoproj.io/sync-wave: "-2"
name: redis
namespace: jarvis
spec:
@@ -232,6 +249,9 @@ spec:
apiVersion: apps/v1
kind: StatefulSet
metadata:
# With the rest of Redis, in the wave before anything reads it.
annotations:
argocd.argoproj.io/sync-wave: "-2"
name: redis-sentinel
namespace: jarvis
spec:
@@ -321,6 +341,9 @@ spec:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
# With the rest of Redis, in the wave before anything reads it.
annotations:
argocd.argoproj.io/sync-wave: "-2"
name: redis-sentinel
namespace: jarvis
spec:
@@ -332,6 +355,9 @@ spec:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
# With the rest of Redis, in the wave before anything reads it.
annotations:
argocd.argoproj.io/sync-wave: "-2"
name: redis
namespace: jarvis
spec:
+84
View File
@@ -97,6 +97,90 @@ about the install screen applies unchanged — including that **whoever reaches
instance first owns it**, with no deadline. Point a public hostname at this after you have
installed, not before.
## GitOps: Argo CD, Flux, or anything that reconciles
`kustomization.yaml` is the entry point. Point a controller at this directory and it renders every
manifest here except one — and `argocd-application.example.yaml` is a worked Application to copy.
```sh
kubectl apply -k . # the same thing, by hand
```
**The exception is `01-secret.example.yaml`, and it is left out on purpose.** It is a template full
of placeholders. A controller applies what it is pointed at, continuously, without asking — so a
Secret full of `REPLACE_ME` inside the source of truth is a controller that overwrites your real
credentials with placeholders on its next reconcile and puts them back every time you fix them. The
api cannot reach its database, the vault key is wrong, and nothing about the sync looks unhealthy
while it happens. Write `jarvis-secrets` by hand, or through SealedSecrets, ExternalSecrets or
Vault, whose ciphertext is safe to commit. `jarvis-generated` is minted in-cluster and never exists
in git at all.
### The order is in the manifests, not in the filenames
A controller reads this directory as a set; `00-`, `01-` and `02-` mean nothing to it. The order
that the `kubectl apply` sequence above keeps by hand is carried by
`argocd.argoproj.io/sync-wave` annotations on the resources themselves, so that it lives beside the
thing being ordered:
| wave | what | why it cannot be later |
| --- | --- | --- |
| `-3` | Namespace | it carries the Pod Security labels that decide whether any of these pods are admitted |
| `-2` | ConfigMaps, the minter's RBAC, Redis | everything below reads the config; the api's readiness probe reaches Redis |
| `-1` | `jarvis-mint-secrets` **(hook)** | the migration reads `DATABASE_URL` out of what it writes |
| `0` | `jarvis-migrate` **(hook)** | an api pod against an unmigrated schema is the failure all of this prevents |
| `1` | api and web Deployments, Services | the web pod's `/readyz` proxies the api's health endpoint, so it cannot be ready before the api is |
| `2` | Ingress, PodDisruptionBudgets | an Ingress applied early is a route to a 503 |
Argo does not begin a wave until the previous one reports healthy, which is what the `kubectl wait`
lines are doing by hand.
### Why the two Jobs are hooks
Without this, the directory works exactly once. **A Job's `spec.template` is immutable**, so the
second release — the first one with a different image digest — is a patch the API server refuses
with `field is immutable`, and the sync fails there and stays failed. `hook: Sync` with
`hook-delete-policy: BeforeHookCreation` deletes the previous Job before creating the new one, which
is the supported way to say "run it again, with new contents".
The Sync phase rather than PreSync, deliberately: a PreSync hook runs before any ordinary resource
is applied, which on a first sync is before the namespace, the ConfigMap and the Secret these Jobs
read exist. In the Sync phase they are ordered by the same waves as everything else.
Two consequences worth knowing:
- **The previous run's Job object, and its logs, are gone** by the time the next one starts. Ship
the logs if you need them after the fact.
- **Every sync runs the migration.** That is safe — the phase is idempotent and holds a Postgres
advisory lock — and it is why the example Application leaves `automated` sync OFF. A commit that
applies a schema change with nobody watching turns the one thing this directory works hardest to
make visible, a migration that REFUSES because it would drop a column, into a notification.
### Pin the images in one place
`kustomization.yaml` carries an `images:` block, so the three digests are set once rather than in
three files. Three, not two: `jarvis-agent-dist` is an initContainer in `04-api.yaml` and is easy to
miss, and missing it renders cleanly while the api pod never starts.
A version bump is then those lines and nothing else.
### What most estates should actually do
Point the Application at **your** repository, not at this one, with this directory as a remote base:
```yaml
# your-repo/jarvis/kustomization.yaml
resources:
- https://git.luxit.be/Luxit/jarvis-selfhost//kubernetes?ref=v1.2.3
images:
- name: git.luxit.be/luxit/jarvis-api
digest: sha256:...
patches:
- path: replicas.yaml # your node count, your ingress host, your storage class
```
Then the digests you run are in a commit you control, upgrading is a diff you can review and revert,
and `?ref=` pins the base to a tag rather than a branch that moves under you. Pin it.
## The three things most likely to bite
**Never mint secrets per pod.** On Compose a one-shot generates the vault key and the JWT secrets
+108
View File
@@ -0,0 +1,108 @@
# An Argo CD Application pointing at this directory. EXAMPLE — read it, do not apply it unedited.
#
# It is `.example.` for the same reason `01-secret.example.yaml` is: it names a repository that is
# not yours. Copy it into whatever repository holds your Argo Applications, change the three values
# marked below, and apply it there.
#
# ─── WHAT TO POINT IT AT ──────────────────────────────────────────────────────────────────────
#
# You have two honest choices, and the second is the one to grow into.
#
# DIRECTLY AT THIS DIRECTORY, as written below. Simplest, and it works — but the image digests live
# in `kustomization.yaml` here, so pinning a version means committing to a repository you do not
# own. Fine for reading, for a first cluster, and for tracking `main` on a lab estate.
#
# OR AS A BASE UNDER YOUR OWN OVERLAY, which is what most estates end up wanting:
#
# # your-repo/jarvis/kustomization.yaml
# apiVersion: kustomize.config.k8s.io/v1beta1
# kind: Kustomization
# resources:
# - https://git.luxit.be/Luxit/jarvis-selfhost//kubernetes?ref=main
# images:
# - name: git.luxit.be/luxit/jarvis-api
# digest: sha256:<the one you are running>
# - name: git.luxit.be/luxit/jarvis-web
# digest: sha256:<the one you are running>
# patches:
# - path: replicas.yaml # your node count, your ingress host, your storage class
#
# Then the Application points at YOUR repository, your digests are in a commit you control, and
# upgrading is a one-line diff you can review and revert. `?ref=` pins the base to a tag or a commit
# rather than tracking a branch that moves under you — pin it.
#
# ─── WHAT IS NOT IN GIT, AND MUST NOT BE ──────────────────────────────────────────────────────
#
# `jarvis-secrets`. See the note at the top of `kustomization.yaml`: a controller that reconciles a
# Secret full of placeholders will overwrite your real credentials with them, repeatedly, and
# nothing about the sync will look unhealthy while it does. Write it by hand or through
# SealedSecrets / ExternalSecrets / Vault, whose ciphertext IS safe to commit.
#
# `jarvis-generated` is minted in-cluster by the hook in `01-secret-job.yaml` and never exists in
# git at all. Back the vault key up out of the cluster the first time it is minted — see that file.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: jarvis
namespace: argocd
# Argo deletes an Application's resources when the Application is deleted. On something holding a
# database's credentials and a vault key, that should be a deliberate act rather than a
# consequence of `kubectl delete app jarvis` — so the finalizer is left OFF and pruning of the
# namespace is not automatic. Add it when you have decided that is what you want:
#
# finalizers:
# - resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
# CHANGE ME — your repository, or the public one if you are only reading.
repoURL: https://git.luxit.be/Luxit/jarvis-selfhost.git
# CHANGE ME — a tag or a commit, not a branch. A branch moves; a cluster that follows one is a
# cluster whose version is decided by somebody else's merge.
targetRevision: main
path: kubernetes
destination:
server: https://kubernetes.default.svc
# Matches the namespace the manifests declare. They carry it explicitly, so this is agreement
# rather than instruction.
namespace: jarvis
syncPolicy:
# DELIBERATELY MANUAL, and this is the one setting to think hardest about before changing.
#
# Every sync runs the migration hook against your production schema. Automated, that means a
# commit to the tracked revision applies a schema change with nobody watching — and the one
# thing this whole directory is arranged to make visible (a migration that REFUSES because it
# would drop a column) becomes a failed sync notification instead of a person deciding.
#
# Turn it on once you trust the pipeline in front of it, not before:
#
# automated:
# prune: true
# selfHeal: true
# NOTHING HERE, and the absence is deliberate in one specific place.
#
# `CreateNamespace=true` is the option most Applications carry, and this one must not. The
# namespace is `00-namespace.yaml` at wave -3, and it carries the Pod Security labels that
# decide whether any of these pods are admitted at all. A namespace Argo creates for you has no
# labels — so the first sync would admit pods under no policy and enforce `restricted` a moment
# later, which is the one ordering this directory exists to get right.
#
# syncOptions: []
# The HorizontalPodAutoscaler in `05-web.yaml` targets the web Deployment, which also declares
# `replicas: 2`. Once the autoscaler acts, git and the cluster disagree about that field forever —
# and it is the autoscaler working, not drift. Without this, a `selfHeal` policy and the HPA push
# the replica count back and forth at each other indefinitely.
#
# The api Deployment is deliberately NOT here: nothing autoscales it, so git owns its replica
# count and a difference there is a real one worth showing.
ignoreDifferences:
- group: apps
kind: Deployment
name: jarvis-web
jsonPointers:
- /spec/replicas
+75
View File
@@ -0,0 +1,75 @@
# The entry point for `kubectl apply -k` and for Argo CD.
#
# It exists so that a GitOps tool has ONE path to point at, and so that the two images are pinned in
# ONE place instead of three. The manifests beside it are unchanged and still apply individually
# with `kubectl apply -f` — this aggregates them, it does not replace them.
#
# ─── WHAT IS DELIBERATELY NOT HERE ────────────────────────────────────────────────────────────
#
# `01-secret.example.yaml`, and its absence is the most important line in this file.
#
# It is a TEMPLATE carrying placeholder values. A GitOps controller applies what it is pointed at,
# continuously, without asking — so a Secret full of `REPLACE_ME` inside the source of truth is a
# controller that will overwrite your real credentials with placeholders on its next reconcile, and
# then keep putting them back every time you fix them. The api would fail to reach its database, the
# vault key would be wrong, and nothing in the sync would look unhealthy.
#
# Write `jarvis-secrets` into the cluster by another route entirely: by hand, or through
# SealedSecrets, ExternalSecrets or Vault — anything whose ciphertext is safe to commit. The
# generated half (`jarvis-generated`) is minted in-cluster by the hook in `01-secret-job.yaml` and
# is likewise never in git.
#
# ─── ORDER ────────────────────────────────────────────────────────────────────────────────────
#
# Filenames do not order anything: `kubectl apply -k` and Argo both read this list as a set. The
# order is carried by `argocd.argoproj.io/sync-wave` annotations on the resources themselves, so
# that it lives beside the thing being ordered rather than in a list somebody has to keep in step.
# See the table in README.md.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- 00-namespace.yaml
# The identity that mints the generated secrets, and the Job that uses it. The Job is a sync hook;
# its ServiceAccount, Role and RoleBinding are ordinary resources in an earlier wave.
- 01-secret-job.yaml
- 02-config.yaml
- 03-migrate-job.yaml
- 04-api.yaml
- 05-web.yaml
- 06-ingress.yaml
- 07-disruption.yaml
# Remove this line if you point REDIS_URL at a managed Redis. It deploys three Redis processes and
# three sentinels; see the README on what that costs on three nodes.
- 08-redis-sentinel.yaml
# ─── THE THREE IMAGES ───────────────────────────────────────────────────────────────────────────
#
# By DIGEST, not by tag, and the manifests carry `@sha256:REPLACE_ME` so that an unedited copy fails
# loudly rather than quietly pulling something.
#
# A tag is a name that can be repointed; a digest is the bytes. It matters more here than in most
# places because the migration Job and the api Deployment must be the SAME BUILD — a schema applied
# by one version and served by another is the failure this whole ordering exists to prevent, and two
# copies of a mutable tag pulled a minute apart can be two different images.
#
# `dev` and `stable` are channels, so they are exactly the kind of name not to pin to. Resolve one
# to its digest and put that here:
#
# docker buildx imagetools inspect git.luxit.be/luxit/jarvis-api:stable --format '{{.Manifest.Digest}}'
#
# This is also the whole of a version bump: change these lines, commit, and let the controller do
# the rest.
#
# THREE, not two. `jarvis-agent-dist` is easy to miss — it is an initContainer in 04-api.yaml rather
# than a container of its own — and missing it is the worst possible half-fix: two images pinned
# from here and one left carrying `REPLACE_ME` in the file, so the render succeeds and the api pod
# never starts. It is also the one that may legitimately be absent: leaving that initContainer out
# is supported, and the installer then answers 503 saying no agent build is published.
images:
- name: git.luxit.be/luxit/jarvis-api
digest: sha256:REPLACE_ME
- name: git.luxit.be/luxit/jarvis-web
digest: sha256:REPLACE_ME
- name: git.luxit.be/luxit/jarvis-agent-dist
digest: sha256:REPLACE_ME