# Mint the generated secrets ONCE, from inside the cluster. # # ─── WHAT THIS REPLACES ─────────────────────────────────────────────────────────────────────── # # On Compose, `init-secrets.cjs` runs as a one-shot writing into a volume every container shares, # and its guarantee that a key is generated exactly once is `openSync(path, "wx")` — 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 --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: jarvis-secret-minter namespace: jarvis 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 script 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 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 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 script 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 command: ["node", "/app/apps/api/mint-k8s-secret.cjs"] env: # Node verifies the API server's certificate against the cluster CA. Without this the # request would fail — which is the correct failure. Do not reach for # NODE_TLS_REJECT_UNAUTHORIZED: a bearer token good enough to write Secrets is a bearer # token worth stealing, and an unverified TLS session is where that happens. - name: NODE_EXTRA_CA_CERTS value: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt resources: requests: cpu: 50m memory: 64Mi limits: memory: 256Mi securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: ["ALL"]