Files
bikoret/deploy/k8s/deployment.yaml
T
Scott KarpandClaude Opus 5 6e34001c58 deployment: split the migration role from the application role (kohen Day-3 42501)
TORAH ANCHOR — Vayikra 18:5-6 / the gilui arayot shape:
The kohen refused the easy fix on the grounds that dissolving a
required distinction for convenience is the boundary violation this
system's own halt rule names. That reading is correct and this commit
implements it rather than working around it.

THE BLOCKER (kohen Day 3, escalated):

  ** (Postgrex.Error) ERROR 42501 (insufficient_privilege)
     permission denied for schema public
     Could not create schema migrations table.

Ecto.Migrator.run/3 calls ensure_schema_migrations_table! BEFORE it
looks at what is pending (see vav_pipeline/lib/vav_pipeline/
release.ex — migrate/0 wraps Ecto.Migrator.run(&1, :up, all: true)).
So the migrate init container issues

  CREATE TABLE IF NOT EXISTS "schema_migrations"

on EVERY pod start, even now that 20260428000001_create_azazel_records
is applied and nothing is pending. As shorashim_app — which has USAGE
but not CREATE on schema public, correctly — that is a permanent
failure and the pod never reaches Running.

ROOT CAUSE: the manifest did not implement what the runbook already
specified. KOHEN.md:184 says "the migration runs as avnadmin; the
application runs as shorashim_app", and then deployment.yaml gave the
init container and the app containers the same shorashim-secrets.
The two-role split existed as prose and not as configuration.

THE FIX:

  deploy/k8s/deployment.yaml — migrate init container only:
    + env:
    +   - name: DATABASE_URL
    +     valueFrom:
    +       secretKeyRef:
    +         name: shorashim-migrate-secrets
    +         key: DATABASE_URL

An explicit `env:` entry rather than swapping the secretRef, as the
kohen proposed. Same effect today, and strictly safer tomorrow:
`env:` takes precedence over every `envFrom` source, so exactly ONE
variable changes role and anything else that later lands in
shorashim-secrets is still inherited by the migrator. Swapping the
ref would have silently dropped such a key from the init container.

App containers keep shorashim-secrets untouched. Append-only holds:
the L3 tables stay avnadmin-owned with the REVOKEs in place, and
shorashim_app still has only INSERT/SELECT.

WHY NOT GRANT shorashim_app CREATE ON SCHEMA public — recorded, not
just decided. The kohen considered it, noted append-only would
technically survive, and rejected it because it hands the application
DDL power over the schema it writes into and collapses the app/
migration boundary KOHEN.md draws on purpose. Concurred, and the
reasoning is now written into deployment.yaml and secrets.yaml at the
two places someone would next hit 42501 — so the shortcut cannot be
quietly re-opened by whoever meets the error without this thread.

ALSO IN THIS COMMIT:

deploy/k8s/secrets.yaml — shorashim-migrate-secrets added as a
TEMPLATE (placeholder value, no credential), with the create command.
The command differs from the one in the escalation in two ways, both
deliberate:

  1. The userinfo rewrite is done in Python, not with
     `sed "s|//shorashim_app:[^@]*@|//avnadmin:${AVN_PW}@|"`. A
     generated password containing `&` re-inserts the matched text,
     one containing `|` ends the sed expression early, and `\` escapes.
     The failure is silent — the result is a DATABASE_URL that looks
     plausible and does not authenticate, which on Day 3 would read as
     a second, different blocker. urllib.parse.quote(safe="") also
     percent-encodes anything not URL-safe in the userinfo field,
     which sed passes through raw.
  2. The value goes through a `umask 077` temp file rather than
     --from-literal, so the avnadmin password is never an argv entry
     visible to `ps` on a shared host.

KOHEN.md — new step 3a-pre (create/verify the secret before applying),
and §Day-3b now says configmap.yaml is not optional on this pass
(it carries SHORESH_ENGINE_BINARY) and that secrets.yaml must NOT be
applied — it is a template and would overwrite both live secrets with
placeholders.

DELIBERATELY NO FALLBACK: if shorashim-migrate-secrets is absent the
pod stops at CreateContainerConfigError. Loud is correct here. A
fallback to the application role would reintroduce 42501 at a moment
when everything else looked healthy.

NOT DONE BY THE BUILDER: the secret itself. It carries a live DDL
credential and it is cluster state; both are the kohen's hands, not
mine. The command is in deploy/k8s/secrets.yaml.

VERIFIED:
- kubectl apply --dry-run=client on deployment.yaml, configmap.yaml
  and secrets.yaml — all four objects valid (client-side only; no
  cluster state touched)
- env/envFrom wiring re-read from the parsed manifest: migrate has
  envFrom [shorashim-config, shorashim-secrets] + env DATABASE_URL
  from shorashim-migrate-secrets; vav-pipeline has envFrom
  [shorashim-config, shorashim-secrets] and no env override
- Doorpost.verify_identity() :ok — no @spec_files entry touched, so
  no @expected_hash change
- l1/integrity.sha256 unchanged: 99bbd07a…4ea377

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 13:58:52 -04:00

277 lines
12 KiB
YAML

# Shorashim Deployment — the mishkan
#
# Shemot 40:33 — Vay'khal Mosheh et ham'lakhah.
#
# Architecture §5.1: All inter-component communication uses
# JSON over Unix domain sockets or Erlang distribution protocol.
# No HTTP between internal components.
#
# L1 Torah is baked into each container image (~29MB).
# The scroll is sealed in the aron. It ships with the vessel.
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: shorashim
namespace: shorashim
labels:
app.kubernetes.io/name: shorashim
app.kubernetes.io/version: "0.1.0"
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: shorashim
template:
metadata:
labels:
app.kubernetes.io/name: shorashim
spec:
# --- VOLUMES ---
#
# ONE shared /tmp volume, not two. There used to be a separate
# `talmudic-socket` volume mounted at /tmp/shorashim in
# vav-pipeline and talmudic-engine, WHILE `tmp-shorashim` was
# mounted at /tmp in vav-pipeline and korban-processor. Nested
# mounts meant /tmp/shorashim resolved to a DIFFERENT volume than
# /tmp/shorashim reached through /tmp — so:
# - korban/azazel (Python) wrote AzazelRecord JSON to
# tmp-shorashim:/shorashim/azazel/pending_l3
# - AzazelIngest (Elixir) watched
# talmudic-socket:/azazel/pending_l3
# Two directories with the same pathname on two different disks.
# The disposal channel would have run, written its records, and
# nothing would ever have reached L3 — silently, because
# AzazelIngest leaves unreadable files in place rather than
# erroring (see azazel_ingest.ex, RETRY SEMANTICS).
#
# Vayikra 16:22 — v'nasa hasa'ir alav et kol avonotam el eretz
# gezerah. The goat has to actually GET there. A disposal channel
# whose records land on a disk no one reads has not disposed of
# anything; it has only stopped looking.
volumes:
- name: manna
emptyDir: {}
# Shared /tmp for every container that speaks over it:
# the Talmudic Unix socket (/tmp/shorashim/talmudic.sock) and
# the azazel file bridge (/tmp/shorashim/azazel/pending_l3).
- name: tmp-shorashim
emptyDir: {}
# Staging area for the Haskell shoresh-engine binary. Written
# once by the shoresh-engine-install init container, read by
# vav-pipeline, which spawns it as an Erlang Port.
- name: shoresh-bin
emptyDir: {}
# --- POD SECURITY CONTEXT ---
# Every image runs as the non-root user `shorashim` (uid/gid
# 1000). fsGroup makes the kubelet chgrp the emptyDir volumes to
# 1000 and set the setgid bit, so uid 1000 can actually WRITE
# them. Without it the write side of each shared path depends on
# the kubelet's default emptyDir mode, and the failures are
# scattered and quiet:
# - shoresh-engine-install: cp ... Permission denied (loud —
# init fails, pod never starts; this one at least shouts)
# - azazel: the Python side cannot create
# /tmp/shorashim/azazel/pending_l3 and the disposal channel
# has nowhere to put a record
# - talmudic-engine: cannot bind the Unix socket
securityContext:
fsGroup: 1000
# --- IMAGE PULL SECRET ---
imagePullSecrets:
- name: shorashim-registry
# --- INIT CONTAINERS ---
initContainers:
# Stage the Haskell shoresh-engine binary where vav-pipeline can
# spawn it.
#
# WHY THIS IS NOT A SIDECAR ANY MORE:
# VavPipeline.ShoreshEnginePort calls
# Port.open({:spawn_executable, binary}, ...)
# An Erlang Port is a CHILD PROCESS of the BEAM. It is stdin/
# stdout, not a network endpoint, and it cannot cross a
# container boundary. shoresh-engine also has no listening
# mode — `shoresh-engine server` is a JSON-LINE server over
# stdin/stdout ("Erlang Port mode", see src/Main.hs), so as a
# sidecar with no stdin attached it read EOF and exited, and
# the bare CMD printed its usage banner and exited 0. Either
# way: CrashLoopBackOff, and vav-pipeline meanwhile logged
# "binary 'shoresh-engine' not in PATH — running without
# Haskell engine" and served in a degraded state.
#
# Copying the binary out of its own image onto a shared volume
# keeps the four-image build (the kohen's gate 0i still checks
# four) while putting the executable where the code has always
# expected it.
#
# NOTE FOR THE SPECIFICATION: deploy/ARCHITECTURE.md described
# shoresh-engine as a "sidecar via Erlang Port". Those two
# things cannot both be true. This manifest resolves it in
# favour of the Port, because that is what the Elixir code does.
# If the intended design was a socket server like the Talmudic
# Engine, that is a specification decision, not a deployment
# one — see the builder handoff-back.
- name: shoresh-engine-install
image: exo.container-registry.com/the-recognition-protocol/shoresh-engine:latest
command:
- sh
- -c
- |
set -e
cp /home/shorashim/shoresh-engine /opt/shoresh/shoresh-engine
chmod 0755 /opt/shoresh/shoresh-engine
/opt/shoresh/shoresh-engine gematria שלום >/dev/null
echo "shoresh-engine staged and exercised at /opt/shoresh/shoresh-engine"
volumeMounts:
- name: shoresh-bin
mountPath: /opt/shoresh
# Run L3 migrations
#
# TWO ROLES, ONE IMAGE. KOHEN.md:184 — "the migration runs as
# avnadmin; the application runs as shorashim_app." This init
# container was inheriting the APPLICATION's DATABASE_URL and so
# ran DDL as shorashim_app, which does not have CREATE on schema
# public and never should:
# ** (Postgrex.Error) ERROR 42501 (insufficient_privilege)
# permission denied for schema public
# Could not create schema migrations table.
#
# Ecto.Migrator.run/3 calls ensure_schema_migrations_table!
# BEFORE it looks at what is pending, so this fired on every pod
# start even with migrations already current — the pod could
# never reach Running. See vav_pipeline/lib/vav_pipeline/
# release.ex.
#
# WHY NOT JUST GRANT shorashim_app CREATE ON SCHEMA public:
# because that is the boundary, not an obstacle in front of it.
# Append-only would technically survive (the L3 tables are
# avnadmin-owned with REVOKEs), but the application would hold
# DDL power over the schema it writes into, and the app/migration
# distinction KOHEN.md draws on purpose would exist only as a
# convention. Vayikra 18 — a required distinction dissolved for
# convenience is the gilui arayot shape, and the halt rule this
# system runs on names exactly that. The kohen refused it on
# those grounds and was right to; recording the reasoning here so
# the next person to hit 42501 does not quietly re-open it.
#
# DATABASE_URL is overridden as an explicit `env:` entry rather
# than by swapping the secretRef. `env:` takes precedence over
# every `envFrom` source, so exactly ONE variable changes role
# and anything else that lands in shorashim-secrets later is
# still inherited by the migrator.
#
# shorashim-migrate-secrets is NOT in this repo and must not be.
# It carries the DDL-capable credential; see
# deploy/k8s/secrets.yaml for the shape and the create command.
# If it is absent the pod fails at CreateContainerConfigError —
# loudly, which is correct. There is deliberately no fallback to
# the application role.
- name: migrate
image: exo.container-registry.com/the-recognition-protocol/vav-pipeline:latest
command: ["./app/bin/vav_pipeline", "eval", "VavPipeline.Release.migrate()"]
envFrom:
- configMapRef:
name: shorashim-config
- secretRef:
name: shorashim-secrets
env:
# DDL role — avnadmin, not shorashim_app. Overrides the
# DATABASE_URL that shorashim-secrets supplies above.
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: shorashim-migrate-secrets
key: DATABASE_URL
# --- CONTAINERS ---
containers:
# === VAV PIPELINE (main) ===
- name: vav-pipeline
image: exo.container-registry.com/the-recognition-protocol/vav-pipeline:latest
ports:
- containerPort: 4000
name: http
envFrom:
- configMapRef:
name: shorashim-config
- secretRef:
name: shorashim-secrets
volumeMounts:
- name: manna
mountPath: /var/lib/shorashim/manna
# One /tmp. The Talmudic socket and the azazel pending_l3
# directory both live under it.
- name: tmp-shorashim
mountPath: /tmp
- name: shoresh-bin
mountPath: /opt/shoresh
readOnly: true
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "1000m"
livenessProbe:
httpGet:
path: /kodesh/health
port: 4000
initialDelaySeconds: 30
periodSeconds: 60
readinessProbe:
httpGet:
path: /kodesh/health
port: 4000
initialDelaySeconds: 10
periodSeconds: 10
# === SHORESH ENGINE ===
# Not a container. It runs as an Erlang Port inside
# vav-pipeline, from the binary staged by the
# shoresh-engine-install init container above. Its memory and
# CPU are vav-pipeline's; that container's limits were left
# unchanged and should be watched on first load.
# === TALMUDIC ENGINE (sidecar) ===
- name: talmudic-engine
image: exo.container-registry.com/the-recognition-protocol/talmudic-engine:latest
volumeMounts:
# Shares vav-pipeline's /tmp so the Unix socket at
# /tmp/shorashim/talmudic.sock is the same file on both
# sides. Mounted at /tmp (not /tmp/shorashim) so this is
# the same volume korban-processor writes azazel records to.
- name: tmp-shorashim
mountPath: /tmp
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
# === KORBAN PROCESSOR (sidecar) ===
# Korban_processor imports azazel_processor (the disposal
# channel) in the same Python process. The Python side writes
# AzazelRecord JSON files to /tmp/shorashim/azazel/pending_l3/;
# vav-pipeline's AzazelIngest GenServer watches that directory
# and ingests into L3. Both containers must mount the shared
# tmp-shorashim volume for the file bridge to work.
- name: korban-processor
image: exo.container-registry.com/the-recognition-protocol/korban-processor:latest
volumeMounts:
- name: tmp-shorashim
mountPath: /tmp
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"