From 9eef301c9ce1df8d8a363b1a3dee924ff5a4220d Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 3 Jul 2026 22:57:34 +0300 Subject: [PATCH] chore: adopt planning-convention v1.1.2 and migrate planning docs Fresh-adopt lesnik512/planning-convention (index.py, templates, planning/README.md, justfile index/check-planning recipes wired into lint-ci, CLAUDE.md workflow/ architecture pointers, architecture/README.md promotion rule). Migrate the existing extraction plan and design spec into planning/changes/2026-07-03.01-compose2pod-extraction/, formalize two prior decisions as records under planning/decisions/, and record the pod-cleanup trap follow-up in planning/deferred.md. --- CLAUDE.md | 39 ++ architecture/README.md | 17 + justfile | 9 + planning/.convention-version | 1 + planning/README.md | 145 +++++++ planning/_templates/change.md | 32 ++ planning/_templates/decision.md | 23 ++ planning/_templates/design.md | 49 +++ planning/_templates/glossary.md | 15 + planning/_templates/plan.md | 46 +++ planning/_templates/release.md | 38 ++ .../design.md | 390 ++++++++++++++++++ .../plan.md | 93 +++-- ...hcheck-start-period-retries-passthrough.md | 62 +++ .../2026-07-03-zero-dependency-core.md | 64 +++ planning/deferred.md | 17 + planning/index.py | 197 +++++++++ 17 files changed, 1197 insertions(+), 40 deletions(-) create mode 100644 CLAUDE.md create mode 100644 architecture/README.md create mode 100644 planning/.convention-version create mode 100644 planning/README.md create mode 100644 planning/_templates/change.md create mode 100644 planning/_templates/decision.md create mode 100644 planning/_templates/design.md create mode 100644 planning/_templates/glossary.md create mode 100644 planning/_templates/plan.md create mode 100644 planning/_templates/release.md create mode 100644 planning/changes/2026-07-03.01-compose2pod-extraction/design.md rename docs/superpowers/plans/2026-07-03-compose2pod-extraction.md => planning/changes/2026-07-03.01-compose2pod-extraction/plan.md (95%) create mode 100644 planning/decisions/2026-07-03-healthcheck-start-period-retries-passthrough.md create mode 100644 planning/decisions/2026-07-03-zero-dependency-core.md create mode 100644 planning/deferred.md create mode 100644 planning/index.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..da6f0d0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,39 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this repo is + +`compose2pod` — a dependency-free, stdlib-only Python package and CLI that +converts a Docker Compose document into a POSIX `sh` script running the +services as a single Podman pod. See `README.md` for usage and the supported +compose subset. + +## Workflow + +This repo follows Artur Shiriev's canonical planning convention +(`lesnik512/planning-convention`, applied version in +`planning/.convention-version`). Before starting any non-trivial change, read +[`planning/README.md`](planning/README.md)'s **Quick path** section — it is +the authoritative process for choosing a lane (Full / Lightweight / Tiny) and +shaping the change bundle. Run `just check-planning` before pushing. + +## Architecture + +`architecture/` (repo root) holds the living truth about what the system does +now — one file per capability, plus `architecture/glossary.md` for shared +terminology. When a change alters a capability's behavior, update the +matching `architecture/.md` in the same PR. + +## Quality gates + +- Core package has **zero runtime dependencies** (stdlib only); PyYAML is + only the optional `[yaml]` extra. +- All imports at module level — the optional PyYAML import uses the + module-level `try/except ImportError` pattern, never an in-function import. +- Annotate every function argument. Use `ty: ignore`, never `type: ignore`. +- `just lint-ci` (ruff `select=ALL` with `--no-fix`, ruff format, ty, + eof-fixer, planning check) must pass clean. Never run bare `ruff check` — + the repo config autofixes destructively. +- `just test-ci` must pass at **100% line coverage** (`--cov-fail-under=100`). +- Commit messages: conventional-commit subjects, no `Co-authored-by` trailer. diff --git a/architecture/README.md b/architecture/README.md new file mode 100644 index 0000000..36b8f6f --- /dev/null +++ b/architecture/README.md @@ -0,0 +1,17 @@ +# Architecture + +This directory holds the living truth about what `compose2pod` does **now** — +one file per capability, plus a single `glossary.md` for shared terminology. +Files here carry no frontmatter; they are living prose, dated by git. + +## Promotion rule + +A change **promotes** its conclusions into the affected +`architecture/.md` by hand, in the same PR as the code — the edit +rides in the same diff and is reviewed with it, never applied as a separate +post-merge step. The bundle in `planning/changes/` stays as the *why*; +`architecture/` stays as the *what, now*. + +Capability files and `architecture/glossary.md` are authored lazily — created +when the first capability or term is worth pinning down, not scaffolded ahead +of need. diff --git a/justfile b/justfile index 96914ce..8819eb9 100644 --- a/justfile +++ b/justfile @@ -18,6 +18,15 @@ lint-ci: uv run ruff format --check uv run ruff check --no-fix uv run ty check + uv run python planning/index.py --check + +# Print the generated planning change/decision index to stdout. +index: + uv run python planning/index.py + +# Validate planning bundles/decisions; prints "planning: OK" or violations. +check-planning: + uv run python planning/index.py --check # Run pytest with NO coverage (targeted runs won't trip the gate). Passes args through. test *args: diff --git a/planning/.convention-version b/planning/.convention-version new file mode 100644 index 0000000..45a1b3f --- /dev/null +++ b/planning/.convention-version @@ -0,0 +1 @@ +1.1.2 diff --git a/planning/README.md b/planning/README.md new file mode 100644 index 0000000..f59bec7 --- /dev/null +++ b/planning/README.md @@ -0,0 +1,145 @@ +# Planning + +This is `compose2pod`'s planning tree, adopted from Artur Shiriev's canonical +[`lesnik512/planning-convention`](https://github.com/lesnik512/planning-convention) +(applied version in [`.convention-version`](.convention-version)). +`architecture/` (repo root) holds the living truth about what the system does +**now**; `planning/changes/` records how it got there. + +## Quick path (start here) + +> The fast lane for making a change. The full reference is in +> [Conventions](#conventions) below — read it only when this isn't enough. + +**1. Choose a lane — first matching rule wins:** + +1. Any of: needs design judgment · new file/module · public-API change · + cross-cutting or multi-file · non-trivial test design → **Full** + (`design.md` + `plan.md`) +2. Purely mechanical: typo · dep bump · linter/formatter/CI tweak · + mechanical rename · single-line config → **Tiny** (no bundle, conventional + commit) +3. Small-but-real, none of the above: ≲30 LOC net · ≤2 files · no new file · + no public-API change · one straightforward test → **Lightweight** + (`change.md`) + +Ambiguous between two? Take the heavier. A `change.md` that outgrows its lane +splits into `design.md` + `plan.md`. + +**2. Create the bundle** (Full / Lightweight only): +`planning/changes/YYYY-MM-DD.NN-/`, where `.NN` is a zero-padded +intra-day counter. Copy the matching template from +[`_templates/`](_templates/). + +**3. Ship in the implementing PR:** hand-edit the affected +`architecture/.md`, finalize the bundle's `summary:` to the +realized result, and run `just check-planning` before pushing. + +## Conventions + +> This is the portable convention, sourced from the canonical repo +> [`lesnik512/planning-convention`](https://github.com/lesnik512/planning-convention) +> (applied version in [`.convention-version`](.convention-version)). To update +> it, run that repo's `APPLY.md` flow. The generated change index (`just index`) +> and the `## Other` pointers below are repo-local. + +### Two axes, never mixed + +- **`architecture/` (repo root) — the present.** One file per capability, plus + a single `glossary.md` (the ubiquitous language); living prose, updated in the + same PR that ships the change. The truth home. +- **`planning/changes/` — the past-and-pending.** One folder per change, + kept in place after ship. + +A change **promotes** its conclusions into the affected +`architecture/.md` by hand **in the implementing PR, alongside the +code** — the edit rides in the same diff and is reviewed with it, never applied +as a separate post-merge step. That hand-edit is what keeps `architecture/` +true; the bundle stays in `changes/` as the *why*. + +### Glossary + +`architecture/glossary.md` is the project's **ubiquitous language** — one page +defining the domain terms that code, specs, and capability pages all share. Like +the capability files beside it, it is living prose with **no frontmatter**, dated +by git, and authored lazily: it appears when the first term is worth pinning down. + +Each entry is a term, a one-or-two-sentence definition of what it *is* (not what +it does), and an optional `_Avoid_:` line naming the synonyms to reject: + +```md +**Timer**: +A scheduled future delivery, identified by a timer id. +_Avoid_: job, task, alarm +``` + +Keep it a glossary, not a spec — no implementation detail. A change that +introduces or sharpens a term updates `glossary.md` in the same PR, the same way +a behavior change promotes into a capability file. + +### Change bundles + +A change is a folder `changes/YYYY-MM-DD.NN-/`: + +- `YYYY-MM-DD` — proposal date; `.NN` — zero-padded intra-day counter + (`.01`, `.02`, …) that breaks same-date ties so the timeline sorts stably. +- `` — kebab-case description, not a story ID. + +`summary` is written when the change is created (the intent one-liner) and +**finalized at ship** to state the realized result — set in the implementing +PR, alongside the code and the `architecture/` promotion. No post-merge +bookkeeping, no folder move. `date` and `slug` are never written — they are +read from the bundle's directory name. + +### Three lanes + +| Lane | Artifacts | Use when | +|------|-----------|----------| +| **Full** | `design.md` + `plan.md` | design judgment; new file/module; public-API change; cross-cutting/multi-file; non-trivial test design | +| **Lightweight** | `change.md` | small-but-real: ≲30 LOC net, ≤2 files, no new file, no public-API change, single straightforward test | +| **Tiny** | none — conventional commit | typo, dep bump, linter/formatter/CI tweak, mechanical rename, single-line config | + +Heavier lane wins on ambiguity. A `change.md` that outgrows its lane splits +into `design.md` + `plan.md`. + +### Artifacts at a glance + +- **`design.md`** — the spec: the *thinking* (why, design, trade-offs, scope). +- **`plan.md`** — the plan: the *sequencing* (the executor's task checklist). +- **`change.md`** — both, condensed, for the lightweight lane. +- **`releases/.md`** — per-release user-facing notes. +- **`audits/-.md`** — findings from a code/docs/bug-hunt sweep; + spawns fix changes. +- **`retros/-.md`** — what we learned after a body of work. +- **`deferred.md`** — real-but-unscheduled items, each with a revisit trigger. +- **`decisions/-.md`** — one file per design decision taken + (especially options *rejected*), each with a revisit trigger; listed by + `just index`. + +Templates live in [`_templates/`](_templates/). + +### Frontmatter + +`date` and `slug` are **derived from the directory / file name** — never +repeated in frontmatter. So: + +- `design.md` / `change.md`: `summary` (single line) only. +- `plan.md`: **no frontmatter** — its identity is the bundle directory. +- `decisions/*.md`: `status` (accepted|superseded), `summary`, and optional + `supersedes` / `superseded_by`. +- Files in `architecture/` carry **no** frontmatter — living prose, dated by git. + +**`summary`** is one line: written at creation as the intent, then **finalized +at ship** to state the realized result — what shipped and its effect. It is the +only field the index renders. + +## Index + +Run `just index` for the generated change/decision listing (never committed — +it is a query over the files, not an artifact). `just check-planning` validates +bundle/decision names and required frontmatter. + +## Other + +- `deferred.md` — real-but-unscheduled follow-ups. +- `architecture/README.md` — the promotion rule for `architecture/.md`. diff --git a/planning/_templates/change.md b/planning/_templates/change.md new file mode 100644 index 0000000..d4c8962 --- /dev/null +++ b/planning/_templates/change.md @@ -0,0 +1,32 @@ +--- +summary: One line — shown in the generated index. Written at creation; finalize at ship to state the realized result. +--- + +# Change: One-line capitalized title + +**Lane:** lightweight — ≲30 LOC net, ≤2 files, no new file, no public-API +change, a single straightforward test. If it outgrows this, split into +`design.md` + `plan.md`. + +## Goal + +One or two sentences: what changes and why. + +## Approach + +The shape of the change in brief — enough that a reviewer sees the design +without a full spec. Link the truth home (`architecture/.md`) if a +capability contract moves. + +## Files + +- `path/to/file.py` — what changes +- `tests/test_x.py` — test added / updated + +## Verification + +- [ ] Failing test first — command + expected error. +- [ ] Apply the change. +- [ ] Test passes — command. +- [ ] `just test` — full suite green. +- [ ] `just lint` — clean. diff --git a/planning/_templates/decision.md b/planning/_templates/decision.md new file mode 100644 index 0000000..45ccaf0 --- /dev/null +++ b/planning/_templates/decision.md @@ -0,0 +1,23 @@ +--- +status: accepted # accepted | superseded +summary: One line — shown in `just index`. +supersedes: null +superseded_by: null +--- + +# One-line capitalized title + +**Decision:** What was decided, in a sentence. + +## Context + +Why this came up; the options that were on the table. + +## Decision & rationale + +The call and why — including why the alternatives were rejected. Enough that a +future explorer doesn't re-litigate it. + +## Revisit trigger + +The concrete signal that should reopen this decision. diff --git a/planning/_templates/design.md b/planning/_templates/design.md new file mode 100644 index 0000000..d63e22d --- /dev/null +++ b/planning/_templates/design.md @@ -0,0 +1,49 @@ +--- +summary: One line — shown in the generated index. Written at creation; finalize at ship to state the realized result. +--- + +# Design: One-line capitalized title + +## Summary + +One paragraph. What changes, at the level a reader needs to decide if this +spec is worth reading in full. + +## Motivation + +Why now. What is broken or missing. Concrete observations / numbers, not +abstract complaints. Link to memory entries or earlier specs when relevant. + +## Non-goals + +What is deliberately out of scope and (when nontrivial) why. Each item is +a sentence; one line each. + +## Design + +### 1. + +What changes, in enough detail that a reader who has not seen the codebase +can follow. Code samples / diagrams welcome. + +### 2. + +... + +## Operations + +Out-of-repo steps (DNS, infra, external account changes). Omit if none. + +## Out of scope + +Already covered above under Non-goals if appropriate. Repeat-list of +explicitly-excluded follow-ups belongs here when the list is long. + +## Testing + +How we know it landed correctly. New pytest? Smoke check on live URL? +Lint pass? Be specific. + +## Risk + +What could go wrong, ranked by likelihood × impact. Mitigations. diff --git a/planning/_templates/glossary.md b/planning/_templates/glossary.md new file mode 100644 index 0000000..82385c3 --- /dev/null +++ b/planning/_templates/glossary.md @@ -0,0 +1,15 @@ +# Glossary + +The project's ubiquitous language — the domain terms that code, specs, and +capability pages share. Living prose, no frontmatter, dated by git. Each entry is +a term, what it *is* (not what it does), and the synonyms to avoid. No +implementation detail; this is a glossary, not a spec. + +**Term**: +A one-or-two-sentence definition of what it is. +_Avoid_: rejected-synonym, another-one + +**Another term**: +Define what it is, tightly. Group related terms under `##` subheadings when +natural clusters emerge; a flat list is fine when they don't. +_Avoid_: … diff --git a/planning/_templates/plan.md b/planning/_templates/plan.md new file mode 100644 index 0000000..132d720 --- /dev/null +++ b/planning/_templates/plan.md @@ -0,0 +1,46 @@ +# — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps +> use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** One sentence — what shipping this plan achieves. No design +rationale; link to the spec for that. + +**Spec:** [`design.md`](./design.md) + +**Branch:** `feat/my-change` (or `fix/`, `chore/`, etc.) + +**Commit strategy:** Per-task commits / single commit / squash on merge. +Whichever fits. + +--- + +### Task 1: + +**Files:** +- Modify: `path/to/file.py` +- Create: `path/to/new.py` + +One sentence on what this task accomplishes. No deeper reasoning — that's +in the spec. + +- [ ] **Step 1: ** + + Run / edit / verify command. Expected output. + +- [ ] **Step 2: ** + + ... + +- [ ] **Step 3: Commit** + + ```bash + git add path/to/file.py + git commit -m ": " + ``` + +--- + +### Task 2: ... diff --git a/planning/_templates/release.md b/planning/_templates/release.md new file mode 100644 index 0000000..5081187 --- /dev/null +++ b/planning/_templates/release.md @@ -0,0 +1,38 @@ +# + + + + + +## Feature + +- **.** What it adds and how to use it. + +## Fix + +- **.** What was broken, now fixed (reference the issue/regression). + +## Internal refactors + +- **.** What changed under the hood, stated as no behavior change. + +## Packaging + +- Metadata / build / dependency changes visible to installers. + +## Why + +Context a reader needs for the headline change. Omit for small releases. + +## Downstream + +What dependents must do — e.g. bump their version floor — or "No action +needed" when there is no API change. Omit if the project has no downstreams. + +## Internals + +- Coverage / tooling notes. diff --git a/planning/changes/2026-07-03.01-compose2pod-extraction/design.md b/planning/changes/2026-07-03.01-compose2pod-extraction/design.md new file mode 100644 index 0000000..34a4f96 --- /dev/null +++ b/planning/changes/2026-07-03.01-compose2pod-extraction/design.md @@ -0,0 +1,390 @@ +--- +summary: Extracted the chats compose-to-podman-pod converter into the standalone public package compose2pod (stdlib-only core, optional YAML extra, CLI + library API, CI, PyPI trusted publishing). +--- + +# Design: compose2pod extraction + +## Summary + +Publish the chats compose-to-podman-pod converter as `compose2pod`, a +standalone public PyPI utility under the modern-python org. It is a +dependency-free, stdlib-only Python package that reads a Docker Compose +document and emits a POSIX `sh` script which runs the compose services as a +single Podman pod. It is both a CLI (`compose2pod`) and an importable +library. YAML input is an optional extra; the core reads JSON. + +**Tech stack:** Python 3.10+ (stdlib only in the core), uv with the +`uv_build` backend, `just` task runner, ruff (`select = ALL`), ty, pytest, +GitHub Actions, PyPI trusted publishing. Optional `[yaml]` extra pulls +PyYAML. Mirrors the `modern-di` package conventions (see "Local location and +references" below). + +## Motivation + +CI runners often give unprivileged containers a read-only `/proc/sys`, so +netavark cannot create bridge networks (its `route_localnet` sysctl write +fails with EROFS). This is upstream-declined: podman issue #20713 is closed +"not planned" and netavark PR #910 is unmerged. Containers in a single podman +pod share one network namespace with no bridge and no netavark: services +reach each other over `127.0.0.1`, and names resolve via per-container +`--add-host` entries. Healthchecks are driven manually with `podman +healthcheck run` because there is no systemd inside the CI job container to +schedule them. + +Research (2026-07) confirmed no existing tool covers this combination: +`docker`/`podman compose` hang on `depends_on: service_healthy` without +systemd; `podman kube play` has no `depends_on` ordering and its probes also +need systemd; `kompose`/`podlet` target multi-pod or systemd/quadlet; every +mature converter is interpreted (no interpreter in minimal runtime images) or +emits bridge-networked `podman run`. `compose2pod` fills that gap. + +## Non-goals + +- Redesigning the runtime behavior of the CI-proven chats converter + (`bin/compose_to_pod.py`) — this is a faithful extraction plus four + targeted review fixes, not a rewrite. +- A `run` subcommand that executes the emitted script — output is the script; + execution is left to the caller. +- Depending on a compose-spec parser library for the core (see "Parsing + approach"). + +## Design + +### 1. Global constraints + +- The **core package has zero runtime dependencies** (stdlib only). This is + the primary differentiator: it must install with no compiled wheels and + run in minimal Python images. No pydantic or compose-parser dependency in + the core (see "Parsing approach"). +- Python **3.10+**. `X | Y` annotation syntax is allowed at runtime; no + `from __future__ import annotations` required. +- Quality gates (match modern-python house style, verified against + `modern-di`): ruff `select = ALL` with justified per-line `# noqa`, ty + clean, `eof-fixer` clean, pytest at **100% line coverage** enforced + (`--cov-fail-under=100`); branch coverage is a diagnostic run (`just + test-branch`), not an enforced gate. +- The emitted script is POSIX `sh` (`set -eu`), not bash. + +### 2. Supported compose subset + +The tool supports an honest subset and grows on demand. Anything outside it +raises `UnsupportedComposeError` with a clear message rather than emitting a +wrong script. The README documents this matrix. + +**Top-level keys:** `services` (required), `version`, `name`, `networks` +(ignored with a warning — all services share the pod namespace). + +**Service keys — supported:** +- `image` — used verbatim. +- `build` — the service runs the CI image passed via `--image` instead of + building. +- `command` — list form is argv; string form becomes `/bin/sh -c + ""`. +- `environment` — list (`KEY=VALUE`) or mapping form. +- `env_file` — string or list; paths resolved against `--project-dir` and + passed to `podman run --env-file` (podman parses the file; compose2pod + does not). +- `volumes` — short-form bind mounts only (`src:dst[:opts]`); `src` must be + a path (starts with `.` or `/`), resolved against `--project-dir`. Named + volumes and long-form syntax are unsupported. +- `healthcheck` — `test` as `CMD` (list → argv) or `CMD-SHELL` (string); + `NONE`/`["NONE"]` disables; `interval`, `timeout`, `retries`, + `start_period` (see "Deviations from the current chats converter"). +- `depends_on` — list form (all `service_started`) or mapping form with + conditions `service_started`, `service_healthy`, + `service_completed_successfully`. `service_healthy` on a dependency + without a healthcheck is a hard error. +- `networks` — only the `aliases` are read, and only to build `--add-host + NAME:127.0.0.1` entries so intra-pod name resolution works. + +**Service keys — ignored with a warning:** `ports`, `restart`, +`stdin_open`, `tty`. + +**Anything else** (e.g. `configs`, `secrets`, `profiles`, `deploy`, +long-form volumes, unknown healthcheck keys) raises +`UnsupportedComposeError`. + +### 3. Emitted script behavior + +Given a `--target` service, the tool computes the dependency closure in +start order (topological sort, cycle detection) and emits a script that: + +1. Sets an `EXIT` trap that removes the pod (`podman pod rm -f `). +2. Creates the pod (`podman pod create --name `). +3. For each service before the target, in dependency order: + - `service_completed_successfully` dependencies run in the foreground + with `--rm` (run-to-completion, e.g. migrations). + - all other dependencies start detached (`podman run -d`). +4. Before the first dependent of a `service_healthy` dependency, waits for + health by polling `podman healthcheck run ` in a loop + (`wait_healthy`), with attempts derived from a fixed budget + (`HEALTHY_WAIT_BUDGET_SECONDS // interval`, minimum 1). Each dependency + is waited on at most once. +5. Runs the target in the foreground, capturing its exit code (`rc=$?`), + then `podman cp`s each `--artifact SRC:DST` out of the target container + (best-effort, before the exit-code gate). +6. Treats `0` and each `--allow-exit-code` value as success. On any other + code it prints the target's `OOMKilled`/`ExitCode` via `podman inspect` + and `podman ps -a`, then exits with `rc`. + +Each `podman run` carries: `--pod`, `--name -`, one +`--add-host NAME:127.0.0.1` per known hostname/alias, `-e`/`--env-file` for +env, `-v` for bind mounts, and `--health-cmd`/`--health-timeout` when a +healthcheck is present. All tokens are shell-quoted with `shlex.quote`. + +### 4. CLI interface + +``` +compose2pod --target NAME --image IMG [FILE] + [--project-dir DIR] [--command CMD] [--pod-name NAME] + [--artifact SRC:DST]... [--allow-exit-code N]... + [--format auto|json|yaml] +``` + +- Reads the compose document from `FILE` or stdin. +- `--target` (required): service to run in the foreground. +- `--image` (required): image substituted for services that have a `build` + section. +- `--project-dir` (default `.`): base for resolving relative volume and + env_file paths. +- `--command`: shell command overriding the target service's command. +- `--pod-name` (default `test-pod`): validated against + `^[A-Za-z0-9][A-Za-z0-9_.-]*$`; invalid → exit 2. +- `--artifact SRC:DST` (repeatable): file to `podman cp` out of the target + after it exits. +- `--allow-exit-code N` (repeatable): target exit code treated as success + in addition to 0. +- `--format` (default `auto`): `json` (stdlib), `yaml` (requires the + `[yaml]` extra), or `auto` (try JSON, fall back to YAML if PyYAML is + importable). +- Warnings for ignored constructs go to stderr prefixed `compose2pod: `. + Invalid JSON/YAML or an unsupported construct exits 2 with a message on + stderr. + +### 5. Public API + +Importable, for programmatic use and testing: + +```python +from compose2pod import validate, emit_script, EmitOptions, UnsupportedComposeError +``` + +- `validate(compose: dict) -> list[str]` — raises `UnsupportedComposeError`; + returns warnings. +- `emit_script(compose: dict, options: EmitOptions) -> str` — returns the + POSIX sh script. +- `EmitOptions` — frozen dataclass: `target`, `ci_image`, `command`, `pod`, + `project_dir`, `artifacts`, `allow_exit_codes`. +- `UnsupportedComposeError` — raised for anything outside the subset. + +The core API operates on already-parsed `dict` data; YAML/JSON loading and +format detection live in the CLI layer. + +### 6. Parsing approach + +The core reads a compose **dict**. Loading is layered: + +- **JSON** via stdlib `json` — always available. +- **YAML** via PyYAML, shipped as the optional `[yaml]` extra. Without it, + `--format yaml` errors with an actionable message ("install + compose2pod[yaml] or pipe the file through yq"). In dependency-constrained + CI, users pipe `yq -o=json` and stay dependency-free. + +We do **not** depend on a compose-spec parser library. Researched candidates +(`compose-spec`, `compose-pydantic`) both require pydantic v2 (a compiled +`pydantic-core` wheel) and are early-stage single-maintainer 0.x projects. +Adopting one would (a) break the zero-dependency differentiator, (b) not +remove our subset validation — full-spec parsers permissively accept +constructs we cannot turn into a single pod, so a subset gate is still +required — and (c) add supply-chain risk to the core. A future optional +`[strict]` extra could cross-validate against `compose-spec` for users who +want full-spec checking, but it is out of scope for v1 (YAGNI). Formalized +as a decision record: +[`2026-07-03-zero-dependency-core.md`](../../decisions/2026-07-03-zero-dependency-core.md). + +### 7. Package layout + +**Root layout** (matches `modern-di`: module at the repo root, `uv_build` +backend with `module-name = "compose2pod"`, `module-root = ""`), splitting +the original single 397-line module into focused units: + +- `compose2pod/__init__.py` — public exports (`validate`, `emit_script`, + `EmitOptions`, `UnsupportedComposeError`). +- `compose2pod/py.typed` — marker (PEP 561; the package is typed). +- `compose2pod/parsing.py` — subset definitions and `validate` (+ + `_validate_service`, `_validate_depends_on`, `_has_healthcheck`). +- `compose2pod/graph.py` — `depends_on`, `hostnames`, `startup_order` (topo + sort, cycle detection). +- `compose2pod/healthcheck.py` — `health_cmd`, `interval_seconds`. +- `compose2pod/emit.py` — `EmitOptions`, `run_flags`, `image_for`, + `command_tokens`, script header, `emit_script` (with the target-run + branch extracted into a helper for readability/complexity). +- `compose2pod/cli.py` — argparse, format detection, JSON/YAML loading, + `main(argv)`. +- `compose2pod/__main__.py` — `python -m compose2pod`. +- `tests/` at the repo root (mirrors `modern-di`). +- Console-script entry point in `[project.scripts]`: + `compose2pod = "compose2pod.cli:main"`. + +### 8. Deviations from the current chats converter + +These are the improvement items identified in review; they were fixed as +part of the move, each with a failing test first: + +1. **Guard non-dict input** — `yq` on an empty file yields `null`; + `validate` must raise `UnsupportedComposeError`, not a raw + `AttributeError`. +2. **`start_period` and `retries`** — previously validated but ignored by + `run_flags`. Decision (refined during planning): pass them through to + `podman run` as `--health-start-period` and `--health-retries` + (alongside the existing `--health-timeout`), recording the author's + intent on the container. The manual `wait_healthy` poll keeps its fixed + `HEALTHY_WAIT_BUDGET_SECONDS` budget as a generous readiness timeout — + it is deliberately **not** shortened to `retries × interval`, which + would cause premature failure for a service with a long `start_period` + (the poll loops until first success, so `start_period` never gates it). + This removes the silently-ignored keys without regressing wait behavior. + Formalized as a decision record: + [`2026-07-03-healthcheck-start-period-retries-passthrough.md`](../../decisions/2026-07-03-healthcheck-start-period-retries-passthrough.md). +3. **Merge the duplicate `_CMD_SHELL_MIN_LENGTH` / `_CMD_MIN_LENGTH` + constants** (both `2`) into one. +4. **Extract the target-run branch of `emit_script`** into a helper. + +### 9. Tooling and distribution + +Mirror `modern-di` exactly so the package is consistent with the rest of the +org: + +- **`pyproject.toml`:** `[project]` with `name = "compose2pod"`, `authors = + [{ name = "Artur Shiriev", email = "me@shiriev.ru" }]`, `license = "MIT"`, + `requires-python = ">=3.10,<4"`, `version = "0"` (real version comes from + the release tag), keywords (podman, docker-compose, compose, pod, ci, + testing, containers), classifiers (Development Status :: 4 - Beta, Python + 3.10–3.14, `Typing :: Typed`, Topic :: Software Development). + `[project.optional-dependencies]` `yaml = ["PyYAML>=6"]`. + `[project.scripts]` `compose2pod = "compose2pod.cli:main"`. + `[build-system]` `uv_build>=0.11,<1.0`; `[tool.uv.build-backend]` + `module-name = "compose2pod"`, `module-root = ""`. +- **`[dependency-groups]`:** `dev` (pytest, pytest-cov), `lint` (ruff, ty, + eof-fixer, typing-extensions). +- **`[tool.ruff]`:** `fix = true`, `unsafe-fixes = true`, `line-length = + 120`, `target-version = "py310"`; `lint.select = ["ALL"]` with the + modern-di ignore set (`D1`, `S101`, `TCH`, `FBT`, `D203`, `D213`, + `COM812`, `ISC001`) plus any justified additions; isort config as in + modern-di. Note: this config **autofixes destructively** — CI and + reviewers must use `ruff check --no-fix`. +- **`justfile`** with modern-di's recipes: `install`, `lint` (eof-fixer, + ruff format, ruff check --fix, ty), `lint-ci` (same, `--no-fix`/`--check`), + `test`, `test-ci` (`--cov=. --cov-fail-under=100`), `test-branch`, + `publish` (`uv version $GITHUB_REF_NAME && uv build && uv publish`). +- **`.gitignore`** mirrors modern-di (notably `uv.lock` is git-ignored). +- **GitHub Actions:** `ci.yml` → `_checks.yml` (lint on 3.10; pytest matrix + 3.10–3.14; via `setup-uv` + `setup-just`), and `release.yml` (tag-driven, + `contents: write` + `id-token: write`, PyPI Trusted Publishing / OIDC, + `environment: pypi`). semver tags. +- **`LICENSE`** (MIT, Artur Shiriev), **`README.md`** with the support + matrix, both CI usage forms (`[yaml]` extra and the yq-pipe), and the + "why this exists" niche. +- GitHub repo `modern-python/compose2pod`; `[project.urls]` + Repository/Issues/Changelog like modern-di. + +## Operations + +GitHub repo `modern-python/compose2pod` and the PyPI Trusted Publisher +(project `compose2pod`, workflow `release.yml`, environment `pypi`) must be +created/registered manually before the first tag — outside this repo, done +by Artur. + +## Out of scope + +This spec covers extracting and publishing the package only. The following +are downstream, each its own spec/plan later: + +- **chats migration:** consume `compose2pod` via pip, delete the in-repo + `bin/compose_to_pod.py` copy, install it where the CI job runs the + converter (the app image). +- **pypelines wiring:** a template job that installs and invokes + `compose2pod`, with a deprecation path for the old docker-compose/dind + test jobs. +- A `[strict]` extra cross-validating against a full compose-spec parser + (YAGNI for v1; see "Parsing approach"). +- The heavier modern-di machinery (`planning/`, `docs/` site, + `architecture/`, benchmarks) was optional for v1 and omitted from the + initial scaffold — subsequently adopted in a later change that migrated + this repo onto the `lesnik512/planning-convention` planning convention + (this bundle itself lives under that convention). + +## Testing + +Ported the existing 65-test suite (originally 519 lines, 100% line+branch) +into the package. The enforced gate is 100% line coverage (`just test-ci`); +branch coverage is kept clean too (`just test-branch`) since it already is. + +Additions over the original suite: +- CLI/format tests: JSON input, YAML input (with the extra), `auto` + fallback, and the actionable error when YAML is requested without the + extra. +- Tests for the four deviations above (non-dict guard, `start_period`/ + `retries`, and that the merged constant and extracted helper preserve + behavior). + +The chats `docker-compose.yml` shape (build args, healthchecks in both CMD +forms, list and mapping env, completion- and health-gated depends_on, +network alias) is kept as the primary realistic fixture +(`tests/conftest.py::chats_compose`). + +## Risk + +- **Silent behavior drift from the chats prototype.** Mitigated by treating + the prototype as the source of truth for anything not explicitly listed as + one of the four deviations, and porting its test suite wholesale. +- **Zero-dependency constraint eroding over time** (e.g. a future + contributor adding a compose-parser dependency for convenience). + Mitigated by the recorded decision + (`2026-07-03-zero-dependency-core.md`) and the CI lint gate that would + need a `pyproject.toml` change to introduce a new runtime dependency, + making it visible in review. +- **`start_period`/`retries` passthrough interacting badly with a long + `start_period`** if the wait budget were ever coupled to `retries × + interval` in a future change. Mitigated by the recorded decision + (`2026-07-03-healthcheck-start-period-retries-passthrough.md`) explaining + why the budget is intentionally decoupled. + +## Local location and references + +- The package is developed at `/Users/kevinsmith/src/pypi/compose2pod` — a + sibling of the other modern-python packages (`modern-di`, `semvertag`, + `that-depends`, `db-retry`, …) that already live under + `/Users/kevinsmith/src/pypi/`. +- `modern-di` is the reference for all conventions above (pyproject, + ruff/ty config, justfile recipes, CI workflows, root layout, `py.typed`, + `.gitignore`, tag-driven trusted-publishing release). Where this spec and + `modern-di` disagree, `modern-di` wins. + +## Versioning and stability + +Start at `0.1.0`. The public surface is the CLI flags and the +emitted-script contract; document that these are stable within a minor +series and that the supported subset grows via minor releases. +`UnsupportedComposeError` messages are not part of the stable contract. + +## Decisions log + +- Audience/distribution: public, modern-python org, public PyPI. + (user-approved) +- Compose scope: honest subset, grow on demand. (approved) +- Name: `compose2pod` (verified free on PyPI). (approved) +- Core is zero-dependency stdlib; YAML is an optional `[yaml]` extra; no + compose-parser dependency. (approved, research-backed — formalized as + [`2026-07-03-zero-dependency-core.md`](../../decisions/2026-07-03-zero-dependency-core.md)) +- `start_period`/`retries` passed through to `podman run` flags; wait budget + not shortened to `retries × interval`. (approved — formalized as + [`2026-07-03-healthcheck-start-period-retries-passthrough.md`](../../decisions/2026-07-03-healthcheck-start-period-retries-passthrough.md)) +- Output is an emitted POSIX sh script; execution is left to the caller (no + `run` subcommand in v1). (approved) +- Python floor 3.10. (approved) +- Package lives at `/Users/kevinsmith/src/pypi/compose2pod`, mirroring + `modern-di` conventions (root layout, `uv_build`, `just`, ty, tag-driven + trusted publishing). (user-directed) +- Enforced coverage gate is 100% line (branch is diagnostic), matching + modern-di. (research-backed) diff --git a/docs/superpowers/plans/2026-07-03-compose2pod-extraction.md b/planning/changes/2026-07-03.01-compose2pod-extraction/plan.md similarity index 95% rename from docs/superpowers/plans/2026-07-03-compose2pod-extraction.md rename to planning/changes/2026-07-03.01-compose2pod-extraction/plan.md index 95ed0d9..c418cc1 100644 --- a/docs/superpowers/plans/2026-07-03-compose2pod-extraction.md +++ b/planning/changes/2026-07-03.01-compose2pod-extraction/plan.md @@ -1,9 +1,18 @@ -# compose2pod Extraction Implementation Plan +# compose2pod-extraction — implementation plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps +> use checkbox (`- [ ]`) syntax for tracking. **Goal:** Port the chats compose-to-podman-pod converter into the standalone public package `compose2pod`, split into focused modules, with the four review fixes, an optional YAML input path, and CI. +**Spec:** [`design.md`](./design.md) + +**Branch:** shipped via `chore: scaffold compose2pod package` → `docs: implementation plan for the converter port` → `Port the compose-to-podman-pod converter into compose2pod (#1)`, squash-merged to `main` at `287bb86`. + +**Commit strategy:** Per-task commits (as listed in each task's final step). + **Architecture:** A dependency-free, stdlib-only Python package (root layout, `uv_build`) that reads a Docker Compose document (dict) and emits a POSIX `sh` script running the services as a single Podman pod. `exceptions` → `healthcheck`/`graph` → `parsing`/`emit` → `cli` → `__main__`, acyclic. YAML input is an optional extra; the core reads JSON. **Tech Stack:** Python 3.10+, stdlib only in the core, PyYAML behind the `[yaml]` extra; uv + `uv_build`, `just`, ruff (`select=ALL`), ty, pytest. @@ -16,7 +25,7 @@ - Quality gates: ruff `select=ALL` (config already in `pyproject.toml`; it **autofixes destructively** — always use `ruff check --no-fix`), ty clean, `eof-fixer` clean, `just test-ci` at **100% line coverage** (`--cov-fail-under=100`). - The emitted script is POSIX `sh` (`set -eu`). - Warnings/errors printed by the CLI are prefixed `compose2pod: ` (renamed from the chats prototype's `compose-to-pod: `). -- Package location: `/Users/kevinsmith/src/pypi/compose2pod` (this repo). Source of truth for behavior is the chats prototype `bin/compose_to_pod.py`; the four fixes below are the only behavior changes. +- Package location: `/Users/kevinsmith/src/pypi/compose2pod`. Source of truth for behavior is the chats prototype `bin/compose_to_pod.py`; the four fixes below are the only behavior changes. - Environment note: this repo's deps are all on public PyPI (pytest, ruff, ty, eof-fixer, PyYAML), so `just install` and `just test` run locally without the internal artifactory. - Commit messages: conventional-commit subjects, **no `Co-authored-by` trailer**. @@ -52,12 +61,12 @@ A shared fixture `CHATS_COMPOSE` is duplicated only where needed; to keep tests - Create: `tests/conftest.py` - Modify: none (scaffold `pyproject.toml`, `justfile` already exist) -- [ ] **Step 1: Install deps** +- [x] **Step 1: Install deps** Run: `cd /Users/kevinsmith/src/pypi/compose2pod && just install` Expected: uv resolves from public PyPI, creates `.venv`, installs the `yaml` extra + `lint` group. No error. -- [ ] **Step 2: Add the shared fixture** +- [x] **Step 2: Add the shared fixture** Create `tests/conftest.py`: @@ -120,7 +129,7 @@ def chats_compose() -> dict: } ``` -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git add tests/conftest.py @@ -138,7 +147,7 @@ git commit -m "test: add shared chats_compose fixture" **Interfaces:** - Produces: `UnsupportedComposeError(Exception)`; `has_healthcheck(svc: dict[str, Any]) -> bool`; `health_cmd(test: object) -> str | None`; `interval_seconds(duration: object) -> int`. -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** Create `tests/test_healthcheck.py`: @@ -217,12 +226,12 @@ class TestHasHealthcheck: assert has_healthcheck({"healthcheck": {"test": "NONE"}}) is False ``` -- [ ] **Step 2: Run tests to verify they fail** +- [x] **Step 2: Run tests to verify they fail** Run: `just test tests/test_healthcheck.py` Expected: FAIL — `ModuleNotFoundError: No module named 'compose2pod.healthcheck'`. -- [ ] **Step 3: Implement `exceptions.py`** +- [x] **Step 3: Implement `exceptions.py`** ```python """Exceptions for compose2pod.""" @@ -232,7 +241,7 @@ class UnsupportedComposeError(Exception): """Raised when the compose file uses a construct outside the supported subset.""" ``` -- [ ] **Step 4: Implement `healthcheck.py`** (fix #3: single `_CMD_MIN_LENGTH`) +- [x] **Step 4: Implement `healthcheck.py`** (fix #3: single `_CMD_MIN_LENGTH`) ```python """Healthcheck translation: compose healthcheck -> podman --health-* values.""" @@ -287,12 +296,12 @@ def interval_seconds(duration: object) -> int: return max(int(float(text)), 1) ``` -- [ ] **Step 5: Run tests + lint** +- [x] **Step 5: Run tests + lint** Run: `just test tests/test_healthcheck.py` → Expected: PASS (20 tests). Run: `just lint-ci` → Expected: clean. -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash git add compose2pod/exceptions.py compose2pod/healthcheck.py tests/test_healthcheck.py @@ -311,7 +320,7 @@ git commit -m "feat: healthcheck translation and UnsupportedComposeError" - Consumes: `UnsupportedComposeError` from `compose2pod.exceptions`. - Produces: `depends_on(svc: dict[str, Any]) -> dict[str, str]`; `hostnames(services: dict[str, Any]) -> list[str]`; `startup_order(services: dict[str, Any], target: str) -> list[str]`. -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** Create `tests/test_graph.py`: @@ -386,12 +395,12 @@ class TestStartupOrder: assert order[-1] == "target" ``` -- [ ] **Step 2: Run tests to verify they fail** +- [x] **Step 2: Run tests to verify they fail** Run: `just test tests/test_graph.py` Expected: FAIL — `ModuleNotFoundError: No module named 'compose2pod.graph'`. -- [ ] **Step 3: Implement `graph.py`** +- [x] **Step 3: Implement `graph.py`** ```python """Dependency graph: normalize depends_on, collect hostnames, compute startup order.""" @@ -445,12 +454,12 @@ def startup_order(services: dict[str, Any], target: str) -> list[str]: return order ``` -- [ ] **Step 4: Run tests + lint** +- [x] **Step 4: Run tests + lint** Run: `just test tests/test_graph.py` → Expected: PASS. Run: `just lint-ci` → Expected: clean. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add compose2pod/graph.py tests/test_graph.py @@ -469,7 +478,7 @@ git commit -m "feat: dependency graph and startup order" - Consumes: `UnsupportedComposeError` (exceptions), `depends_on` (graph), `has_healthcheck` (healthcheck). - Produces: `validate(compose: dict[str, Any]) -> list[str]`; module constants `SUPPORTED_SERVICE_KEYS`, `IGNORED_SERVICE_KEYS`, `SUPPORTED_HEALTHCHECK_KEYS`, `SUPPORTED_TOP_LEVEL_KEYS`, `DEPENDS_ON_CONDITIONS`. -- [ ] **Step 1: Write the failing tests** (ports the chats `TestValidate` + a new non-dict test) +- [x] **Step 1: Write the failing tests** (ports the chats `TestValidate` + a new non-dict test) Create `tests/test_parsing.py`: @@ -557,12 +566,12 @@ class TestValidate: validate(compose) ``` -- [ ] **Step 2: Run tests to verify they fail** +- [x] **Step 2: Run tests to verify they fail** Run: `just test tests/test_parsing.py` Expected: FAIL — `ModuleNotFoundError: No module named 'compose2pod.parsing'`. -- [ ] **Step 3: Implement `parsing.py`** (fix #1 is the `isinstance` guard at the top of `validate`) +- [x] **Step 3: Implement `parsing.py`** (fix #1 is the `isinstance` guard at the top of `validate`) ```python """Validate a compose document against the supported subset.""" @@ -643,12 +652,12 @@ def validate(compose: dict[str, Any]) -> list[str]: return warnings ``` -- [ ] **Step 4: Run tests + lint** +- [x] **Step 4: Run tests + lint** Run: `just test tests/test_parsing.py` → Expected: PASS. Run: `just lint-ci` → Expected: clean. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add compose2pod/parsing.py tests/test_parsing.py @@ -669,7 +678,7 @@ git commit -m "feat: compose subset validation with non-dict guard" **Design note (for reviewer/Artur):** fix #2 implements the spec's refined decision — `start_period`/`retries` are passed to `podman run` as `--health-start-period`/`--health-retries`; the `wait_healthy` budget is unchanged (still `HEALTHY_WAIT_BUDGET_SECONDS // interval` attempts). This deliberately does not shorten the wait to `retries × interval`, which would risk premature failure for a long `start_period`. -- [ ] **Step 1: Write the failing tests** (ports `TestRunFlags`, `TestImageAndCommand`, `TestEmitScript`, plus fix-#2 cases) +- [x] **Step 1: Write the failing tests** (ports `TestRunFlags`, `TestImageAndCommand`, `TestEmitScript`, plus fix-#2 cases) Create `tests/test_emit.py`: @@ -819,12 +828,12 @@ class TestEmitScript: assert target_line.rstrip().endswith("python -m chats.api") ``` -- [ ] **Step 2: Run tests to verify they fail** +- [x] **Step 2: Run tests to verify they fail** Run: `just test tests/test_emit.py` Expected: FAIL — `ModuleNotFoundError: No module named 'compose2pod.emit'`. -- [ ] **Step 3: Implement `emit.py`** (fix #2 in `run_flags`; fix #4 splits `_emit_target`/`_run_tokens`/`_render`) +- [x] **Step 3: Implement `emit.py`** (fix #2 in `run_flags`; fix #4 splits `_emit_target`/`_run_tokens`/`_render`) ```python """Render the podman-pod test script for a target service and its dependencies.""" @@ -997,12 +1006,12 @@ def emit_script(compose: dict[str, Any], options: EmitOptions) -> str: return "\n".join(lines) + "\n" ``` -- [ ] **Step 4: Run tests + lint** +- [x] **Step 4: Run tests + lint** Run: `just test tests/test_emit.py` → Expected: PASS. Run: `just lint-ci` → Expected: clean. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add compose2pod/emit.py tests/test_emit.py @@ -1022,7 +1031,7 @@ git commit -m "feat: pod script emission with start_period/retries pass-through" - Consumes: `validate` (parsing), `EmitOptions`/`emit_script` (emit), `UnsupportedComposeError` (exceptions). - Produces: `main(argv: list[str] | None = None) -> int`; `POD_NAME_PATTERN`; package exports `validate`, `emit_script`, `EmitOptions`, `UnsupportedComposeError`. -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** Create `tests/test_cli.py`: @@ -1137,12 +1146,12 @@ class TestModuleEntrypoint: assert result.stdout.startswith("#!/bin/sh") ``` -- [ ] **Step 2: Run tests to verify they fail** +- [x] **Step 2: Run tests to verify they fail** Run: `just test tests/test_cli.py` Expected: FAIL — `ModuleNotFoundError: No module named 'compose2pod.cli'`. -- [ ] **Step 3: Implement `cli.py`** (module-level optional yaml import; `# noqa: ANN401` where returning parsed data) +- [x] **Step 3: Implement `cli.py`** (module-level optional yaml import; `# noqa: ANN401` where returning parsed data) ```python """Command-line interface: read a compose document and emit the pod script.""" @@ -1241,7 +1250,7 @@ def main(argv: list[str] | None = None) -> int: Note: the non-mapping case (`42`, `"not json"` under `auto`) reaches `validate`, which raises `UnsupportedComposeError` (fix #1) → caught in the second `try` → exit 2. The `test_non_mapping_document_returns_2` test asserts that path. -- [ ] **Step 4: Implement `__main__.py`** +- [x] **Step 4: Implement `__main__.py`** ```python """python -m compose2pod entry point.""" @@ -1255,7 +1264,7 @@ if __name__ == "__main__": # pragma: no cover - exercised via subprocess in tes sys.exit(main()) ``` -- [ ] **Step 5: Implement `__init__.py` exports** +- [x] **Step 5: Implement `__init__.py` exports** ```python """compose2pod: convert a Docker Compose file into a single-Podman-pod run script.""" @@ -1273,13 +1282,13 @@ __all__ = [ ] ``` -- [ ] **Step 6: Run tests + lint + full coverage** +- [x] **Step 6: Run tests + lint + full coverage** Run: `just test tests/test_cli.py` → Expected: PASS. Run: `just test-ci` → Expected: PASS at 100% line coverage. Run: `just lint-ci` → Expected: clean. -- [ ] **Step 7: Commit** +- [x] **Step 7: Commit** ```bash git add compose2pod/cli.py compose2pod/__main__.py compose2pod/__init__.py tests/test_cli.py @@ -1295,7 +1304,7 @@ git commit -m "feat: CLI with JSON/YAML input and public API exports" **Interfaces:** none (CI only). Mirrors `modern-di`, minus the docs and planning-bundle jobs. -- [ ] **Step 1: Create `.github/workflows/ci.yml`** +- [x] **Step 1: Create `.github/workflows/ci.yml`** ```yaml name: main @@ -1314,7 +1323,7 @@ jobs: uses: ./.github/workflows/_checks.yml ``` -- [ ] **Step 2: Create `.github/workflows/_checks.yml`** +- [x] **Step 2: Create `.github/workflows/_checks.yml`** ```yaml name: checks @@ -1354,7 +1363,7 @@ jobs: - run: just test-ci ``` -- [ ] **Step 3: Create `.github/workflows/release.yml`** +- [x] **Step 3: Create `.github/workflows/release.yml`** ```yaml name: Release @@ -1397,17 +1406,17 @@ jobs: draft: false ``` -- [ ] **Step 4: Validate workflow YAML** +- [x] **Step 4: Validate workflow YAML** Run: `python3 -c "import yaml,glob; [yaml.safe_load(open(f)) for f in glob.glob('.github/workflows/*.yml')]; print('ok')"` Expected: `ok`. -- [ ] **Step 5: Final full-suite gate** +- [x] **Step 5: Final full-suite gate** Run: `just test-ci` → Expected: PASS at 100% line coverage. Run: `just lint-ci` → Expected: clean. -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash git add .github/workflows/ @@ -1431,3 +1440,7 @@ git commit -m "ci: lint + pytest matrix and tag-driven PyPI trusted publishing" - Coverage is line-based (`--cov-fail-under=100`), matching modern-di. `# pragma: no cover` is used on the optional-yaml `except ImportError` (PyYAML is installed via `--all-extras`, so that line never runs in CI) and on the `__main__` guard. - Never run bare `ruff check` in this repo — the config autofixes destructively. Use `just lint-ci` (`ruff check --no-fix`). - Commit messages: conventional-commit subjects, no `Co-authored-by` trailer. + +## Status + +All tasks shipped: `72422be` (scaffold), `048231e` (this plan, as `docs/superpowers/plans/2026-07-03-compose2pod-extraction.md`), `287bb86` (`#1`, squash-merged to `main`). This bundle is the planning-convention home for that history; see [`design.md`](./design.md) for the spec it implements. diff --git a/planning/decisions/2026-07-03-healthcheck-start-period-retries-passthrough.md b/planning/decisions/2026-07-03-healthcheck-start-period-retries-passthrough.md new file mode 100644 index 0000000..7265195 --- /dev/null +++ b/planning/decisions/2026-07-03-healthcheck-start-period-retries-passthrough.md @@ -0,0 +1,62 @@ +--- +status: accepted +summary: healthcheck start_period/retries pass through to podman run as flags; the wait_healthy budget is not shortened to retries x interval. +supersedes: null +superseded_by: null +--- + +# Healthcheck start_period/retries pass-through without shortening the wait budget + +**Decision:** `run_flags` passes a service's healthcheck `start_period` and +`retries` through to `podman run` as `--health-start-period` and +`--health-retries` respectively (alongside the existing +`--health-timeout`). The `wait_healthy` polling budget in the emitted +script stays `HEALTHY_WAIT_BUDGET_SECONDS // interval` attempts — +it is **not** shortened to `retries × interval`. + +## Context + +The chats prototype validated `start_period` and `retries` as recognized +healthcheck keys but silently ignored both when building `podman run` +flags and when computing the `wait_healthy` poll budget — a +review-identified gap (deviation #2 in the extraction design). Two things +needed fixing: recording the author's intent on the container itself, and +deciding whether the emitted script's own wait loop should account for +`retries`/`start_period`. + +The options considered for the wait loop: +1. Keep the loop's fixed `HEALTHY_WAIT_BUDGET_SECONDS` budget, independent + of the podman-level `--health-retries`/`--health-start-period` values. +2. Recompute the loop's attempt count from `retries × interval` (plus + `start_period`), so the script-level wait tracks the container's own + healthcheck retry policy more precisely. + +## Decision & rationale + +- **Pass `start_period`/`retries` to `podman run`** so the container's own + healthcheck scheduling (as podman understands it) matches what the + compose file declares — this is the straightforward, low-risk half of + the fix, and was uncontroversial. +- **Do not shorten the wait budget to `retries × interval`.** `wait_healthy` + polls `podman healthcheck run ` directly in a loop and returns as + soon as the first successful check is observed — it does not wait for the + full budget on a healthy service. Coupling the budget to `retries × + interval` would risk **premature failure** for a service with a long + `start_period`: `retries` counts consecutive *failures* podman tolerates + before marking a container unhealthy, not the time before the first + check is meaningful, and a short `retries × interval` product could expire + the script's wait loop before `start_period` has even elapsed. The fixed, + generous `HEALTHY_WAIT_BUDGET_SECONDS` (120s) is a safer default that + errs toward giving slow-starting services enough time, at the cost of a + slower failure signal for services that are genuinely broken. + +## Revisit trigger + +- A real service in the fleet needs a `start_period` long enough that the + fixed 120s `HEALTHY_WAIT_BUDGET_SECONDS` is insufficient (i.e. the + container needs more than 120s from first `podman run` to first healthy + check), which would call for making the budget configurable rather than + coupling it to `retries × interval`. +- Repeated false-positive "did not become healthy" failures in CI point at + the fixed budget being too tight for a class of services, prompting a + reconsideration of how the budget is derived. diff --git a/planning/decisions/2026-07-03-zero-dependency-core.md b/planning/decisions/2026-07-03-zero-dependency-core.md new file mode 100644 index 0000000..1e740e3 --- /dev/null +++ b/planning/decisions/2026-07-03-zero-dependency-core.md @@ -0,0 +1,64 @@ +--- +status: accepted +summary: Core package stays stdlib-only with PyYAML as an optional [yaml] extra; no compose-parser dependency. +supersedes: null +superseded_by: null +--- + +# Zero-dependency core, no compose-parser dependency + +**Decision:** The `compose2pod` core package has zero runtime dependencies +(stdlib only). PyYAML is shipped only behind the optional `[yaml]` extra. +The core does not depend on any compose-spec parser library. + +## Context + +`compose2pod` reads a Docker Compose document and must decide how it is +loaded (JSON/YAML) and validated against the supported subset. Two +dependency questions came up while designing the package: + +1. Should YAML parsing be a hard dependency, so the CLI always accepts + `docker-compose.yml` directly? +2. Should the core adopt an existing compose-spec parser library + (`compose-spec`, `compose-pydantic`) instead of hand-rolled subset + validation, to get broader spec coverage for free? + +The primary differentiator for `compose2pod` is that it installs with no +compiled wheels and runs in minimal Python images (the same CI containers +that motivate the tool in the first place — see the design's "Why this +exists"). Any hard dependency, especially one with a compiled wheel, +undermines that. + +## Decision & rationale + +- **YAML stays optional.** JSON parsing via stdlib `json` is always + available. YAML parsing via PyYAML is the optional `[yaml]` extra; without + it, `--format yaml` errors with an actionable message pointing at `pip + install compose2pod[yaml]` or piping through `yq -o=json`. This keeps the + dependency-constrained-CI path (where even PyYAML may not be installable) + fully functional. +- **No compose-spec parser dependency.** Researched candidates + (`compose-spec`, `compose-pydantic`) both require pydantic v2 (a compiled + `pydantic-core` wheel) and are early-stage single-maintainer 0.x projects. + Adopting one would: + (a) break the zero-dependency differentiator for the core, + (b) not actually remove the subset validation — full-spec parsers + permissively accept constructs (`configs`, `secrets`, `profiles`, + `deploy`, long-form volumes, ...) that `compose2pod` cannot turn into a + single pod, so a subset gate is still required regardless of what parses + the document, and + (c) add supply-chain risk (compiled wheel, single maintainer, early 0.x) + to a package whose whole pitch is minimal-footprint installability. +- A future optional `[strict]` extra could cross-validate against + `compose-spec` for users who want full-spec checking, but that is + deliberately out of scope for v1 (YAGNI) — no user need has been + identified yet. + +## Revisit trigger + +- A mature, pure-Python (no compiled wheel) compose-spec parser reaches a + stable 1.x release with more than one maintainer, and a concrete user + need for full-spec validation (beyond the subset `compose2pod` supports) + emerges. +- PyYAML itself becomes uninstallable in a target CI environment that + `compose2pod` needs to support, forcing a rethink of the YAML story. diff --git a/planning/deferred.md b/planning/deferred.md new file mode 100644 index 0000000..80d390a --- /dev/null +++ b/planning/deferred.md @@ -0,0 +1,17 @@ +# Deferred + +Real-but-unscheduled items, each with a revisit trigger. + +## Harden the pod-cleanup trap's nested `shlex.quote` for the `emit_script` library path + +`emit.py`'s `EXIT` trap quotes the pod name with a nested `shlex.quote` inside +an already-quoted trap command. The CLI never reaches this edge because +`cli.py` validates pod names against `POD_NAME_PATTERN` before calling +`emit_script`, but a library caller invoking `emit_script`/`EmitOptions` +directly can pass an unvalidated `pod` value that produces a malformed or +unsafe trap command. + +**Revisit trigger:** a library-API test is added that exercises `emit_script` +with adversarial `pod` values (quotes, spaces, shell metacharacters), or a bug +report surfaces from a caller using the library path without CLI validation. +Needs its own change bundle. diff --git a/planning/index.py b/planning/index.py new file mode 100644 index 0000000..f2ea3e6 --- /dev/null +++ b/planning/index.py @@ -0,0 +1,197 @@ +# ruff: noqa: INP001 # planning/ is not a Python package (this file is vendored into consumers' planning/) +"""Generate the planning index from frontmatter. + +Run via ``just index``. Globs ``planning/changes/*/`` (each bundle's +``design.md``, falling back to ``change.md``) and ``planning/decisions/*.md``, +reads their frontmatter, and prints a Markdown listing to stdout — changes +then decisions, newest-first. Never writes a file: +the listing is a query over the files, not a committed artifact. + +``date`` and ``slug`` are derived from the directory / file name, not +frontmatter — the name is the single source of truth for both. +""" + +import pathlib +import re +import sys + + +ROOT = pathlib.Path(__file__).parent +VALID_DECISION_STATUS = {"accepted", "superseded"} +BUNDLE_RE = re.compile(r"^(?P\d{4}-\d{2}-\d{2})\.\d{2}-(?P.+)$") +DECISION_RE = re.compile(r"^(?P\d{4}-\d{2}-\d{2})-(?P.+)$") +ALLOWED_BUNDLE_FILES = {"design.md", "plan.md", "change.md"} +SPEC_REQUIRED = ("summary",) +DECISION_REQUIRED = ("status", "summary") + + +def parse_frontmatter(text: str) -> dict[str, str]: + """Parse a single-line-scalar YAML frontmatter block into a dict.""" + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return {} + fields: dict[str, str] = {} + for line in lines[1:]: + if line.strip() == "---": + break + if line[:1] in (" ", "\t"): + continue + key, sep, value = line.partition(": ") + if not sep: + continue + cleaned = value.strip().strip('"').strip("'") + fields[key.strip()] = "" if cleaned == "null" else cleaned + return fields + + +def _named(fields: dict[str, str], name: str, pattern: re.Pattern[str]) -> dict[str, str]: + """Inject ``date``/``slug`` derived from a dir/file name into ``fields``.""" + match = pattern.match(name) + if match: + fields["date"] = match.group("date") + fields["slug"] = match.group("slug") + return fields + + +def load_bundles(root: pathlib.Path) -> list[dict[str, str]]: + """Read each bundle's summary; derive date/slug from the directory name.""" + changes_dir = root / "changes" + bundles: list[dict[str, str]] = [] + if not changes_dir.is_dir(): + return bundles + for bundle in sorted(changes_dir.iterdir()): + if not bundle.is_dir(): + continue + spec = bundle / "design.md" + if not spec.exists(): + spec = bundle / "change.md" + if not spec.exists(): + continue + fields = _named(parse_frontmatter(spec.read_text(encoding="utf-8")), bundle.name, BUNDLE_RE) + fields["path"] = f"changes/{bundle.name}/{spec.name}" + fields["name"] = bundle.name + bundles.append(fields) + return bundles + + +def load_decisions(root: pathlib.Path) -> list[dict[str, str]]: + """Read each decision's frontmatter; derive date/slug from the file name.""" + decisions_dir = root / "decisions" + decisions: list[dict[str, str]] = [] + if not decisions_dir.is_dir(): + return decisions + for path in sorted(decisions_dir.glob("*.md")): + if path.name == "README.md" or path.name.startswith("_"): + continue + fields = _named(parse_frontmatter(path.read_text(encoding="utf-8")), path.stem, DECISION_RE) + fields["path"] = f"decisions/{path.name}" + fields["name"] = path.stem + decisions.append(fields) + return decisions + + +def format_row(bundle: dict[str, str]) -> str: + """Render one bundle as a Markdown list item.""" + slug = bundle.get("slug", "?") + path = bundle.get("path", "") + date = bundle.get("date", "") + summary = bundle.get("summary") or "(no summary)" + line = f"- **[{slug}]({path})** ({date}) — {summary}" + if bundle.get("supersedes"): + line += f" _(supersedes {bundle['supersedes']})_" + if bundle.get("superseded_by"): + line += f" _(superseded by {bundle['superseded_by']})_" + return line + + +def render(bundles: list[dict[str, str]], decisions: list[dict[str, str]]) -> str: + """Render the full Markdown listing: changes then decisions, newest-first.""" + out = ["# Planning index", "", "_Generated by `just index` — do not edit._", "", "## Changes", ""] + change_rows = sorted(bundles, key=lambda b: b.get("name", ""), reverse=True) + out += [format_row(b) for b in change_rows] if change_rows else ["_None._"] + out += ["", "## Decisions", ""] + decision_rows = sorted(decisions, key=lambda d: d.get("name", ""), reverse=True) + out += [format_row(d) for d in decision_rows] if decision_rows else ["_None._"] + out.append("") + return "\n".join(out).rstrip() + "\n" + + +def _require(fields: dict[str, str], keys: tuple[str, ...], rel: str, violations: list[str]) -> None: + """Append a violation for each required key that is absent or empty.""" + violations.extend(f"{rel}: missing or empty frontmatter key '{key}'" for key in keys if not fields.get(key)) + + +def _check_spec_file(path: pathlib.Path, rel: str, violations: list[str]) -> None: + """Validate a design.md / change.md spec file (requires `summary`).""" + fields = parse_frontmatter(path.read_text(encoding="utf-8")) + _require(fields, SPEC_REQUIRED, rel, violations) + + +def _check_bundle(bundle: pathlib.Path, violations: list[str]) -> None: + """Validate one change bundle directory.""" + rel = f"changes/{bundle.name}" + if BUNDLE_RE.match(bundle.name) is None: + violations.append(f"{rel}: directory name is not 'YYYY-MM-DD.NN-slug'") + violations.extend( + f"{rel}/{child.name}: unexpected file in bundle (allowed: {', '.join(sorted(ALLOWED_BUNDLE_FILES))})" + for child in sorted(bundle.iterdir()) + if child.name not in ALLOWED_BUNDLE_FILES + ) + design = bundle / "design.md" + change = bundle / "change.md" + if not design.exists() and not change.exists(): + violations.append(f"{rel}: bundle has neither design.md nor change.md") + for spec_file in (design, change): + if spec_file.exists(): + _check_spec_file(spec_file, f"{rel}/{spec_file.name}", violations) + # plan.md carries no frontmatter — its identity comes from the bundle dir. + + +def _check_decision(path: pathlib.Path, violations: list[str]) -> None: + """Validate one decision file (requires `status` + `summary`).""" + rel = f"decisions/{path.name}" + if DECISION_RE.match(path.stem) is None: + violations.append(f"{rel}: file name is not 'YYYY-MM-DD-slug.md'") + fields = parse_frontmatter(path.read_text(encoding="utf-8")) + _require(fields, DECISION_REQUIRED, rel, violations) + status = fields.get("status", "") + if status and status not in VALID_DECISION_STATUS: + violations.append(f"{rel}: invalid status '{status}' (allowed: {', '.join(sorted(VALID_DECISION_STATUS))})") + + +def check(root: pathlib.Path) -> list[str]: + """Validate every bundle and decision; return the list of violation strings.""" + violations: list[str] = [] + changes_dir = root / "changes" + decisions_dir = root / "decisions" + if changes_dir.is_dir(): + for bundle in sorted(changes_dir.iterdir()): + if bundle.is_dir(): + _check_bundle(bundle, violations) + if decisions_dir.is_dir(): + for path in sorted(decisions_dir.glob("*.md")): + if path.name == "README.md" or path.name.startswith("_"): + continue + _check_decision(path, violations) + return violations + + +def main(argv: list[str] | None = None, root: pathlib.Path | None = None) -> int: + """Print the listing to stdout, or validate bundles with --check.""" + argv = sys.argv[1:] if argv is None else argv + root = ROOT if root is None else root + if "--check" in argv: + violations = check(root) + if violations: + sys.stderr.write(f"planning: {len(violations)} violation(s)\n") + for violation in violations: + sys.stderr.write(f" - {violation}\n") + return 1 + sys.stdout.write("planning: OK\n") + return 0 + sys.stdout.write(render(load_bundles(root), load_decisions(root))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())