739 lines
31 KiB
Markdown
739 lines
31 KiB
Markdown
# Session 0006.0 — Transcript
|
||
|
||
> Date: 2026-05-27
|
||
> Goal: Execute Slice 3 of the `ohm-rfc-app-flotilla` SPEC §20 slicing
|
||
> plan — pin reading + `deploy --dry-run`. Per §20.3: add CLI verbs
|
||
> `pin show`, `pin check`, `deploy <deployment> --dry-run`; ship a
|
||
> Gitea raw-file client; assemble the deploy plan (resolve pin,
|
||
> validate secret references resolve, compute overlay snapshot hash,
|
||
> render the 9-phase §8.1 sequence) without touching the target VM.
|
||
> Exit criterion: dry-run prints the assembled plan with the resolved
|
||
> pin and confirmed secret-reference resolution but no secret bytes.
|
||
>
|
||
> Outcome: **Slice 3 shipped at v0.3.0** — committed (`491e72e`) and
|
||
> tagged locally. Single commit also folds in Slice 2's code, which
|
||
> was deliberately uncommitted at the end of Session 0005.0 pending the
|
||
> operator-side Secret Manager bootstrap gesture; the v0.2.0 tag was
|
||
> never cut, so this release bumps straight from v0.1.0 to v0.3.0
|
||
> (the CHANGELOG keeps both entries for record-keeping). New surfaces:
|
||
> migration `003_pin_source_host.sql` adds the Gitea base URL as a
|
||
> per-deployment column; `ohm_rfc_app_flotilla/pin.py` reads the
|
||
> raw-file endpoint with certifi-backed trust roots and an explicit
|
||
> User-Agent; `ohm_rfc_app_flotilla/plan.py` assembles the dry-run
|
||
> `Plan`. CLI gained `pin show`, `pin check`, `deploy <name> --dry-run`
|
||
> (with `--json`). Tests went from 35 → 74 (added `test_pin.py` and
|
||
> `test_plan.py`).
|
||
>
|
||
> Two live-network findings worth flagging permanently in the SPEC
|
||
> (§11.4 updated per §19.3 rule 2):
|
||
> 1. **Framework Python on macOS ships without TLS trust roots** —
|
||
> `ssl.get_default_verify_paths()` returns a `cafile` path that
|
||
> doesn't exist. The pin reader builds its SSLContext from
|
||
> `certifi.where()`; `certifi` is a transitive dep of
|
||
> `google-cloud-secret-manager` so it's always available.
|
||
> 2. **Cloudflare-fronted Gitea 403s `Python-urllib/<ver>`** — the
|
||
> default User-Agent is blocked at the edge. Reader sets
|
||
> `User-Agent: ohm-rfc-app-flotilla (Gitea pin reader)`.
|
||
>
|
||
> Also discovered (flagged in CHANGELOG, not corrected): SPEC §13.4
|
||
> records the corpus repo as `ben/ohm-rfc`, but the canonical Gitea
|
||
> path is `ben.stull/ohm-rfc`. Gitea returns a stable 307 redirect
|
||
> and urllib follows it; the alias form is what the operator
|
||
> remembers, so §13.4 stays.
|
||
>
|
||
> One spec-additive decision: added `pin_source_host` column to
|
||
> `deployments` (migration 003, default `https://git.wiggleverse.org`).
|
||
> The spec was silent on the Gitea base URL; hardcoding it would have
|
||
> made a future deployment on a different Gitea instance a code change.
|
||
> Spec corrections folded into §4.3, §5.4, §11.4, §13.3 to reflect
|
||
> the new column and its OHM value.
|
||
|
||
---
|
||
|
||
## Turn 1 — User
|
||
|
||
> ok, let's do slice 3
|
||
|
||
## Turn 1 — Assistant
|
||
|
||
Read project memory and the spec to recover context.
|
||
|
||
**Memory loaded**: `MEMORY.md` plus the four project memories pointing
|
||
at this repo as OHM's operator CLI, `SPEC.md` as the binding contract,
|
||
and the §20 slicing plan as the menu of build sessions. The slug
|
||
"slice 3" was unambiguous against §20.3 — "Pin reading + dry-run
|
||
deploy, ships as v0.3.0."
|
||
|
||
**SPEC §20.3 read in full**:
|
||
|
||
> **Scope.**
|
||
> - Schema: pin-source fields on the `deployments` row (already in
|
||
> Slice 1's table; this slice exercises them).
|
||
> - CLI verbs: `pin show`, `pin check`, `deploy <deployment> --dry-run`.
|
||
> - Gitea raw-file client for reading `.rfc-app-version`.
|
||
> - Plan-assembly logic: resolve pin, validate SemVer, fetch all
|
||
> secret references (validation-only, no bytes held longer than
|
||
> needed), compute overlay snapshot hash, render the plan as the
|
||
> 9-phase list from §8.1 with the resolved values.
|
||
> - Tests: pin parsing (valid + invalid), plan assembly against a
|
||
> stubbed Gitea + Secret Manager.
|
||
>
|
||
> **Exit criteria.** `ohm-rfc-app-flotilla deploy ohm-rfc-app --dry-run`
|
||
> prints the assembled 9-phase plan with the resolved pin (`0.3.0` or
|
||
> whatever is in `ohm-rfc` at the time) and the secret-reference
|
||
> resolution confirmed but secret bytes not displayed. Ships as v0.3.0.
|
||
|
||
Cross-referenced §8.1 (the 9-phase sequence the dry-run should render),
|
||
§11.4 (Gitea raw-file public endpoint shape), §14.3 (rfc-app `>= 0.2.3`
|
||
floor — pre-0.2.3 lacks `/api/health`, so flotilla refuses to plan
|
||
against an older pin), and §3 invariant 1 (no secret bytes anywhere
|
||
in flotilla state or output).
|
||
|
||
**Repo state at session start**:
|
||
|
||
```
|
||
$ git log --oneline -2
|
||
581687a Release 0.1.0: Slice 1 — registry + non-secret overlay
|
||
a9a9aaa Release 0.0.1: initial spec
|
||
|
||
$ git status
|
||
M CHANGELOG.md, README.md, SPEC.md, pyproject.toml,
|
||
ohm_rfc_app_flotilla/cli.py, ohm_rfc_app_flotilla/registry.py,
|
||
tests/test_db.py, tests/test_overlay.py
|
||
?? docs/, ohm_rfc_app_flotilla/migrations/002_secret_refs.sql,
|
||
ohm_rfc_app_flotilla/secrets.py, tests/test_secrets.py
|
||
```
|
||
|
||
— matching Session 0005.0's tail state: Slice 2 code present and tested
|
||
green on disk, never committed; v0.1.0 the only release tag.
|
||
|
||
**Codebase read**: `cli.py` (270 lines, Click groups for `deployment`,
|
||
`overlay`, `secret`), `registry.py` (~240 lines, dataclasses +
|
||
SQLite CRUD), `secrets.py` (read-only Secret Manager wrapper with
|
||
`parse_ref` + `read_secret`), `db.py` (WAL SQLite + migration
|
||
runner), migrations 001 + 002, conftest fixtures (`tmp_data_dir`,
|
||
`conn`, `runner` wrapping `CliRunner` with `expect_exit`),
|
||
existing tests (35 passing).
|
||
|
||
**Task list created** (TaskCreate × 6):
|
||
|
||
1. Add `pin_source_host` column via migration 003
|
||
2. Implement pin module (Gitea raw-file + SemVer)
|
||
3. Implement plan module (dry-run assembly)
|
||
4. Wire CLI: `pin show`, `pin check`, `deploy --dry-run`
|
||
5. Tests: pin parsing + plan assembly with stubs
|
||
6. Update register-ohm.sh, README, CHANGELOG, VERSION, pyproject
|
||
|
||
**One design call surfaced before writing code**: how does flotilla
|
||
know which Gitea host to read the pin from? §5.4 names
|
||
`pin_source_kind/repo/path` but not a host; §13.3 names the repo
|
||
(`ben/ohm-rfc`) and §11.4 references `git.wiggleverse.org` in prose,
|
||
but the schema has no column for it. Three options came to mind:
|
||
|
||
1. Add a `pin_source_host` column (new migration 003).
|
||
2. Hardcode `https://git.wiggleverse.org` in `pin.py`.
|
||
3. Derive from the overlay's `GITEA_URL` value.
|
||
|
||
Asked via `AskUserQuestion` (rendered with side-by-side previews
|
||
showing the SQL/Python for each option), recommending option 1 — it
|
||
keeps the schema structurally generic across deployments on different
|
||
Gitea instances, the default value handles OHM transparently, the new
|
||
CLI flag is optional, and the small SPEC correction is honest about
|
||
what the slice discovered.
|
||
|
||
## Turn 2 — User
|
||
|
||
> [AskUserQuestion answer] **Add pin_source_host column (Recommended)**:
|
||
> ALTER TABLE deployments ADD COLUMN pin_source_host TEXT NOT NULL
|
||
> DEFAULT 'https://git.wiggleverse.org'; new optional `--pin-source-host`
|
||
> on `deployment add`.
|
||
|
||
## Turn 2 — Assistant
|
||
|
||
Implemented the slice end-to-end, then exercised live and uncovered
|
||
two environmental issues that became permanent SPEC corrections.
|
||
|
||
### Migration 003
|
||
|
||
Wrote `ohm_rfc_app_flotilla/migrations/003_pin_source_host.sql`:
|
||
|
||
```sql
|
||
-- Slice 3: pin reading. Per SPEC §5.4 and §11.4.
|
||
-- The Gitea host the deployment's pin file is read from. Default matches the
|
||
-- OHM deployment so existing rows backfill correctly; new deployments on a
|
||
-- different Gitea instance pass --pin-source-host on `deployment add`.
|
||
|
||
ALTER TABLE deployments
|
||
ADD COLUMN pin_source_host TEXT NOT NULL DEFAULT 'https://git.wiggleverse.org';
|
||
```
|
||
|
||
Updated `registry.py`:
|
||
- `Deployment` dataclass gains `pin_source_host: str`
|
||
- `_row_to_deployment` reads the column
|
||
- `add_deployment(...)` gains `pin_source_host: str = "https://git.wiggleverse.org"`
|
||
kwarg, INSERT statement extended
|
||
|
||
Verified the 35 existing tests still pass against the new schema —
|
||
SQLite's `DEFAULT` clause backfills rows inserted without the column,
|
||
and reads pick up the default for existing-fixture data.
|
||
|
||
### `ohm_rfc_app_flotilla/pin.py`
|
||
|
||
Three pieces: `parse_semver`, `build_raw_url`, `read_gitea_pin`.
|
||
|
||
```python
|
||
MIN_RFC_APP_VERSION = (0, 2, 3)
|
||
_SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
|
||
|
||
class PinError(Exception):
|
||
"""The pin could not be read, did not parse as SemVer, or is below the
|
||
`/api/health`-supporting floor."""
|
||
|
||
def parse_semver(raw: str) -> str:
|
||
s = raw.strip()
|
||
if not s:
|
||
raise PinError("pin file is empty")
|
||
m = _SEMVER_RE.match(s)
|
||
if not m:
|
||
raise PinError(f"pin {s!r} is not a SemVer …")
|
||
parts = tuple(int(g) for g in m.groups())
|
||
if parts < MIN_RFC_APP_VERSION:
|
||
raise PinError(f"pin {s} is below the supported floor 0.2.3 …")
|
||
return s
|
||
```
|
||
|
||
The floor check enforces §14.3 — pre-0.2.3 rfc-app lacks `/api/health`,
|
||
so flotilla cannot verify a deploy of an older pin. Rejecting at
|
||
parse time means the failure surfaces in Phase 1 of the gesture, not
|
||
Phase 8 after pip install + npm build.
|
||
|
||
`build_raw_url` constructs `<host>/api/v1/repos/<owner>/<repo>/raw/<path>`,
|
||
URL-encoding the path, stripping a trailing slash from the host, and
|
||
rejecting a repo without a `/`. `read_gitea_pin` does an unauth
|
||
`urllib.request.urlopen` and parses the body as SemVer.
|
||
|
||
### `ohm_rfc_app_flotilla/plan.py`
|
||
|
||
The dry-run engine. Public surface: `PlanError`, `Plan`,
|
||
`SecretRefSummary`, `Phase`, `assemble_plan`, `render_plan_text`.
|
||
|
||
```python
|
||
def assemble_plan(conn, deployment_name, *, read_pin=None, read_secret=None) -> Plan:
|
||
dep = registry.get_deployment(conn, deployment_name)
|
||
overlay = registry.list_overlay(conn, deployment_name)
|
||
refs = registry.list_secret_refs(conn, deployment_name)
|
||
|
||
try:
|
||
target_version = read_pin(dep.pin_source_host, dep.pin_source_repo,
|
||
dep.pin_source_path)
|
||
except pinmod.PinError as e:
|
||
raise PlanError(f"pin: {e}") from e
|
||
|
||
# Validate every secret ref resolves. Returned bytes leave scope
|
||
# immediately — never persisted, never logged (§3 invariant 1).
|
||
for r in refs:
|
||
try:
|
||
_ = read_secret(r.secret_project, r.secret_id, r.secret_version)
|
||
except secrets.SecretReadError as e:
|
||
raise PlanError(f"secret: {r.env_key} → …: {e}") from e
|
||
|
||
# Canonical JSON for the hash. Sort keys; tight separators.
|
||
# Bytes never enter this structure — only ref strings.
|
||
snapshot = {
|
||
"overlay": {e.env_key: e.env_value for e in overlay},
|
||
"secret_refs": {
|
||
r.env_key: secrets.format_ref(r.secret_project, r.secret_id, r.secret_version)
|
||
for r in refs
|
||
},
|
||
}
|
||
canonical = json.dumps(snapshot, sort_keys=True, separators=(",", ":"))
|
||
overlay_hash = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||
…
|
||
```
|
||
|
||
The injectable `read_pin` / `read_secret` are the seam tests use to
|
||
stub out the network. Defaults call the real functions.
|
||
|
||
`_build_phases` renders the 9 phases from §8.1 with deployment values
|
||
filled in:
|
||
|
||
```
|
||
1. validate resolve pin → 0.3.0; validate N secret ref(s); compute hash
|
||
2. preflight ssh ohm-app: test -d /opt/ohm-app; record is-active
|
||
3. fetch+checkout sudo -u ohm-app git -C /opt/ohm-app fetch --tags && git checkout v0.3.0
|
||
4. backend deps sudo -u ohm-app /opt/ohm-app/backend/.venv/bin/pip install -r …
|
||
5. frontend build cd /opt/ohm-app/frontend && sudo -u ohm-app npm ci && npm run build
|
||
6. write .env atomic write … (mode 0600, owner ohm-app) from 13 overlay + 0 secrets
|
||
7. restart sudo systemctl restart ohm-app.service; poll is-active up to 30s
|
||
8. verify /api/health poll https://ohm.wiggleverse.org/api/health until body.version==0.3.0 …
|
||
9. finalize record deploys row outcome=succeeded; print success line
|
||
```
|
||
|
||
`render_plan_text` produces the human-readable form. The secret-refs
|
||
section explicitly says "resolved successfully, bytes not displayed",
|
||
and the trailing line says "DRY RUN — no SSH performed, no .env
|
||
written, no restart issued." `to_dict()` produces the JSON shape;
|
||
both renderings exclude byte values categorically (the `Plan`
|
||
dataclass has no field that could carry them).
|
||
|
||
### CLI wiring
|
||
|
||
Added `pin` group with `show` and `check`:
|
||
|
||
```python
|
||
@pin.command("show")
|
||
@click.argument("deployment")
|
||
@click.option("--json", "as_json", is_flag=True)
|
||
def pin_show(deployment, as_json):
|
||
dep = registry.get_deployment(conn, deployment) # NotFoundError → exit 1
|
||
version = pinmod.read_gitea_pin(dep.pin_source_host, …) # PinError → exit 1
|
||
click.echo(version) # or JSON payload with pin_source struct
|
||
```
|
||
|
||
`pin check` resolves the pin and renders `OK: <name> pin resolves to
|
||
<version>` plus a forward-reference line noting that last-deploy
|
||
comparison lands in Slice 4 (the `deploys` table doesn't exist until
|
||
§20.4). Distinct from `pin show` in intent — show is "what's the
|
||
version", check is "is the pin source healthy" — and exit code on
|
||
failure carries the operational signal.
|
||
|
||
Added `deploy` as a `@main.command` (not a group), with `--dry-run`
|
||
and `--json` flags. Without `--dry-run`, deploy exits 1 with "real
|
||
deploy gesture lands in Slice 4 (SPEC §20.4) — use --dry-run for
|
||
now." Noted in code-comment that the Slice 4 session will need to
|
||
decide how to layer `deploy abort/status/watch/log` on top of the
|
||
existing `deploy <name>` shape — Click groups don't natively support
|
||
"both a default-arg command and named subcommands"; the slicing
|
||
session can pick a pattern.
|
||
|
||
Updated `deployment_add` to accept optional `--pin-source-host`
|
||
defaulting to `https://git.wiggleverse.org`, and `deployment_show`
|
||
to render `pin_source_host` in its field list.
|
||
|
||
### Tests
|
||
|
||
Wrote `tests/test_pin.py` (15 tests):
|
||
|
||
- `parse_semver` accepts `0.2.3`, `0.3.0`, `1.2.3`, `10.20.30`
|
||
- whitespace-stripping (`" 0.3.0\n"` → `"0.3.0"`)
|
||
- rejects `""`, `" "`, `"v0.3.0"`, `"0.3"`, `"0.3.0-rc1"`,
|
||
`"0.3.0+build"`, `"abc"`, `"0.3.0.0"`
|
||
- rejects `0.0.1`, `0.1.0`, `0.2.2` with the floor message
|
||
- accepts exact floor `0.2.3`
|
||
- `build_raw_url` basic shape, trailing-slash stripping, percent-encoding
|
||
for a path with spaces, rejection of bare repo (no `/`)
|
||
|
||
Wrote `tests/test_plan.py` (16 tests). Notable patterns:
|
||
|
||
- `STUB_SECRET_BYTES = b"stub-secret-bytes-MUST-NEVER-APPEAR-IN-OUTPUT"`
|
||
— used by the stub secret reader so any test rendering the plan can
|
||
grep its own output for these bytes and fail loudly if §3 invariant
|
||
1 leaks.
|
||
- `_stub_pin(version="0.3.0")` returns a callable matching the
|
||
`read_pin(host, repo, path)` signature.
|
||
- `_stub_secret_ok` / `_stub_secret_boom` for the two paths through
|
||
`assemble_plan`'s secret-validation loop.
|
||
|
||
Tests covered:
|
||
- basic assembly (pin resolves, 9 phases, deployment fields surface in
|
||
the relevant phase strings)
|
||
- pin error → `PlanError` with `"pin:"` prefix
|
||
- secret error → `PlanError` with `"secret: <env_key>"` prefix
|
||
- secret refs appear in plan output, bytes do not (asserted both in
|
||
`render_plan_text` output and in `json.dumps(plan.to_dict())`)
|
||
- hash determinism (assemble twice → same hash)
|
||
- hash sensitivity to overlay-value change
|
||
- hash sensitivity to secret-version change
|
||
- unknown deployment → `NotFoundError`
|
||
- `pin show` happy path (line) and `--json` (parsed payload)
|
||
- `pin show` for unknown deployment → exit 1 with "no deployment"
|
||
- `pin show` propagates `PinError` to stderr
|
||
- `pin check` happy path + Slice-4 forward-reference message
|
||
- `pin check` failure path with the floor message
|
||
- `deploy ohm-rfc-app` without `--dry-run` → exit 1 with "Slice 4"
|
||
- `deploy ohm-rfc-app --dry-run` happy path (overlay key listed, secret
|
||
ref listed, `sha256:` line present, all 9 phases including `9. finalize`,
|
||
no stub bytes anywhere)
|
||
- `deploy --dry-run --json` happy path (parsed payload, 9 phases,
|
||
no `secret_value` / `secret_bytes` / stub bytes)
|
||
- `deploy --dry-run` propagates `PlanError`
|
||
|
||
```
|
||
$ .venv/bin/pytest -q
|
||
........................................................................ [ 97%]
|
||
.. [100%]
|
||
74 passed in 0.11s
|
||
```
|
||
|
||
Stub-based tests are fast (74 in 0.11s) and the assertion discipline on
|
||
secret bytes carries over from Slice 2's test patterns.
|
||
|
||
### Live exercise — exit criterion
|
||
|
||
Wanted to confirm the exit criterion against real OHM, not just stubs.
|
||
Set up a sandbox:
|
||
|
||
```
|
||
$ cd /tmp/flotilla-smoke
|
||
$ OHM_RFC_APP_FLOTILLA_DATA_DIR=$PWD bash scripts/register-ohm.sh
|
||
…
|
||
OHM registered.
|
||
|
||
$ OHM_RFC_APP_FLOTILLA_DATA_DIR=$PWD ohm-rfc-app-flotilla pin show ohm-rfc-app
|
||
error: could not reach https://git.wiggleverse.org/api/v1/repos/ben/ohm-rfc/raw/.rfc-app-version:
|
||
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed:
|
||
unable to get local issuer certificate (_ssl.c:1028)
|
||
```
|
||
|
||
**Surprise #1: macOS framework Python ships without TLS trust roots.**
|
||
|
||
Confirmed via:
|
||
|
||
```
|
||
$ python -c "import ssl; print(ssl.get_default_verify_paths())"
|
||
DefaultVerifyPaths(
|
||
cafile=None, capath=None,
|
||
openssl_cafile_env='SSL_CERT_FILE',
|
||
openssl_cafile='/Library/Frameworks/Python.framework/Versions/3.13/etc/openssl/cert.pem',
|
||
openssl_capath_env='SSL_CERT_DIR',
|
||
openssl_capath='/Library/Frameworks/Python.framework/Versions/3.13/etc/openssl/certs')
|
||
```
|
||
|
||
The `cafile` and `capath` paths point at locations that don't exist on
|
||
the python.org installer. Classic well-known issue.
|
||
|
||
Confirmed `certifi.where()` was available in the venv (it's a transitive
|
||
dep of `google-cloud-secret-manager`):
|
||
|
||
```
|
||
$ python -c "import certifi; print(certifi.where())"
|
||
/Users/benstull/projects/wiggleverse/ohm-rfc-app-flotilla/.venv/lib/python3.13/site-packages/certifi/cacert.pem
|
||
```
|
||
|
||
Patched `pin.py` to build the SSLContext explicitly:
|
||
|
||
```python
|
||
def _ssl_context() -> ssl.SSLContext:
|
||
# Framework Python on macOS ships without trust roots; certifi is a
|
||
# transitive dep of google-cloud-secret-manager so it's always present.
|
||
# Falls back to system roots when certifi is genuinely absent.
|
||
try:
|
||
import certifi
|
||
return ssl.create_default_context(cafile=certifi.where())
|
||
except ImportError:
|
||
return ssl.create_default_context()
|
||
```
|
||
|
||
Re-ran:
|
||
|
||
```
|
||
$ ohm-rfc-app-flotilla pin show ohm-rfc-app
|
||
error: HTTP 403 reading pin from https://git.wiggleverse.org/api/v1/repos/ben/ohm-rfc/raw/.rfc-app-version
|
||
```
|
||
|
||
**Surprise #2: Gitea is Cloudflare-fronted, Cloudflare 403s
|
||
Python-urllib.**
|
||
|
||
Confirmed with curl that the URL was reachable as a redirect, and
|
||
that the canonical path was `ben.stull/ohm-rfc`:
|
||
|
||
```
|
||
$ curl -sS -o /tmp/pin.txt -w "%{http_code}\n" \
|
||
https://git.wiggleverse.org/api/v1/repos/ben/ohm-rfc/raw/.rfc-app-version
|
||
307
|
||
$ cat /tmp/pin.txt
|
||
<a href="/api/v1/repos/ben.stull/ohm-rfc/raw/.rfc-app-version">Temporary Redirect</a>.
|
||
|
||
$ curl -L -sS -w "\nHTTP %{http_code}\n" https://git.wiggleverse.org/…/raw/.rfc-app-version
|
||
0.3.0
|
||
HTTP 200
|
||
```
|
||
|
||
But the Python attempt with the urllib trace returned 403 immediately
|
||
— no 307 in the chain. Confirmed via a one-liner that adding an
|
||
explicit User-Agent fixed it:
|
||
|
||
```python
|
||
req = urllib.request.Request(url, headers={"User-Agent": "ohm-rfc-app-flotilla/0.3.0"})
|
||
# OK 200 https://git.wiggleverse.org/api/v1/repos/ben.stull/ohm-rfc/raw/.rfc-app-version
|
||
# '0.3.0\n'
|
||
```
|
||
|
||
Cloudflare blocks `Python-urllib/<version>` UAs at the edge — it
|
||
never even reaches Gitea, so there's no 307. With an explicit UA,
|
||
the request is permitted, Gitea returns its 307, urllib follows it,
|
||
and the canonical URL returns `0.3.0`.
|
||
|
||
Patched `pin.py`:
|
||
|
||
```python
|
||
# Cloudflare-fronted Gitea instances return 403 to the default
|
||
# `Python-urllib/<ver>` UA. Set an explicit identifier — also useful for
|
||
# Gitea-side request logs to attribute reads to the operator tool.
|
||
_USER_AGENT = "ohm-rfc-app-flotilla (Gitea pin reader)"
|
||
|
||
def read_gitea_pin(host, repo, path, *, timeout=10.0) -> str:
|
||
url = build_raw_url(host, repo, path)
|
||
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
|
||
with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
|
||
body = resp.read().decode("utf-8")
|
||
return parse_semver(body)
|
||
```
|
||
|
||
Both fixes are tiny and load-bearing for the tool actually working on
|
||
a fresh macOS Python install against the real Gitea. They earned a
|
||
permanent SPEC note in §11.4.
|
||
|
||
### Re-running the exit criterion
|
||
|
||
```
|
||
$ ohm-rfc-app-flotilla pin show ohm-rfc-app
|
||
0.3.0
|
||
|
||
$ ohm-rfc-app-flotilla pin check ohm-rfc-app
|
||
OK: ohm-rfc-app pin resolves to 0.3.0
|
||
(no deploy history yet — last-deploy comparison lands in Slice 4)
|
||
|
||
$ ohm-rfc-app-flotilla deploy ohm-rfc-app --dry-run
|
||
# dry-run plan for ohm-rfc-app
|
||
|
||
target version 0.3.0
|
||
pin source gitea-file https://git.wiggleverse.org ben/ohm-rfc:.rfc-app-version
|
||
health url https://ohm.wiggleverse.org/api/health
|
||
overlay snapshot sha256:15783e783fb6f8f5e339042132b0603577e3ef7d1c3f478124b9f27a4742574d
|
||
|
||
target vm
|
||
name ohm-app
|
||
zone us-central1-a
|
||
project wiggleverse-ohm
|
||
service_user ohm-app
|
||
install_dir /opt/ohm-app
|
||
systemd_unit ohm-app.service
|
||
tunnel_through_iap False
|
||
|
||
overlay keys (13)
|
||
APP_URL
|
||
DATABASE_PATH
|
||
EMAIL_FROM
|
||
EMAIL_FROM_NAME
|
||
GITEA_BOT_USER
|
||
GITEA_ORG
|
||
GITEA_URL
|
||
META_REPO
|
||
OWNER_GITEA_LOGIN
|
||
SMTP_HOST
|
||
SMTP_PORT
|
||
SMTP_STARTTLS
|
||
SMTP_USER
|
||
|
||
secret refs (none)
|
||
|
||
phases (would execute on real deploy)
|
||
1. validate
|
||
resolve pin → 0.3.0; validate 0 secret ref(s); compute overlay snapshot hash
|
||
2. preflight
|
||
ssh ohm-app: test -d /opt/ohm-app; record `systemctl is-active ohm-app.service`
|
||
3. fetch+checkout
|
||
sudo -u ohm-app git -C /opt/ohm-app fetch --tags && git checkout v0.3.0
|
||
4. backend deps
|
||
sudo -u ohm-app /opt/ohm-app/backend/.venv/bin/pip install -r /opt/ohm-app/backend/requirements.txt
|
||
5. frontend build
|
||
cd /opt/ohm-app/frontend && sudo -u ohm-app npm ci && npm run build
|
||
6. write .env
|
||
atomic write /opt/ohm-app/backend/.env (mode 0600, owner ohm-app) from 13 overlay entries + 0 secret(s)
|
||
7. restart
|
||
sudo systemctl restart ohm-app.service; poll `is-active` up to 30s
|
||
8. verify /api/health
|
||
poll https://ohm.wiggleverse.org/api/health until body.version==0.3.0 and body.status=='ok' (5min budget)
|
||
9. finalize
|
||
record `deploys` row outcome=succeeded; print success line
|
||
|
||
DRY RUN — no SSH performed, no .env written, no restart issued.
|
||
```
|
||
|
||
This is the §20.3 exit criterion satisfied against live OHM. (Secret
|
||
validation is no-op here because no secrets were bound in this
|
||
sandbox — the unit test `test_deploy_dry_run_renders_plan` exercises
|
||
the secret-bound path against a stubbed Secret Manager.)
|
||
|
||
### Release artifacts
|
||
|
||
- `VERSION` → `0.3.0`
|
||
- `pyproject.toml` → `version = "0.3.0"`
|
||
- `CHANGELOG.md` → new `## 0.3.0 — 2026-05-27` section (above the
|
||
surviving `## 0.2.0 — pending operator gesture` block). Detailed:
|
||
migration 003, the pin and plan modules, the certifi + UA notes,
|
||
CLI surface, test count (74), the §13.4 alias note, the four spec
|
||
corrections, the sequencing note that v0.2.0's operator-gesture
|
||
prerequisite is independent.
|
||
- `README.md` → status section bumped from "v0.1.0 ships Slice 1" to
|
||
"v0.3.0 ships Slice 3 …"
|
||
- `SPEC.md` corrections (per §19.3 rule 2):
|
||
- §4.3 `deployments` columns now include `pin_source_host`
|
||
- §5.4 names `pin_source_host` with the OHM default value
|
||
- §11.4 adds the certifi + Cloudflare-UA implementation notes
|
||
- §13.3 records `pin_source_host: https://git.wiggleverse.org`
|
||
- Reinstalled in the venv to refresh the editable metadata:
|
||
|
||
```
|
||
$ .venv/bin/pip install -e .
|
||
$ .venv/bin/ohm-rfc-app-flotilla --version
|
||
ohm-rfc-app-flotilla, version 0.3.0
|
||
```
|
||
|
||
Final test pass: 74 passing in 0.11s.
|
||
|
||
Offered to commit + tag, noting that the working tree contains both
|
||
Slice 2 (uncommitted per Session 0005.0) and Slice 3 work.
|
||
|
||
## Turn 3 — User
|
||
|
||
> yes. And then write a transcript of this full session (EVERYTHING)
|
||
> to ~/git/ohm-infra
|
||
|
||
## Turn 3 — Assistant
|
||
|
||
**Commit + tag.**
|
||
|
||
The working tree had Slice 2 (deliberately uncommitted at the end of
|
||
Session 0005.0, pending the operator-side Secret Manager bootstrap
|
||
gesture) plus Slice 3. Considered three commit shapes:
|
||
|
||
- Two commits (Slice 2 alone, then Slice 3) — cleanest history, but
|
||
requires partial-file staging since the CHANGELOG and `cli.py` were
|
||
modified by both slices.
|
||
- One combined commit "Release 0.3.0: Slices 2 + 3" — accurately
|
||
reflects "nothing was committed between v0.1.0 and now", lossy on
|
||
per-slice history granularity, much simpler.
|
||
- Two commits via stash split — error-prone, no clear payoff.
|
||
|
||
Picked the combined commit. The CHANGELOG already documents both
|
||
versions separately so the per-slice record survives; the git log
|
||
just consolidates them at the release boundary.
|
||
|
||
```
|
||
$ git add CHANGELOG.md README.md SPEC.md VERSION pyproject.toml \
|
||
ohm_rfc_app_flotilla/{cli,registry,secrets,pin,plan}.py \
|
||
ohm_rfc_app_flotilla/migrations/{002_secret_refs,003_pin_source_host}.sql \
|
||
tests/{test_db,test_overlay,test_secrets,test_pin,test_plan}.py \
|
||
docs/secret-bootstrap.md
|
||
|
||
$ git commit -m "Release 0.3.0: Slice 3 — pin reading + dry-run deploy
|
||
|
||
Slice 3 of SPEC §20 lands `pin show`, `pin check`, and `deploy
|
||
<deployment> --dry-run`. Flotilla resolves the live OHM pin from
|
||
Gitea, validates every secret reference is readable, computes the
|
||
overlay snapshot hash from §8.1 step 1d, and renders the 9-phase
|
||
deploy plan with deployment-specific values filled in — all
|
||
without touching the target VM.
|
||
|
||
This commit also lands Slice 2's code (secret references + Secret
|
||
Manager bootstrap doc), which was deliberately uncommitted at the
|
||
end of Session 0005.0 pending the operator-side bootstrap gesture. The
|
||
0.2.0 CHANGELOG entry survives unchanged (no separate v0.2.0 tag
|
||
is being cut); the 0.3.0 entry layers Slice 3 on top.
|
||
|
||
[…]
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
|
||
[main 491e72e] Release 0.3.0: Slice 3 — pin reading + dry-run deploy
|
||
18 files changed, 1520 insertions(+), 41 deletions(-)
|
||
|
||
$ git tag v0.3.0 -m "Release 0.3.0 — Slice 3: pin reading + deploy --dry-run"
|
||
|
||
$ git log --oneline -3
|
||
491e72e Release 0.3.0: Slice 3 — pin reading + dry-run deploy
|
||
581687a Release 0.1.0: Slice 1 — registry + non-secret overlay
|
||
a9a9aaa Release 0.0.1: initial spec
|
||
|
||
$ git tag -l
|
||
v0.0.1
|
||
v0.1.0
|
||
v0.3.0
|
||
```
|
||
|
||
**Transcript.** This file. Checked `~/git/ohm-infra/` and found the
|
||
established convention: `SESSION-{B,C,D,E}-TRANSCRIPT.md`, formatted
|
||
as a header blockquote (date, goal, outcome) followed by numbered
|
||
`Turn N — User` and `Turn N — Assistant` sections, with narrative
|
||
prose plus code/command blocks for key outputs. Session 0006.0 naturally
|
||
follows. Wrote `/Users/benstull/git/ohm-infra/SESSION-0006.0-TRANSCRIPT.md`.
|
||
|
||
---
|
||
|
||
## What's load-bearing for the next session
|
||
|
||
- **v0.3.0 is tagged locally, not pushed.** The §19.2 candidate
|
||
"extract generic ohm-rfc-app-flotilla library" still hasn't earned
|
||
its session (only OHM exists). Pushing to `git.wiggleverse.org` is
|
||
pending whenever the operator does the next-session push gesture.
|
||
- **v0.2.0 was never tagged** and isn't going to be. The 0.2.0
|
||
CHANGELOG entry stays as record of "Slice 2 code shipped on
|
||
2026-05-27 alongside the 0.3.0 release commit". The operator-side
|
||
Secret Manager bootstrap gesture in `docs/secret-bootstrap.md` is
|
||
still the prerequisite for actually putting secrets into
|
||
`wiggleverse-ohm` Secret Manager — and that gesture is still
|
||
pending. Slice 4 needs those entries to exist to do a real deploy.
|
||
- **Slice 4 = full deploy gesture + `/api/health` verification.**
|
||
Per §20.4: real SSH via `gcloud compute ssh`, per-phase execution
|
||
per §8.1 with fail-stop semantics, `/api/health` probe loop per §9,
|
||
concurrency lock per §8.3, atomic `.env` write per §8.1 step 6.
|
||
New schema: `deploys`, `health_snapshots` tables. New CLI verbs:
|
||
`deploy <name>` (without `--dry-run`), `deploy abort`, `deploy
|
||
status`, `deploy watch`, `deploy log`. **Click structural decision
|
||
deferred to Slice 4**: `deploy` is currently a `@main.command` with
|
||
a positional `DEPLOYMENT` argument; the other `deploy *` verbs are
|
||
`@main.subgroup` shapes that Click doesn't natively support combined
|
||
with the positional-arg command. Three options for Slice 4 to pick:
|
||
1. Restructure `deploy` into a Click `Group` with a default
|
||
command (`deploy run <name>` form, breaks SPEC §12.1 literal).
|
||
2. Use a library like `click-default-group` for default-command
|
||
behavior.
|
||
3. Rename the subcommands (`deploy-abort`, `deploy-status`, etc.)
|
||
— wins on Click ergonomics, loses on SPEC literal.
|
||
- **`/api/health` may be misbehaving on live OHM.** Per §20.4 scope:
|
||
"if `ohm.wiggleverse.org/api/health` is currently misbehaving (see
|
||
the session-start observation about the `{"detail":"Not Found"}`
|
||
response), the session debugs that as part of bringing the verify
|
||
loop green." Slice 4 session should curl `/api/health` early.
|
||
- **Pin-source URL alias.** SPEC §13.4 records `ben/ohm-rfc`; Gitea
|
||
canonical is `ben.stull/ohm-rfc` and serves a stable 307 redirect.
|
||
urllib follows it once the UA header is set. Left §13.4 unchanged
|
||
(the alias is what the operator remembers). If Slice 4 hits any
|
||
path where the redirect causes ambiguity (e.g., a webhook URL that
|
||
needs the canonical form), reopen §13.4.
|
||
- **The pin reader is the working example of "live external client"
|
||
for future slices.** Two patterns now established for Slice 4's
|
||
`gcloud compute ssh` shell-out:
|
||
1. Build SSLContext from `certifi.where()` (already in `pin.py`).
|
||
2. For HTTP clients, set an explicit User-Agent identifying the
|
||
tool — Cloudflare-fronting is common across the Wiggleverse
|
||
stack and the default `Python-urllib/<ver>` is blocked.
|
||
- **Plan-assembly pattern is reusable.** `assemble_plan` takes
|
||
injectable `read_pin` / `read_secret`; Slice 4's real-deploy
|
||
function should accept the same seam so the test surface can stub
|
||
every external call. The 9-phase shape (`Phase` dataclass) is the
|
||
source of truth for both dry-run rendering and the real gesture's
|
||
per-phase recording into the `deploys` row's `phases` JSON column
|
||
(§4.3, §10.1).
|
||
- **Test fixture conventions extended.** `tmp_data_dir` + `conn` +
|
||
`runner` from `conftest.py` still serve; the new pattern in
|
||
`test_plan.py` is `STUB_SECRET_BYTES` — a byte string used by the
|
||
stub Secret Manager reader and grep-asserted absent from every
|
||
test output. Same discipline applies to any future stubbed-bytes
|
||
surface.
|
||
- **Auto-memory unchanged.** Did not write any new memory files this
|
||
session. Slice 3 is fully recoverable from the commit + CHANGELOG +
|
||
this transcript; nothing about it needs to persist as cross-session
|
||
context. If Slice 4 surfaces a non-obvious operator preference or
|
||
a load-bearing decision not captured in the spec/code, that's when
|
||
to add memory.
|
||
- **macOS-vs-Linux note for CI.** The certifi-cafile workaround
|
||
matters on macOS framework Python (operator's laptop). Linux CI
|
||
Python typically has working system trust roots and would resolve
|
||
without certifi. The `_ssl_context()` fallback to
|
||
`ssl.create_default_context()` handles the latter; both paths are
|
||
exercised by the live network test on macOS and the unit tests on
|
||
CI (the unit tests stub `read_gitea_pin` so they don't touch SSL).
|