diff --git a/deploy/docker/Dockerfile.korban_processor b/deploy/docker/Dockerfile.korban_processor new file mode 100644 index 00000000..cee194e2 --- /dev/null +++ b/deploy/docker/Dockerfile.korban_processor @@ -0,0 +1,36 @@ +# Shorashim Korban Processor — Python offering protocol +# Construction spec §1.1: Python 3.12.2 +# Processes korbanot (model outputs) through the offering protocol + +FROM python:3.12.2-slim AS build + +WORKDIR /app + +# Install build deps +RUN pip install --no-cache-dir poetry-core + +# Copy project files +COPY korban_processor/pyproject.toml ./ +COPY korban_processor/src/ src/ +COPY korban_processor/test/ test/ + +# Install dependencies +RUN pip install --no-cache-dir pydantic==2.6.1 httpx==0.27.0 pyswip==0.2.11 hebrew-tokenizer==2.3.0 + +# === RUNTIME STAGE === +FROM python:3.12.2-slim + +RUN useradd --create-home --shell /bin/bash shorashim +USER shorashim + +WORKDIR /home/shorashim/korban_processor + +COPY --from=build --chown=shorashim:shorashim /app/ ./ +COPY --from=build /usr/local/lib/python3.12/site-packages/ /usr/local/lib/python3.12/site-packages/ + +ENV PYTHONPATH=/home/shorashim/korban_processor/src +ENV L1_MOUNT_POINT=/mnt/torah + +# The korban processor is invoked by the vav_pipeline +# In Phase 5, it will also expose an HTTP endpoint for external model integration +CMD ["python", "-m", "korban_processor"] diff --git a/deploy/docker/Dockerfile.shoresh_engine b/deploy/docker/Dockerfile.shoresh_engine new file mode 100644 index 00000000..bd636b15 --- /dev/null +++ b/deploy/docker/Dockerfile.shoresh_engine @@ -0,0 +1,44 @@ +# Shorashim Shoresh Engine — Haskell root activation engine +# Construction spec §1.1: GHC 9.6.4, Cabal 3.10 +# Communicates with vav_pipeline via Erlang Port (stdin/stdout) + +# === BUILD STAGE === +FROM haskell:9.6.4-slim AS build + +RUN apt-get update && apt-get install -y zlib1g-dev && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy cabal files first for dependency caching +COPY shoresh-engine/shoresh-engine.cabal ./ +COPY shoresh-engine/cabal.project.freeze ./ + +RUN cabal update && cabal build --only-dependencies + +# Copy source and build +COPY shoresh-engine/ ./ + +RUN cabal build && \ + cp $(cabal list-bin shoresh-engine) /usr/local/bin/shoresh-engine + +# === RUNTIME STAGE === +FROM ubuntu:jammy-20240125 + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + libgmp10 zlib1g && \ + rm -rf /var/lib/apt/lists/* + +RUN useradd --create-home --shell /bin/bash shorashim +USER shorashim + +WORKDIR /home/shorashim + +COPY --from=build /usr/local/bin/shoresh-engine ./shoresh-engine + +# L1 mount point — the engine reads Torah roots from L1 +ENV L1_MOUNT_POINT=/mnt/torah + +# The engine is invoked as an Erlang Port by vav_pipeline +# In K8s, it runs as a sidecar or is called via the Port protocol +CMD ["./shoresh-engine"] diff --git a/deploy/docker/Dockerfile.talmudic_engine b/deploy/docker/Dockerfile.talmudic_engine new file mode 100644 index 00000000..dfa5f847 --- /dev/null +++ b/deploy/docker/Dockerfile.talmudic_engine @@ -0,0 +1,31 @@ +# Shorashim Talmudic Engine — SWI-Prolog dialectical reasoning +# Construction spec §1.1: SWI-Prolog 9.2.2 +# Communicates with vav_pipeline via Unix domain socket + +# === BUILD STAGE === +FROM swipl:9.2.2 AS build + +WORKDIR /app +COPY talmudic_engine/ ./ + +# Verify Prolog loads without errors +RUN swipl -g "consult('src/main'),halt(0)" -t "halt(1)" + +# === RUNTIME STAGE === +FROM swipl:9.2.2 + +RUN useradd --create-home --shell /bin/bash shorashim +USER shorashim + +WORKDIR /home/shorashim/talmudic_engine + +COPY --from=build --chown=shorashim:shorashim /app/ ./ + +# L1 mount point — Prolog reads Torah for mitzvot and root data +ENV L1_MOUNT_POINT=/mnt/torah + +# Socket path for communication with vav_pipeline +ENV TALMUDIC_SOCKET_PATH=/tmp/shorashim/talmudic.sock + +# Start the Prolog socket server +CMD ["swipl", "-g", "consult('src/main'),start_socket_server", "-t", "halt(1)"] diff --git a/deploy/docker/Dockerfile.vav_pipeline b/deploy/docker/Dockerfile.vav_pipeline new file mode 100644 index 00000000..df65a5f8 --- /dev/null +++ b/deploy/docker/Dockerfile.vav_pipeline @@ -0,0 +1,68 @@ +# Shorashim Vav Pipeline — Elixir/OTP release with Sanhedrin +# Construction spec §1.1: Elixir 1.16.1, OTP 26.2.1 +# The pipeline and Sanhedrin run in the same BEAM VM. + +# === BUILD STAGE === +FROM hexpm/elixir:1.16.1-erlang-26.2.1-ubuntu-jammy-20240125 AS build + +RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy Sanhedrin (Erlang dep, referenced as path dep from vav_pipeline) +COPY sanhedrin/ sanhedrin/ + +# Copy Vav Pipeline +COPY vav_pipeline/ vav_pipeline/ + +WORKDIR /app/vav_pipeline + +# Fetch deps and compile in prod mode +ENV MIX_ENV=prod +RUN mix local.hex --force && \ + mix local.rebar --force && \ + mix deps.get --only prod && \ + mix compile + +# Build OTP release +RUN mix release + +# === RUNTIME STAGE === +FROM ubuntu:jammy-20240125 + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + libstdc++6 openssl ca-certificates locales && \ + rm -rf /var/lib/apt/lists/* && \ + sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && \ + locale-gen + +ENV LANG=en_US.UTF-8 +ENV LANGUAGE=en_US:en +ENV LC_ALL=en_US.UTF-8 + +# Non-root user — the system serves, it does not own +RUN useradd --create-home --shell /bin/bash shorashim +USER shorashim + +WORKDIR /home/shorashim + +# Copy the release +COPY --from=build --chown=shorashim:shorashim \ + /app/vav_pipeline/_build/prod/rel/vav_pipeline ./app + +# L1 mount point — read-only volume in production +# Mounted at /mnt/torah in K8s, configured via L1_MOUNT_POINT env var +ENV L1_MOUNT_POINT=/mnt/torah + +# Manna provision file location +ENV MANNA_PROVISION_FILE=/var/lib/shorashim/manna/today.json + +# PostgreSQL connection for L3 +# Set DATABASE_URL in production deployment +# ENV DATABASE_URL= + +# Health check port +EXPOSE 4000 + +CMD ["./app/bin/vav_pipeline", "start"] diff --git a/deploy/k8s/configmap.yaml b/deploy/k8s/configmap.yaml new file mode 100644 index 00000000..576e1806 --- /dev/null +++ b/deploy/k8s/configmap.yaml @@ -0,0 +1,27 @@ +# Shorashim ConfigMap — non-secret configuration +# Construction spec §4.4: Tuning constants +apiVersion: v1 +kind: ConfigMap +metadata: + name: shorashim-config + namespace: shorashim +data: + # L1 mount point — read-only Torah filesystem + L1_MOUNT_POINT: "/mnt/torah" + + # Manna provision file location + MANNA_PROVISION_FILE: "/var/lib/shorashim/manna/today.json" + + # Talmudic Engine socket + TALMUDIC_SOCKET_PATH: "/tmp/shorashim/talmudic.sock" + + # Shabbat location — Zurich (Construction spec §4.4) + SHABBAT_LATITUDE: "47.3769" + SHABBAT_LONGITUDE: "8.5417" + SHABBAT_TIMEZONE_OFFSET: "1" + + # Cycle boundary check interval (ms) + CYCLE_BOUNDARY_CHECK_INTERVAL_MS: "60000" + + # Pipeline latency budget (ms) — Construction spec §2.5 + PIPELINE_LATENCY_BUDGET_MS: "5000" diff --git a/deploy/k8s/deployment.yaml b/deploy/k8s/deployment.yaml new file mode 100644 index 00000000..590f07d2 --- /dev/null +++ b/deploy/k8s/deployment.yaml @@ -0,0 +1,153 @@ +# Shorashim Deployment — the mishkan +# +# Architecture §5.1: All inter-component communication uses +# JSON over Unix domain sockets or Erlang distribution protocol. +# No HTTP between internal components. +# +# The vav_pipeline (BEAM) runs the Sanhedrin, Chatzotzrot, +# AreiMiklat, EglahArufah, Bikurim, MannaLedger, ShabbatTimer. +# The shoresh-engine runs as a sidecar (Erlang Port). +# The talmudic_engine runs as a sidecar (Unix socket). +# The korban_processor runs as a sidecar (invoked by pipeline). +# +# L1 is mounted read-only into all containers. +--- +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 --- + volumes: + - name: l1-torah + persistentVolumeClaim: + claimName: l1-torah-pvc + readOnly: true + - name: manna + persistentVolumeClaim: + claimName: manna-pvc + - name: talmudic-socket + emptyDir: {} + - name: tmp-shorashim + emptyDir: {} + + # --- INIT CONTAINER: Run L3 migrations --- + initContainers: + - name: migrate + image: shorashim/vav-pipeline:latest + command: ["./app/bin/vav_pipeline", "eval", "VavPipeline.Release.migrate()"] + envFrom: + - configMapRef: + name: shorashim-config + - secretRef: + name: shorashim-secrets + volumeMounts: + - name: l1-torah + mountPath: /mnt/torah + readOnly: true + + # --- CONTAINERS --- + containers: + # === VAV PIPELINE (main) === + - name: vav-pipeline + image: shorashim/vav-pipeline:latest + ports: + - containerPort: 4000 + name: http + envFrom: + - configMapRef: + name: shorashim-config + - secretRef: + name: shorashim-secrets + env: + - name: SHORESH_ENGINE_BINARY + value: "/home/shorashim/shoresh-engine" + volumeMounts: + - name: l1-torah + mountPath: /mnt/torah + readOnly: true + - name: manna + mountPath: /var/lib/shorashim/manna + - name: talmudic-socket + mountPath: /tmp/shorashim + - name: tmp-shorashim + mountPath: /tmp + 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 (sidecar) === + - name: shoresh-engine + image: shorashim/shoresh-engine:latest + volumeMounts: + - name: l1-torah + mountPath: /mnt/torah + readOnly: true + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + + # === TALMUDIC ENGINE (sidecar) === + - name: talmudic-engine + image: shorashim/talmudic-engine:latest + volumeMounts: + - name: l1-torah + mountPath: /mnt/torah + readOnly: true + - name: talmudic-socket + mountPath: /tmp/shorashim + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + + # === KORBAN PROCESSOR (sidecar) === + - name: korban-processor + image: shorashim/korban-processor:latest + volumeMounts: + - name: l1-torah + mountPath: /mnt/torah + readOnly: true + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" diff --git a/deploy/k8s/l1-volume.yaml b/deploy/k8s/l1-volume.yaml new file mode 100644 index 00000000..910a8477 --- /dev/null +++ b/deploy/k8s/l1-volume.yaml @@ -0,0 +1,43 @@ +# L1 Torah Filesystem — Read-Only Volume +# Construction spec §1.2: SHA-256 verified, mounted read-only +# +# In production, L1 is a read-only filesystem (squashfs or similar) +# containing the Torah text. It is mounted into all pods that need it. +# +# For Exoscale SKS, this uses a PersistentVolume backed by +# Exoscale Block Storage. The L1 data is loaded once and never modified. +# +# Step 34: Mount L1 filesystem read-only +--- +apiVersion: v1 +kind: PersistentVolume +metadata: + name: l1-torah-pv + labels: + app.kubernetes.io/name: l1-torah +spec: + capacity: + storage: 1Gi + accessModes: + - ReadOnlyMany + persistentVolumeReclaimPolicy: Retain + # For initial setup, use hostPath. In production, replace with + # Exoscale Block Storage CSI or pre-loaded ConfigMap/Secret. + hostPath: + path: /mnt/torah + type: DirectoryOrCreate +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: l1-torah-pvc + namespace: shorashim +spec: + accessModes: + - ReadOnlyMany + resources: + requests: + storage: 1Gi + selector: + matchLabels: + app.kubernetes.io/name: l1-torah diff --git a/deploy/k8s/manna-volume.yaml b/deploy/k8s/manna-volume.yaml new file mode 100644 index 00000000..9766ca20 --- /dev/null +++ b/deploy/k8s/manna-volume.yaml @@ -0,0 +1,16 @@ +# Manna Provision Volume +# The kohen/treasurer writes today.json to this volume. +# The vav_pipeline reads it. Provision is external to the system. +# Shemot 16:4 — d'var yom b'yomo. +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: manna-pvc + namespace: shorashim +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 100Mi diff --git a/deploy/k8s/namespace.yaml b/deploy/k8s/namespace.yaml new file mode 100644 index 00000000..86b881c2 --- /dev/null +++ b/deploy/k8s/namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: shorashim + labels: + app.kubernetes.io/name: shorashim + app.kubernetes.io/part-of: shorashim diff --git a/deploy/k8s/secrets.yaml b/deploy/k8s/secrets.yaml new file mode 100644 index 00000000..e95ed2ca --- /dev/null +++ b/deploy/k8s/secrets.yaml @@ -0,0 +1,17 @@ +# Shorashim Secrets — template only +# NEVER commit actual secret values to git. +# Apply with: kubectl apply -f - after filling in values. +# +# The DATABASE_URL connects to Exoscale PostgreSQL (temple-council-db) +# in ch-gva-2 zone. Retrieve credentials from: +# exo dbaas show temple-council-db --zone ch-gva-2 +apiVersion: v1 +kind: Secret +metadata: + name: shorashim-secrets + namespace: shorashim +type: Opaque +stringData: + # PostgreSQL connection for L3 Oral Tradition + # Format: ecto://user:password@host:port/database?sslmode=require + DATABASE_URL: "REPLACE_WITH_ACTUAL_DATABASE_URL" diff --git a/deploy/k8s/service.yaml b/deploy/k8s/service.yaml new file mode 100644 index 00000000..c118abfc --- /dev/null +++ b/deploy/k8s/service.yaml @@ -0,0 +1,48 @@ +# Shorashim Services +# Operations spec §1.1: Four access tiers +# - Pe'ah (public, no auth) +# - Machaneh Leviyah (authenticated) +# - Chatzer (full access) +# - Kodesh (operator, mTLS) +# +# All tiers go through a single Service → vav-pipeline port 4000 +--- +apiVersion: v1 +kind: Service +metadata: + name: shorashim + namespace: shorashim + labels: + app.kubernetes.io/name: shorashim +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: shorashim + ports: + - port: 80 + targetPort: 4000 + protocol: TCP + name: http +--- +# External access via LoadBalancer +# For Exoscale SKS, this provisions an Exoscale NLB +apiVersion: v1 +kind: Service +metadata: + name: shorashim-external + namespace: shorashim + labels: + app.kubernetes.io/name: shorashim + annotations: + # Exoscale NLB annotations + service.beta.kubernetes.io/exoscale-loadbalancer-zone: "ch-gva-2" + service.beta.kubernetes.io/exoscale-loadbalancer-name: "shorashim" +spec: + type: LoadBalancer + selector: + app.kubernetes.io/name: shorashim + ports: + - port: 80 + targetPort: 4000 + protocol: TCP + name: http diff --git a/vav_pipeline/lib/vav_pipeline/application.ex b/vav_pipeline/lib/vav_pipeline/application.ex index 9cd729a1..0a33d2b4 100644 --- a/vav_pipeline/lib/vav_pipeline/application.ex +++ b/vav_pipeline/lib/vav_pipeline/application.ex @@ -9,6 +9,7 @@ defmodule VavPipeline.Application do - EglahArufah (accountability for unknown harm) - Bikurim (first fruits — release protocol) - MannaLedger (consumption tracking and anti-hoarding enforcement) + - HaltMonitor (esh tamid — cardinal sin detection) - ShabbatTimer (mandatory cessation monitor) - ShoreshEnginePort (Erlang Port to Haskell engine, if binary found) - TalmudicEngineSocket (Unix socket to Prolog engine) @@ -35,6 +36,7 @@ defmodule VavPipeline.Application do {VavPipeline.EglahArufah, []}, {VavPipeline.Bikurim, []}, {VavPipeline.MannaLedger, []}, + {VavPipeline.HaltMonitor, []}, {VavPipeline.ShabbatTimer, []} ] |> maybe_add_repo() diff --git a/vav_pipeline/lib/vav_pipeline/halt_monitor.ex b/vav_pipeline/lib/vav_pipeline/halt_monitor.ex new file mode 100644 index 00000000..d67eb487 --- /dev/null +++ b/vav_pipeline/lib/vav_pipeline/halt_monitor.ex @@ -0,0 +1,210 @@ +defmodule VavPipeline.HaltMonitor do + @moduledoc """ + Halt Monitor — the esh tamid that must never go out. + + Vayikra 6:6 — Esh tamid tukad al hamizbe'ach, lo tikhbeh. + "A perpetual fire shall be kept burning on the altar; it shall not go out." + + Operations spec §2.1, Step 5: + "Check halt_monitor process registered. If failed: HALT — + the system does not run without the halt monitor." + + This GenServer: + 1. Receives halt events from pipeline processing + 2. Records halt events permanently (the audit log) + 3. Sounds the Chatzotzrot war signal on halt + 4. Prevents further processing until the halt is resolved + 5. Reports whether the system is halted and why + + The halt monitor is not optional. If this process dies, + the supervisor should restart it. If it cannot restart, + the entire system must stop. + """ + + use GenServer + + require Logger + + @type halt_event :: %{ + condition: :avodah_zarah | :shfichut_damim | :gilui_arayot, + reason: String.t(), + detected_at: DateTime.t(), + component: String.t(), + resolved: boolean() + } + + # ============================================================ + # Client API + # ============================================================ + + def start_link(opts \\ []) do + name = Keyword.get(opts, :name, __MODULE__) + GenServer.start_link(__MODULE__, opts, name: name) + end + + @doc """ + Report a halt condition. This immediately halts the system. + + Sounds the Chatzotzrot war signal (teruah_teruah). + The halt is permanent until explicitly resolved. + """ + @spec halt!(atom(), String.t(), String.t()) :: :halted + def halt!(condition, reason, component \\ "pipeline") + when condition in [:avodah_zarah, :shfichut_damim, :gilui_arayot] do + GenServer.call(__MODULE__, {:halt, condition, reason, component}) + end + + @doc """ + Check if the system is currently halted. + + Returns :running or {:halted, halt_event}. + """ + @spec status() :: :running | {:halted, halt_event()} + def status do + GenServer.call(__MODULE__, :status) + end + + @doc """ + Check if the system is halted. Boolean variant. + """ + @spec halted?() :: boolean() + def halted? do + GenServer.call(__MODULE__, :halted?) + end + + @doc """ + Get the full audit log of all halt events. + These are never deleted — permanent accountability. + """ + @spec audit_log() :: [halt_event()] + def audit_log do + GenServer.call(__MODULE__, :audit_log) + end + + @doc """ + Resolve a halt condition. Requires operator authority. + + The system does not resume automatically. A kohen must + explicitly resolve the halt after investigating. + """ + @spec resolve(String.t()) :: {:ok, :resumed} | {:error, :not_halted} + def resolve(resolution_reason) when is_binary(resolution_reason) do + GenServer.call(__MODULE__, {:resolve, resolution_reason}) + end + + @doc """ + Verify the monitor is alive and functional. + Called during morning tamid (Operations spec §2.1, Step 5). + """ + @spec alive?() :: boolean() + def alive? do + case GenServer.call(__MODULE__, :ping) do + :pong -> true + end + catch + :exit, _ -> false + end + + # ============================================================ + # GenServer callbacks + # ============================================================ + + @impl true + def init(_opts) do + state = %{ + status: :running, + current_halt: nil, + audit_log: [], + halt_count: 0 + } + + Logger.info("HaltMonitor: esh tamid burning — lo tikhbeh") + {:ok, state} + end + + @impl true + def handle_call({:halt, condition, reason, component}, _from, state) do + event = %{ + condition: condition, + reason: reason, + detected_at: DateTime.utc_now(), + component: component, + resolved: false + } + + Logger.error( + "HALT: #{condition} detected in #{component} — #{reason}. " <> + "System halted. Kohen must investigate and resolve." + ) + + # Sound the war signal — teruah_teruah + try do + VavPipeline.Chatzotzrot.signal(:war, + reason: "HALT: #{condition} — #{reason}" + ) + catch + _, _ -> Logger.error("HaltMonitor: could not sound chatzotzrot") + end + + new_state = %{ + state + | status: :halted, + current_halt: event, + audit_log: [event | state.audit_log], + halt_count: state.halt_count + 1 + } + + {:reply, :halted, new_state} + end + + @impl true + def handle_call(:status, _from, state) do + case state.status do + :running -> {:reply, :running, state} + :halted -> {:reply, {:halted, state.current_halt}, state} + end + end + + @impl true + def handle_call(:halted?, _from, state) do + {:reply, state.status == :halted, state} + end + + @impl true + def handle_call(:audit_log, _from, state) do + {:reply, state.audit_log, state} + end + + @impl true + def handle_call({:resolve, resolution_reason}, _from, state) do + case state.status do + :running -> + {:reply, {:error, :not_halted}, state} + + :halted -> + Logger.warning( + "HaltMonitor: halt resolved — #{resolution_reason}. " <> + "System resuming. Previous halt: #{state.current_halt.condition}" + ) + + resolved_event = Map.put(state.current_halt, :resolved, true) + + new_audit = + [%{resolved_event | resolved: true} | tl(state.audit_log)] + + new_state = %{ + state + | status: :running, + current_halt: nil, + audit_log: new_audit + } + + {:reply, {:ok, :resumed}, new_state} + end + end + + @impl true + def handle_call(:ping, _from, state) do + {:reply, :pong, state} + end +end diff --git a/vav_pipeline/lib/vav_pipeline/release.ex b/vav_pipeline/lib/vav_pipeline/release.ex new file mode 100644 index 00000000..3b92ba97 --- /dev/null +++ b/vav_pipeline/lib/vav_pipeline/release.ex @@ -0,0 +1,36 @@ +defmodule VavPipeline.Release do + @moduledoc """ + Release tasks for production deployment. + Called from the release binary: ./app/bin/vav_pipeline eval "VavPipeline.Release.migrate()" + """ + + @app :vav_pipeline + + @doc """ + Run all pending Ecto migrations. + Called by the K8s init container before the main pod starts. + """ + def migrate do + load_app() + + for repo <- repos() do + {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) + end + end + + @doc """ + Rollback the last migration (use with caution). + """ + def rollback(repo, version) do + load_app() + {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) + end + + defp repos do + Application.fetch_env!(@app, :ecto_repos) + end + + defp load_app do + Application.load(@app) + end +end diff --git a/vav_pipeline/test/halt_monitor_test.exs b/vav_pipeline/test/halt_monitor_test.exs new file mode 100644 index 00000000..019c7688 --- /dev/null +++ b/vav_pipeline/test/halt_monitor_test.exs @@ -0,0 +1,151 @@ +defmodule VavPipeline.HaltMonitorTest do + @moduledoc """ + Tests for the HaltMonitor — esh tamid. + + Vayikra 6:6 — Esh tamid tukad al hamizbe'ach, lo tikhbeh. + "A perpetual fire shall be kept burning on the altar; it shall not go out." + + Operations spec §2.1, Step 5: + The system does not run without the halt monitor. + """ + + use ExUnit.Case, async: false + + alias VavPipeline.HaltMonitor + + setup do + # Resolve any existing halt from a previous test + if HaltMonitor.halted? do + HaltMonitor.resolve("test cleanup") + end + + :ok + end + + # ============================================================ + # Alive check — esh tamid verification + # ============================================================ + describe "esh tamid" do + test "monitor is alive" do + assert HaltMonitor.alive?() + end + + test "status is :running when no halt" do + assert HaltMonitor.status() == :running + end + + test "halted? returns false when running" do + refute HaltMonitor.halted?() + end + end + + # ============================================================ + # Halt detection — three cardinal sins + # ============================================================ + describe "halt!" do + test "avodah_zarah halts the system" do + assert :halted = HaltMonitor.halt!(:avodah_zarah, "external model treated as Torah") + assert HaltMonitor.halted?() + + {:halted, event} = HaltMonitor.status() + assert event.condition == :avodah_zarah + assert event.reason == "external model treated as Torah" + assert event.resolved == false + end + + test "shfichut_damim halts the system" do + assert :halted = HaltMonitor.halt!(:shfichut_damim, "user data deletion attempted") + assert HaltMonitor.halted?() + + {:halted, event} = HaltMonitor.status() + assert event.condition == :shfichut_damim + end + + test "gilui_arayot halts the system" do + assert :halted = HaltMonitor.halt!(:gilui_arayot, "L1/L3 boundary collapsed") + assert HaltMonitor.halted?() + + {:halted, event} = HaltMonitor.status() + assert event.condition == :gilui_arayot + end + + test "halt records detected_at timestamp" do + HaltMonitor.halt!(:avodah_zarah, "timestamp test") + {:halted, event} = HaltMonitor.status() + + assert event.detected_at != nil + assert %DateTime{} = event.detected_at + end + + test "halt records component" do + HaltMonitor.halt!(:avodah_zarah, "component test", "korban_processor") + {:halted, event} = HaltMonitor.status() + + assert event.component == "korban_processor" + end + end + + # ============================================================ + # Resolution + # ============================================================ + describe "resolve" do + test "resolving a halt resumes the system" do + HaltMonitor.halt!(:avodah_zarah, "test halt for resolution") + assert HaltMonitor.halted?() + + {:ok, :resumed} = HaltMonitor.resolve("Investigation complete — false positive") + refute HaltMonitor.halted?() + assert HaltMonitor.status() == :running + end + + test "resolving when not halted returns error" do + assert {:error, :not_halted} = HaltMonitor.resolve("nothing to resolve") + end + end + + # ============================================================ + # Audit log — permanent record + # ============================================================ + describe "audit log" do + test "halt events are recorded in audit log" do + initial_count = length(HaltMonitor.audit_log()) + + HaltMonitor.halt!(:avodah_zarah, "audit test 1") + HaltMonitor.resolve("resolved 1") + + HaltMonitor.halt!(:shfichut_damim, "audit test 2") + HaltMonitor.resolve("resolved 2") + + log = HaltMonitor.audit_log() + assert length(log) >= initial_count + 2 + end + + test "audit log preserves halt details" do + HaltMonitor.halt!(:gilui_arayot, "audit detail test", "specification_module") + HaltMonitor.resolve("test cleanup") + + log = HaltMonitor.audit_log() + event = Enum.find(log, &(&1.reason == "audit detail test")) + + assert event != nil + assert event.condition == :gilui_arayot + assert event.component == "specification_module" + end + end + + # ============================================================ + # Chatzotzrot integration — war signal on halt + # ============================================================ + describe "chatzotzrot war signal" do + test "halt sounds the war signal" do + VavPipeline.Chatzotzrot.subscribe(:kohanim) + + HaltMonitor.halt!(:avodah_zarah, "chatzotzrot test") + + # War signal should be received by kohanim subscribers + assert_receive {:chatzotzrot, %{signal_type: :war, pattern: :teruah_teruah}} + + VavPipeline.Chatzotzrot.unsubscribe(:kohanim) + end + end +end