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: replicas: 2 selector: matchLabels: app.kubernetes.io/name: jarvis app.kubernetes.io/component: api strategy: type: RollingUpdate rollingUpdate: # Never fewer pods than are being served now. A run holds its conversation through a unique # index, so a surge pod cannot double-answer anything — the old constraint that made an # overlap dangerous is gone. maxUnavailable: 0 maxSurge: 1 template: metadata: labels: app.kubernetes.io/name: jarvis app.kubernetes.io/component: api spec: # 90 seconds, and every part of it is accounted for: # 10s preStop, so this pod leaves the Service's endpoints BEFORE it starts draining # 25s RUN_SHUTDOWN_GRACE_SEC — in-flight assistant runs stop and close their transcripts # ~15s agent sockets closed and their rows written, httpServer.close(), Prisma, Redis # the rest is margin # The default of 30 would SIGKILL the pod in the middle of the run drain, which is the one # thing the drain exists to prevent. terminationGracePeriodSeconds: 90 # Required by the `restricted` Pod Security Standard that 00-namespace.yaml enforces. At pod # level rather than per container, because the agent-releases initContainer below needs it # too — and a missing seccompProfile is not a warning, it is the pod being refused outright. securityContext: seccompProfile: type: RuntimeDefault # SPREAD THE REPLICAS, because nothing else will. # # The scheduler has no reason of its own to put two pods of the same Deployment on different # nodes, and quite often has a reason not to. Two API replicas on one node is a deployment # that reads as highly available in `kubectl get pods` and loses every agent websocket, every # live run and every open shell the moment that one node reboots. The PodDisruptionBudget in # 07-disruption.yaml does not help here: it constrains voluntary evictions, and a node dying # is not one. # # `ScheduleAnyway` rather than `DoNotSchedule`: on a single-node cluster the strict form # leaves the second replica Pending forever, which is a worse first experience than an # unspread pair. On a real cluster it spreads. If yours spans zones, add a second constraint # on `topology.kubernetes.io/zone` — a rack is a failure domain too. topologySpreadConstraints: - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: ScheduleAnyway labelSelector: matchLabels: app.kubernetes.io/name: jarvis app.kubernetes.io/component: api containers: - name: api # BY DIGEST. `:stable` is a moving name, so replicas that restart at different times land # on different builds — and a rollback becomes "hope the tag still points where it did". image: git.luxit.be/luxit/jarvis-api@sha256:REPLACE_ME ports: - name: http containerPort: 4000 lifecycle: preStop: exec: # Endpoint removal and SIGTERM race each other, and losing that race is visible: # a prompt that arrives after Nest sets `draining` is persisted and broadcast as # queued, and this pod will never answer it. Ten seconds is comfortably more than # kube-proxy needs to stop sending new connections here. command: ["/bin/sh", "-c", "sleep 10"] env: - name: JARVIS_SKIP_MIGRATIONS # The schema is the migration Job's business, once, before this rolls. N pods each # running `prisma db push` is N writers racing the same DDL while the previous # generation is still selecting the columns being altered. value: "true" envFrom: - configMapRef: name: jarvis-api-config # The half that is random bytes, minted once by `01-secret-job.yaml`. Kept apart from # the half an operator writes because one of them can be regenerated and the other # cannot: re-running the minter is a no-op by design, which is only safe while nothing # anybody typed lives in the same object. # # `optional: true` so a deployment that mints by hand — or through SealedSecrets, # ExternalSecrets, Vault — does not need this object to exist at all. - secretRef: name: jarvis-generated optional: true # LAST, and the order is the safety property rather than a formatting choice. `envFrom` # resolves in sequence and the last occurrence of a name wins, so anything written by # hand here overrides the generated value — never the other way round. Reversed, an # operator who had already minted a vault key and then applied the Job by reflex would # have every stored credential silently become unreadable. - secretRef: name: jarvis-secrets volumeMounts: - name: agent-releases mountPath: /srv/agent-releases readOnly: true # Liveness NEVER touches the database. A Postgres failover with a database check here # restarts every pod at once, turning a thirty-second blip into a cold start of the whole # deployment at the moment the database can least afford a reconnection stampede. livenessProbe: httpGet: path: /api/health/live port: http periodSeconds: 10 failureThreshold: 3 # Readiness DOES, and it checks Redis too. A pod cut off from Redis keeps answering HTTP # perfectly while its broadcasts reach nobody — the clients queue rather than fail — so it # looks healthier than a pod that is down and is more dangerous. readinessProbe: httpGet: path: /api/health/ready port: http periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 3 # Boot does a run-recovery sweep and reads settings before it listens. The startup probe # is what stops liveness killing it halfway through on a slow database. startupProbe: httpGet: path: /api/health/live port: http periodSeconds: 5 failureThreshold: 30 resources: requests: cpu: 500m # Not generous — honest. A terminal recording holds up to 8 MiB of Buffers in heap # for the life of the session, and argon2 takes 64 MiB per share verification. # # Attached and uploaded files add four holders, every one capped, so that their sum is # a number rather than a hope. Their figures are MEASURED on this image, in a cgroup # like this pod's, not estimated — the estimates were five to seven times too low. # # Uploads being checked and stored: 3 at a time (`files/upload-slots.ts`). Prisma is # what costs: its JSON protocol turns the bytes into base64, then into a request # string, and its engine copies that again. A 10 MiB file adds ~200 MiB of RSS while # it is written; a workspace image with the 3 MiB copy of it prepared for the model, # ~310 MiB, and ~330 to 360 MiB each with three at once, three bursts in a row. So # ~1.1 GiB. Six at a time, as this once allowed, took a 2 GiB container to 1.8-1.95 # GiB with nothing else in it, and three such bursts were OOM-killed. # # Files read back — their bytes over HTTP, a window of their text for the viewer or # for the assistant's `read_attachment`: 8 at a time, up to ~40 MiB each (a 10 MiB log # is decoded whole, 25 to 39 MiB measured, until its window is cut). So 320 MiB. # # Images for the model: 64 MiB of `data:` URLs, reserved before a byte is read, so the # same 64 MiB also bounds what is being read for them at any instant — at most ~48 MiB # raw, which Prisma's transfer copies a few times more for the milliseconds a request # takes to build. So ~200 MiB at the worst instant. # # A PDF worker is the one without a flat cap. `resourceLimits` bounds its V8 heap # (192 MB, plus 32 MB of young generation) and NOT its ArrayBuffers, where a # decompressed stream lives. Those are read from outside every 50 ms and the worker is # killed past 128 MB, which a buffer doubling between two reads can overshoot to ~256 # MB. So one hostile file can hold up to ~500 MB before it dies, two ~1 GB. Measured: # two decompression bombs at once added ~310 MB to the process, both refused in under # two seconds. # # Every holder full at once is therefore ~2.5 GiB (1.1 GiB of uploads, 320 MiB of # files read back, ~200 MiB of images, ~1 GB of PDF workers), on top of the API itself # (~200 MiB once booted): ~2.75 GiB. That cannot fit 2Gi at any upload concurrency # worth having — at 2Gi, one upload at a time beside two hostile PDFs is all there is # room for — so the limit is 3Gi, with ~250 MiB left for the terminal recordings and # share verifications above. It runs well past the room above this request, and that # is deliberate: the request is what the scheduler sets aside for an ordinary day, not # for the worst minute. If more margin is ever wanted, the levers are the PDF worker's # own caps (`files/pdf-text.ts`) and keeping an upload's bytes out of Prisma's JSON # protocol — not fewer upload slots. memory: 1Gi limits: memory: 3Gi securityContext: runAsNonRoot: true runAsUser: 1000 allowPrivilegeEscalation: false capabilities: drop: ["ALL"] initContainers: # The compiled agent binaries, into this pod's own emptyDir. # # PER POD ON PURPOSE. The content is immutable and small, so a copy per pod costs nothing; # a shared RWO PVC would either pin every pod to one node or leave the second in # Multi-Attach error. Leaving this out entirely is supported: enrollment still works and the # installer answers 503 saying no build is published. - name: agent-releases image: git.luxit.be/luxit/jarvis-agent-dist@sha256:REPLACE_ME command: ["/bin/sh", "-c", "cp -a /dist/. /srv/agent-releases/"] # AN initContainer IS A CONTAINER, and the `restricted` Pod Security Standard judges it # exactly like the one below. This block was missing, and the result was not a warning # about the initContainer — it was the WHOLE POD refused, so the Deployment sat at zero # replicas reporting FailedCreate while every other line in this file was correct. securityContext: runAsNonRoot: true runAsUser: 1000 allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: ["ALL"] volumeMounts: - name: agent-releases mountPath: /srv/agent-releases volumes: - name: agent-releases emptyDir: {} --- apiVersion: v1 kind: Service metadata: # With the Deployment it fronts. annotations: argocd.argoproj.io/sync-wave: "1" name: jarvis-api namespace: jarvis spec: type: ClusterIP # STATED, not left to the default, because the reflex is to reach for it the moment websockets # are involved. It would not help: the hard case is co-locating an operator's browser with an # agent's websocket, which arrives separately and whose id is unknown at the browser's handshake. # That is handled in the application, over Redis. See the README. sessionAffinity: None selector: app.kubernetes.io/name: jarvis app.kubernetes.io/component: api ports: - name: http port: 4000 targetPort: http