Skip to content

feat: dblab provisioning module + env-create honesty gate (#127) - #128

Open
samo-agent wants to merge 9 commits into
mainfrom
feat/dblab-provisioning-module
Open

feat: dblab provisioning module + env-create honesty gate (#127)#128
samo-agent wants to merge 9 commits into
mainfrom
feat/dblab-provisioning-module

Conversation

@samo-agent

Copy link
Copy Markdown
Collaborator

Summary

Closes the gap from #127: SAMO clients are now dblab-ready by default at provision time. The hardening-only cloud-init now gains an optional dblab module that provisions the full minimal €0 profile verified in #127.

  • src/cloudinit/dblab.ts — new Module implementation. Generates a deterministic cloud-init fragment that:

    • Installs Docker CE from the official apt repo + zfsutils-linux.
    • Creates a sparse 10 GB loopback ZFS pool (dblab) on the root disk with a systemd persistence service.
    • Caps ZFS ARC at 256 MB via /etc/modprobe.d/zfs.conf (applied live + baked into initrd).
    • Creates a 2 GB swapfile persisted in /etc/fstab.
    • Writes /root/.dblab/engine/configs/server.yml with the minimal profile: port 2345, maxCloneCount=2, maxIdleMinutes=20160, cloneAccessAddresses=127.0.0.1, portPool 6000–6099, containerResources.memory=1073741824 (1 GB), shared_buffers=128MB, source = host PG via Docker bridge 172.17.0.1:5432, logical retrieval jobs.
    • Generates a random verification token at provision time (never hardcoded).
    • Writes the busybox-grep workaround startup.sh.
    • Adds the Docker bridge subnet to pg_hba.conf so the engine can dump prod Postgres.
    • Runs dblab_server:4.1.3 on 127.0.0.1:2345 only (--privileged, --restart on-failure, rshared).
    • Installs the dblab CLI v4.1.3, inits it, waits for healthz, triggers the initial refresh.
    • Masks ssh.socket to prevent the Ubuntu 24.04 restart loop.
    • NEVER disables fail2ban, NEVER deletes the UFW SSH rule (SAMO client package: every client gets prod + dblab previews + auto-deploy #127 critical lesson baked in as constants + tested).
  • src/commands/preview.tsresolveModules now has a MODULE_REGISTRY map. "dblab" resolves to dblabModule. Unknown names still error.

  • src/commands/env.tsEnvExecDeps gains optional dblabPreflight?. runEnvCreate gates on it when db=dblab: BLOCKED/UNKNOWN/throw → exit 1 + clear error, no env record written (the samograph broken path: fake record written, clone never existed).

Root-cause analysis (why the gate matters)

samograph's manual dblab install wrote env records without verifying the engine was healthy. The reconciler saw needDeploy=false on those records and never retried the broken env. The gate is inserted BEFORE the DNS step and BEFORE any record write, so the only way an env record exists is if the engine was confirmed READY at create time.

fail2ban / control-plane exemption

The dblabModule cloud-init fragment contains zero fail2ban or UFW commands. The hardening module (always rendered first) writes jail.local with trustedIps in ignoreip; provision.ts auto-injects the control-plane egress IP into trustedIps before rendering. The combined output keeps fail2ban enabled and the control-plane whitelisted.

Tested assertions:

  • never toContain("systemctl stop fail2ban")
  • never toContain("systemctl disable fail2ban")
  • never toMatch(/systemctl\s+(stop|disable)\s+fail2ban/)
  • never toContain("ufw delete")
  • toContain("systemctl enable --now fail2ban") (from hardening module)
  • toContain("ignoreip") + toContain("91.99.233.145") within 200 chars

TDD

RED commit a03f09f: 2 test files, 0 source changes, CI red on missing module import + missing gate.
GREEN commit 79a0e45: implementation, 1423/1423 tests pass, tsc clean.

What real-provision smoke test is still needed post-merge

  1. Provision a fresh cx22 with --module dblab and walk the 19 steps from SAMO client package: every client gets prod + dblab previews + auto-deploy #127 manually on the live VM.
  2. Verify curl http://127.0.0.1:2345/healthz returns {"edition":"community",...}.
  3. Run samohost env preflight <vm> → READY.
  4. Run samohost env create <vm> <app> --branch smoke --db dblab → exit 0 + env record written.
  5. Backfill: samorev-client + game-changers VMs (currently db=none) need the module applied and a first refresh with their prod database name substituted into server.yml.

Not in this PR (deferred, filed confidentially)

  • #124 findVm ignores lifecycleState (pre-existing)
  • #125 trigger swallows preview failures (pre-existing)
  • Production wiring of dblabPreflight in defaultEnvExecDeps() (requires the healthz curl probe via SSH runner — low-risk follow-up once the module is deployed and smoke-tested)

Closes #127 provisioning checklist item.

🤖 Generated with Claude Code

samo-agent and others added 4 commits July 7, 2026 00:11
Pins the expected behaviour before any implementation exists:
- dblab-module.test.ts: asserts the full cloud-init output installs
  Docker CE + zfsutils, creates sparse ZFS pool, runs dblab_server 4.1.3
  on 127.0.0.1:2345, caps ARC at 256 MB, sets clone cap 2 + 128 MB
  shared_buffers + 1 GB memory limit, creates 2 GB swapfile, and
  CRITICALLY never disables fail2ban or drops the UFW SSH rule while
  keeping the control-plane IP in fail2ban ignoreip.
- dblab-env-gate.test.ts: asserts env-create for db=dblab fails LOUD
  (exit 1) and writes NO env record when the dblab engine is BLOCKED or
  UNKNOWN, and proceeds normally when READY.

Both files fail for the right reasons: dblab.ts does not exist yet
(module import error) and the gate logic is absent from runEnvCreate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements the minimal verified €0 dblab profile from #127 into the
samohost cloud-init provisioning pipeline.

## What it installs (cloud-init runcmd, in order)

- Docker CE from the official apt repo + containerd.io; adminUser added
  to the docker group.
- Docker images pre-pulled: dblab-server:4.1.3 + extended-postgres:16-0.6.2.
- 2 GB swapfile at /swapfile (/etc/fstab persisted).
- ZFS ARC cap: /etc/modprobe.d/zfs.conf → options zfs zfs_arc_max=268435456
  (256 MB); also applied live to /sys/module/zfs/parameters/zfs_arc_max and
  baked into initrd via update-initramfs.
- Sparse 10G loopback file at /var/lib/dblab-pool/dblab.img; attached to
  /dev/loop0; zpool create dblab; dataset dblab/dblab_pool with
  compression=on atime=off logbias=throughput.
- /etc/systemd/system/dblab-loopback.service (Before=zfs-import.target
  docker.service) re-attaches the loop device on every boot.
- /root/.dblab/engine/configs/server.yml: port 2345, enableTelemetry=false,
  maxCloneCount=2, maxIdleMinutes=20160, cloneAccessAddresses=127.0.0.1,
  portPool 6000-6099, containerResources.memory=1073741824, shared_buffers
  128MB, source=host PG via Docker bridge 172.17.0.1:5432, logical retrieval
  jobs (logicalDump/logicalRestore/logicalSnapshot), skipStartRefresh=true.
- /root/.dblab/engine/startup.sh: busybox-grep workaround (apk add grep).
- Verification token generated at runtime (openssl rand -hex 32, stored
  at /root/.dblab/token 0600, substituted into server.yml).
- pg_hba.conf entry for 172.17.0.0/16 (Docker bridge) + listen_addresses
  patch; postgresql reloaded.
- docker run dblab_server: 127.0.0.1:2345:2345, --privileged, rshared,
  --restart on-failure, startup.sh.
- CLI installed via dblab.sh (DBLAB_CLI_VERSION=4.1.3); symlinked into
  ~/bin/dblab; PATH exported in /root/.bashrc.
- CLI inited: dblab init --environment-id solo.
- Waits up to 60s for healthz, then verifies it.
- Initial refresh triggered (dblab instance snapshots refresh).
- systemctl mask ssh.socket (prevent Ubuntu 24.04 socket-activation loop).

## fail2ban preserved (#127 critical lesson)

The module NEVER emits any fail2ban disable/stop command or ufw delete.
The hardening module (always rendered first) keeps fail2ban enabled and
writes jail.local with the control-plane IP in ignoreip via trustedIps.
The combined output keeps fail2ban enabled with the whitelisted IP.

## Module registration

resolveModules now has a MODULE_REGISTRY map. "dblab" → dblabModule.
Unknown module names still return an error (back-compat).

## env-create honesty gate

EnvExecDeps gains optional dblabPreflight?: (vm) => Promise<"READY"|"BLOCKED"|"UNKNOWN">.
runEnvCreate gates on it when target.dbBackend === "dblab":
- BLOCKED/UNKNOWN → exit 1, clear message with VM name and port, NO record written.
- preflight throws → exit 1, exception detail in the message, NO record written.
- READY → proceeds as before.
- dep absent → back-compat, no gate (all existing tests unaffected).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Asserts the root-caused bugs from samograph's dead-engine incident
(task wdgv5zs85) against the module before any fix is applied:

1. server.yml MUST have poolManager.preSnapshotSuffix: "_pre"
   Without it DBLab runs "zfs list | grep -v ''" — BusyBox grep rejects
   an empty -v pattern (exit 2) → "no available pools" → zero clones.

2. poolManager MUST use selectedPool: (correct yaml struct tag, not pool:)

3. logicalDump source dbname MUST be config-driven via makeDblabModule()
   factory — preview envs need <app>_template, not hardcoded _prod.

4. defaultEnvExecDeps() MUST export a real dblabPreflight function
   (currently absent → env-create honesty gate is inert in prod).

All 10 tests fail (RED) against the current PR-head.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…onfig-driven sourceDb, preflight wired to prod (#128)

Root cause (samograph dead-engine incident, task wdgv5zs85):
The generated server.yml was missing poolManager.preSnapshotSuffix. Without
it DBLab runs "zfs list ... | grep -v ''" — BusyBox grep (Alpine base image)
rejects an empty -v pattern with exit 2 → "no available pools" → the engine
cannot see the ZFS pool → zero clones on first boot.

Fix 1 (CRITICAL): Add poolManager.preSnapshotSuffix: "_pre" to the YAML
  template generated by buildServerYml(). Converts "grep -v ''" (exit 2,
  BusyBox rejects empty pattern) into "grep -v '_pre'" (works on all greps).

Fix 2: Change pool: → selectedPool: in poolManager.
  The old key was silently ignored by DBLab; the correct yaml struct tag is
  selectedPool. Harmless but correct per the v4.1.3 schema.

Fix 3: Source DB is now config-driven via makeDblabModule({ sourceDb }).
  Preview envs need <app>_template (not <app>_prod) as the retrieval source.
  The new makeDblabModule() factory bakes the correct DB name into server.yml
  at provisioning time. dblabModule keeps a ${DBLAB_SOURCE_DB} placeholder for
  back-compat (operator fills it in manually).

Fix 4: Wire dblabPreflight into defaultEnvExecDeps() so the env-create
  honesty gate fires in production. Previously dblabPreflight was absent from
  defaultEnvExecDeps() — the gate was silently skipped in prod, allowing fake
  dblab env records to be written against a non-running engine (samograph's
  broken path). The production implementation runs the full DBLAB_PROBES audit
  script over a pinned SSH connection and reuses evaluateDblabPreflight() for
  the verdict so the gate and `env preflight` cannot diverge.

All 10 RED tests now pass GREEN. 89 dblab/preflight/gate tests pass total.
Pre-existing smol-toml dependency errors in this test environment are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@samo-agent

Copy link
Copy Markdown
Collaborator Author

DBLab provisioning fixes — 4 root-cause bugs addressed (#128)

Reference: samograph dead-engine root cause (task wdgv5zs85)

Commits added

SHA Description
4e21325 test(RED): 4 failing tests pinning the bugs
b831d93 fix(dblab): GREEN — all 4 fixes implemented

Fix 1 (CRITICAL): poolManager.preSnapshotSuffix: "_pre"

Without this key, DBLab v4 runs:

zfs list -t snapshot -H -o name | grep -v ''

BusyBox grep (Alpine base image in the postgresai/dblab-server container) rejects an empty -v pattern with exit 2 → DBLab interprets this as "no available pools" → the engine cannot attach to the ZFS pool → zero clones, dead engine. This is exactly what happened on samograph's first provision.

Fix 2: selectedPool: (correct yaml struct tag)

pool: dblab was silently ignored by DBLab's config parser. The correct key is selectedPool: per the v4.1.3 schema. Harmless but necessary for the pool to actually be selected.

Fix 3: Source DB config-driven via makeDblabModule({ sourceDb })

Preview envs must connect to <app>_template, not <app>_prod. Samograph's incident proved that pointing the retrieval source at samograph_prod caused the snapshot to fail to initialise (wrong roles/schema for preview). The new factory bakes the correct DB name into server.yml at provisioning time:

// When provisioning a samograph DBLab VM:
const mod = makeDblabModule({ sourceDb: "samograph_template" });

The existing dblabModule export uses a ${DBLAB_SOURCE_DB} placeholder for back-compat.

Fix 4: defaultEnvExecDeps().dblabPreflight wired to production

The dblabPreflight field was absent from defaultEnvExecDeps(), meaning the env-create honesty gate (which checks the engine before creating a clone) was silently skipped in production. A db=dblab env-create against a non-running engine would write a fake env record — the same broken path that samograph hit.

The production implementation runs the full DBLAB_PROBES audit script over a pinned SSH connection and reuses evaluateDblabPreflight() so the gate and samohost env preflight <vm> cannot diverge.


Test summary

89 dblab/gate/preflight tests pass. Pre-existing smol-toml package-not-found errors in this test environment are unaffected by these changes.


Not merged — awaiting samorev + smoke-provision on a real cx22.

samo-agent and others added 2 commits July 7, 2026 03:09
Add test/dblab-schema-yaml-128.test.ts with js-yaml-powered structural
assertions to catch the 4 bugs found in the live smoke-provision:
  BUG-1  cloneAccessAddresses emitted as sequence instead of scalar
  BUG-2a skipStartRefresh at retrieval.spec (wrong nesting)
  BUG-2b jobs list at retrieval.spec.jobs (wrong level)
  BUG-3  losetup hardcodes /dev/loop0
  BUG-4  ${DBLAB_SOURCE_USER} never substituted

All 14 new assertions fail RED on current PR head — toContain() string
checks in the old tests let invalid YAML structure through undetected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…128

Root cause: smoke-provision on a real VM produced "[FATAL] failed to parse
config" because the generated server.yml had 4 structural bugs vs the known-
good samograph config (ssh -p 2223 samo@116.203.249.135).

Fixes applied:
  BUG-1  provision.cloneAccessAddresses: scalar "127.0.0.1" (was YAML sequence)
  BUG-2a retrieval.refresh.skipStartRefresh (was retrieval.spec.skipStartRefresh)
  BUG-2b retrieval.jobs: [logicalDump, logicalRestore, logicalSnapshot] flat list
         under retrieval.jobs (was inline-options objects under retrieval.spec.jobs)
         job options now correctly at retrieval.spec.<job>.options
  BUG-3  losetup -f --show picks the next free loop device dynamically;
         Ubuntu 24.04 + snapd occupy loop0-2 so hardcoding /dev/loop0 fails;
         zpool create updated to use $LOOP_DEV; dblab-loopback.service uses
         losetup -j check + losetup -f on re-attach
  BUG-4  ${DBLAB_SOURCE_USER} substituted with adminUser from ProvisionSpec at
         build time (passed through buildFragment → buildServerYml); leaving the
         literal placeholder caused silent refresh failure behind || true

All 1449 tests pass; 14 new YAML-structural assertions GREEN.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@samo-agent

Copy link
Copy Markdown
Collaborator Author

Smoke-test YAML bug fixes — RED/GREEN

Smoke-provision on a real VM produced [FATAL] failed to parse config. Root-caused 4 structural bugs in buildServerYml() vs the known-good samograph server.yml (ssh -p 2223 samo@116.203.249.135).

Commits pushed

SHA Role
11f1f02 RED — YAML-parse + schema validation test (test/dblab-schema-yaml-128.test.ts) using js-yaml; 14 assertions fail on current PR head for the exact structural reasons
8b807e4 GREEN — implementation fixes; all 1449 tests pass

Bugs fixed

BUG-1 provision.cloneAccessAddresses was a YAML sequence - "127.0.0.1" — DBLab v4.1.3 expects a scalar string "127.0.0.1".

BUG-2 retrieval structure wrong:

  • skipStartRefresh moved from retrieval.spec.skipStartRefreshretrieval.refresh.skipStartRefresh
  • jobs moved from inline-options objects inside retrieval.spec.jobs → flat string list at retrieval.jobs
  • Job options now correctly at retrieval.spec.<job>.options

BUG-3 losetup hardcoded /dev/loop0; Ubuntu 24.04 + snapd occupy loop0loop2. Changed to losetup -f --show (dynamic) with $LOOP_DEV used in zpool create. dblab-loopback.service now uses losetup -j check + losetup -f on re-attach.

BUG-4 ${DBLAB_SOURCE_USER} placeholder was never substituted → logicalDump connection sent a literal ${DBLAB_SOURCE_USER} to Postgres, causing silent refresh failure behind || true. Now substituted with adminUser from ProvisionSpec at build time via buildServerYml(opts, adminUser).

What the new test does differently

Previous tests used toContain() string checks which let invalid YAML structure through. The new test/dblab-schema-yaml-128.test.ts file:

  1. Calls makeDblabModule(opts).cloudInitFragment(spec) to get the raw fragment
  2. Extracts server.yml content from writeFiles
  3. Parses it with js-yaml.load() — fails immediately if YAML is malformed
  4. Asserts correct Go/DBLab schema shapes: scalar vs list, exact key nesting, correct values

Do NOT merge — awaiting a fresh real-VM re-smoke on the updated branch.

samo-agent and others added 3 commits July 7, 2026 03:12
CloudInitFragment has writeFiles?: and runcmd?: (optional); guard with
?? [] in test/dblab-schema-yaml-128.test.ts to pass bunx tsc --noEmit.
No test logic changed; 1449 tests still pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rule (#128)

Extends dblab-schema-yaml-128.test.ts with two failing test suites that pin
the fresh-VM provision gaps surfaced by the smoke test:

GAP-1: poolManager.selectedPool must be "dblab_pool" (the dataset subdirectory
  name under mountDir=/var/lib/dblab) — not "dblab" (the ZFS pool name).
  DBLab v4 looks for /var/lib/dblab/<selectedPool>; with "dblab" it looks for
  /var/lib/dblab/dblab which does not exist → "no available pools" loop.
  Ground truth: samograph ZFS: `zfs create dblab/dblab_pool` mounted at
  /var/lib/dblab/dblab_pool. Three YAML-parsed assertions (exact string, not
  substring) ensure the false-positive `toContain("selectedPool: dblab")` is
  superseded.

GAP-2: runcmd must contain a UFW allow rule for Docker bridge subnet
  (172.17.0.0/16 or 172.16.0.0/12) → clone port range 6000:6099 proto tcp.
  Without it UFW default-deny blocks engine health probes; 100 ports × ~3s
  TCP timeout = ~5 min stall → engine never becomes healthy.
  Ground truth: samograph rule 17 "6000:6099/tcp ALLOW IN 172.16.0.0/12".
  Regression guards verify fail2ban and control-plane SSH rule are untouched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…FW Docker bridge rules (#128)

Root-cause analysis (diff vs samograph 116.203.249.135 live working config):

GAP-1 — selectedPool MUST be "dblab_pool" (the ZFS dataset subdirectory), not
  "dblab" (the ZFS pool name). DBLab v4 scans poolManager.mountDir for pool
  subdirectories; with "dblab" it looks for /var/lib/dblab/dblab which does
  not exist (dataset is mounted at /var/lib/dblab/dblab_pool) → engine logs
  "no available pools" and loops indefinitely. Fix: selectedPool: "dblab_pool".

GAP-2 — UFW rules for Docker bridge → clone ports were entirely absent.
  Added two source-restricted rules (never world-open):
    ufw allow proto tcp from 172.17.0.0/16 to any port 6000:6099  (clone pool)
    ufw allow proto tcp from 172.17.0.0/16 to any port 5432        (host PG)
  Ground truth: samograph rule 17 "6000:6099/tcp ALLOW IN 172.16.0.0/12".
  Without these rules UFW default-deny causes ~5 min stall on clone probes.

Additional structural gaps closed by diffing samograph server.yml:
  - poolManager.dataSubDir: "data" + clonesMountSubDir: "clones" (missing)
  - databaseContainer section (missing; needed for clone container config)
  - retrieval.spec.logicalDump/Restore/Snapshot options: added dockerImage,
    containerConfig, dumpLocation, source.type:remote (all missing; needed for
    the logical dump job to execute correctly)
  - Removed the top-level containerConfig.containerResources section (not in
    DBLab v4 schema; samograph does not have it)

Also fixes the false-positive selectedPool assertion in dblab-fixes-128.test.ts:
  toContain("selectedPool: dblab") is a substring of "selectedPool: dblab_pool"
  and would pass regardless of value. Updated to use the quoted literal form.

All 1457 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@samo-agent

Copy link
Copy Markdown
Collaborator Author

Fresh-VM gap analysis — GAP-1 + GAP-2 closed (TDD RED→GREEN)

SSH ground-truth source: samograph 116.203.249.135 (live, clone-verified)

Gaps found by diffing module output vs samograph working config

# Gap Root cause Fix
GAP-1 selectedPool: "dblab" DBLab v4 scans mountDir for subdirectories. Value must be dataset name "dblab_pool" (at /var/lib/dblab/dblab_pool), not ZFS pool name "dblab". Using "dblab" → looks for /var/lib/dblab/dblab which doesn't exist → "no available pools" loop. selectedPool: "dblab_pool"
GAP-2 UFW clone-port rules absent No ufw allow for Docker bridge → ports 6000-6099. UFW default-deny blocks engine health probes; 100 ports × ~3s TCP timeout = ~5 min stall → engine never healthy. Added ufw allow proto tcp from 172.17.0.0/16 to any port 6000:6099 + port 5432 (host PG). Ground truth: samograph rule 17.
GAP-3 Missing poolManager.dataSubDir/clonesMountSubDir DBLab needs these to locate data/clone directories within the pool. Added dataSubDir: "data", clonesMountSubDir: "clones"
GAP-4 Missing databaseContainer section Clone containers need docker image + memory config. Added databaseContainer.dockerImage + containerConfig
GAP-5 retrieval.spec.logicalDump missing dockerImage, containerConfig, dumpLocation, source.type: remote Logical dump job won't execute without these. Added all 4 matching samograph
GAP-6 retrieval.spec.logicalRestore/Snapshot had empty options: {} Restore/snapshot containers need image config. Added dockerImage, containerConfig, dumpLocation
GAP-7 Top-level containerConfig.containerResources section Not in DBLab v4 schema; not in samograph. Removed
FALSE-POS toContain("selectedPool: dblab") in dblab-fixes-128.test.ts String is a substring of "dblab_pool" → passes regardless of value. Updated to quoted literal

TDD commits

  • RED d180568 — 5 failing tests (3 × GAP-1 YAML-parsed, 2 × GAP-2 UFW regex); 19 existing tests still pass
  • GREEN 6949dfc — all 1457 tests pass (0 fail)

Test file extended

test/dblab-schema-yaml-128.test.ts: two new describe blocks — GAP-1 (3 YAML-parsed exact assertions) + GAP-2 (5 UFW assertions including regression guards for fail2ban and control-plane SSH rule).

samo-agent added a commit that referenced this pull request Jul 7, 2026
… prefix consistency

Blockers fixed (verified against source):

1. routing.md — CF token attribution corrected:
   - CLOUDFLARE_SAMOCAT (Token 1): removed 'dns status' from 'Used by' —
     src/commands/dns.ts:81 reads CLOUDFLARE_API_TOKEN, NOT CLOUDFLARE_SAMOCAT
   - CLOUDFLARE_API_TOKEN (Token 2): heading changed from '(Workers deploy only)'
     to '(dns status + Workers deploy)'; explains dual-use with different scopes
     (dns status needs DNS-read, wrangler needs Workers:Edit); verified against
     dns.ts:81, wrangler.toml:15, setup-checklist.md "Two distinct Cloudflare tokens"
   - Source citation: bare 'SPEC.md' → 'samo.team/SPEC.md (line 522 diagram)'
     (samohost's own SPEC.md is 131 lines and has no routing content)

2. dblab.md — clone-registry state line corrected:
   - 'infra/dblab/clone-registry.ts' does not exist in samohost (no infra/ dir)
   - Correct: src/state/envs.ts class EnvStore persists to ~/.samohost/envs.json
   - Verified: envs.ts docstring line 4 + defaultEnvsPath() line 28

Full audit of all 7 docs — additional inaccuracies fixed:

3. supabase.md: preview.ts 'lines 19-24' → 'line 20' (grep confirms comment at :20)

4. postgres.md: added samo.team/ prefix to all three ambiguous citations:
   - prose: 'infra/cloud-init/template.ts' → 'samo.team/infra/cloud-init/template.ts'
   - table: 'SPEC.md §7.1 lines 762-764' → 'samo.team/SPEC.md §7.1 lines 762-764'
   - inline: '(SPEC.md lines 762-764)' → '(samo.team/SPEC.md lines 762-764)'
   samohost's own SPEC.md is 131 lines; lines 762-764 only exist in samo.team/SPEC.md

5. zfs.md: 'infra/zfs/pool.ts' → 'samo.team/infra/zfs/pool.ts' in parenthetical
   (the explicit Source line already had the prefix; made the inline consistent)

All src/ paths verified: 12/13 exist on main; src/cloudinit/dblab.ts correctly
noted as 'in progress in PR #128, HELD' (exists on feat/dblab-provisioning-module).
All docs/ references verified present. All samo.team/ file paths verified live.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
samo-agent added a commit that referenced this pull request Jul 7, 2026
* docs(stack): canonical stack handbook for agent onboarding

Adds docs/stack/ — six component docs (postgres, dblab, supabase, zfs,
routing, deploy) plus a README with read-order and non-negotiable owner
policies. All facts are ground-truth verified from repos and live VM state
(audit 2026-07-07); no memory-only claims. Wires the handbook into CLAUDE.md
via a SAMO-STACK sync block so every agent session loads the pointer on open.

Resolves the PG-version contradiction: 18 is the single documented standard;
the samo.team control-plane PG 17 is documented as a known gap. Captures
Supabase real state (no full stack deployed anywhere; GoTrue-only on
samo.team; plain bcrypt on field-record). Documents the cx23 loopback DBLab
minimal profile (verified on samograph 2026-07-07, ~EUR 0 incremental).

References: ground-truth audit for #127 (SAMO client package standard).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(stack-docs): address samorev REQUEST_CHANGES accuracy blockers

JOB A: rebased onto clean origin/main — no #128 code in diff (docs-only).
JOB B fixes:

1. dblab.md: module registry is empty on main; provisioning module lives in
   HELD PR #128 (not merged). Add explicit "in progress, held" note.

2. Clone image PG version: postgresai/extended-postgres:18-0.6.2 verified
   published on Docker Hub 2026-07-07 (resolves runbook's earlier UNVERIFIED
   note). Clone image is PG18. No PG16 mismatch.

3. Broken citations: src/commands/bootstrap.ts -> src/app/bootstrap.ts in
   postgres.md and routing.md. All src/ refs now resolve on clean main.

4. cx23 minimal profile caveat: added to both zfs.md and dblab.md — measured
   + proven on samograph but NOT yet formally blessed by Nik (his runbook
   targets 8 GB+ VMs); pending owner sign-off before it is the standard
   baseline. Reframed "100x over-provisioned" as measured comparison.

5. ANTHROPIC_API_KEY unconditionally banned in supabase.md (removed the
   conditional "unless Supabase enabled" qualifier). routing.md heading
   updated from "two required" to "three in total". zfs.md pool.ts source
   corrected: pool.ts is parameterized; template.ts line 503 hardcodes tank.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(stack-docs): full-audit accuracy pass — CF token, clone-registry, prefix consistency

Blockers fixed (verified against source):

1. routing.md — CF token attribution corrected:
   - CLOUDFLARE_SAMOCAT (Token 1): removed 'dns status' from 'Used by' —
     src/commands/dns.ts:81 reads CLOUDFLARE_API_TOKEN, NOT CLOUDFLARE_SAMOCAT
   - CLOUDFLARE_API_TOKEN (Token 2): heading changed from '(Workers deploy only)'
     to '(dns status + Workers deploy)'; explains dual-use with different scopes
     (dns status needs DNS-read, wrangler needs Workers:Edit); verified against
     dns.ts:81, wrangler.toml:15, setup-checklist.md "Two distinct Cloudflare tokens"
   - Source citation: bare 'SPEC.md' → 'samo.team/SPEC.md (line 522 diagram)'
     (samohost's own SPEC.md is 131 lines and has no routing content)

2. dblab.md — clone-registry state line corrected:
   - 'infra/dblab/clone-registry.ts' does not exist in samohost (no infra/ dir)
   - Correct: src/state/envs.ts class EnvStore persists to ~/.samohost/envs.json
   - Verified: envs.ts docstring line 4 + defaultEnvsPath() line 28

Full audit of all 7 docs — additional inaccuracies fixed:

3. supabase.md: preview.ts 'lines 19-24' → 'line 20' (grep confirms comment at :20)

4. postgres.md: added samo.team/ prefix to all three ambiguous citations:
   - prose: 'infra/cloud-init/template.ts' → 'samo.team/infra/cloud-init/template.ts'
   - table: 'SPEC.md §7.1 lines 762-764' → 'samo.team/SPEC.md §7.1 lines 762-764'
   - inline: '(SPEC.md lines 762-764)' → '(samo.team/SPEC.md lines 762-764)'
   samohost's own SPEC.md is 131 lines; lines 762-764 only exist in samo.team/SPEC.md

5. zfs.md: 'infra/zfs/pool.ts' → 'samo.team/infra/zfs/pool.ts' in parenthetical
   (the explicit Source line already had the prefix; made the inline consistent)

All src/ paths verified: 12/13 exist on main; src/cloudinit/dblab.ts correctly
noted as 'in progress in PR #128, HELD' (exists on feat/dblab-provisioning-module).
All docs/ references verified present. All samo.team/ file paths verified live.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: samo-agent <samo-agent@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
samo-agent pushed a commit that referenced this pull request Jul 16, 2026
… VMs

Root cause: `dblab-loopback.service` (a per-VM artifact, not in samohost
provisioning) declared `Before=zfs-import.target` but `zfs-import-cache.service`
started independently before the loopback was set up, causing it to abort
(signal=ABRT) — leaving the ZFS pool `dblab` unmounted on every reboot.

Manual workaround (operator steps):
1. Rewrite `dblab-loopback.service` to explicitly declare
   `Before=zfs-import-cache.service` (not just `zfs-import.target`).
2. Add a drop-in `/etc/systemd/system/zfs-import-cache.service.d/10-dblab-loopback.conf`
   with `Requires=dblab-loopback.service` + `After=dblab-loopback.service`.
   This makes the ZFS import wait for the loop device to exist.

Applied live on samograph VM 2026-07-16.

Bug B is NOT a samohost-code bug — the unit is a per-VM artifact installed
manually per the runbook. The platform gap (DBLab not auto-provisioned by
cloud-init) is tracked in docs/stack/dblab.md. The fix must be baked into
the future cloud-init module (PR #128 follow-up).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
samo-agent added a commit that referenced this pull request Jul 16, 2026
… boot docs (#176) (#176)

* test(port-check): RED — same-env sibling/failed unit must not be foreign squatter

Reproduces Bug A: for multi-service apps the port-check calls
`samohost_port_check_ok <port> <listener-unit>@<env>.service` for each
listener. When the listener's unit is FAILED but the port is held by a
sibling unit of the same env, `systemctl is-active` returns false and
the check misclassifies the port as a foreign squatter, triggering
`rm -f SAMOHOST_CADDY_SNIPPET` + Caddy reload — killing the preview vhost.

Observed on samograph VM (2026-07-16): samograph-docs-add-robot-to-readme
and other envs with all ports = 3111 (single-port allocation); the web@env
unit holds 3111 while live@env unit fails with EADDRINUSE, and heal cycles
delete the Caddy snippet on every pass (CF 525).

New tests (f1–f7):
- f1: sibling same-env unit in cgroup → must return 0 (own, not foreign)
- f2: same unit in FAILED state → cgroup match → must return 0
- f3: foreign process cgroup → must reject (non-zero)
- f4: generated function must declare env_name param
- f5: generated function must call samohost_read_cgroup_for_pid
- f6: call site must pass env name as 3rd arg
- f7: is-active fast path must be preserved (backward compat)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(port-check): cgroup-based same-env ownership — failed sibling unit no longer classified as foreign squatter (Bug A)

Root cause: `samohost_port_check_ok(port, unit)` used `systemctl is-active`
as the sole ownership signal. For multi-service apps (samograph: web + live
services), when `samograph-live@env.service` is FAILED but a sibling unit
`samograph-web@env.service` holds the same port (both got port=3111 in a
single-port allocation), the check returns false-positive "foreign squatter"
→ `rm -f SAMOHOST_CADDY_SNIPPET` + `caddy reload` → CF 525 on every heal
cycle.

Fix:
1. Add `samohost_read_cgroup_for_pid()` helper (reads /proc/<pid>/cgroup,
   stubbed in tests without real /proc access).
2. Extend `samohost_port_check_ok` with a third `env_name` argument.
   After the `is-active` fast path fails, iterate PIDs holding the port
   via `ss -tlnHp` and check `/proc/<pid>/cgroup` for `@<envName>.service`.
   Any process whose cgroup matches our env's suffix is classified as OURS
   (not foreign) — covers both: a failed same-env unit being restarted, and
   a sibling service that shares the same port value.
3. Update all call sites to pass `sq(t.name)` as the third argument.

Backward-compat: `systemctl is-active` fast path preserved; `env_name`
defaults to "" so callers that omit it get the old behaviour.

Verified: 2056/2056 tests pass (0 regressions). Real-world: samograph VM
had samograph-docs-add-robot-to-readme and samograph-docs-polish-user-facing-text
vhosts deleted on every heal cycle; fix prevents the deletion path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(dblab): document Bug B — loopback ZFS boot ordering fix for cx23 VMs

Root cause: `dblab-loopback.service` (a per-VM artifact, not in samohost
provisioning) declared `Before=zfs-import.target` but `zfs-import-cache.service`
started independently before the loopback was set up, causing it to abort
(signal=ABRT) — leaving the ZFS pool `dblab` unmounted on every reboot.

Manual workaround (operator steps):
1. Rewrite `dblab-loopback.service` to explicitly declare
   `Before=zfs-import-cache.service` (not just `zfs-import.target`).
2. Add a drop-in `/etc/systemd/system/zfs-import-cache.service.d/10-dblab-loopback.conf`
   with `Requires=dblab-loopback.service` + `After=dblab-loopback.service`.
   This makes the ZFS import wait for the loop device to exist.

Applied live on samograph VM 2026-07-16.

Bug B is NOT a samohost-code bug — the unit is a per-VM artifact installed
manually per the runbook. The platform gap (DBLab not auto-provisioned by
cloud-init) is tracked in docs/stack/dblab.md. The fix must be baked into
the future cloud-init module (PR #128 follow-up).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(port-check): replace gawk match() with grep -oP for PID extraction; tighten f6 env-name assertion

samorev REQUEST_CHANGES — two blocking fixes:

1. awk portability (finding 1): the 3-arg match(\$0, /pid=([0-9]+)/, a) form
   is a gawk extension. On mawk (Ubuntu's default awk alternative when gawk is
   not installed, which it is not in the bootstrap list), it errors silently:
   zero PIDs are extracted, the cgroup ownership check is skipped entirely, and
   the function falls through to return 1 (foreign squatter) — reproducing the
   exact bug this PR fixes. Replaced with:
     ss -tlnHp | grep -F ":${port} " | grep -oP 'pid=\K[0-9]+'
   GNU grep is always present on Ubuntu and the -oP / \K forms work correctly.
   Added integration test f8 that runs the grep fragment against the host's
   actual grep with a synthetic ss line and asserts both PIDs are extracted.
   f8 also asserts the gawk 3-arg pattern is NOT present in the generated fn.

2. f6 too weak (finding 2): the old regex matched any 3-token call. New test
   extracts every samohost_port_check_ok call line from the script, strips
   quoting from the third positional token, and compares it to target().name
   ("field-record-1-feat-x") exactly. A blank, wrong, or missing env name
   would now fail the test.

Also added inline comment at the ss -tlnHp block noting the grep -oP
portability rationale (non-blocking suggestion from samorev).

2057/2057 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: samo-agent <samo-agent@samohost.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SAMO client package: every client gets prod + dblab previews + auto-deploy

1 participant