# Mint the generated secrets ONCE, from inside the cluster. # # ─── WHAT THIS REPLACES ─────────────────────────────────────────────────────────────────────── # # On Compose, `jarvis init-secrets` runs as a one-shot writing into a volume every container shares, # and its guarantee that a key is generated exactly once is creating a file that must not exist yet # (`O_EXCL`) — two writers race, the kernel picks one. There is no shared filesystem across pods, so the exclusion moves to # the one thing every pod does share: the API server. Creating a Secret that already exists is a # 409, decided by one serialized writer. Same guarantee, same strength. # # This is the alternative to minting by hand, NOT a replacement for understanding what it mints. If # your estate already has a secrets manager — SealedSecrets, ExternalSecrets, Vault — use it and # skip this file; write `jarvis-generated` yourself with the four keys listed in the Role below. # # ─── THE ONE WAY TO GET HURT ────────────────────────────────────────────────────────────────── # # Deleting `jarvis-generated` and re-running this mints a NEW vault key against a database whose # credentials are sealed under the old one. Nothing fails at boot. Every stored credential becomes # permanently unreadable, and you find out the first time somebody opens one. There is no undo, and # the key is not in the database backup by design. # # kubectl apply -f 01-secret.example.yaml # the half you write: DATABASE_URL, OPENAI_API_KEY… # kubectl apply -f 01-secret-job.yaml # the half that is random bytes # kubectl wait --for=condition=complete job/jarvis-mint-secrets -n jarvis --timeout=2m # # Then back the vault key up, out of this cluster: # kubectl get secret jarvis-generated -n jarvis -o jsonpath='{.data.VAULT_MASTER_KEY}' | base64 -d # # ────────────────────────────────────────────────────────────────────────────────────────────── apiVersion: v1 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`. # # Deliberate, and worth the paragraph: this identity cannot read any Secret in the namespace, # including the one it just wrote. That is why the command does not look before it writes — the # 409 is the check, and it is a stronger one than a read-then-write could be, because a read and # a write are two operations and something else can happen between them. # # `create` cannot be narrowed by resourceName — Kubernetes evaluates authorization before the # object's name is known — so this permits creating any Secret in this namespace. That is the # floor, and it is why the ServiceAccount belongs to this Job and to nothing else. - apiGroups: [""] resources: ["secrets"] verbs: ["create"] --- apiVersion: rbac.authorization.k8s.io/v1 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 name: jarvis-secret-minter subjects: - kind: ServiceAccount name: jarvis-secret-minter namespace: jarvis --- apiVersion: batch/v1 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. backoffLimit: 2 # The Job object is tidied up an hour later. The Secret it created is a separate object and is # not touched by this. ttlSecondsAfterFinished: 3600 template: metadata: labels: app.kubernetes.io/name: jarvis-mint-secrets spec: restartPolicy: Never serviceAccountName: jarvis-secret-minter securityContext: runAsNonRoot: true runAsUser: 10001 # Required by the `restricted` Pod Security Standard that 00-namespace.yaml enforces. # Omitted, the Job is created and its pod is refused — which reads as a Job that never runs. seccompProfile: type: RuntimeDefault containers: - name: mint # The api image, because the command ships in it and a first deployment should pull one # image rather than three. By digest like every other image here — the same one you put # in 03-migrate-job.yaml and 04-api.yaml. image: git.luxit.be/luxit/jarvis-api@sha256:REPLACE_ME # Exec form: `jarvis` is a bash script that execs the API, and a `/bin/sh -c` (dash) in between # would drop the image's dotted logging variables. It verifies the API server's certificate # against the service account's own ca.crt, and trusts nothing else for it — so no variable # names the CA (Node's NODE_EXTRA_CA_CERTS went with the TypeScript image). A bearer token good # enough to write Secrets is a bearer token worth stealing: never turn that check off. command: ["jarvis", "mint-k8s-secret"] resources: requests: cpu: 50m memory: 64Mi limits: memory: 256Mi securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: ["ALL"]