diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed9cde1..7f80c1f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,7 @@ jobs: - run: ruff format --check . - run: ruff check . - run: pylint tunstrap/ - - run: vulture tunstrap/ vulture_whitelist.py + - run: vulture tunstrap/ - run: mypy --strict tunstrap - name: combined unit + integration coverage gate run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f0d64af..91b679c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,7 +32,7 @@ jobs: run: pylint tunstrap/ - name: vulture if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' - run: vulture tunstrap/ vulture_whitelist.py + run: vulture tunstrap/ - name: mypy --strict run: mypy --strict tunstrap - name: pytest (unit) @@ -84,6 +84,126 @@ jobs: if-no-files-found: error retention-days: 1 + e2e: + # Real kind cluster + real OpenTofu providers through a real tunnel. + # + # A separate job, not part of `integration`: the integration marker's + # contract is "docker compose alone", while this needs kind, tofu, kubectl, + # a 1.45 GB node image and an external Docker network. Folding it in would + # break that contract and make every integration run pay ~90s of cluster + # setup. + # + # It produces NO coverage artifact and is deliberately absent from the + # `coverage` job's combine: that gate downloads exactly two artifacts and + # enforces --fail-under=80, and a third, slowest, most environment-sensitive + # input would let a cluster flake take the coverage gate down with it. + # + # If this proves flaky, demote it with `continue-on-error: true` here rather + # than by weakening any assertion in tests/e2e/. + runs-on: ubuntu-latest + # Bounds a hung `kind create`, a stuck image pull, or a wedged provider + # download. Realistic runtime is a few minutes (cluster create + provider + # downloads dominate; per-init registry version-resolution is the slowest + # step); this has carried a stale local number twice, so it is intentionally + # qualitative. 20 min is a comfortable multiple of that peak and well under + # the 360m Actions default a true hang would otherwise ride. + timeout-minutes: 20 + needs: unit + env: + # Turns every "tool missing" skip in tests/e2e/rig.py::skip_or_fail into a + # failure. Without it a runner that lost `kind` between the install step + # and the test step would report a green job with most of the tier + # skipped - a cluster tier that silently stops running is worse than none. + TUNSTRAP_E2E_REQUIRE_ALL: "1" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: python -m pip install --upgrade pip + - run: pip install -e ".[dev]" + - name: install kind + run: | + curl -fsSL -o ./kind https://kind.sigs.k8s.io/dl/v0.30.0/kind-linux-amd64 + chmod +x ./kind + sudo mv ./kind /usr/local/bin/kind + kind version + - name: install kubectl + # Matched to kindest/node:v1.34.0, which tests/e2e/rig.py pins. + run: | + curl -fsSL -o ./kubectl https://dl.k8s.io/release/v1.34.0/bin/linux/amd64/kubectl + chmod +x ./kubectl + sudo mv ./kubectl /usr/local/bin/kubectl + kubectl version --client + - name: install terragrunt + # tests/e2e/test_recipe_terragrunt.py drives real `terragrunt hcl validate` + # and `terragrunt render` against the HCL pinned in docs/recipe_terragrunt.md. + # Pinned to 1.1.1 - the version the recipe's "Measured Terragrunt facts" + # section is measured against; the recipe's CLI vocabulary is + # version-specific (hcl validate / render / run --all). + run: | + curl -fsSL -o ./terragrunt https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_amd64 + chmod +x ./terragrunt + sudo mv ./terragrunt /usr/local/bin/terragrunt + terragrunt --version + - uses: opentofu/setup-opentofu@v1 + with: + tofu_version: 1.12.5 + # MANDATORY. The action's default wrapper replaces `tofu` on PATH with + # a script that adds GitHub annotations to real tofu's output, and this + # tier asserts on that output. The real-tofu path lives in + # test_tofu_providers.py (init/plan/apply/destroy call `tofu` directly, + # or a recorder that execs it); test_shim.py is unaffected because it + # puts a fake `tofu` first on PATH, which shadows any wrapper. + tofu_wrapper: false + - run: docker compose version + - name: pytest (e2e) + run: pytest tests/e2e -m e2e -v --junitxml=e2e-results.xml + - name: require the whole tier to have run + # Second, independent guard. TUNSTRAP_E2E_REQUIRE_ALL covers a missing + # tool; this covers everything else that can quietly shrink the tier - + # a stray @pytest.mark.skip, a collection error swallowed by -k, a + # deleted module. stdlib only, exact numbers, no log scraping. + if: always() + run: | + python - <<'PY' + import xml.etree.ElementTree as ET + + # pytest writes ; accept both + # shapes so a pytest change cannot make this silently read zeros. + root = ET.parse("e2e-results.xml").getroot() + suite = root if root.tag == "testsuite" else root.find("testsuite") + assert suite is not None, "no element in the JUnit report" + counts = {k: int(suite.get(k, 0)) for k in ("tests", "skipped", "failures", "errors")} + print(counts) + assert counts["skipped"] == 0, "the e2e tier must never skip in CI" + assert counts["failures"] == 0 and counts["errors"] == 0 + # Floor, not equality: adding tests must not require a CI edit, but + # losing them must fail. Kept as a hand-set literal deliberately: a + # number derived by collecting the suite would be computed from the + # same files pytest runs, so a deleted module shrinks the collected + # count and the JUnit `tests` count in lockstep and the guard would + # stay GREEN for exactly the deletion it exists to catch (proven + # tautological). Only an independent literal anchors against deletion; + # at 31 == the current tier it catches the loss of any one test. Bump + # to the new total whenever the tier grows. (Was 32; lowered to 31 by + # the tunstrap_tofu migration: retired 4 shell-shim-text/drift tests + # that the in-package proxy makes obsolete, added 3 proxy/install/gap- + # fix replacements, net -1. test_terragrunt_apply's pollution sub-check + # retired too - the proxy runs run in-process so the tunstrap-wrapper + # negative control no longer reaches the stream; purity is now proven + # by the byte-equality test. Raised back to 33 by the run_cmd follow-up: + # +1 test pinning the load-bearing `--terragrunt-quiet` run_cmd marker + # (proves the parse breaks with the marker removed) and +1 pinning the + # labelled shell-shim-alt alternative fence (sh -n + bypass smoke).) + # Raised to 34 by the inheritance follow-up: +1 test proving a unit that + # forgets `include "root"` is caught (apply fails at 127.0.0.1:0) AND + # distinguished from a forgotten-commands entry (TUNSTRAP_INPUT present + # vs absent) - after the rig stopped concatenating root+unit into one file. + assert counts["tests"] >= 34, f"expected at least 34 e2e tests, ran {counts['tests']}" + print("e2e tier ran in full") + PY + coverage: runs-on: ubuntu-latest needs: integration diff --git a/.gitignore b/.gitignore index db69bcc..eff43b6 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,12 @@ Thumbs.db # Integration test SSH keypair (generated per session) tests/integration/_keys/ +# E2E rig: own SSH keypair and the extracted in-node kubeconfig. +# Both are generated per session by tests/e2e/conftest.py. The e2e tier +# deliberately does not share tests/integration/_keys/, which is generated by +# a fixture pytest never loads for tests/e2e and would be absent in a clean +# checkout. +tests/e2e/_keys/ +tests/e2e/_kube/ + /docs/artifacts/ diff --git a/README.md b/README.md index 230c27e..061d8e9 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,20 @@ tunstrap stop --session-dir "$SESSION_DIR" rm -f "$KUBECONFIG_FILE" ``` +### `start` JSON output and credential-bearing entries + +With the default `daemon.materialize: false`, `start --output json` carries +`content_b64` on stdout because it is the only delivery channel for the patched +kubeconfig or fetched file. These values can include operator-chosen secrets. +Treat this mode's stdout as secret material: do not send it to CI logs or durable +shell captures. + +Set `daemon.materialize: true` (or pass `--materialize` in flag mode) when the +session directory is an acceptable credential location. Each patched kubeconfig +and fetched file is then written mode `0600` under `tunnel-data/`. Materialized +kube targets contain only `{path, context, endpoint}`; materialized fetched +files contain only `{path, size, sha256}`. Neither form carries inline content. + The `daemon.auto_stop_idle_seconds: 600` setting makes the daemon shut itself down after 10 minutes with no client connections. Useful for ephemeral CI runs that may abort before reaching `tunstrap stop`. @@ -116,6 +130,10 @@ sed -i "s|server: https://127.0.0.1:6443|server: https://127.0.0.1:${PORT}|" \ tunstrap stop --session-dir "$SESSION_DIR" ``` +With `daemon.materialize: true` (or `--materialize` in flag mode), read the +fetched file from `fetch_files.kubeconfig.path` instead; see [`start` JSON +output and credential-bearing entries](#start-json-output-and-credential-bearing-entries). + ## CLI run modes (flag input, `--output env`, `run`) Besides the JSON-on-stdin interface above, a single remote host can be driven @@ -149,34 +167,72 @@ tunstrap start root@edge1.example.net \ ### `--output env` (consume via `eval`) `start` defaults to `--output json`. With `--output env` it instead prints -POSIX `export` lines (and force-materializes kube files), ready for `eval`: +POSIX `export` lines and force-materializes both patched kubeconfigs and +fetched files under `tunnel-data/` (mode 0600; see [On-disk +materialization](#on-disk-materialization)), ready for `eval`: ```bash eval "$(tunstrap start root@edge1 --ssh-key ~/.ssh/id_ed25519 \ --target api=127.0.0.1:6443 --kube k3s=/etc/rancher/k3s/k3s.yaml --output env)" -curl "http://$TUNSTRAP_API_ENDPOINT/healthz" kubectl get nodes # KUBECONFIG is exported automatically tunstrap stop --session-dir "$TUNSTRAP_SESSION_DIR" ``` -Variables emitted (no node segment; names upper-cased, non-alphanumerics → `_`): +Variables emitted: | Variable | Meaning | |---|---| | `TUNSTRAP_SESSION_DIR` | Session dir — pass to `stop --session-dir`. | | `TUNSTRAP_PID` | Daemon PID. | -| `TUNSTRAP__PORT` | Local forwarded port for `--target NAME=...`. | -| `TUNSTRAP__ENDPOINT` | `127.0.0.1:` for a target; full URL for a kube target. | -| `TUNSTRAP__KUBECONFIG` | Materialized kubeconfig path for `--kube NAME=...`. | -| `KUBECONFIG` | Colon-joined paths of all kube targets. | +| `TUNSTRAP_OUTPUT_FILE` | Absolute path to the materialized unified output JSON. | +| `KUBECONFIG` | Colon-joined materialized paths of all kube targets; emitted when at least one kube file exists. | +| `KUBE_CONFIG_PATH` | Same value as `KUBECONFIG`; emitted with exactly one kube file. It takes precedence over `KUBE_CONFIG_PATHS` in the OpenTofu providers. | +| `KUBE_CONFIG_PATHS` | Same value as `KUBECONFIG`; emitted with two or more kube files. It is not emitted with `KUBE_CONFIG_PATH`. | ### `run` (foreground wrapper with guaranteed teardown) -`run` opens the tunnel, injects the same `TUNSTRAP_*` / `KUBECONFIG` -environment into a child command, waits for it, and then **always** tears the -tunnel down (even if the child crashes or fails to launch): +`run` opens the tunnel and injects the same session scalars and kube channel +(`KUBECONFIG` / `KUBE_CONFIG_PATH` / `KUBE_CONFIG_PATHS`, as applicable) into a +child command. It always removes inherited `KUBECONFIG`, +`KUBE_CONFIG_PATH`, and `KUBE_CONFIG_PATHS` before starting the child, even +when the input has no kube targets. A direct `tunstrap run host -- kubectl ...` +therefore does not retain an unrelated operator kube configuration. + +`run` waits for the child and then **always attempts teardown** (even if the +child crashes or fails to launch). Teardown normally +stops the daemon and removes the session data. When it cannot confirm the stop +— the stop reports a failure, raises, or the recorded identity is unreadable — +it **keeps** the session data instead and prints the `tunstrap stop +--session-dir …` command that finishes the job, rather than destroying the only +handle on a daemon that may still be running. Either way the child's exit code +is never changed by teardown: + +`stop` follows the same rule, so that recovery command is safe to run and safe +to repeat: it removes `tunnel-data` only after a confirmed stop (or a `not +found`, which means no daemon is recorded), and otherwise leaves the session +data in place, adds `"preserved": true` to its JSON line and explains itself on +stderr. That includes the cases where it cannot read +`tunnel-data/daemon.pid` at all — missing, unreadable or malformed: `stop` +deletes nothing on any of them, so all three report `"preserved": true` too. +`stop` exits 0 only for the three outcomes that clean `tunnel-data`: stopped, +forced, and `not found`. It exits 1 for every preserved outcome: identity +mismatch, identity check unavailable, still alive, identity changed during +grace, and the three identity-read failures (missing, unreadable, or malformed +`daemon.pid`). Repeating an unresolved recovery command keeps returning 1 until +the session is resolved by hand; the loop's behaviour is unchanged, only its +status, and repetition could never resolve a preserved session on its own. +`"preserved"` is therefore present on exactly the outcomes that kept data, and +absent on exactly those that cleaned it; the three cleaning shapes +(`{"stopped": true}`, `{"stopped": true, "forced": true}` and +`{"stopped": false, "reason": "not found"}`) are unchanged to the byte, so a +strict-schema consumer of those is unaffected. Two outcomes it cannot resolve on its own are `identity mismatch` and +`identity check unavailable` — the recorded pid can no longer be verified as +ours, so `stop` refuses to signal it rather than risk killing an unrelated +process. Re-running will keep reporting the same thing; that is the case the +preserved `tunnel-data/daemon.pid` exists for, and it has to be resolved by +hand: ```bash tunstrap run root@edge1 \ @@ -188,10 +244,111 @@ tunstrap run root@edge1 \ Everything after `--` is the child command and its arguments. `SIGINT` / `SIGTERM` are forwarded to the child. +**`--` is mandatory** whenever the child command or any of its arguments +begins with `-`. Without it Click parses those tokens as tunstrap's own +options: `tunstrap run --input-env X tofu -version` fails with +`No such option: '-v'`. Everything after `--` reaches the child verbatim, +including flags spelled like tunstrap's own. + +#### Env input: `--input-env VAR` + +`run` can take the complete `InputSchema` as JSON from an environment +variable instead of from a connection argument and flags. This is the only +out-of-band input channel a foreground wrapper has: `run`'s child inherits +stdin, so stdin is not available to `run` as a control channel. + +```bash +TUNSTRAP_INPUT="$(cat payload.json)" \ + tunstrap run --input-env TUNSTRAP_INPUT -- helm list +``` + +In this mode there is no `USER@HOST[:PORT]` argument — every token after `--` +is the child command. The following are usage errors (exit `64`), because the +payload's own `nodes` and `daemon` blocks are complete and authoritative and +there must be exactly one place to look: + +- any connection flag: `--ssh-key`, `--ssh-key-passphrase`, + `--ssh-password-stdin`, `--target`, `--kube`, `--fetch`; +- any daemon flag: `--auto-stop-idle-seconds` (use `daemon.auto_stop_idle_seconds`), + `--grace-seconds` (use `daemon.shutdown_grace_seconds`), + `--log-file` (use `daemon.log_file`), `--materialize` (redundant, see below). + +Payload problems are exit `1` with a `SchemaValidationError` envelope on +**stderr**: the variable unset, empty or whitespace-only; its content not +JSON; or the JSON not satisfying `InputSchema`. All of these are decided +before any daemon is started. + +**`run` always materializes.** It overrides `daemon.materialize` to `true` +even when the payload sets it to `false`. `KUBECONFIG` injection needs a real +file on disk, and an unmaterialized kube target would give `--output-var` +consumers `"path": null`. This is the one place `run` modifies the supplied +schema. + +#### Structured output: `--output-var NAME` + +`--output-var NAME` puts a JSON-encoded, node-keyed unified structure under +`NAME`, alongside the three session scalars and the kube channel when kube +files exist. Its top-level keys are `session` and `nodes`; each node contains +`ports`, `kube`, and `fetch_files`. + +Each `nodes..ports.` value is the string +`"127.0.0.1:"`, not an integer. The Terragrunt/OpenTofu recipe +extracts the port with `split(":", ...)[1]`; see +[`docs/recipe_terragrunt.md`](docs/recipe_terragrunt.md). + +`run` always materializes. The unified structure carries kube references as +`{path, context, endpoint}` and fetched-file references as `{path, size, +sha256}` (or `{error}` for an optional fetch failure). It does not carry file +content or kube credentials. The documented consumer binds `NAME` to a +Terraform variable, while `path` names the on-disk file a consumer can read. +`tunstrap start --output json` likewise projects materialized kube and +fetched-file entries; only an unmaterialized `start` envelope carries inline +content on stdout. + +```bash +tunstrap run --input-env TUNSTRAP_INPUT --output-var TF_VAR_tunstrap \ + -- tofu plan +``` + +The scalar environment carries session bookkeeping and kube file locations; it +does not describe ports, warnings, or node-qualified metadata. `--output-var` +is the node-keyed structured channel for that metadata. + +- The variable named by `--input-env` is **removed** from the child's + environment. It holds the `InputSchema`, whose `ssh_pkey` is an SSH private + key, and the child (`tofu`) would otherwise pass it to every provider + plugin, `external` data source and `local-exec` provisioner. + +- `NAME` must match `[A-Za-z_][A-Za-z0-9_]*`, else exit `64`. +- `NAME` may not collide with a variable `run` itself injects or scrubs, else + exit `64`. + Collision with an unrelated inherited variable is a documented overwrite. +- **Any node count:** the three session scalars are injected, as is the + cardinality-appropriate kube channel when kube files exist; `NAME` is added + if given. Ports remain available in the unified JSON or its materialized file, + not as target-scoped environment variables. + +> This is the flag the Terragrunt/OpenTofu recipe builds on — it puts the +> connection envelope (kube credentials projected out) into `TF_VAR_tunstrap` +> for a module to decode. See +> [`docs/recipe_terragrunt.md`](docs/recipe_terragrunt.md). + +#### `run` never writes to stdout + +After the child starts, `run` writes nothing to file descriptor 1 — stdout +belongs exclusively to the child. Every tunstrap diagnostic, including +teardown failures, goes to stderr, and a teardown failure never changes the +exit code. (`tunstrap stop` is unaffected: its JSON line on stdout is still +its documented contract.) + **Exit codes (`run`):** the child's exit code wins on success. Before the -child runs, `run` may exit with `2` (required tunnel failure), `3` (a live -session already holds the requested `--session-dir`), or `4` (daemon error); -`127` if the child binary cannot be launched. +child runs, `run` may exit with `64` (usage error, including every row of the +`--input-env` conflict matrix and an invalid or colliding `--output-var`), +`1` (bad `--input-env` payload), +`2` (required tunnel failure), `3` (a live session already holds the +requested `--session-dir`), or `4` (daemon error). `127` if the child binary +cannot be launched, and `4` for any unexpected failure after the tunnel came +up — in which case the daemon has already been torn down. ## Input reference (`InputSchema`) @@ -202,6 +359,7 @@ session already holds the requested `--session-dir`), or `4` (daemon error); | `nodes` | `dict[str, NodeInput]` | required | One entry per remote host | | `daemon.log_file` | `str \| null` | `null` | If set, daemon's stdout/stderr go here. Never contains fetched content. | | `daemon.shutdown_grace_seconds` | `int` | `10` | SIGTERM grace period before SIGKILL | +| `daemon.startup_timeout_seconds` | `int` | `300` | Bounds the parent's wait for the worker's startup IPC frame. On expiry the parent terminates the worker within `shutdown_grace_seconds`. Must exceed a node's worst-case startup: `fetch_files` and `kube_targets` are fetched serially, each bounded by `ssh_options.connect_timeout`. | | `daemon.auto_stop_idle_seconds` | `int \| null` | `null` | Seconds of idle (no active forward connections) before the daemon SIGTERMs itself. `null` disables. | | `daemon.materialize` | `bool` | `false` | Write patched kubeconfig files to `/tunnel-data/` (mode 0600). See [On-disk materialization](#on-disk-materialization). | @@ -217,7 +375,7 @@ session already holds the requested `--session-dir`), or `4` (daemon error); | `ssh_pkey_passphrase` | `str \| null` | `null` | Optional passphrase for `ssh_pkey` | | `remote_targets` | `dict[str, str] \| null` | `null` | Up to 16 entries; each value is `"host:port"`. Host is resolved on the SSH server side, enabling bastion-style cross-host forwards. | | `ssh_options.compression` | `bool` | `false` | Enable SSH compression | -| `ssh_options.connect_timeout` | `int` | `60` | Seconds | +| `ssh_options.connect_timeout` | `int` | `60` | Seconds for connection establishment and each SFTP file fetch. | | `required` | `bool` | `true` | If false, this node may fail without aborting `start` | | `fetch_files` | `dict[str, FileSpec] \| null` | `null` | Files to read at start (max 16) | | `kube_targets` | `dict[str, KubeTarget] \| null` | `null` | Kubernetes clusters to access via the SSH tunnel (max 16). See [Kube mode](#kube-mode-kube_targets). | @@ -249,12 +407,23 @@ each entry the tool: 5. Probes the apiserver's TLS certificate SAN to choose a `tls-server-name`. 6. Rewrites `server:` to `https://127.0.0.1:` and injects `tls-server-name`. Other clusters in the file are byte-stable. -7. Returns the patched kubeconfig plus already-extracted fields +7. Renames the `current-context`'s context, cluster, and user to the + deterministic `tunstrap--` -- the consumer-facing literal + documented in `docs/recipe_terragrunt.md`. Non-selected contexts keep + their own names; their `cluster`/`user` references are rewritten when they + point at the renamed cluster/user. A fetched kubeconfig that already + contains `tunstrap--` in any `clusters`, `users`, or + `contexts` entry is rejected: the target fails (subject to `required`) + rather than the name being uniquified, because the deterministic name is a + contract consumers rely on. +8. Returns the patched kubeconfig plus already-extracted fields (`endpoint`, `certificate_authority_data`, `client_certificate_data`, `client_key_data`, `tls_server_name`). -**One cluster per target.** The tool takes the `current-context` and ignores -all other contexts/clusters in the file. To access two clusters, use two +**One cluster per target.** Only the `current-context` triple (its context, +cluster, and user) is selected and renamed; other contexts keep their own +names, but their `cluster`/`user` references are rewritten when they point at +the renamed cluster/user. To access two clusters, use two `kube_targets` entries. If the kubeconfig contains more than one context, a `warnings[]` entry names the ignored contexts. @@ -291,17 +460,22 @@ explicit `tls_server_name` is set: | Field | Description | |---|---| -| `cluster_name` | Cluster name from the kubeconfig | -| `context_name` | `current-context` value | -| `local_port` | OS-assigned local forwarded port | +| `cluster_name` | Deterministic renamed cluster identity `tunstrap--` (unmaterialized only) | +| `context_name` | Deterministic renamed context identity `tunstrap--`, i.e. the patched file's `current-context` (unmaterialized only) | +| `local_port` | OS-assigned local forwarded port (unmaterialized only) | | `endpoint` | `https://127.0.0.1:` | -| `tls_server_name` | Chosen TLS server name, or `null` on insecure fallback | -| `certificate_authority_data` | Base64 CA cert, or `""` on insecure fallback | -| `client_certificate_data` | Base64 client cert | -| `client_key_data` | Base64 client private key | -| `content_b64` | Full patched kubeconfig (always present) | +| `tls_server_name` | Chosen TLS server name, or `null` on insecure fallback (unmaterialized only) | +| `certificate_authority_data` | Base64 CA cert, or `""` on insecure fallback (unmaterialized only) | +| `client_certificate_data` | Base64 client cert (unmaterialized only) | +| `client_key_data` | Base64 client private key (unmaterialized only) | +| `content_b64` | Full patched kubeconfig (unmaterialized only) | | `path` | Absolute path to the materialized file, or `null` if `daemon.materialize=false` | +Materialized `start --output json` targets use the projected form `{path, +context, endpoint}`. Only the key is renamed from the raw envelope's +`context_name` to `context`; its value is identical in both shapes and is +always `tunstrap--`. + ## Output reference **Success (`OutputSchema`)** @@ -322,8 +496,8 @@ explicit `tls_server_name` is set: }, "kube_targets": { "k3s": { - "cluster_name": "default", - "context_name": "default", + "cluster_name": "tunstrap-edge1-k3s", + "context_name": "tunstrap-edge1-k3s", "local_port": 40124, "endpoint": "https://127.0.0.1:40124", "tls_server_name": "edge1.example.net", @@ -337,7 +511,6 @@ explicit `tls_server_name` is set: } }, "pid": 12345, - "token": "", "session_dir": "/tmp/tunstrap-session-abc123", "started_at": "2026-05-30T10:00:00Z", "warnings": [] @@ -346,6 +519,40 @@ explicit `tls_server_name` is set: `session_dir` is **always** present. Pass it to `stop --session-dir`. +With `daemon.materialize: true`, `start --output json` retains the +`OutputSchema` envelope but projects every materialized content-bearing entry +to a reference. For example: + +```jsonc +{ + "connections": { + "edge1": { + "ports": {"kubeapi": 40123}, + "fetch_files": { + "kubeconfig": { + "path": "/tmp/tunstrap-session-abc123/tunnel-data/fetch-edge1-kubeconfig", + "size": 2918, + "sha256": "d2a0bf3c..." + } + }, + "kube_targets": { + "k3s": { + "path": "/tmp/tunstrap-session-abc123/tunnel-data/kube-edge1-k3s", + "context": "tunstrap-edge1-k3s", + "endpoint": "https://127.0.0.1:40124" + } + } + } + } +} +``` + +The materialized kube projection renames only raw `context_name`'s key to +`context`; its value is identical in both shapes and is always +`tunstrap--`. +`--output-var` uses the same projected references under its node-keyed +`{session, nodes}` structure, rather than this `OutputSchema` envelope. + **Failure (`ErrorOutput`)** ```json @@ -382,11 +589,12 @@ failure. - `daemon.log_file` (if set) receives only asyncssh/asyncio debug noise. No `print`/`log` call path in this codebase carries decoded file bytes. - `content_b64` is base64; callers must decode and protect it. -- `token` returned by `start` is the authorization handle for `stop`/`status`. - Store it like a credential. - Private keys (`ssh_pkey`) stay in process memory; they are never written to `~/.ssh` or to a tempfile. Parsing happens via `asyncssh.import_private_key`. +- A caller-supplied `--session-dir` must be owned by the invoking user; tunstrap + clears its group/other write bits on use, because it stores 0600 credentials + (`tunnel-data/`) there. No pre-`chmod` is required of the operator. **On-disk materialization** (`daemon.materialize`) @@ -396,8 +604,13 @@ tool itself never writes content to disk — the "content never to disk" guarant is preserved. When `materialize=true`: the patched kubeconfig (including embedded private keys) -is written mode 0600 to `/tunnel-data/-`. -The daemon removes these files on `stop` or `atexit`. The `path` field in the +is written mode 0600 to `/tunnel-data/kube--`. +Fetched files materialize to `fetch--`; these leaf names are an +implementation detail, and consumers must read `path` from the output envelope +rather than construct it. +The daemon removes these files on `stop` or `atexit` — except when `stop` cannot +confirm the daemon died, in which case it deliberately keeps them (see the `run` +teardown notes above) and says so with `"preserved": true`. The `path` field in the kube target output becomes non-null. Callers opting in accept that decoded files (including private keys) land on disk until `stop`/`atexit` runs. If the daemon is killed with `kill -9`, `tunnel-data/` is orphaned and must be cleaned up @@ -426,7 +639,6 @@ future feature. | `kube_targets[name]` missing or has error | Check `warnings[]` for SAN-probe details; try setting explicit `tls_server_name`. | | `start` with a supplied `--session-dir` fails with "tunnel-data already exists" | Orphaned `tunnel-data/` from a previous `kill -9`. Remove it: `rm -rf /tunnel-data`. | | `start` hangs | Node firewalled / DNS-stuck. Increase `ssh_options.connect_timeout` or remove the node. | -| `status` says alive but `stop` says "token mismatch" | The PID was reused. Token guards against this — investigate which process holds the PID. | ## Migration from `v2026.10516.11702` @@ -517,6 +729,7 @@ pytest tests/integration -m integration ## Project documents +- Terragrunt / OpenTofu recipe: [`docs/recipe_terragrunt.md`](docs/recipe_terragrunt.md) - Kube-targets design: `docs/specs/2026-05-30-kube-targets-design.md` - Fetch-files design: `docs/specs/2026-05-20-feature-fetch-files-design.md` - Original design (historical): `docs/specs/2026-05-16-tunstrap-design.md` diff --git a/docs/recipe_terragrunt.md b/docs/recipe_terragrunt.md new file mode 100644 index 0000000..01e42dd --- /dev/null +++ b/docs/recipe_terragrunt.md @@ -0,0 +1,784 @@ +# Recipe: Terragrunt / OpenTofu through a tunstrap tunnel + +This recipe shows how to drive Terragrunt and OpenTofu (`tofu`) through a +tunstrap tunnel using the **CLI-proxy model**: the shipped `tunstrap_tofu` +console entry point installed as Terragrunt's `terraform_binary`, which brings +the tunnel up and runs `tofu` with the connection details in the environment. + +It is written for someone adopting this in their own repo. It carries the +measured facts a future reader would otherwise have to re-derive, the failure +modes you will hit, and an honest statement of what is and is not proven. + +> **Companion design.** The full design — why this shape over the alternatives +> (`--owner` watchdog, `--output-file`, `--placeholder-host`) — lives in +> [`docs/specs/2026-07-31-run-env-io-and-tofu-proxy-design.md`](specs/2026-07-31-run-env-io-and-tofu-proxy-design.md). +> The e2e tier that proves the central claim lives in `tests/e2e/`. + +## The model in one paragraph + +Terragrunt lets you point `terraform_binary` at any executable. `tunstrap_tofu` +is that executable. For commands that touch a live cluster (`plan`, `apply`, …) +the proxy opens the tunnel and **becomes `tofu`'s parent**: it injects the +connection details into `tofu`'s environment, waits for `tofu`, and tears the +tunnel down in a `finally` — so the tunnel's lifetime is exactly the child's. +For commands that do not need a tunnel (`init`, `-version`), and whenever no +tunnel is wanted, the proxy `execvp`s `tofu` directly. tunstrap is never a +daemon you normally start and stop by hand in this model; it owns the child and +tears it down automatically. Whenever that teardown ends without a confirmed +stop — it reports a failure, itself raises, or the recorded identity is +unreadable — tunstrap keeps the session data instead of deleting it and prints +the `tunstrap stop --session-dir …` command that finishes the job by hand. That +command applies the same rule, so it is safe to repeat: it clears the tunnel +data once the daemon is confirmed gone, and otherwise preserves it and reports +`"preserved": true`. When the preserved directory is one tunstrap minted under +`TMPDIR`, the diagnostic names it too — `stop` never removes its own +`--session-dir` argument, so that directory is yours to delete once the daemon +is dealt with. A caller-supplied session dir must be owned by the invoking user; +tunstrap clears its group/other write bits on use, because it stores 0600 +credentials (`tunnel-data/`) there — no pre-`chmod` is required. + +`stop` exits 0 only when it clears `tunnel-data`: stopped, forced, or `not +found`. It exits 1 for every outcome that preserves data: identity mismatch, +identity check unavailable, still alive, identity changed during grace, and the +three identity-read failures (missing, unreadable, or malformed `daemon.pid`). +Repeating an unresolved recovery command keeps returning 1 until the session is +resolved by hand; the loop's behaviour is unchanged, only its status, and a +preserved session was never recoverable through repetition alone. + +## Prerequisites + +- `tunstrap_tofu` on `PATH` (the installed proxy entry point — see Installation + below for a one-line install). `tofu` on `PATH` too (the proxy `execvp`s it + for the pass-through branches and as the tunnelled child). +- An OpenTofu/Terraform module that reads the connection details from a + `TF_VAR_*` variable. The shape is given below and is load-bearing. + +## Installation + +One piece, installed once: the `tunstrap` package, which ships **two** console +entry points — `tunstrap` and `tunstrap_tofu` (the proxy). + +tunstrap is **not on PyPI** — a direct-reference dependency (the asyncssh fork +the package is built on) blocks publishing — so install it from the git source +or a local checkout: + +```sh install +uv tool install "git+https://github.com/AlexMKX/tunstrap.git" +# or from a local checkout: +uv tool install /path/to/tunstrap +``` + +Use `uv tool install`, not `uvx`. `uv tool install` yields **stable** entry +points at `~/.local/bin/tunstrap` and `~/.local/bin/tunstrap_tofu`, identical +across reinstalls; `uvx` runs from an ephemeral `~/.cache/uv/archive-v0/…` path +that changes per resolution. There is nothing to copy into your repo: point +`terraform_binary` at the installed `tunstrap_tofu` and you are done. + +## How the proxy works + +`tunstrap_tofu` is a thin dispatcher around `tofu` with three branches, decided +from `argv` and `TUNSTRAP_INPUT`: + +| Condition | Branch | What happens | +|---|---|---| +| `TUNSTRAP_INPUT` unset | pass-through | `execvp tofu "$@"` — no tunnel, no `tunstrap`. This is the "infra not applied yet" path: Terragrunt omits the env_var, so the proxy is a transparent `tofu` wrapper. | +| subcommand is `init`/`version`/`validate`/`fmt`/`-version`/`-help`, or no subcommand | pass-through | `execvp tofu "$@"` — same. `init` only configures the backend and downloads providers; `validate` checks the configuration against installed provider schemas only; `fmt` touches only local `.tf` files — none of the three ever reach the cluster API. Skipping them avoids a redundant tunnel per `terragrunt plan`/`validate`/`fmt` (Terragrunt's `extra_arguments.env_vars` reaches the listed commands **and** their automatic `init`). The subcommand is parsed past global flags, so `tofu -chdir=DIR init` also bypasses (see "A fixed gap" below). **Note:** this bypasses `TUNSTRAP_INPUT` even if you deliberately list `validate`/`fmt` in `commands` below — both are provably cluster-free, so the proxy does not build a tunnel for them regardless of that opt-in. | +| otherwise | tunnelled | opens the tunnel, injects `TF_VAR_tunstrap` (the connection envelope) plus the scalar env, runs `tofu`, and tears the tunnel down in a `finally` — so the tunnel's lifetime is exactly the child's. Reuses `tunstrap run`'s hardened path in-process; no second process level. | + +**Never write to stdout.** Terragrunt captures and labels `tofu`'s stdout by +default, and `terragrunt output -json` consumers parse it. Diagnostics go to +stderr or a file. `tunstrap run` is silent on stdout after the child starts. + +**`KUBECONFIG` is suppressed in the child environment, not on the command line — +and only `KUBECONFIG`.** For a single-node payload `run` injects `KUBECONFIG` +(pointing at the same materialized file `config_path` would use); left in place +it is a **silent fallback for `var.tunstrap`-driven configs (Mode B, below)** — +if the `TF_VAR_tunstrap` → `config_path` wiring were broken, providers would +still find a working cluster via `KUBECONFIG` and everything would appear fine. +The proxy sets `suppress_kubeconfig`, so `run` builds the child environment with +the *injected* `KUBECONFIG` removed, making the decoded `config_path` the +**only** route to the cluster for a Mode B config. `KUBE_CONFIG_PATH`/ +`KUBE_CONFIG_PATHS` — the provider-facing names Mode A (below) relies on — are +**not** touched by this guard: providers never read plain `KUBECONFIG` at all, +so suppressing it protects a different, real audience instead — +`kubectl`/`helm` CLI invocations inside `local-exec` provisioners, which do +honour it. **Mode A works through `tunstrap_tofu` as documented**: any +inherited `KUBECONFIG`/`KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` from the operator's +own shell is also always dropped before `run` injects the real channel, on both +the plain and the proxied path, so a stray operator environment can never leak +into or override it either. The e2e tier proves the suppression by recording +`tofu`'s actual environment and asserting `KUBECONFIG` is absent. + +### Wiring it into Terragrunt + +`terraform_binary` takes a **path only** (see "Measured Terragrunt facts" +below). The **default** is a literal absolute path — nothing runs at config +parse time and there is one less moving part. Find the path once with +`command -v tunstrap_tofu` (`uv tool install` places it at +`~/.local/bin/tunstrap_tofu`) and paste it: + +```hcl terragrunt-root +# root.hcl - shared across units; each unit inherits it with +# include "root" { path = find_in_parent_folders("root.hcl") } +# (see the unit block below). terraform_binary is a TOP-LEVEL attribute, not a +# member of the terraform {} block: TG rejects it there with "An argument named +# terraform_binary is not expected here." See "Measured Terragrunt facts" below. +# For a single-unit repo you may instead put this line at the top of the unit's +# terragrunt.hcl and drop the include. +# +# Default: the absolute path of the installed tunstrap_tofu entry point. +terraform_binary = "$HOME/.local/bin/tunstrap_tofu" +``` + +#### Localizing the bootstrap with `run_cmd` (optional) + +If you would rather the bootstrap lived entirely in `terragrunt.hcl` — no pasted +path to update after a reinstall — `terraform_binary` also accepts `run_cmd`, +which resolves the entry point at run time: + +```hcl terragrunt-root-runcmd +# Optional: resolve the installed tunstrap_tofu at run time instead of pasting +# the path. run_cmd execs its first arg directly (no shell), so go through +# `sh -c` to use the POSIX `command -v` builtin. +# +# "--terragrunt-quiet" is LOAD-BEARING and it is the first argument to run_cmd +# itself, not a terragrunt CLI flag. run_cmd consumes it to suppress logging the +# command's output; without it, that output (the resolved path) is prepended to +# every `terragrunt output -json`, corrupting the JSON. Measured against +# Terragrunt v1.1.1: +# run_cmd("--terragrunt-quiet", "sh", "-c", "command -v tunstrap_tofu") -> clean +# run_cmd("sh", "-c", "command -v tunstrap_tofu") -> path leaks +# This is the same shape as `env -u KUBECONFIG` in the old shell shim: drop the +# incantation and the failure surfaces somewhere that looks unrelated. +terraform_binary = run_cmd("--terragrunt-quiet", "sh", "-c", "command -v tunstrap_tofu") +``` + +`run_cmd`'s real costs: it runs **before** Terragrunt's `-version` probe, is +cached once per `plan`, and runs once **per unit** under `run --all` — and it +needs the marker. The literal-path default has none of those moving parts, which +is why it is the recommendation. + +A `before_hook` **cannot** install the proxy instead. Terragrunt probes +` -version` roughly 50 ms *before* any hook runs, and with the +binary missing it fails outright before the hook executes (measured; the hook's +marker file never appears). + +### Alternative: a shell shim for the fast path + +For the unusual consumer for whom every millisecond of the fast path matters, +`tunstrap_tofu` costs ~25 ms per pass-through invocation (vs ~2 ms for a shell +`exec`) — about ~74 ms added per `terragrunt plan`, noise beside an 8 s +`tofu init`. A 3-line `/bin/sh` shim recovers the ~2 ms fast path at the cost of +copying, committing and keeping it in sync (it is not driven by the e2e tier; +`sh -n`-checked and smoke-tested as a labelled fence below — +`tofu-shim-alt`). For nearly everyone the entry point is the better trade. + +```sh tofu-shim-alt +#!/bin/sh +# Lower-overhead alternative to tunstrap_tofu: a shell exec on the fast paths +# (~2 ms vs ~25 ms). Copy into bin/tofu-tunstrap, chmod 0755, commit. Behaves +# the same as the proxy EXCEPT it cannot parse past -chdir to a global flag, so +# `tofu -chdir=DIR init` builds a needless tunnel (the fixed gap, unfixed here). +[ -n "$TUNSTRAP_INPUT" ] || exec tofu "$@" +case "$1" in init|-version) exec tofu "$@" ;; esac +exec tunstrap run --input-env TUNSTRAP_INPUT --output-var TF_VAR_tunstrap \ + -- env -u KUBECONFIG tofu "$@" +``` + +Then, in the unit that needs the tunnel, declare `TUNSTRAP_INPUT` as an +`extra_arguments` env var scoped to the commands that actually contact the +cluster. The unit **must** inherit the root for `terraform_binary` to take +effect — without `include "root"`, Terragrunt silently falls back to plain `tofu` +on `PATH` and the apply dies at the inert `https://127.0.0.1:0` endpoint (see +"Failure modes" — this is indistinguishable from a forgotten `commands` entry by +the exit code alone, and the recipe exists to keep them apart): + +```hcl terragrunt-unit +# unit terragrunt.hcl +# +# `terraform_binary` lives in root.hcl; inherit it. find_in_parent_folders +# defaults to searching for "terragrunt.hcl", so name "root.hcl" explicitly - +# the bare find_in_parent_folders() errors with ParentFileNotFoundError when the +# parent is root.hcl (measured). +include "root" { + path = find_in_parent_folders("root.hcl") +} + +terraform { + source = "." + + extra_arguments "tunstrap" { + # Commands that make provider API calls. init/validate/output are + # intentionally absent: they read state/files or the registry, not the + # cluster. import IS included - it reads a live resource and is the easiest + # state-mutating command to forget. + commands = ["plan", "apply", "destroy", "refresh", "import"] + arguments = [] + + # dependency.* resolves here [measured]; it does NOT resolve in `locals`, + # so the conditional must be inline in env_vars, not factored out. + env_vars = local.cluster_host != "" ? { + TUNSTRAP_INPUT = jsonencode({ + nodes = { + node = { + host = local.cluster_host + port = 22 + user = "root" + ssh_pkey = local.ssh_private_key + remote_targets = { k3s = "127.0.0.1:6443" } + kube_targets = { k3s = { kubeconfig_path = "/etc/rancher/k3s/k3s.yaml" } } + required = true + } + } + daemon = { + shutdown_grace_seconds = 10 + materialize = true + # auto_stop_idle_seconds is intentionally absent: the daemon's + # lifetime is now exactly the tofu child's. + } + }) + } : {} + } +} + +``` + +The unit references `local.cluster_host` / `local.ssh_private_key`, which it does +not define, so a copy-paster gets `"local.cluster_host is not defined"` until they +add a `locals` block. `dependency.*` does NOT resolve in `locals` (measured +below), so build these from your source of truth and keep any `dependency.*` +reference inline in `env_vars` above. Expected shape (uncomment and fill): + +```hcl terragrunt-locals +# locals { +# cluster_host = "k3s.example.internal" +# # PEM-format PRIVATE key (fed to asyncssh.import_private_key) - NOT the +# # ssh-ed25519 AAAA... .pub line. Pull it from your secret store rather +# # than committing it inline. +# ssh_private_key = get_env("TUNSTRAP_SSH_PRIVATE_KEY", "") +# } +``` + +When `local.cluster_host == ""` (infra not applied, or a mock-state run), the +`env_vars` map is empty, `TUNSTRAP_INPUT` is unset, and the proxy takes its +pass-through branch — so the same unit plans cleanly with no tunnel and +no mock-state workaround. This replaces the entire `--placeholder-host` idea: +`env_vars` is an ordinary HCL map, so "no tunnel" is just "key omitted". + +### The `commands` list is an enumeration, not a copy of an old hook list + +If you are migrating from a `run_cmd`/`after_hook` design, do **not** copy the +old hook's command list. That list answered "which commands evaluated `inputs` +and therefore needed teardown". This list answers a different question: "which +commands make provider API calls". Concretely: + +| Command | Tunnel | Why | +|---|---|---| +| `plan`, `apply`, `destroy`, `refresh` | yes | providers read and write live cluster state | +| `import` | **yes** | reads the live resource to populate state; omitting it is a silent trap | +| `console` | yes, if you use it | can evaluate provider data sources; add it if interactive | +| `init`, `validate` | no | backend config / schema checks only; no cluster contact | +| `output`, `show`, `state *`, `taint`, `untaint`, `fmt`, `providers` | no | read/rewrite state and files | + +`validate` and `fmt` are also in the proxy's own bypass set (see "How the proxy +works," above): even if you list either in `commands`, the proxy still +`execvp`s `tofu` directly for them rather than tunnelling, because both are +provably cluster-free. Consequence: `tofu validate` therefore runs *without* +`TF_VAR_tunstrap` set at all — keep a default on the variable, as the module +below does (`default = ""`), or `validate` hits an unset-variable error. + +Everything not listed gets `TUNSTRAP_INPUT` unset and takes the proxy's +pass-through branch, so the failure mode of forgetting a command is a **provider +error against an inert loopback endpoint**, not a silent wrong result. + +## The module side + +The proxy hands the module the connection envelope as a JSON string in +`TF_VAR_tunstrap`. The module decodes it and derives its provider `config_path` +from it. This is the exact chain the e2e tier exists to prove: + +```hcl tf-module +variable "tunstrap" { + type = string + default = "" + sensitive = true +} + +locals { + # try() is load-bearing: jsondecode("") is an error, so a bare jsondecode + # would make `tofu plan` fail whenever the infrastructure is not applied yet + # (the empty-string default). + tunnel = try(jsondecode(var.tunstrap), { nodes = {} }) + kubepath = try(local.tunnel.nodes.node.kube.k3s.path, "") + + inert = local.kubepath == "" + kube_config_path = local.inert ? null : local.kubepath + kube_host = local.inert ? "https://127.0.0.1:0" : null + kube_ca_certificate = local.inert ? "" : null + kube_client_cert = local.inert ? "" : null + kube_client_key = local.inert ? "" : null +} + +provider "kubernetes" { + config_path = local.kube_config_path + host = local.kube_host + cluster_ca_certificate = local.kube_ca_certificate + client_certificate = local.kube_client_cert + client_key = local.kube_client_key +} + +provider "helm" { + kubernetes { + config_path = local.kube_config_path + host = local.kube_host + cluster_ca_certificate = local.kube_ca_certificate + client_certificate = local.kube_client_cert + client_key = local.kube_client_key + } +} +``` + +Four things to get right, each of which the tier proved can pass for the wrong +reason if dropped: + +1. **`try()` around `jsondecode`.** `jsondecode("")` errors. Without `try`, + every non-tunnelled command fails. The e2e tier has a dedicated inert-path + test for this. +2. **The inert branch pins an unreachable host and empty cert material.** When + `kubepath == ""`, the providers get `host = "https://127.0.0.1:0"` with empty + cert/key fields so they cannot fall back to `$KUBECONFIG` or `~/.kube/config`. + This is what makes a forgotten `commands` entry fail loudly instead of + silently reaching a cluster some other way. +3. **`path` comes from the materialized file, not from a hand-written path.** + `run` always forces `daemon.materialize = true`, so `nodes.*.kube.*.path` + is a real on-disk kubeconfig (mode 0600), patched so `server:` and + `tls-server-name` already point at the tunnelled port. The provider just reads it. +4. **`sensitive = true` on the variable.** Defence in depth, not the fix — it + suppresses rendering in plan/apply output and diagnostics, but it does *not* + keep the value out of the plan file. See the note below. + +### What is, and is not, in `TF_VAR_tunstrap` + +`run` **projects** the envelope before exporting it on this channel. Each +`kube` entry keeps: + +`path`, `context`, `endpoint` + +and **drops** `client_key_data` (a private key), `content_b64` (the whole +patched kubeconfig, which embeds that key) and `client_certificate_data` (not a +key, but it discloses the Kubernetes RBAC identity — CN is the username, O the +groups). + +The reason is the consumer, not the transport: OpenTofu persists root-module +variable values in the **plan file**, which pipelines routinely archive, and +renders unmarked variables in diagnostics. `sensitive = true` fixes the +rendering half only — the plan file still contains the value — so the material +has to not be there in the first place. + +Nothing is lost. `run` always materializes, so `path` points at an on-disk +kubeconfig (mode 0600) that contains every dropped field. A module that wants +inline provider configuration rather than `config_path` reads +`certificate_authority_data` (a published trust anchor, not a credential), +`tls_server_name`, and its own client credentials from that file directly — +only `path`, `context` and `endpoint` travel through `TF_VAR_tunstrap` itself. + +`tunstrap start --output json` projects every materialized kube target through +the same `path` / `context` / `endpoint` allow-list and every materialized +fetched file through `{path, size, sha256}`. Without `--materialize`, each +entry's `content_b64` is its only delivery channel, so that entry instead +retains the complete envelope on stdout. Treat this unmaterialized mode's stdout +as credential material; do not place it in CI logs or durable shell captures. + +### Fetched files are materialized, not carried in the envelope + +The projection above (kube) and this one (`fetch_files`) follow the same rule: +`run` and materialized `tunstrap start --output json` materialize content to +disk under the session dir's `tunnel-data/`, mode `0600`, and their +consumer-facing envelope carries only a reference to it. Each `fetch_files` +entry becomes `{path, size, sha256}` on success, `{error}` on failure — never +`content_b64`. + +`FetchedFile` **has a `path`** (`schemas.py`, extended for this ticket), so +the lossless on-disk alternative exists, the same way it already existed for +kube. + +**The plan-file-persistence risk is resolved as a class, not documented +around**: since fetched content never enters `TF_VAR_tunstrap` or the +materialized file at all, `--fetch`ing a secret cannot land it in a saved +Terraform plan file through this channel. Read the file directly at +`fetch_files..path` if you need its contents. + +One free-form string rides this channel unprojected: `warnings[*].error`. +It is exception text from an optional-node or kube-target failure (`manager.py`, +`kube.py`) — connection, auth or TLS messages, not key material — so it is left +intact rather than truncated. + +### The input variable is scrubbed + +The variable named by `--input-env` — `TUNSTRAP_INPUT` in the proxy above — +holds the `InputSchema`, including `ssh_pkey`. `run` removes it from the child's +environment before exec'ing `tofu`, because `tofu` passes its environment to +every provider plugin, `external` data source and `local-exec` provisioner. +Nothing in the module needs it; if you need a value from the payload downstream, +export it explicitly rather than relying on inheritance. + +If you have more than one node in the payload, the kube env channel — +`KUBECONFIG`/`KUBE_CONFIG_PATH(S)` — is still injected: it is unconditional +on node count and aggregates every kube target across every node into one +file list. Only the old `TUNSTRAP__*` scalars — a concept that no +longer exists — were ever suppressed for multi-node. `TF_VAR_tunstrap` is set +unconditionally too, and the module picks the node out of `nodes[]`. +See the `--output-var` rules in the README. + +## Mode A: env-native kube (satisfies the ticket's strict "nothing live enters Terraform" contract) + +The module above reads its kube identity from `var.tunstrap`, which is +connection data travelling through a Terraform input variable — exactly what +ticket #15 asked to stop. Mode A is the alternative that actually satisfies +that contract: no `var.`-bound value, no file read in HCL at all, for kube. + +**Mode A works through `tunstrap_tofu`**, the proxy this recipe recommends as +`terraform_binary` — that is the point of it. The proxy's +`suppress_kubeconfig` guard only removes the injected `KUBECONFIG`; it never +touches `KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS`, so item 1 below reaches a +provider block unfiltered whether you invoke `tofu` directly or through the +proxy (see "How the proxy works," above, for why). + +1. `run` exports `KUBE_CONFIG_PATH` (one kube target) or `KUBE_CONFIG_PATHS` + (two or more) from its own process environment, unconditionally — see "The + input variable is scrubbed," above. A provider block that sets nothing but + a literal `config_context = "tunstrap--"` per alias resolves + its `config_path`/`config_paths` from those env vars alone, via the + provider's own `EnvDefaultFunc`. A two-alias worked example, one provider + block per kube target, sharing the same `KUBE_CONFIG_PATHS` list: + + ```hcl + provider "kubernetes" { + alias = "node1_k3s" + config_context = "tunstrap-node1-k3s" # literal -- never derived from var.tunstrap + } + provider "kubernetes" { + alias = "node2_k3s" + config_context = "tunstrap-node2-k3s" + } + ``` + +2. **Warning, explicit:** never derive `config_context`'s value from + `var.tunstrap` or any other live/decoded data. It must be a literal string + in the config, matching the deterministic naming scheme exactly + (`tunstrap--`). Deriving it live would reintroduce a + variable-bound value for data that has an env-native, fully static + alternative, defeating the point of Mode A. + +3. **Measured facts a consumer needs**, restated from the ticket's own six + findings plus this design's provider findings, not re-derived: + + - **#1** — provider configuration **is** re-evaluated at apply. + - **#2** — outputs **freeze silently**: `file()` read through an output + returns the plan-time value at apply, with no error — the nastiest + failure mode, name it as such. + - **#3** — per-alias `config_context` works with an env-supplied + kubeconfig path (Mode A's own basis, shown in item 1's example). + - **#4** — plan-safe end to end, measured live in the unpublished #15 spike: + plan with one set of + ports, mutate only the kubeconfig, apply the *saved* plan → the alias + uses the mutated value, zero "Mismatch between input and plan variable + value" — the e2e-level confirmation that Mode A's env-native path + really is plan-safe across a saved-plan reuse, not just a theoretical + consequence of finding #1. + - **#5** — `KUBE_CONFIG_PATHS` is colon-separated on Linux (comma + silently falls back to `localhost:80`). + - **#6** — a live value bound to a `var.` **does** trip "Mismatch between + input and plan variable value" on a saved plan (Mode B's one-shot rule + rests on this). + - A live value bound to a **resource attribute** (not a provider config + block) produces `Error: Provider produced inconsistent final plan` — + confirmed for `hashicorp/kubernetes` v2.38.0 + (`docs/specs/2026-08-10-issue15-provider-env-precedence.md`, Q3). + Provider-block placement, as shown in item 1's example, is the only + supported shape in both Mode A and Mode B. + +4. The `config_context` values above follow tunstrap's deterministic naming + scheme (`tunstrap--`) exactly — the same names + `rename_identities` writes into the materialized kubeconfig. That matters + beyond providers: anyone who pipes the materialized file straight into + `kubectl --context` instead of through a provider block uses the same + literal context names. + +## Mode B: unified-file convenience (ports + kube references; does NOT satisfy the ticket's strict contract) + +A real consumer may use Mode A for kube and Mode B for ports in the same +module. Nothing here satisfies the ticket's strict "nothing live enters +Terraform" guarantee — state that plainly to a reader, not glossed over. +There is no literal, operator-pinned path and no `var.`-derived locator +anywhere in this section: the session dir stays ephemeral unconditionally. + +5. **The shape**, a worked example reading the env-carried + `TUNSTRAP_OUTPUT_FILE` locator via Terragrunt's `get_env(...)` — no + `--session-dir` precondition and no operator-agreed path: + + ```hcl + locals { + tunnel = try( + jsondecode(file(get_env("TUNSTRAP_OUTPUT_FILE"))), + { nodes = {} }, + ) + } + + provider "kubernetes" { + config_path = local.tunnel.nodes.node1.kube.k3s.path + } + ``` + + Read directly inside the `locals` block that feeds the provider config — + never through an `output`, per finding #2. + +6. **Ports lose their integer form** (`"host:port"`, not a bare port + number) — the extraction idiom: + + ```hcl + locals { + service1_port = split(":", local.tunnel.nodes.node1.ports.service1)[1] + } + ``` + +7. **The stability contract**, restated plainly: **both** Mode B forms — + item 5's `TUNSTRAP_OUTPUT_FILE` form and the `--output-var` + (`var.tunstrap`) form — are **one-shot `plan && apply` only**, with no + saved-plan reuse across a tunstrap restart for either and no locator + exemption of any kind (the check compares the variable's whole value; the + file itself is deleted at teardown alongside the rest of `tunnel-data/`). + Findings #1, #2 and #6 back this. Stated as plainly as the design doc + states it: *"Neither Mode B form survives a tunstrap restart. If you need + a saved plan to apply cleanly against fresh ports or fetched-file + content, re-run plan in the same tunstrap invocation."* + +8. **`jsondecode`, not JavaScript.** Consumption is via HCL's `jsondecode`; + there is no JS runtime anywhere in this stack (ADR entry 12). + +9. **Fetched files:** *"Fetched file content never enters a Terraform + variable or plan file — only its path, size, and checksum do. Read the + file itself at `fetch_files..path` if you need its contents."* + +## Measured Terragrunt facts + +All measured 2026-07-31 against **Terragrunt v1.1.1** and **OpenTofu v1.12.5**. +These are observations about that pair, not tunstrap invariants — but the whole +recipe rests on them. + +1. **`terraform_command_line` does not exist.** The hook is `terraform_binary` + / `--tf-path` / `TG_TF_PATH`, and it accepts a **path only**: + `--tf-path "/tmp/wrapper.sh --flag"` fails on the `-version` probe with + `fork/exec /tmp/wrapper.sh --flag: no such file or directory`. This is why + `terraform_binary` resolves a path (whether the `tunstrap_tofu` entry point + or a shell-shim alternative), not a command-line template. +2. **`inputs` are delivered to the child as `TF_VAR_` environment + variables** (JSON-encoded values), not `.tfvars.json`, not `-var-file`. This + is why the envelope travels as `TF_VAR_tunstrap`. +3. **`extra_arguments.env_vars` reach the listed command *and* its automatic + `init`, but not `-version`.** So a `terragrunt plan` with + `commands = ["plan","apply"]` sets your env var for the auto-`init` and for + `plan`, but leaves it unset for the `-version` probe — which is exactly why + the proxy's `-version` pass-through works without a tunnel. + 4. **`dependency.*` resolves inside `extra_arguments.env_vars`, but not inside + `locals`.** In `locals` you get `"dependency" is not defined`. This is why + the recipe keeps any `dependency.*` reference inline in the `env_vars` block. + 5. **Payloads survive byte-for-byte.** A ~10 KB JSON value arrived at both the + auto-`init` and `plan` with identical length and SHA-256 — no truncation, no + `E2BIG`. Multi-line content with PEM delimiters, `"` and `$` arrived + byte-identical. So embedding SSH private keys in the payload is safe at the + transport level (see "Security" for the stronger alternative). + +## Failure modes you will hit + +Both of the first two land at the same symptom — a provider error against +`https://127.0.0.1:0`, the module's inert branch — so they are easy to confuse. +They have different causes, and the recipe's job is to keep them straight: + +- **A unit that forgot `include "root"`.** The root's `terraform_binary` is not + inherited, so Terragrunt silently falls back to plain `tofu` on `PATH`. The + `extra_arguments.env_vars` still delivers `TUNSTRAP_INPUT` to that `tofu`, but + nothing sets `TF_VAR_tunstrap` (the proxy never runs), the module takes its + inert branch, and the provider dials `127.0.0.1:0`. Tell-tale: a recording + `tofu` shows `TUNSTRAP_INPUT` **present** but `TF_VAR_tunstrap` **absent**. + Fix: add the `include "root" { path = find_in_parent_folders("root.hcl") }` + block to the unit. +- **A `commands` entry you forgot.** The command runs without `TUNSTRAP_INPUT` + (the list controls delivery), so `TF_VAR_tunstrap` is absent for the same + reason, the module takes its inert branch, and the provider errors against + `https://127.0.0.1:0`. Tell-tale: a recording `tofu` shows `TUNSTRAP_INPUT` + **absent** for that command. That is the *designed* loud failure — add the + command to the list. `import` is the classic omission. + + The two are distinguished by whether `TUNSTRAP_INPUT` reached `tofu`: present + ⇒ missing include; absent ⇒ missing `commands` entry. Both are loud (non-zero + exit, a named endpoint); neither is a silent wrong result. +- **A `--` you forgot inside `tunstrap run`.** This is a `run`-level rule. The + proxy always passes `--` for the user, so a consumer driving `tunstrap_tofu` + never hits it; it only surfaces if you hand-edit the `run` invocation (e.g. in + the shell-shim alternative). `--` is mandatory whenever the child command or + any of its arguments begins with `-`. +- **A broken `config_path` chain that *succeeds*.** This is the silent one, and + it is what `suppress_kubeconfig` exists to prevent. If you ever see an apply + succeed after a wiring change you expected to break it, the first thing to + check is whether `KUBECONFIG` is still being cleared from the child env. +- **An `init` that *builds* a tunnel.** If the proxy's `init` bypass stops + matching, you get two tunnels per `plan` (one for auto-`init`, one for the + plan itself). See "A fixed gap" below for one known way this used to happen. + +## A fixed gap: `tofu -chdir=DIR init` bypasses correctly + +The original consumer shell shim matched the bypass with a literal first token: + +```sh +case "$1" in init|-version) exec tofu "$@" ;; esac +``` + +This matched `tofu init` and `tofu -version`, but **not** `tofu -chdir=somewhere +init`, because the first token is `-chdir=…`, not `init`. So a `-chdir` +invocation missed the bypass and built a tunnel it did not need — wasteful, not +dangerous, and silent (a slower `init`, no error). + +**`tunstrap_tofu` closes the gap.** The proxy parses argv structurally past +global flags (`-chdir DIR` and `-chdir=DIR`, both space and `=` forms), so +`tunstrap_tofu -chdir=DIR init` correctly identifies `init` as the subcommand +and bypasses. The bypass set is pinned exhaustively by a unit test +(`test_should_bypass_*` in `tests/unit/test_tofu_proxy.py`), so a future edit +that re-broadens or re-narrows it cannot pass silently. + +If you use the shell-shim alternative instead of the entry point, the gap +returns — the shell `case` cannot parse past flags without becoming a substring +match (which would wrongly bypass `tofu -chdir init plan`). The entry point is +the recommended path precisely because it can make this distinction. + +## What is proven — and what is not + +The e2e tier (`tests/e2e/`) is real evidence for this design, but its scope is +exact. Cite it for what it proves; do not let prose drift past it. + +**Proven by the e2e tier:** + +- Real `kubernetes` and `helm` providers reach a real cluster (a local `kind` + node) through a real tunstrap tunnel, via the + `--output-var` → `TF_VAR_tunstrap` → `try(jsondecode(...))` → `config_path` + chain. +- The decoded `config_path` is the **only** route from the module to the + cluster. This was proven three ways: a valid path mutates real objects and the + used path is read back from state; an absent path fails naming the inert + `127.0.0.1:0` endpoint; a present-but-dead path fails naming the dead port. + Clearing `KUBECONFIG` is what makes these distinctions meaningful. +- A dead endpoint surfaces as a non-zero exit. +- `tofu`'s exit code propagates verbatim through `tunstrap run` (the tier proves + an exact code outside tunstrap's reserved set, not merely non-zero). +- `tunstrap run` adds no bytes to `tofu`'s stdout (asserted byte-for-byte + against a direct-run oracle). + +**Not proven — do not imply:** + +- **Nothing about a remote cluster over a real network.** The entire tier runs + against a local `kind` node on one workstation. Latency, packet loss, and + real-network SSH behaviour are unexercised. +- **Nothing about TLS, auth, timeout or 5xx failures.** The only failure mode + exercised is *connection-refused against a dead endpoint*. Other provider + failures may not render a port at all, and the "`config_path` is the route" + proof does not generalise to them. +- **`terragrunt output -json` parsing IS now tested end-to-end** (in + `tests/e2e/test_terragrunt_apply.py`), in two configurations: with `output` + absent from `commands` (the pass-through proxy → `tofu`) and with `output` + added (the worst case: output routed through `tunstrap run`, proving tunstrap + run's own stdout survives a real Terragrunt consumer's parse — the property + the proxy's "never write to stdout" rule guards). **Still not tested:** + stdout purity for `plan`/`apply` under real Terragrunt (their stdout is the + plan/apply diff, consumed differently) and any consumer other than + `terragrunt output -json`. The worst-case test proves the purity property; it + does **not** recommend tunnelling `output` — the recipe keeps `output` out of + `commands` (it reads state, not the cluster). + +## Why a console script (now) — and what the consumer-file shim was protecting + +> **Decision reversed.** The proxy now also ships **in-package** as a second +> console script, `tunstrap_tofu` (`tunstrap/tofu_proxy.py`), so +> `uv tool install` produces both `tunstrap` and `tunstrap_tofu` and +> `terraform_binary` can point at a stable installed path with nothing copied +> into the consumer's repo. The consumer-file shell shim is retired from this +> recipe (the e2e tier now drives `tunstrap_tofu`); it survives only as the +> lower-overhead alternative the section after next mentions. The two agree on +> every command the consumer deliberately opted into Terragrunt's `commands` +> (the proxy must not veto that with a cluster-only allow-list of its own), and +> both bypass `init`. The two deliberate differences: the proxy also bypasses +> `version`/`-version`/`-help`/no-subcommand (harmless no-cluster cases the shell +> pointlessly tunnelled), and it parses argv past `-chdir` so `tofu -chdir=DIR +> init` bypasses correctly — closing the shell shim's documented gap. The +> original three objections are kept below, each with its resolution, because the +> trade is real and worth knowing before you choose between them. + +The three reasons the design originally argued for a consumer file, and where +each stands now: + +1. **A Python console script pays interpreter startup on the fast paths.** + Real, and it kills the naive approach. Measured: the `sh` shim's fast path + costs ~2 ms; bare Python startup ~17 ms; Python plus `import tunstrap.cli` + ~225 ms (the import alone is ~184 ms by `-X importtime`). At 225 ms and + three fast-path invocations per `terragrunt plan`, a naive entry point that + imported `cli` on every call would add ~0.7 s per plan. + **The `tunstrap_tofu` entry point does not import `cli` (or anything heavy) + on the pass-through paths** — it `execvp`s `tofu` first — and + `tunstrap/__init__.py` resolves `__version__` lazily (PEP 562), so the + package import loads no `importlib.metadata` either. Measured fast path, + end-to-end via the installed entry: **~25 ms** (≈17 ms interpreter + a + now-cheap package import + the execvp handoff). That is about **12× the + ~2 ms shell shim**, i.e. **~74 ms added per `terragrunt plan`**, noise beside + an 8 s `tofu init`. (Before the lazy `__init__`, the same path was ~59 ms — + `importlib.metadata` contributed ~41 ms; making `__version__` lazy dropped + `import tunstrap` 67.3→17.5 ms and the pass-through 58.8→24.6 ms.) The cost + discipline is guarded by a unit test that imports only `tunstrap.tofu_proxy` + in a fresh interpreter and asserts none of `tunstrap.cli`/`click`/`pydantic`/ + `asyncssh`/`cryptography`/`ruamel`/`importlib.metadata` loaded. The shell + shim remains cheaper (≈2 ms); a consumer for whom every millisecond of the + fast path matters can still write the 3-line shim — but for everyone else + ~25 ms is noise, and the entry point removes the copy/commit/drift entirely. +2. **A committed file is the stable path `terraform_binary` wants.** Answered. + `uv tool install` yields a stable `tunstrap_tofu` entry point at + `~/.local/bin/tunstrap_tofu` (mirroring `tunstrap`), identical across + reinstalls; the ephemeral `~/.cache/uv/…` path is a `uvx` artefact only. So + the package entry point *is* a stable path, and `terraform_binary` can point + at it with no consumer-side file to copy, `chmod`, commit or drift. +3. **It keeps Terraform vocabulary out of tunstrap.** This one is **being + consciously reversed by the owner.** `init`, `-version`, and `TF_VAR_` are + Terraform concepts, and the original design was structured to keep them in a + consumer shim, not in the package (see the spec's decision log, items 7 and + 20). That principle is now deliberately traded for the ergonomics of a + shipped entry point: `tunstrap/tofu_proxy.py` owns exactly that vocabulary. + The trade is recorded in the spec where the Terraform-free principle is + stated, not silently abandoned. The consumer-file shim is the escape hatch + for anyone who prefers the package to stay Terraform-free. + +**Which to use.** Prefer `tunstrap_tofu` (nothing to copy, stable path, the + `-chdir` gap above is fixed). Keep the consumer shim if you want the ~2 ms fast + path or want no Terraform vocabulary in the package you depend on. + +## Security + +Private keys still travel in the child's **environment** — but in this model +they travel in `TUNSTRAP_INPUT` (an env var), never on the command line. That +matters: Terragrunt's `ProcessExecutionError` joins the command and all +arguments, so the old `run_cmd` design could print a PEM on any failure. The +proxy model removes that exposure surface entirely. + +**The stronger option, recommended: use ssh-agent.** If you export +`SSH_AUTH_SOCK` and drop `ssh_pkey` from the payload, key material leaves the +payload altogether — tunstrap will use the agent. See +[`docs/specs/2026-06-25-ssh-agent-fallback-design.md`](specs/2026-06-25-ssh-agent-fallback-design.md). +It is not required by this recipe, but it is the right end state for any +non-disposable environment. + +Two standing caveats, unchanged by this recipe: + +- **Host-key verification is not enforced** in this release. The tool targets + disposable/CI hosts on trusted networks. Do not use it over untrusted + networks until host-key pinning lands. +- **Materialized kubeconfigs land on disk** (mode 0600, under + `/tunnel-data/`) for the lifetime of the child. `run` removes + them in its teardown; a `kill -9` of the daemon orphans them and you must + clean up manually (`rm -rf /tunnel-data`). This is inherent to + giving the provider a real `config_path`. diff --git a/docs/specs/2026-07-31-run-env-io-and-tofu-proxy-design.md b/docs/specs/2026-07-31-run-env-io-and-tofu-proxy-design.md new file mode 100644 index 0000000..f0c9529 --- /dev/null +++ b/docs/specs/2026-07-31-run-env-io-and-tofu-proxy-design.md @@ -0,0 +1,1501 @@ +# `run` env I/O + the tofu proxy pattern + +> **Redaction/repoint note (2026-08-10):** Replaced an ignored local-artifact +> path with its accurate unpublished description; no design claim changed. + +- Status: design, awaiting review +- Date: 2026-07-31 +- Scope: three generic CLI additions (`--input-env`, `--output-var`, a + single-variadic `run` argument surface), the stdout and cleanup invariants a + foreground wrapper needs, a consumer-side `tofu` shim recipe, and a new `e2e` + test tier proving real Kubernetes/Helm providers through a tunnel. Supersedes + an untracked superseded owner-tracking design. +- Measurement basis: Terragrunt **v1.1.1** + OpenTofu **v1.12.5**, and **Click + 8.4.2** (the version `click>=8.3,<9` in `pyproject.toml:12` resolves to today) + for the argument-parsing findings, and **kind 0.30.0 / `kindest/node` v1.34.0 / + Docker 28.1.1 / kubectl v1.28.1 / tunstrap 0.0.4** for the `e2e` tier. Linux, + all measured 2026-07-31. Every fact labelled *[measured 2026-07-31]* was + re-derived then; none is a repo invariant. Items the current code cannot yet + execute are labelled *[designed, unverified]*. +- Code citations (`cli.py:NNN` etc.) are against **`ddde94d`**, the tip of `main` + at the time of writing. An in-flight `ValidationError`-leak fix on + `fix/validation-error-leak` shifts `cli.py` by +4 lines; re-resolve citations + against the merge base before implementing. + +## Problem + +The Terragrunt consumer (`consumer-repo/garuda/`) starts tunstrap from inside +`inputs` via `run_cmd`, writes an `OutputSchema` to a marker file, reads it back +with `jsondecode(file(...))`, and tears the daemon down in an `after_hook` — +~150 lines of bash embedded in HCL (`terragrunt.hcl:11-14`, `:55-83`, `:221-237`, +`:250-306`) plus `locals.tf:72`. Two consequences: + +1. **Secret exposure in a command line.** `run_cmd` offers argv only — no stdin. + The full `InputSchema`, including three `ssh_pkey` PEMs, is an argv element + (`terragrunt.hcl:257-305`). Terragrunt's `ProcessExecutionError` joins Command + and all args, so any failure of that `bash -c` prints the keys. +2. **Lifetime is inferred, not owned.** Nothing is the daemon's parent, so the + `after_hook` is the only teardown authority and it misses every command not in + its list (`terragrunt.hcl:222`). The consumer compensates with + `auto_stop_idle_seconds = 7200` (`terragrunt.hcl:299`). + +The previous spec attacked (2) with a `--owner` watchdog. This spec removes the +problem class instead: make tunstrap the **parent** of `tofu`. + +## Current state (as-is) + +- `start` takes input from a `USER@HOST[:PORT]` argument plus flags, or JSON on + stdin read whole at `cli.py:194` (`cli.py:172-211`). It emits the + `OutputSchema` envelope as JSON on stdout (`cli.py:219`) or, with + `--output env`, as `export K='V'` lines (`cli.py:215-217`). +- `run` declares a **required** CONNECTION positional (`cli.py:251`) *followed by* + a variadic COMMAND (`cli.py:255`), plus the same flags; no stdin input path, no + `--output` flag. It merges the single-node scalar env into the child + environment (`cli.py:303`), launches the child with `Popen` (`cli.py:311-313`), + forwards `SIGINT`/`SIGTERM` (`cli.py:315-322`), and tears the session down in a + `finally` (`cli.py:327-330`, `_teardown_run` at `cli.py:334-342`). +- **`run`'s teardown writes to stdout.** `_teardown_run` → `_kill_with_identity` + emits a `{"stopped": ...}` JSON line on stdout for *every* outcome + (`cli.py:376-437`). `stop_command` depends on that behaviour as its documented + contract; `run` inherits it as a defect. Unguarded: the `run` integration tests + assert only return codes and cleanup + (`tests/integration/test_cli_modes.py:127-197`). +- **`run` has an unprotected post-spawn window.** `OutputSchema.model_validate` + and `render_env` run at `cli.py:302-304`, after `spawn_daemon` succeeded but + before the `try` that owns teardown opens at `cli.py:308`. Anything raised + there orphans the daemon. +- `run` forces `daemon.materialize=True` regardless of the `--materialize` flag + (`force_materialize=True`, `cli.py:290`), because `render_env` requires + materialized kube paths (`envrender.py:42-43`). +- The daemon flags `--auto-stop-idle-seconds`, `--materialize` and `--log-file` + are attached by `_connection_options` (`cli.py:75-77`) but deliberately + excluded from `_conn_flags_present` (`cli.py:84-93`). +- `run` **cannot** read its payload from stdin: `Popen` is called without a + `stdin=` argument (`cli.py:311-313`), so the child inherits the parent's stdin + and a `sys.stdin.read()` would hand it a drained pipe. The one existing stdin + use in `run` is bounded to a single line for `--ssh-password-stdin` + (`cli.py:111-112`). +- `render_env` requires exactly one node and raises a bare `ValueError` + otherwise (`envrender.py:19-20`). That `ValueError` has no exit-code mapping; + it would surface through `run`'s absence of a top-level guard as a traceback + (`start` has one at `cli.py:234-247`; `run` has none). +- `OutputSchema` fields are `connections`, `pid`, `session_dir`, `started_at`, + `warnings` (`schemas.py:353-362`). `token` was removed by the 2026-06-24 + session-reuse design. +- Exit codes are `1` schema, `2` required/kube, `3` session-active, `4` daemon + (`exceptions.py:57-63`), plus `64` for usage via `_UsageExit64` + (`cli.py:33-49`). **`5` is unallocated.** +- `--owner`, `--output-file` and `--placeholder-host` from the superseded spec + were **never implemented** — `grep -rn "owner|output_file|placeholder" + tunstrap/*.py` matches only unrelated identifiers in `kube.py:235,245`. They + are cancelled designs, not removals. + +## Design (to-be) + +### The I/O matrix + +`start` and `run` have an incomplete input/output matrix: + +| | input | output | +|---------|--------------------------|---------------------------------| +| `start` | stdin JSON · flags | stdout JSON · shell exports | +| `run` | **flags only** | env → child (scalars only) | + +Two cells are missing and both are needed. The input gap is **not** a Terraform +accommodation — it is forced by the shape of a foreground wrapper: `run` owns a +child that inherits stdin (`cli.py:311-313`), so stdin is unavailable as a +control channel, and the only remaining out-of-band input channel a parent has +is its own environment. + +### Addition 1 — `--input-env VAR` + +Read the `InputSchema` JSON from `os.environ[VAR]` instead of from stdin or +flags — illustrative surface only: + +``` +tunstrap run --input-env TUNSTRAP_INPUT -- CMD [ARGS...] +``` + +**Lands on `run` only.** `start` already has a working, uncontended input +channel; the missing matrix cell is `run`'s. `start_command` already carries a +three-way input conflict guard (`cli.py:159-179`) and an explicit +`too-many-branches,too-many-statements` suppression (`cli.py:142`); a third +input mode there would add branches for zero unmet need. The flag is additive +and can be extended to `start` later. Parsing and validation reuse `start`'s +stdin path verbatim (`cli.py:194-211`), with the same three +`SchemaValidationError` shapes and exit 1. + +### Addition 2 — `--output-var NAME` + +Inject the `OutputSchema`, JSON-encoded, into the child's environment +under `NAME`, alongside (not instead of) the existing scalar `TUNSTRAP_*` set. + +``` +tunstrap run --input-env TUNSTRAP_INPUT --output-var TF_VAR_tunstrap -- CMD +``` + +Generic framing: *give the child the structure, not a flattened projection of +one node*. The scalar env is lossy by construction — single-node +(`envrender.py:19-20`), and it drops `warnings`, `started_at` and every +`kube_targets` field except `path`/`endpoint`. `--output-var` is the structured +channel; consumers wanting structure parse JSON, consumers wanting `KUBECONFIG` +keep the scalars. It is **not** byte-identical to `start`'s stdout: the kube +credentials are projected out before export (`render_output_var`, not +`OutputSchema.model_dump_json()`), and `fetch_files[*].content_b64` passes +through verbatim — see "Out of scope" and `docs/recipe_terragrunt.md`. `start` +stdout is unchanged and still writes the complete envelope. + +`NAME` must match `[A-Za-z_][A-Za-z0-9_]*`, else usage error; `NAME` colliding +with a `render_env` key is a usage error too, mirroring the collision discipline +already inside `render_env` (`envrender.py:29-30`). Collision with an unrelated +inherited variable is a documented overwrite. + +**Multi-node interaction.** `--input-env` makes multi-node input reachable from +`run` for the first time. Rule: one node → scalars + `KUBECONFIG` as today, plus +`--output-var` if given; >1 node with `--output-var` → `--output-var` only, no +scalars; >1 node without `--output-var` → typed error, exit 1. + +That last case is decided **before `spawn_daemon`**, from `len(schema.nodes)` on +the *input* schema — never after, where it would orphan a daemon (see "Cleanup +must own the whole post-spawn window"). The bare `ValueError` at +`envrender.py:20` still becomes a `TunstrapError` subclass with an exit code, as +defence in depth for any path that reaches `render_env` with the wrong shape. + +### Addition 3 — `run`'s argument surface becomes a single variadic + +**Making `connection` merely `required=False` does not work, and would have made +the documented shim invocation unreachable.** `run` declares CONNECTION as a +positional *before* the variadic COMMAND (`cli.py:251`, `cli.py:255`). In Click, +`--` terminates **option** parsing only; the tokens after it are still +distributed over the declared positionals in order. Measured on Click 8.4.2 +[2026-07-31]: + +``` +run --input-env TUNSTRAP_INPUT --output-var TF_VAR_tunstrap -- tofu plan + → connection='tofu' command=('plan',) +``` + +So `tofu` binds to CONNECTION, `plan` becomes the whole child command, and the +spec's own "CONNECTION + `--input-env` → 64" rule then rejects the one +invocation the shim must use. The positional pair cannot express "no connection". + +**Resolution: collapse the two positionals into one variadic.** CONNECTION stays +a positional (no `--connection` option, no new subcommand — both would break the +documented `run USER@HOST -- CMD` form in `README.md:175-195` and its integration +tests), but the split is decided *after* parsing, by whether `--input-env` is +present: + +```python +@main.command("run") +@_connection_options +@click.option("--input-env", "input_env", default=None, metavar="VAR") +@click.option("--output-var", "output_var", default=None, metavar="NAME") +@click.option("--session-dir", "session_dir", default=None) +@click.option("--grace-seconds", "grace_seconds", type=int, default=10, show_default=True) +@click.argument("args", nargs=-1, type=click.UNPROCESSED) +def run_command(..., input_env: str | None, output_var: str | None, args: tuple[str, ...]) -> None: + ... +``` + +Split rule: + +| `--input-env` | `args` | CONNECTION | child command | +|---|---|---|---| +| absent | `(conn, *cmd)` | `args[0]` | `args[1:]` | +| present | `(*cmd,)` | — (none exists) | `args` | + +This is behaviour-preserving for flag mode. Measured on Click 8.4.2 +[2026-07-31] with the single variadic: + +``` +run user@host --ssh-key /k --target web=a:80 -- helm list + → args=('user@host','helm','list') ssh_key='/k' targets=('web=a:80',) +run --input-env TUNSTRAP_INPUT --output-var TF_VAR_tunstrap -- tofu plan + → args=('tofu','plan') +run --input-env X -- tofu plan -out=x -var a=b + → args=('tofu','plan','-out=x','-var','a=b') +run --input-env X -- env --ssh-key sneaky + → args=('env','--ssh-key','sneaky') ssh_key=None +``` + +Two properties this buys, both measured: option-looking child arguments after +`--` are never absorbed by tunstrap (`-out=x`, `-var`, even `--ssh-key`), and the +`--` position is *not* needed to find the split — Click consumes the first `--` +and only the first (`run user@host -- -- helm` → `args=('user@host','--','helm')`, +which is exactly why the existing strip at `cli.py:272-274` exists; it is +retained, applied to the child-command slice). + +**`--` is mandatory whenever the child command or any of its arguments begins +with `-`.** Without it Click parses those tokens as tunstrap options: +`run --input-env X tofu -version` → `NoSuchOption: No such option '-v'` +[measured 2026-07-31]. The shim always passes `--`; the docs must say so. + +### Conflict matrix for `run` + +Because env mode has no connection slot, the old "CONNECTION + `--input-env`" +conflict cannot be expressed and is therefore **structurally impossible** rather +than an error. The matrix is stated over `args` length instead: + +| `--input-env` | `args` | conn flags | result | +|---|---|---|---| +| absent | `len ≥ 2` | any | flag mode, unchanged | +| absent | `len == 1` | any | usage **64** — "run requires a command: `tunstrap run USER@HOST ... -- CMD`" (today's message, `cli.py:276`) | +| absent | empty | any | usage **64** — "run requires USER@HOST[:PORT] or `--input-env`" (today Click emits "Missing argument 'CONNECTION'"; message becomes explicit, code stays 64) | +| present | `len ≥ 1` | none | env-input mode | +| present | empty | any | usage **64** — "run requires a command" | +| present | any | **any set** | usage **64** — "`--input-env` supplies the full InputSchema; connection flags are redundant" | + +"conn flags" is `_conn_flags_present` (`cli.py:84-93`): `--ssh-key`, +`--ssh-key-passphrase`, `--ssh-password-stdin`, `--target`, `--kube`, `--fetch`. +`--ssh-password-stdin` is additionally the one stdin consumer in `run` +(`cli.py:111-112`), so its rejection under `--input-env` is doubly required. + +**Daemon flags.** `--auto-stop-idle-seconds`, `--materialize` and `--log-file` +are attached by `_connection_options` (`cli.py:75-77`) but deliberately excluded +from `_conn_flags_present` (`cli.py:84-93`), so they need their own rule. Chosen +rule: **each is a usage error 64 under `--input-env`**, not an override and not +silently ignored. + +| `--input-env` + | result | +|---|---| +| `--auto-stop-idle-seconds` | usage **64** — set `daemon.auto_stop_idle_seconds` in the payload | +| `--log-file` | usage **64** — set `daemon.log_file` in the payload | +| `--materialize` | usage **64** — redundant; `run` always forces it (below) | + +Silent precedence between two authorities is the worst outcome for a caller +debugging a tunnel: the `InputSchema` already carries a complete `daemon` block +(`schemas.py:270`), so there must be exactly one place to look. Rejecting is +cheap and reversible; a precedence rule is neither. + +**Invariant: `run` forces `daemon.materialize = True`, including on an +`--input-env` payload.** Today `run` passes `force_materialize=True` +(`cli.py:290`), so the flag mode cannot produce unmaterialized kube targets. An +env payload can say `materialize: false`, and then `render_env` raises "kube +target not materialized; cannot set KUBECONFIG" (`envrender.py:42-43`) — and, +worse for the shim, `--output-var` would hand the consumer `path: null` and the +`kubernetes`/`helm` providers would get an empty `config_path`. So `run` +overrides the payload's `materialize` to `True` unconditionally and documents it. +This is the one place `run` mutates the supplied schema; it is an invariant of +the verb, not a flag precedence rule. + +`--output-var` is orthogonal and composes with every row. Payload-state failures +are separate and all exit 1 — see Error handling. + +### `run` must not write to stdout + +The shim's load-bearing rule is that **only the child's own output reaches +stdout**, because Terragrunt parses tofu's stdout and `terragrunt output -json` +consumers parse it downstream [measured 2026-07-31, fact 8]. `run` violates this +today, in its teardown: + +`_teardown_run` (`cli.py:334-342`) calls `_kill_with_identity`, which writes a +`{"stopped": ...}` JSON line **to stdout on every outcome** — success +(`cli.py:399-402`, `:409-412`, `:430-433`, `:434-437`), `not found` +(`cli.py:381-384`), `identity mismatch` (`cli.py:385-389`), `unavailable` +(`cli.py:390-394`), and `identity changed during grace` (`cli.py:421-426`). Under +the shim this line lands in the middle of tofu's output stream. The existing +integration tests never catch it: `tests/integration/test_cli_modes.py:127-197` +assert only `returncode` and that `tunnel-data` is gone. + +Fix: split the mechanism from its reporting. + +```python +# identity/session-side primitive: performs the stop, writes nothing. +def stop_session(session_dir: str, pid: int, grace_seconds: int, *, force: bool) -> StopOutcome: + ... # returns e.g. StopOutcome(stopped=bool, reason=str|None, forced=bool) +``` + +- `stop_command` (`cli.py:345-358`) calls it and renders **exactly today's JSON + on stdout**, key for key — `{"stopped": false, "reason": "not found"}`, + `{"stopped": true, "forced": true}`, and so on. `stop`'s stdout is its + documented contract and is unchanged. +- `_teardown_run` calls it and prints **nothing on success**. A failed teardown + is a real diagnostic and goes to **stderr**, never stdout. +- **The failure diagnostic must be reachable.** `SessionDir.cleanup_path` is + `shutil.rmtree(data, ignore_errors=True)` (`session.py:132-135`), which + swallows every filesystem error — so a promise to report cleanup failures on + stderr would be unsatisfiable as long as `run` calls it unchanged. Reconciled + by making `cleanup_path` **report without raising**: it returns an outcome + (e.g. the list of paths it could not remove) and still never propagates an + exception, so `stop_command`'s behaviour is untouched while `run` has something + to print. Concretely, the two teardown failure sources are: a non-`stopped` + `StopOutcome` from `stop_session`, and a non-empty removal-failure list. Both + go to stderr; neither changes the exit code. +- `read_identity` failure remains a silent branch (`cli.py:336-340`) — with (3) + above, a missing identity file no longer means the path is unknown. + +Combined with `run`'s existing stderr-only error paths (`cli.py:294`, `:299`, +`:325`), this makes the invariant total: **after the child starts, tunstrap +writes nothing to fd 1, ever.** + +### Cleanup must own the whole post-spawn window + +Today `run` does real work between a successful `spawn_daemon` and the `try` +whose `finally` tears down: `OutputSchema.model_validate` and `render_env` +(`cli.py:302-304`), with the `try` opening only at `cli.py:308`. Anything raised +in that window orphans the daemon, and `run` has no top-level guard (`start` has +one at `cli.py:234-247`). `--output-var` adds JSON encoding to the same window. + +Requirements: + +1. **All usage validation happens before `spawn_daemon`** — the whole conflict + matrix, `--output-var` NAME validity, and reading/parsing/validating the + `--input-env` payload. Nothing that can exit 64 or 1 may run after a daemon + exists. +2. **The multi-node/`--output-var` check is pre-spawn too.** Node count is a + property of the *input* schema (`len(schema.nodes)`), not the output, so + `len(schema.nodes) != 1 and output_var is None` → exit 1 **before** spawning. + This removes the largest new orphan risk this design would otherwise add. +3. **`run` mints the session path *before* spawning, and never learns it from + the payload.** Today `run` passes `session_dir` straight through to + `spawn_daemon`, and when it is `None` the **worker** generates it + (`session.py:create`, `tempfile.mkdtemp`), so the parent can only recover the + path by parsing the success envelope. That is the root cause of the orphan + window: cleanup depends on the very object whose validation can fail. Instead, + when the caller supplies no `--session-dir`, `run` creates one itself + (`tempfile.mkdtemp`) and passes it explicitly. `SessionDir.create` already + accepts a supplied absolute path and does `mkdir(parents=True, + exist_ok=True)`, so an empty pre-created directory is valid input. The session + path is then a **precondition of spawning**, known before the daemon exists + and independent of the payload. + + Consequence to honour: a supplied path sets `generated=False`, so the worker + will not remove the directory root. `run` therefore removes its **own** minted + temp root after teardown, and never removes a caller-supplied `--session-dir` + (matching today's `_teardown_run`, which only clears `tunnel-data`). +4. **One `try/finally` opens the instant `spawn_daemon` returns success**, with + *nothing whatsoever* between the two. It encloses `model_validate`, + `render_env`, `--output-var` encoding, `Popen`, signal handling and `wait`. + Because the session path came from (3), the `finally` can always locate and + stop the daemon — including when `model_validate` raises on a malformed + success payload, which is exactly the case an earlier draft of this spec left + unguarded. +5. **Teardown must not be skippable by a failure in the `finally` itself.** + Signal-handler restoration (`cli.py:328-329`) precedes `_teardown_run` + (`cli.py:330`) in the same `finally`; if restoration raised, teardown would + never run. Restoration therefore goes in its own nested `try/finally` whose + `finally` performs the teardown, so the daemon is stopped regardless. The stop + primitive itself must not raise: unexpected exceptions inside it are caught, + reported on stderr, and **must not override the already-determined child exit + code** — a child that ran and returned 7 still exits 7 even if teardown + misbehaves. +6. **A top-level guard on `run`** mirroring `start`'s (`cli.py:234-247`) but + writing the `DaemonError` envelope to **stderr** (never stdout, see above) and + exiting **4**. + +### Explicitly NOT done: generalizing `render_env` to multi-node + +`render_env` requires exactly one node (`envrender.py:19-20`) because +`TUNSTRAP__*` has no node dimension: two nodes with a target named `k3s` +collide irreducibly, and `2026-06-25-cli-run-modes-design.md:245` already places +multi-node CLI input in Out of scope. `--output-var` serves the case with a +channel that *has* a node dimension (`connections` is keyed by node), so the +single-node contract stays exactly as documented — untouched and unbroken — and +the codebase gains one fewer branch. The only `envrender.py` change is turning +the bare `ValueError` into a typed error. + +### The tofu shim — consumer-facing, Terraform-specific, outside tunstrap + +```sh +#!/bin/sh +[ -n "$TUNSTRAP_INPUT" ] || exec tofu "$@" +case "$1" in init|-version) exec tofu "$@" ;; esac +exec tunstrap run --input-env TUNSTRAP_INPUT --output-var TF_VAR_tunstrap \ + -- env -u KUBECONFIG tofu "$@" +``` + +**Why `env -u KUBECONFIG`.** For a single-node payload `run` injects +`KUBECONFIG` into the child (`envrender.py:48-49`), pointing at the same +materialized file `config_path` would use. Left in place it is a silent +fallback: if the `TF_VAR_tunstrap` → `config_path` wiring were broken or +removed, the `kubernetes` and `helm` providers would still find a working +cluster via `KUBECONFIG` and everything would appear fine. Clearing it makes the +decoded `config_path` the **only** route to the cluster, so a broken chain fails +instead of silently working. This restores a protection the consumer already has +and this spec had dropped: the script being replaced does exactly this at +`terragrunt.hcl:61-63` — *"Clobber $KUBECONFIG so neither tunstrap itself nor +any child process probe can fall back to the operator's personal kubeconfig."* +The same reasoning makes the e2e tier's central assertion meaningful rather than +decorative (assertion 4 below). + +Both Terraform-specific decisions live here, not in tunstrap: + +- **Pass-through when `TUNSTRAP_INPUT` is unset.** Replaces the whole + `--placeholder-host` design: `env_vars` is an ordinary HCL map, so the + consumer omits the key entirely when infra is not applied. +- **Skip the tunnel for `init` and `-version`.** `tofu init` configures the + backend and downloads providers; it contacts neither the k8s API nor Helm, and + the consumer's state backend is S3-compatible over the public internet + (`root.hcl:24-41`), not tunneled. Without the skip, [measured 2026-07-31] + env-var scoping (below) yields **two** tunnels per `terragrunt plan` — one for + the auto-`init`, one for `plan`. + +`exec` is correct in both pass-through branches (nothing to clean up). Nothing +must `exec` *past* teardown, and nothing does: `tunstrap run` already owns the +child via `Popen` + signal forwarding + `finally` teardown (`cli.py:306-331`). +`exec`ing *into* `tunstrap run` is fine and desirable — one less process level, +and Terragrunt's signals reach tunstrap directly. + +**The shim must never write to stdout.** Terragrunt captures and labels tofu +stdout by default (`--tf-forward-stdout` changes this) and `terragrunt output +-json` consumers parse it. Diagnostics go to stderr or a file. + +### Shipping the shim: consumer file *and* a console script (revised) + +> **Revised after this design landed.** The original recommendation was +> "consumer keeps the shim in its own repo; the package ships none". The owner +> has since reversed that: the proxy **also** ships in-package as a second +> `[project.scripts]` entry, `tunstrap_tofu` (`tunstrap/tofu_proxy.py`), so +> `uv tool install` yields both `tunstrap` and `tunstrap_tofu` and +> `terraform_binary` points at a stable installed path with nothing copied into +> the consumer's repo. The consumer-file shim is retired from the recipe and the +> e2e tier (the tier now drives `tunstrap_tofu`); it remains only as the +> lower-overhead alternative the recipe mentions for cost-sensitive consumers. + +The original three reasons for keeping the proxy out of the package, and where +each stands after the reversal: + +(a) **Interpreter startup on the fast paths** — real, and it kills the naive +approach. Measured (see `docs/recipe_terragrunt.md` "Why a console script +(now)"): `sh` shim ~2 ms; bare Python ~17 ms; Python plus `import +tunstrap.cli` ~225 ms (the import alone ~184 ms). The shipped `tunstrap_tofu` +does **not** import `cli` on the pass-through paths — it `execvp`s `tofu` +first — and `tunstrap/__init__.py` resolves `__version__` lazily (PEP 562), so +the package import loads no `importlib.metadata` either. The measured fast +path is **~25 ms end-to-end via the installed entry** (≈17 ms interpreter + a +now-cheap package import + the execvp handoff) — about **12× the ~2 ms shell +shim**, i.e. **~74 ms per `terragrunt plan`** at three fast-path hits, judged +noise beside an 8 s `tofu init`. (Before the lazy `__init__`, the same path was +~59 ms — `importlib.metadata` contributed ~41 ms; making `__version__` lazy +dropped `import tunstrap` 67.3→17.5 ms and the pass-through 58.8→24.6 ms.) The +discipline (no `cli`/`click`/`pydantic`/etc. on the pass-through paths, and no +`importlib.metadata` either) is guarded by a unit test; the consumer-file shim +remains the ~2 ms option for cost-sensitive consumers. + +(b) **Terraform vocabulary inside the package** — this is the trade being +**deliberately taken**. `init`, `-version`, and `TF_VAR_` now live in +`tunstrap/tofu_proxy.py`. The Terraform-free principle this design is structured +around (decision-log items 7 and 20) is therefore no longer absolute; the +reversal is recorded there, not silently applied. `cli.py` itself stays generic +— the only vocabulary added there is a `suppress_kubeconfig` parameter, not +any Terraform name. + +(c) **No stable distribution path** — answered. `uv tool install` yields a +stable `tunstrap_tofu` entry point identical across reinstalls; the ephemeral +`~/.cache/uv/…` path was always a `uvx` artefact, not a `uv tool install` one. + +The shipped entry point also closes the consumer shim's documented +`tofu -chdir=DIR init` gap (see the recipe): it parses argv past global flags +rather than matching a literal first token. + +### Measured Terragrunt facts + +All *[measured 2026-07-31, Terragrunt v1.1.1 / OpenTofu v1.12.5]*. These are +observations about that pair, not repo invariants. + +1. `terraform_command_line` is **not** an attribute. The hook is + `terraform_binary` / `--tf-path` / `TG_TF_PATH`, and it is a **path only**: + `--tf-path "/tmp/.../wrapper.sh --dummy"` fails on the `-version` probe with + `fork/exec /tmp/.../wrapper.sh --dummy: no such file or directory`. +2. `inputs` reach tofu as **`TF_VAR_` env vars with JSON-encoded values** — + not `.tfvars.json`, not `-var-file`. Observed: + `TF_VAR_secret_thing={"key":"-----BEGIN OPENSSH PRIVATE KEY-----\n..."}`. The + consumer's `ssh_private_key` is already in the child environment today. +3. `terraform { extra_arguments "n" { commands env_vars } }` sets env vars for + the tofu child **and the shim itself receives them** — the crux. +4. **Env-var scoping**, per-unit. `terragrunt plan` with + `commands = ["plan","apply"]`: `-version` unset, auto-`init` **set**, `plan` + set. `terragrunt output` (unlisted): all three unset. +5. **Invocation counts.** `plan`/`apply`/`output`/`validate` → 3 tofu + invocations (`-version`, `init`, the command); `init` → 2; a `dependency` + adds 2 in the *dependency's own* unit (`init`, `output -json`), which never + see the dependent unit's `env_vars`. `-version` fires once per terragrunt + run, not once per unit. +6. **`dependency.*` resolves inside `terraform { extra_arguments { env_vars } }`, + but not inside `locals`** (there: `"dependency" is not defined`). The + consumer's comment at `terragrunt.hcl:50-51` — "Terragrunt 1.0.x resolves + `dependency.*` only inside `inputs`" — is **too narrow** on 1.1.1. This is the + fact the whole design rests on. +7. **Payload fidelity.** A 10,065-byte JSON value arrived at both auto-`init` and + `plan` with identical length and SHA-256 — no truncation, no `E2BIG`. + Multi-line content with PEM delimiters, `"` and `$` arrived byte-identical. +8. Terragrunt labels and processes tofu stdout by default; anything the shim + writes there can corrupt `terragrunt output -json`. + +## Components touched + +| File | Change | +|------|--------| +| `cli.py` | `run`: replace the CONNECTION + COMMAND positional pair (`:251`, `:255`) with one `args` variadic and the post-parse split; add `--input-env VAR` and `--output-var NAME`; implement the full conflict matrix **pre-spawn**; build the schema from the env payload reusing `start`'s parse/validate path (`:194-211`); force `daemon.materialize=True` on that payload; **mint the session path before `spawn_daemon` so cleanup never depends on the payload**; inject `--output-var` into `child_env` beside `render_env(out)` (`:303`); move `model_validate`/`render_env` inside the teardown `try` (`:302-308`) with signal restoration in a nested `try/finally`; make `_teardown_run` silent on success and stderr-only on failure; add a stderr-only top-level guard (exit 4). | +| `cli.py` (`stop`/`status`) | `_kill_with_identity` (`:376-437`) loses its `sys.stdout.write` calls; `stop_command` (`:345-358`) renders the identical JSON from the returned outcome, so `stop`'s stdout contract is byte-for-byte unchanged. | +| `identity.py` or `session.py` | New silent `stop_session(session_dir, pid, grace_seconds, *, force) -> StopOutcome` primitive carrying the mechanism with no I/O. | +| `session.py` (`cleanup_path`) | Return an outcome (paths it could not remove) instead of discarding every error via `ignore_errors=True` (`:132-135`); still never raises, so `stop` is unaffected, but `run`'s stderr diagnostic becomes reachable. | +| `cli_input.py` | New `build_schema_from_env(var_name)` — read, JSON-decode, `InputSchema.model_validate`, all failures as `SchemaValidationError`. Mirrors the existing `build_single_node_schema` error discipline (`:113-123`). | +| `envrender.py` | Replace the bare `ValueError` for multi-node (`:19-20`) with the new typed error. No multi-node rendering. | +| `exceptions.py` | Add `MultiNodeEnvUnsupported(TunstrapError)` mapped to exit **1** in `_EXIT_CODES` (`:57-63`). No new exit code is needed; `5` stays unallocated. | +| `README.md` | Document both flags and the mandatory `--` on `run`; link the new recipe; fix the three stale `token` references. | +| `docs/recipe_terragrunt.md` | New. | +| `pyproject.toml` (test config + `tunstrap_tofu` entry) | Add the `e2e` marker; `addopts` → `-m 'not integration and not e2e'`. **Revised (see "Shipping the shim"):** add the second console script `tunstrap_tofu = "tunstrap.tofu_proxy:main"`. No dependency changes. | +| `tunstrap/tofu_proxy.py` (revised) | New module: the in-package `tunstrap_tofu` entry point. Pass-through branches `execvp` `tofu` without importing `cli`; the tunnelled branch delegates to `run` in-process via `run_via_env_input` with `suppress_kubeconfig=True`. Parses argv past global flags so `-chdir=DIR init` bypasses correctly. See "Shipping the shim (revised)". | +| `tests/e2e/` | New, self-contained tier: `conftest.py` (own keypair + kind + compose lifecycle), `docker-compose.yml` (one `sshd-kube` on the external `kind` network), committed `_sshd_conf/allow_tcpfwd.conf`, `shim/tofu-tunstrap-novar` (the `--output-var` negative control; test-only), `module/` (providers + local chart), `test_tofu_providers.py`, `test_shim.py`, `test_terragrunt_apply.py`, `test_recipe_terragrunt.py`. **Revised:** the main consumer shim (`shim/tofu-tunstrap`) is retired; the tier drives the installed `tunstrap_tofu` entry point, not a copied shim. Borrows nothing from `tests/integration/`. | +| `.gitignore` | Add `tests/e2e/_keys/` and `tests/e2e/_kube/` (both fixture-generated). | +| `.github/workflows/test.yml` | New `e2e` job installing kind + tofu; not added to the coverage combine. | + +`pyproject.toml` now adds the `tunstrap_tofu` console script (revised — see +"Shipping the shim"). `cli.py` gains only a generic `suppress_kubeconfig` +parameter on `_build_child_env`/`_run_child`/`_supervise_child`/`run_command` +and a generic `run_via_env_input` programmatic entry; **no Terraform vocabulary +is added to `cli.py`** — the proxy's `init`/`TF_VAR_tunstrap`/`tofu` names live +entirely in `tunstrap/tofu_proxy.py`. +`daemon.py`, `_worker.py`, `schemas.py`, `manager.py`, `kube.py`, `fetcher.py` +are untouched, and `OutputSchema` gains no fields — no `owner`, no +`placeholder`. + +## Error handling + +Every row above the `spawn_daemon` line is evaluated **pre-spawn**, so none of +them can orphan a daemon. + +| Condition | Phase | Channel | Exit | +|---|---|---|---| +| any row of the conflict matrix (incl. conn flags and daemon flags under `--input-env`) | pre-spawn | Click usage message, stderr | 64 | +| `--output-var` NAME invalid, or colliding with a `render_env` key | pre-spawn | Click usage message | 64 | +| named variable unset / empty / whitespace | pre-spawn | `SchemaValidationError` JSON on stderr | 1 | +| named variable not JSON | pre-spawn | `SchemaValidationError` + `{"position": n}` | 1 | +| JSON fails `InputSchema` | pre-spawn | `SchemaValidationError` + pydantic errors | 1 | +| `len(schema.nodes) != 1` and no `--output-var` | pre-spawn | `MultiNodeEnvUnsupported` JSON on stderr | 1 | +| required tunnel failed / session active / daemon error | spawn | existing paths (`cli.py:297-300`) | 2 / 3 / 4 | +| anything raised post-spawn (`model_validate`, `render_env`, `--output-var` encode) | post-spawn, inside the `try` | `DaemonError` JSON on stderr, teardown runs | 4 | +| child cannot be launched | post-spawn, inside the `try` | `cli.py:324-326` | 127 | +| teardown itself fails (non-`stopped` outcome, or unremovable paths) | `finally` | diagnostic on **stderr** only | **never** changes the exit code | +| child ran | — | child's code (`cli.py:331`) | child | + +**Every one of these channels is stderr.** `run`'s existing error paths already +are (`cli.py:294`, `:299`, `:325`); this design closes the last stdout leak in +the teardown (see "`run` must not write to stdout"). Under the shim, fd 1 belongs +to tofu and to nothing else. + +## Testing (TDD) + +**Parser tests come first.** The argument-surface change (Addition 3) is the one +place where a plausible-looking implementation silently mis-binds, so these are +written before anything else and use the **exact documented shim invocation +verbatim**, not a paraphrase: + +```python +# must yield: connection=None, command=("tofu", "plan") +["run", "--input-env", "TUNSTRAP_INPUT", "--output-var", "TF_VAR_tunstrap", + "--", "tofu", "plan"] +``` + +Plus, at parser level: `run user@host --ssh-key K --target web=a:80 -- helm list` +→ connection `user@host`, command `("helm","list")`, flags bound (flag-mode +regression); `run --input-env X -- tofu plan -out=x -var a=b` → command keeps all +five tokens; `run --input-env X -- env --ssh-key sneaky` → command keeps +`--ssh-key sneaky` **and** tunstrap's own `ssh_key` stays `None`; +`run user@host -- -- helm` → command `("helm",)` after the existing strip +(`cli.py:272-274`); `run --input-env X tofu -version` (no `--`) → usage error 64, +documenting that `--` is mandatory for `-`-prefixed child arguments. + +Unit (`tests/unit/`): + +- `--input-env`: valid single-node JSON → schema equals the stdin-parsed + equivalent; valid multi-node JSON → N nodes; variable absent, empty or + whitespace → exit 1; malformed JSON → exit 1 with `details.position`; + schema-invalid JSON → exit 1 with `details.errors`. +- Conflict matrix: every row → exit 64 **and** `spawn_daemon` asserted not + called — including each daemon flag (`--auto-stop-idle-seconds`, `--materialize`, + `--log-file`) under `--input-env`. No usage error may ever leak a daemon. +- Forced materialize: an `--input-env` payload with `daemon.materialize=false` + reaches `spawn_daemon` with `materialize=True`. +- `--output-var`: child env has NAME whose value round-trips through + `OutputSchema.model_validate`, scalars still present for one node; invalid + NAME and `render_env`-key collision → 64. +- Multi-node: with `--output-var` → NAME present, **no** `TUNSTRAP_*` scalars, + `connections` carries every node; without it → exit 1 **pre-spawn** + (`spawn_daemon` asserted not called), typed error, not a traceback. +- Post-spawn safety: with `spawn_daemon` mocked to succeed and + `OutputSchema.model_validate` / `render_env` / the `--output-var` encode each + patched to raise in turn, teardown is still invoked exactly once and the exit + code is 4. Explicitly including a **malformed success payload** (no + `session_dir` key, or a non-string one): teardown must still stop the daemon, + which is only possible because the path was minted pre-spawn. +- Teardown is not skippable: with signal restoration patched to raise, teardown + still runs. With the stop primitive patched to raise, the child's exit code + (7) is still what `run` exits with, and the diagnostic lands on stderr. +- Silent teardown: `_teardown_run` writes nothing to stdout on success, and its + failure diagnostic goes to stderr. +- `stop` regression: `stop_command` stdout is byte-identical to today for each + outcome — `{"stopped": true}`, `{"stopped": true, "forced": true}`, + `{"stopped": false, "reason": "not found"}`, `"identity mismatch"`, + `"identity check unavailable"`, `"identity changed during grace"`. +- Regression: `run USER@HOST -- CMD` with no new flags produces a + byte-identical child env. + +Integration (`tests/integration/`, whose compose already provides `sshd-a/b/c`, +`sshd-bastion`, `http-target-1/2` and `fake-apiserver`): + +- `run --input-env X --output-var Y -- ` against one sshd + node: child sees a valid `OutputSchema` and its endpoints are live. Same with + two sshd nodes: `connections` has both keys, no `TUNSTRAP_*` scalars, both + endpoints reachable. +- Teardown: after exit, `session_dir` is gone and no daemon remains (mirrors + `2026-06-25-cli-run-modes-design.md:238-240`); `-- sh -c 'exit 7'` → exit 7. +- **Stdout purity — `run`'s stdout is byte-for-byte the child's stdout**, with + the child emitting a known sentinel, asserted across all five outcomes: + (a) success; (b) child exit 7; (c) graceful stop (daemon exits within + `--grace-seconds`); (d) forced stop (daemon ignores SIGTERM → SIGKILL path, + `cli.py:427-437`); (e) teardown identity error (`tunnel-data` identity + tampered so `verify_session` returns `mismatch`). Today's tests + (`tests/integration/test_cli_modes.py:127-197`) assert only return codes and + cleanup, which is exactly why this defect was unguarded. +- Shim behaviour without tofu: the shim + a fake `tofu`, asserting pass-through + when the variable is unset, pass-through for `init`/`-version`, tunnel + + `TF_VAR_*` injection otherwise, `KUBECONFIG` absent from the child env, and + clean stdout in every branch. + +### The `e2e` tier — real Kubernetes through the tunnel + +**Why it must exist.** The current rig's `fake-apiserver` / +`fake-apiserver-nosan` are `openssl req -x509` TLS listeners that accept a +handshake and immediately close (`tests/integration/docker-compose.yml`). They +prove SAN probing and kubeconfig patching and nothing more. **No test anywhere +proves that a real Kubernetes client — let alone OpenTofu's `kubernetes` and +`helm` providers — works through a tunstrap tunnel**, which is this design's +central value claim. This tier would have caught the iteration-2 +`materialize: false` → `path: null` → empty `config_path` defect by execution +rather than by inspection. + +Everything in this subsection labelled *[measured 2026-07-31]* was executed +end-to-end on this workstation (Docker 28.1.1, kind 0.30.0, `kindest/node` +v1.34.0, OpenTofu v1.12.5, kubectl v1.28.1, tunstrap 0.0.4). Items labelled +*[designed, unverified]* could not be executed and say why. + +#### Topology + +kind creates its own Docker bridge network named `kind`; the control-plane +container joins it and publishes `6443` on a **random** host port +(`127.0.0.1:45491->6443/tcp` in the probe run), so the host-side kubeconfig is +useless to a container. Measured reachability: + +| From | To | Result | +|---|---|---| +| container on network `kind` | `https://-control-plane:6443/version` | HTTP 200 | +| container on network `kind` | `https://172.18.0.2:6443/version` | HTTP 200 | +| container on the default bridge | `https://172.18.0.2:6443/version` | connect failure | + +So the SSH node container **must** join the `kind` network. This mirrors +production faithfully: tunstrap SSHes to a host that can reach the API server, +and forwards to it. + +Compose changes — a new `tests/e2e/docker-compose.yml` (not an edit to the +integration one, see Constraints below) with a single `sshd-kube` service that +declares the kind network as **external**: + +```yaml +services: + sshd-kube: + image: lscr.io/linuxserver/openssh-server:latest + environment: [PUID=1000, PGID=1000, USER_NAME=tester, + PUBLIC_KEY_FILE=/keys/id_test.pub, + SUDO_ACCESS=false, PASSWORD_ACCESS=false] + volumes: + - ./_keys:/keys:ro # generated by the e2e fixture, NOT shared + - ./_sshd_conf:/config/sshd/sshd_config.d:ro # REQUIRED, see below + - ./_kube:/etc/kube:ro # the node's kubeconfig, fixture-populated + ports: ["127.0.0.1::2222"] + networks: [kind] +networks: + kind: + external: true # created by `kind create cluster` +``` + +**The `sshd_config.d` mount is mandatory, not cosmetic.** In +`lscr.io/linuxserver/openssh-server:latest` as pulled 2026-07-31, the shipped +`/config/sshd/sshd_config` sets `AllowTcpForwarding no` at line 92, and the image +emits an `Include /config/sshd/sshd_config.d/*.conf` line **only when that +directory exists**. Because OpenSSH takes the first obtained value for a keyword, +the mounted `allow_tcpfwd.conf` (`AllowTcpForwarding yes`) wins over line 92 — +but only if the directory is mounted. Without it the server refuses every +forwarded connection with `administratively prohibited`, while the tunnel still +appears to start cleanly [measured 2026-07-31 — see "Why the `sshd_config.d` +mount matters" below]. + +#### The remote kubeconfig + +The fixture copies the control-plane's **in-node** kubeconfig +(`/etc/kubernetes/admin.conf`, obtained with `docker cp` / `docker exec cat`) +into `tests/e2e/_kube/admin.conf`, which the compose file mounts read-only at +`/etc/kube/admin.conf` inside `sshd-kube`. That path is what `kube_targets` +reads over SSH, exactly as the consumer reads `/etc/rancher/k3s/k3s.yaml`. + +Measured properties of that file, all of which the flow depends on: + +- `server: https://-control-plane:6443` — a DNS name, resolvable on the + `kind` network. `kube.py:295` parses this to decide the forward target, so the + forward lands on the right host without any extra configuration. +- It carries embedded `certificate-authority-data`, `client-certificate-data` + and `client-key-data` — the shape `parse_kubeconfig` expects. +- The apiserver certificate SANs are + `DNS:kubernetes, kubernetes.default, kubernetes.default.svc, + kubernetes.default.svc.cluster.local, localhost, -control-plane` and + `IP:10.96.0.1, 172.18.0.2, 127.0.0.1`. + +Because the original host (`-control-plane`) **is** in the DNS SAN list, +`choose_tls_server_name` (`kube.py:177-196`) returns it as an exact match with +`fellback=False`, so the tier exercises the **clean, warning-free** path rather +than a fallback. Measured result of `tunstrap start` against this fixture: + +``` +endpoint : https://127.0.0.1:37141 +tls_server_name : -control-plane +materialized : /tunnel-data/node-k3s +warnings : [] +``` + +and `kubectl --kubeconfig get nodes` returned the control-plane +node `Ready` [measured 2026-07-31]. Cluster naming must be deterministic +(`kind create cluster --name tunstrap-e2e` → container +`tunstrap-e2e-control-plane`) because that name is both the forward target and +the expected `tls_server_name`. + +#### Fixture layout + +``` +tests/e2e/ + __init__.py + conftest.py # kind + compose lifecycle, session-scoped + docker-compose.yml + _keys/ # GENERATED, gitignored: id_test, id_test.pub + _sshd_conf/ # TRACKED: allow_tcpfwd.conf ("AllowTcpForwarding yes") + _kube/ # GENERATED, gitignored: admin.conf + shim/tofu-tunstrap-novar # TRACKED: --output-var negative control (test-only) + module/ + versions.tf # kubernetes + helm provider constraints + main.tf # var.tunstrap -> try(jsondecode) -> config_path + charts/probe/ + Chart.yaml # apiVersion: v2, name: probe, version: 0.1.0 + templates/configmap.yaml # one ConfigMap: data.proof = "through-the-tunnel" + test_tofu_providers.py + test_shim.py +``` + +**The tier must be self-contained; it cannot borrow the integration rig's +fixtures.** `tests/integration/_keys/` is gitignored (`.gitignore:42`) and +untracked (`git ls-files tests/integration/_keys/` → empty); the keypair exists +only as a side effect of the `ssh_keypair` fixture in +`tests/integration/conftest.py:24-57`, which pytest loads **only** for tests +under `tests/integration/`. A `pytest tests/e2e` run in a clean checkout would +therefore mount a non-existent directory. Hence: the e2e fixture generates its +**own** Ed25519 keypair into `tests/e2e/_keys/` (same `cryptography`-based +approach as the integration fixture, which avoids a paramiko dependency), and +`_sshd_conf/allow_tcpfwd.conf` is **committed** rather than referenced across +suites. No cross-suite coupling in either direction — which also keeps the +"do not break the existing rig" constraint literally true. + +`.gitignore` gains `tests/e2e/_keys/` and `tests/e2e/_kube/`. + +#### Session fixture lifecycle + +One session-scoped fixture, yielding a dict of connection facts. Sequence: + +1. **Preflight.** Skip the whole tier with a clear reason if `kind`, `tofu` or + `docker` is absent — a missing tool must not look like a product failure. +2. **Keys.** Generate `_keys/id_test` (0600) + `id_test.pub` if absent. +3. **Cluster.** `kind create cluster --name tunstrap-e2e --wait 90s`. The name is + fixed because the control-plane container name (`tunstrap-e2e-control-plane`) + is both the SSH forward target and the expected `tls_server_name`. Delete any + pre-existing cluster of that name first, so a crashed prior run cannot leave a + half-configured cluster that silently changes results. +4. **Kubeconfig.** `docker exec tunstrap-e2e-control-plane cat + /etc/kubernetes/admin.conf` → `_kube/admin.conf` (0644 — it is read over SSH + by the `tester` user). +5. **Compose.** `docker compose up -d --wait`, which joins the external `kind` + network. Then **SSH readiness**: poll an actual authenticated SSH command + (not a TCP connect — the listener accepts before the key is installed) until + it succeeds or a timeout expires. Then discover the **dynamic** host port with + `docker compose port sshd-kube 2222`, exactly as the integration conftest does + (`conftest.py:91-99`); the published port is random by design. +6. **Yield** `{host, port, private_pem, cluster_name, kubeconfig_in_node_path, + module_dir, shim_path}`. +7. **Teardown**, unconditional and in reverse: `docker compose down -v`, then + `kind delete cluster --name tunstrap-e2e`, then remove `_kube/`. Cluster + deletion must run even if compose teardown fails. + +**Per-test isolation.** Each test gets its own copy of `module/` in a `tmp_path`, +with `TF_DATA_DIR` and the state file inside it, so tests cannot share +`.terraform/` or `terraform.tfstate` and cannot pass because of a neighbour's +leftovers. Provider *downloads* are shared via a session-scoped +`TF_PLUGIN_CACHE_DIR`, so isolation costs one `init` per test but not one +download per test. + +`module/main.tf` is the **exact chain this spec designs**, not a hand-written +kubeconfig path: + +```hcl +variable "tunstrap" { + type = string + default = "" + sensitive = true +} + +locals { + tunnel = try(jsondecode(var.tunstrap), { connections = {} }) + kubepath = try(local.tunnel.connections.node.kube_targets.k3s.path, "") +} + +provider "kubernetes" { + config_path = local.kubepath != "" ? local.kubepath : null + host = local.kubepath == "" ? "https://127.0.0.1:0" : null + cluster_ca_certificate = local.kubepath == "" ? "" : null + client_certificate = local.kubepath == "" ? "" : null + client_key = local.kubepath == "" ? "" : null +} + +provider "helm" { + kubernetes { + # same five lines + } +} + +resource "kubernetes_namespace" "probe" { + metadata { name = "tunstrap-e2e" } +} + +resource "helm_release" "probe" { + name = "probe" + chart = "${path.module}/charts/probe" + namespace = kubernetes_namespace.probe.metadata[0].name +} +``` + +The inert branch is deliberately identical in shape to the consumer's +(`terragrunt.hcl:35-47`), so this module is also a regression test for the +provider configuration the recipe tells consumers to write. + +**Pin the providers.** `>= 2.30.0` resolved to `hashicorp/kubernetes` **v3.2.1**, +which emits `Deprecated; use kubernetes_namespace_v1` for +`kubernetes_namespace` [measured 2026-07-31]. Pin `~> 2.30` (matching the +consumer's `versions.tf:9-33`) or use the `_v1` resource names, so a provider +major bump cannot turn this tier red for a reason unrelated to tunnelling. + +#### Assertions + +Every assertion below states **how it fails when its target is broken**. An +assertion with no such mechanism is decorative and does not belong here. + +| # | Assertion | Fails when broken because… | Status | +|---|---|---|---| +| 1 | `tofu apply` creates Namespace `tunstrap-e2e`, read back through the API | no tunnel ⇒ provider cannot reach any apiserver ⇒ apply errors | *[measured — "Apply complete! Resources: 2 added"]* | +| 2 | `helm_release` creates ConfigMap `probe-cm` with `data.proof=through-the-tunnel` | value is compared, not just existence; a stale/foreign object fails the compare | *[measured]* | +| 3 | Helm release recorded in-cluster as Secret `sh.helm.release.v1.probe.v1` | absent if the provider only rendered locally without reaching the cluster | *[measured]* | +| 4 | **Chain integrity** (see below) | KUBECONFIG cleared + negative control + `kubepath` output compare | *[measured in part — see "On assertion 4"]* | +| 5 | `tofu destroy` removes both; Namespace lookup returns `NotFound` | a no-op destroy leaves the Namespace present and the lookup succeeds | *[measured — "Destroy complete! Resources: 2 destroyed"]* | +| 6 | Inert branch: `TF_VAR_tunstrap` unset ⇒ `tofu plan` succeeds | if `try()` were dropped to bare `jsondecode`, `jsondecode("")` errors and plan fails | *[measured]* | +| 7 | **`init` pass-through** (see below) | poisoned `TUNSTRAP_INPUT` makes any accidental tunnel abort before tofu starts | *[measured in part]* | +| 8 | **Child exit-code propagation** (see below) | sentinel proves the child ran; exit code 42 is outside tunstrap's reserved set | *[designed, unverified]* | +| 9 | **stdout purity** (see below) | byte-equality against the same child run without the shim | *[designed, unverified — the leak it guards is reproduced live below]* | +| 10 | Real provider failure still surfaces: apply against a stopped cluster exits non-zero | distinct from 8; covers the error path 8 deliberately excludes | *[designed, unverified]* | + +**On assertion 4 — chain integrity.** The tier's whole purpose is to prove +providers are configured by `--output-var` → `TF_VAR_tunstrap` → +`try(jsondecode(...))` → `config_path`. Three mutually reinforcing checks, +because the naive version can pass for the wrong reason: + +- **KUBECONFIG is cleared** by the shim (`env -u KUBECONFIG`, above), and the + test asserts it: a `tofu` wrapper on `PATH` dumps its environment, and the test + requires `KUBECONFIG` to be **absent** and `TF_VAR_tunstrap` **present**. + Without this, a broken chain would silently succeed via the injected + `KUBECONFIG`, which points at the very same materialized file. +- **Negative control.** The identical apply with `TF_VAR_tunstrap` **unset** but + everything else unchanged (tunnel up, `run` still injecting its env) must + **fail**. If it succeeds, some other path is reaching the cluster and the + positive result proves nothing. This is the single most valuable assertion in + the tier. +- **Value compare.** `module/` exposes `output "kubepath_used" { value = + local.kubepath }`; the test asserts it equals + `connections.node.kube_targets.k3s.path` from the envelope. A hard-coded or + fallback path fails the compare. + +**On assertion 7 — `init` pass-through.** Checking that no session directory +exists *after* `init` returns cannot distinguish "never tunnelled" from +"tunnelled and torn down correctly". Make the wrong path fail loudly instead: +run `init` with `TUNSTRAP_INPUT` set to a **deliberately invalid, non-empty** +value (e.g. `{invalid`). Pass-through never reads it, so `init` succeeds and the +`tofu` wrapper records that it ran. Any accidental `tunstrap run` exits **1** +(`SchemaValidationError`, pre-spawn) and tofu never launches. Assert exit 0 **and** +the wrapper's execution sentinel — the two together are unambiguous. + +**On assertion 8 — child exit code.** Asserting merely "non-zero" proves nothing: +tunnel failure (2/3/4), usage error (64) and provider failure are all non-zero +and most do not involve the child running at all. So: a fake `tofu` on `PATH` +prints a fixed sentinel to stdout and exits **42** — outside tunstrap's reserved +set (1, 2, 3, 4, 64) and distinct from the launch-failure code 127. Assert the +exact code 42 **and** the sentinel. Real provider failures are covered separately +by assertion 10, so this assertion is not weakened to accommodate them. + +**On assertion 9 — stdout purity.** Asserting merely that the known +`{"stopped": …}` line is absent would pass while some *other* tunstrap message +contaminated the stream. The oracle is byte equality: the same deterministic +fake `tofu` (fixed sentinel bytes, no timestamps, no random ordering) is run +twice — once directly, once through the shim — and the two captured stdouts must +be **byte-for-byte identical**. Any injected byte, from any source, fails. + +**What "measured in part" means for assertions 7-9.** The designed shim calls +`tunstrap run --input-env ... --output-var ...`, and those flags do not exist in +0.0.4, so the real shim could not be executed. What was executed is a +**branch-for-branch simulation** using today's `start`/`stop`, with identical +dispatch: + +``` +$ shim init → PASSTHRU(init/version) init … (no session created) +$ shim apply → TUNNEL apply … session_created=yes + Apply complete! Resources: 2 added +``` + +So the *dispatch logic* and the *init-skip* are measured; the exact flag surface +is not, and becomes verifiable the moment Additions 1–2 land. + +The stdout defect assertion 9 guards is not hypothetical. Under today's code: + +``` +$ tunstrap run … -- sh -c 'echo CHILD_STDOUT_SENTINEL' +1 CHILD_STDOUT_SENTINEL +2 {"stopped": false, "reason": "identity changed during grace"} +``` + +[measured 2026-07-31] — line 2 is `_kill_with_identity` writing to stdout, and +under the shim it would land inside tofu's output stream. This tier is where +that assertion runs against real tofu output, complementing the unit-level +checks. + +#### Marker, CI job, and blast radius + +**Own marker `e2e`, own CI job.** `pyproject.toml` gets +`markers = [..., "e2e: requires kind + tofu (real cluster)"]` and `addopts` +becomes `-m 'not integration and not e2e'`. Rationale: the existing +`integration` marker's contract is "docker compose alone", and this tier needs +kind, `tofu`, a 1.45 GB node image and an external Docker network. Folding it +into `integration` would (a) break that contract, (b) make every integration run +pay ~90 s of cluster setup, and (c) couple a fast, always-run suite to a slow +one. A separate job also lets it be `continue-on-error` or nightly if it proves +flaky, without weakening the required checks. + +- **`addopts` effect:** today `-m 'not integration'` already excludes unmarked-as- + integration tests; adding `and not e2e` keeps `pytest` (bare) and the unit job + unchanged. **Unit tests still run on macOS untouched** — nothing in this tier + is imported by `tests/unit/`. +- **Coverage combine:** the `coverage` job currently downloads exactly two + artifacts and runs `coverage combine` + `--fail-under=80`. Simplest correct + choice: **the `e2e` job does not produce coverage data** and is not added to + the combine step. It exercises tofu and a cluster, not new tunstrap lines + beyond what the integration suite already covers, and adding a third artifact + would make the gate depend on the slowest, most environment-sensitive job. +- **Existing rig untouched:** new directory `tests/e2e/`, new compose file. The + `integration` marker stays runnable with docker compose alone. + +#### Cost + +Measured on this workstation (10 cores, 31 GB, Docker 28.1.1): + +| Step | Time | +|---|---| +| `kindest/node` image pull (1.45 GB, cold) | ~28 s | +| `kind create cluster --wait` (image cached) | 36 s | +| `kind create cluster` including the pull | 64 s | +| `sshd-kube` container ready | ~14 s | +| `tunstrap start` (SSH + SAN probe + materialize) | ~2–3 s | +| `tofu init`, first time (downloads `kubernetes` + `helm`) | 8.8 s | +| `tofu init`, per test with `TF_PLUGIN_CACHE_DIR` warm | ~1–2 s | +| `tofu apply` (2 resources) | 1.3 s | +| `tofu destroy` | 7.6 s | +| `kind delete cluster` | ~5 s | + +Per-test module isolation (see "Session fixture lifecycle") multiplies only the +warm `init`, not the download, so a handful of tests adds seconds rather than +minutes. + +**Realistic total: ~2–2.5 minutes of work**, plus GitHub Actions job overhead +(checkout, Python, `pip install -e ".[dev]"`, installing kind and tofu) — call it +**4–5 minutes wall clock** for the job. That is cheap enough to run per-PR and is +why this is worth automating. + +**`tofu init` needs network** to fetch providers from +`registry.opentofu.org`; it cannot be avoided. Options: accept the 8.8 s +(recommended — it is small next to cluster setup), or cache +`~/.terraform.d/plugin-cache` keyed on `versions.tf` via `actions/cache` and set +`TF_PLUGIN_CACHE_DIR`. Note this is the *runner's* network, unrelated to the +tunnel, and unrelated to the shim's `init`-skip decision. + +#### Why the `sshd_config.d` mount matters — and why the existing rig is fine + +**The existing integration suite is green.** Measured 2026-07-31: +`pytest tests/integration -m integration -q` → **`29 passed in 78.53s`**. Nothing +in this subsection reports a defect in the current rig; it explains a fixture +constraint the new `e2e` tier must respect. + +Two independent facts about the rig, both measured, which are easy to conflate: + +1. **Forwarding is administratively disabled on `sshd-a/b/c`.** They have no + `sshd_config.d` mount, and in `lscr.io/linuxserver/openssh-server:latest` as + pulled 2026-07-31 the shipped config sets `AllowTcpForwarding no` (line 92). + The image emits `Include /config/sshd/sshd_config.d/*.conf` **only when that + directory exists**, so only `sshd-bastion` gets the override. Effective + configuration, read from the running containers with + `sshd.pam -T -f /config/sshd/sshd_config -h …`: + + ``` + sshd-a allowtcpforwarding no + sshd-bastion allowtcpforwarding yes + ``` + + and a forward through `sshd-a` to `127.0.0.1:2222` — a target inside the + container itself, needing no cross-network path — is refused by the server: + `channel 2: open failed: administratively prohibited: open failed`. + +2. **`sshd-a/b/c` are not on the `internal` network.** They declare no + `networks:` block (`docker-compose.yml:2-16`), so they sit on + `integration_default` only, while `target-1`/`target-2` are aliased solely on + `internal`. `sshd-bastion` is the only service on both. So `sshd-a` has no + route to `target-1` **independently** of fact 1 — being the only cross-network + host is precisely the bastion's purpose, and what the cross-host forwarding + tests exercise. + +An earlier draft of this spec attributed a single probe (`sshd-a` → `target-1` +returning empty) wholly to fact 1 and extrapolated that the suite must be red. +That extrapolation was wrong: **both** facts applied to that probe, and the suite +is green because **no test moves data through an `sshd-a` forward**. The `sshd-a` +tests are SFTP-only (`test_fetch_files.py`, `test_fetch_security.py`) or assert +port *allocation* without traffic (`test_multiport.py:20-47` checks only that two +forwards get distinct local ports; `test_start.py:30-54` checks `connections` +keys and `pid`). Every test that actually moves bytes through a forward uses the +bastion — including `test_kube_targets.py:70` and `:121`, which pass +`ssh_test_cluster["bastion_port"]`. + +**Consequence for the `e2e` tier — this is the part that matters.** The new +`sshd-kube` service *does* move data through a forward, so it **must** mount +`_bastion_sshd_config` at `/config/sshd/sshd_config.d`, exactly as the compose +snippet above shows. Omitting it produces a tunnel that opens cleanly and then +refuses every connection. + +Two notes, neither a defect and both **out of scope**: + +- The image tag is unpinned (`:latest`). That is genuine supply-chain fragility + — an upstream change to the shipped `sshd_config` would alter fixture + behaviour with no repo change — and pinning a digest would be cheap insurance. +- `tunstrap start` returns success when a forward target is later unroutable or + administratively refused. This is **not a bug**: SSH `direct-tcpip` failures + surface per *connection*, not at listener-setup time, so a local listener can + only ever be optimistic. A startup reachability probe (open and immediately + close one channel per target, downgrading a failure to a `TunnelWarning`) is a + plausible **enhancement** — it would have turned the fixture hazard above into + an immediate diagnostic — but it changes `start`'s contract and cost, and + belongs in its own proposal rather than here. + +### Verification gates + +From `.github/workflows/test.yml`: `black --check .`, `ruff format --check .`, +`ruff check .`, `pylint tunstrap/` (`fail-under = 9.0`, `pyproject.toml`), +`vulture tunstrap/ vulture_whitelist.py`, `mypy --strict tunstrap`, +`pytest tests/unit` on {ubuntu, macos} × {3.10–3.13}, `pytest tests/integration +-m integration`, and combined `coverage report --fail-under=80`. + +Added by this spec: a **separate `e2e` job** (ubuntu only) running +`pytest tests/e2e -m e2e`, which installs kind and `tofu` and creates a real +cluster. It is **not** part of the coverage combine (see "Marker, CI job, and +blast radius"), and it does not alter the unit or integration jobs. + +**Pre-existing breakage found here, resolved during implementation.** When this +spec was written `pyproject.toml` pinned only `ruff>=0.8`, so a fresh +`pip install -e ".[dev]"` resolved ruff **0.16.1**, against which +[measured 2026-07-31] `ruff check .` reported **59 errors** in `.py` sources and +tests (19 `I001`, 8 `PLW1510`, 7 `UP037`, 6 `RUF100`, 4 `UP035`, …) and +`ruff format --check .` wanted **6 files** — *all Markdown*, because ruff 0.16 +formats Python code fences inside `.md`. `black --check .` was clean; ruff +**0.15.18** was clean on both gates. So black and `ruff format` had **not** +diverged on Python — zero `.py` files differed; the gate broke on new 0.16 lint +rules plus Markdown formatting. + +Fixed as the diagnosis predicted — a version pin plus an exclude: `pyproject.toml` +now pins `ruff>=0.16,<0.17` (an upper bound, because the formatter's behaviour +moves between minors and the file counts are gate values), and +`[tool.ruff.format] exclude = ["docs/**/*.md"]` keeps ruff out of prose. All +lint findings were fixed or explicitly suppressed. Both gates are green. + +## HCL consumer impact + +**Before** (`consumer-repo/garuda/`): `terragrunt.hcl:11-14` (`mktemp -u` marker), +`:55-83` (28-line `tunnel_up_script` with `jq`, `umask 077`, `unset KUBECONFIG`, +mock short-circuit, `uvx ... tunstrap start`), `:221-237` (`after_hook` with a +second inline bash + `jq` + `tunstrap stop`), `:250-306` (`run_cmd` passing the +whole `InputSchema` as argv), `locals.tf:72` +(`jsondecode(var.tunnel_path == "" ? ... : file(var.tunnel_path))`), and +`variables.tf:273` (`variable "tunnel_path"`). + +**After.** `root.hcl:5` already reads `terraform_binary = "tofu"`, so this is a +one-line edit, not a new attribute: `terraform_binary = +"${get_repo_root()}/bin/tofu-tunstrap"`. Unit `terragrunt.hcl`: + +```hcl +terraform { + source = "." + + extra_arguments "tunstrap" { + commands = ["plan", "apply", "destroy", "refresh", "import"] + arguments = [] + + # dependency.* resolves here [measured 2026-07-31]; it does NOT + # resolve in `locals`, so the conditional must be inline. + env_vars = dependency.infra.outputs.connection_data_hub.host != "0.0.0.0" ? { + TUNSTRAP_INPUT = jsonencode({ + nodes = merge( + { + hub = { + host = dependency.infra.outputs.connection_data_hub.host + port = 22 + user = dependency.infra.outputs.connection_data_hub.user + ssh_pkey = dependency.infra.outputs.connection_data_hub.ssh_private_key + remote_targets = { k3s = "127.0.0.1:6443" } + kube_targets = { k3s = { kubeconfig_path = "/etc/rancher/k3s/k3s.yaml" } } + required = true + } + }, + { + for k, cd in dependency.infra.outputs.connection_data_edges : + k => { + host = cd.host + port = 22 + user = cd.user + ssh_pkey = cd.ssh_private_key + remote_targets = { k3s = "127.0.0.1:6443" } + kube_targets = { k3s = { kubeconfig_path = "/etc/rancher/k3s/k3s.yaml" } } + required = true + } + }, + ) + daemon = { + shutdown_grace_seconds = 10 + materialize = true + # auto_stop_idle_seconds is intentionally absent: the daemon's + # lifetime is now exactly the tofu child's. + } + }) + } : {} + } +} +``` + +That `nodes`/`daemon` body is the existing payload from +`terragrunt.hcl:257-304`, moved verbatim except for the dropped +`auto_stop_idle_seconds`. + +`locals.tf:72` becomes: + +```hcl +tunnel = try(jsondecode(var.tunstrap), { connections = {} }) +``` + +with `variable "tunstrap" { type = string, default = "" }` replacing +`tunnel_path` (`variables.tf:273`). **The `try()` is required, not stylistic**: +the default is `""`, and bare `jsondecode("")` fails, so plain +`jsondecode(var.tunstrap)` would break every command that gets no tunnel — the +exact case the next paragraph requires the module to tolerate. The two read sites +are unchanged in shape: `locals.tf:76` and `locals.tf:79` still take +`connections[].kube_targets.k3s.path` — the comment at `locals.tf:65` +("Consumer reads only the path string") stays true. + +### Which tofu commands need a tunnel + +The `commands` list is a deliberate enumeration, not a copy of the old +`after_hook` list (`terragrunt.hcl:222`). The old list existed because the tunnel +was started during `inputs` evaluation and therefore had to be torn down for +*any* command that evaluated `inputs`; the new list answers a different question +— which commands actually make provider API calls. + +| Command | Tunnel | Why | +|---|---|---| +| `plan`, `apply`, `destroy`, `refresh` | **yes** | `kubernetes`/`helm` providers read and write live cluster state | +| `import` | **yes** | reads the live resource to populate state; omitting it is a silent trap | +| `console` | **yes**, if used | can evaluate provider data sources; add it if the consumer uses it interactively | +| `init` | no | backend config + provider downloads only; the state backend is a public S3-compatible endpoint (`root.hcl:19-44`) reached directly rather than through the tunnel, and providers come from the public registry (`garuda/versions.tf:9-33`) | +| `validate` | no | schema/expression checks only; no provider calls. It *was* in the old `after_hook` list solely because it evaluated `inputs` | +| `output`, `show`, `state *`, `taint`, `untaint`, `fmt`, `providers` | no | read or rewrite state and files; no cluster contact | + +Everything not listed in `commands` gets `TUNSTRAP_INPUT` unset and takes the +shim's pass-through branch, so the failure mode of forgetting a command is a +**provider error against the inert loopback endpoint**, not a silent wrong +result. `import` is called out explicitly because it is the one state-mutating +command that is easy to leave off the list. + +**Deleted from the consumer:** the `run_cmd` block (`:250-306`), the `mktemp` +marker local (`:11-14`), the `tunnel_up_script` heredoc (`:55-83`), the +`after_hook` (`:221-237`), both `jq` invocations, the `umask 077`, and the +`InputSchema` in argv. The `auto_stop_idle_seconds = 7200` workaround (`:299`) +can shrink or go away — the daemon's lifetime is now the child's. +**`session_dir` has no Terraform consumer**: it appears in `.tf` only inside a +comment (`locals.tf:64`), and its sole functional use is the down-hook shell at +`terragrunt.hcl:229`, which this design deletes. + +**`terragrunt output` gets no tunnel — deliberately.** `output` is not in +`commands`, so `TUNSTRAP_INPUT` is unset for it and its auto-`init` [measured +2026-07-31, fact 4]; it does not need one, since `tofu output` reads state. +**Consequence: the module must tolerate an unset `tunstrap` variable** — and it +already does. `locals.tf:69-72` has the empty-string branch, and +`terragrunt.hcl:35-47` defines the inert provider body substituted into the +generated `providers.tf` at `:129`, `:137`, `:144`, `:151`, pinning +`host = "https://127.0.0.1:0"` with empty cert material so the providers cannot +fall back to `$KUBECONFIG`. That branch now serves three cases instead of two: +`tofu test`, mock state, and non-tunneled commands. + +**Security, stated honestly.** Private keys still travel in the child's +**environment** — but they already do today, as `TF_VAR_connection_data_hub` +[measured 2026-07-31, fact 2]. What this design removes is the *command line*: +`ProcessExecutionError` can no longer print a PEM. The stronger follow-on is the +already-shipped ssh-agent fallback (`schemas.py:272-283`, +`docs/specs/2026-06-25-ssh-agent-fallback-design.md`): if the consumer exports +`SSH_AUTH_SOCK` and drops `ssh_pkey` from the payload, key material leaves the +payload entirely. Recommend it in the recipe; it is not required by this design. + +## Documentation deliverables + +- **`docs/recipe_terragrunt.md`** (new) — the canonical recipe: the shim + verbatim, the before/after HCL, and the ssh-agent recommendation. It must carry + the measured facts a future agent would otherwise re-derive — path-only + `terraform_binary`; env-var scoping including the auto-`init` behaviour; the + invocation counts; `dependency.*` in `extra_arguments.env_vars` but not in + `locals`; `TF_VAR_*` JSON inputs; payload fidelity at ~10 KB; and the hard rule + that **the shim must never write to stdout**. +- **`README.md`** — document both flags in the `run` section (`:175-195`), + extend its exit-code paragraph (`:191-195`), link the recipe from "Project + documents" (`:518-522`), and fix three stale `token` references: `:340` (a + `"token": ""` line in the Output reference example), `:385` (a + Security-notes bullet calling `token` the authorization handle), `:429` (a + Troubleshooting row on "token mismatch"). `token` was removed from + `OutputSchema` by the 2026-06-24 design and is absent from `schemas.py:353-362`. + Lines `:474-485` mention `token` legitimately in a migration note — leave those. + +## Decision log + +Carried forward from the superseded spec (still binding): + +1. **Deterministic `--session-dir`** — rejected; `session_dir` has no Terraform + consumer (reconfirmed: `locals.tf:64` is a comment). +2. **Hash-keyed session reuse** — rejected; latency-only. +3. **Idempotent `up` verb / replace-active semantics** — rejected; breaks the + race-free `SessionActive` invariant from issue #7. +4. **"Wait for zero active connections before replacing"** — rejected; idle ≠ + unused (the consumer regressed on this at `auto_stop_idle_seconds = 300`). +5. **Named sessions per terragrunt command** — rejected; multiplies daemons. +6. **`--owner-ancestor N` / negative pid as ancestor depth** — rejected; fragile + to launcher depth, collides with `kill(2)` semantics. + 7. **A `--config` file with Terragrunt-output adapters inside tunstrap** — + rejected; layering violation. **Reaffirmed and strengthened here**: it is the + root principle behind putting the shim outside the package. + **Revised (post-land):** the *direction* of this principle is reversed for + the proxy specifically — see "Shipping the shim (revised)" and new item 31. + The `--config` adapters themselves remain rejected; what changed is that the + *consumer shim's logic* (pass-through, `init`/`-version` bypass, the + `TF_VAR_tunstrap` + `tofu` invocation) now also ships in-package as + `tunstrap/tofu_proxy.py`, not that tunstrap grew generic Terragrunt + adapters. `cli.py` stays Terraform-vocabulary-free; only `tofu_proxy.py` + carries it. +8. **Matching the joined `/proc//cmdline`** — rejected; the owner pattern + appears in its own cmdline [measured 2026-07-30]. Moot under (15). +9. **`re.fullmatch` / anchoring over `argv[0]`** — rejected. Moot under (15). +10. **Owner as a synthetic `TunnelWarning`** — rejected; `TunnelWarning` means + "non-fatal failure on an optional node". Moot under (15) — `OutputSchema` + gains no fields at all here. +11. **`--owner` on `run`** — rejected; `run` already guarantees teardown in its + `finally` (`cli.py:327-330`). **This is the observation the new architecture + generalizes.** +12. **Split exit codes for owner failures (64 vs a new 5)** — moot under (15); + `5` returns to the unallocated pool (`exceptions.py:57-63`). +13. **`SecretStr` on the three secret fields** — deferred; `spawn_daemon` + serializes with `model_dump_json()`, which `SecretStr` would break. +14. **Terragrunt-based integration tests in this repo** — out of scope; needs + tofu + terragrunt in the test image and a state backend. + +New: + +15. **The entire `--owner` process-ownership feature** (watchdog, `owner_gone` + IPC kind, exit 5, pid-reuse start-time guard) — **obsolete, cancelled before + implementation.** Under the proxy model tunstrap *is* `tofu`'s parent and its + lifetime is exactly the child's: orphans become impossible by construction + rather than detected after the fact. Nothing is removed — none of it was ever + built (`grep -rn "owner" tunstrap/*.py` → only `kube.py:235,245`). +16. **`--output-file`** — obsolete. The result goes into the child's environment + via `--output-var`; under the shim, stdout belongs to tofu and is never a + result channel [measured 2026-07-31, fact 8]. The 0600-file design solved a + problem that no longer exists. +17. **`--placeholder-host`** — obsolete. `env_vars` is an ordinary HCL map, so + the consumer omits `TUNSTRAP_INPUT` when infra is not applied; the shim's + first line passes through and the module's inert branch (`locals.tf:69-72`) + fires. Proven end-to-end both ways [measured 2026-07-31]. Zero tunstrap code, + versus a new flag plus an `OutputSchema.placeholder` field. +18. **`terraform_command_line`** — does not exist. The attribute is + `terraform_binary` / `--tf-path` / `TG_TF_PATH`, and it is path-only + [measured 2026-07-31, fact 1]. +19. **Generalizing `render_env` to multi-node** — rejected. `TUNSTRAP__*` + has no node dimension and same-named targets across nodes collide + irreducibly; `2026-06-25-cli-run-modes-design.md:245` already ruled + multi-node CLI input out of scope. `--output-var` serves the case with a + node-keyed structure, so the documented single-node contract stays intact + and the code gains one fewer branch. +20. **Putting the `init` skip or the pass-through inside tunstrap** — rejected. + Both are Terraform knowledge (`init` is a tofu subcommand; "no input means + no tunnel" is a Terragrunt mock-state convention). They belong in a + small consumer shim. Same principle as (7). + **Revised (post-land):** reversed by item 31. The `init`/`-version` bypass + and the `TF_VAR_tunstrap` pass-through now also live in + `tunstrap/tofu_proxy.py` (the shipped `tunstrap_tofu` entry point). The + consumer-file shim remains a supported option; the principle is no longer + absolute, by deliberate owner decision. +21. **Reading connection data from Terragrunt-generated tfvars files** — + rejected. Such files do not exist: inputs travel as `TF_VAR_*` JSON env vars + [measured 2026-07-31, fact 2]. Even if they did, parsing them would couple + tunstrap to Terragrunt internals. + +Added in review (iteration 2): + +22. **Making `run`'s CONNECTION merely `required=False`** — rejected; it does not + work. Click distributes post-`--` tokens over declared positionals in order, + so `run --input-env X -- tofu plan` binds `connection='tofu'` + [measured 2026-07-31, Click 8.4.2]. Alternatives weighed: a + `--connection` **option** (rejected — breaks the documented positional form + in `README.md:175-195` and its integration tests) and a **separate + subcommand** (rejected — duplicates the entire `run` surface to express one + input-source difference). Chosen: **one `args` variadic**, split after + parsing on the presence of `--input-env`. Flag mode is untouched, and the + old "CONNECTION + `--input-env`" conflict becomes structurally impossible + instead of an error to enforce. +23. **Leaving `_kill_with_identity`'s stdout writes in `run`'s teardown** — + rejected; it is a correctness bug under the shim, not a cosmetic one. + `run` teardown emitted `{"stopped": ...}` on **every** outcome + (`cli.py:376-437`) into the stream Terragrunt parses as tofu's stdout. + Rejected fixes: redirecting fd 1 during teardown (fragile, hides genuine + child output ordering) and making `stop` silent too (breaks `stop`'s + documented JSON contract). Chosen: a silent `stop_session` primitive, with + `stop_command` rendering today's JSON byte-for-byte and `run` printing + nothing on success and stderr on failure. +24. **Daemon flags (`--auto-stop-idle-seconds`, `--materialize`, `--log-file`) + under `--input-env`** — rejected as overrides and as silent no-ops; they are + **usage errors (64)**. The payload's `daemon` block is complete and + authoritative, so there must be exactly one place to look when a tunnel + misbehaves. These flags are excluded from `_conn_flags_present` + (`cli.py:84-93`), so without an explicit rule an implementer would have + invented a precedence order silently. +25. **`run` forces `daemon.materialize = True` on an `--input-env` payload** — + accepted as an invariant of the verb, matching today's unconditional + `force_materialize=True` (`cli.py:290`). A payload saying + `materialize: false` would make `render_env` raise (`envrender.py:42-43`) + and would hand `--output-var` consumers `path: null`, giving the + `kubernetes`/`helm` providers an empty `config_path`. This is the only place + `run` mutates the supplied schema, and it is documented as such. +26. **Copying the old `after_hook` command list into `extra_arguments.commands`** + — rejected. That list (`terragrunt.hcl:222`) answered "which commands + evaluate `inputs` and therefore need teardown", which is not the new question + ("which commands make provider API calls"). `init` and `validate` drop off; + **`import` is added**, since it reads live resources and is the easiest + state-mutating command to forget. Unlisted commands fail loudly against the + inert loopback endpoint rather than silently returning wrong results. + +Added for the `e2e` tier: + +27. **kind, over k3d / a raw k3s container / a real remote cluster** — chosen for + three concrete reasons, not familiarity. (a) It reproduces the *production + shape* of the thing under test: its in-node kubeconfig lives at a real path + (`/etc/kubernetes/admin.conf`) with an embedded CA and a `server:` naming a + host reachable only from inside the cluster network — structurally identical + to the consumer's `/etc/rancher/k3s/k3s.yaml`. (b) Its apiserver cert carries + the control-plane hostname as a DNS SAN, so `choose_tls_server_name` + (`kube.py:177-196`) takes the exact-match branch and the tier tests the + warning-free path [measured 2026-07-31]. (c) It is a single static binary + with no daemon. k3d was the closest alternative and would very likely work + identically, but it adds a k3s-vs-kubeadm difference for no gain; a raw k3s + container needs privileged mode and hand-rolled readiness; a real remote + cluster is not hermetic. **This choice is cheap to revisit** — the tier + depends on kind only through cluster creation and one `docker exec cat`. +28. **A local chart directory, not a remote Helm repository** — chosen. A + `helm_release` needs a chart, and a two-file local chart (`Chart.yaml` + + one ConfigMap template) removes a network dependency, a version-drift source + and an availability risk from a test whose subject is the *tunnel*, not Helm. + Measured: it produced a real in-cluster release Secret + (`sh.helm.release.v1.probe.v1`), so nothing about the Helm path is stubbed. +29. **Own `e2e` marker and CI job, rather than extending `integration`** — + chosen. `integration`'s contract is "docker compose alone"; this tier needs + kind, tofu, a 1.45 GB image and an external Docker network. Extending + `integration` would break that contract and add ~90 s of cluster setup to + every integration run. Rejected alternative: one marker with a skip-if-no-kind + guard — it silently degrades to a no-op in exactly the environment where the + tier matters. `addopts` becomes `-m 'not integration and not e2e'`; the unit + job and macOS are unaffected. +30. **Keeping the `e2e` job out of the coverage combine** — chosen. The combine + step gates at `--fail-under=80` on merged data from exactly two artifacts; + adding a third from the slowest and most environment-sensitive job would make + the coverage gate hostage to cluster flakiness, while adding almost no + tunstrap lines that the integration suite does not already cover. The tier's + value is behavioural proof, not line coverage. + +Added post-land (the `tunstrap_tofu` entry point): + +31. **Shipping the proxy in-package as `tunstrap_tofu`, alongside the consumer + shim** — chosen, reversing items (7) and (20) for the proxy specifically. + `uv tool install` yields a stable `tunstrap_tofu` entry point (item (c) of + "Shipping the shim" answered), so `terraform_binary` points at a stable + installed path with nothing copied into the consumer's repo. The fast-path + cost (item (a)) is managed by `execvp`-ing `tofu` before any `cli` import + and resolving `__version__` lazily — measured **~25 ms end-to-end via the + installed entry** (~12× the ~2 ms shell shim; ~74 ms per plan) vs ~225 ms + naive (see the recipe). The Terraform-vocabulary objection (item (b)) is + the trade being deliberately taken: `init`/`-version`/`TF_VAR_tunstrap`/ + `tofu` live in `tunstrap/tofu_proxy.py`, while `cli.py` stays generic (it + gains only a `suppress_kubeconfig` parameter and a `run_via_env_input` + entry, no Terraform names). The consumer-file shim remains supported and is + still what the e2e tier drives (revised again: the e2e tier now drives + `tunstrap_tofu`, and the consumer-file shim is retired from the recipe). + The `env -u KUBECONFIG` incantation becomes + `suppress_kubeconfig=True` inside the built child env — same property (a + broken `config_path` chain cannot fall back to KUBECONFIG), no child-side + wrapper; the property is pinned by a unit test proven red against the wrong + implementation. The `-chdir=DIR init` gap closes as a side-effect of + structural argv parsing. + +## Out of scope + +- **The pydantic `ValidationError` secret leak.** `cli.py:205-211` and + `cli_input.py:113-123` both put `json.loads(exc.json())` into `details`, and + pydantic v2 error entries include the offending `input` — so a malformed node + echoes `ssh_pkey`. `TunstrapError._scrub` (`exceptions.py:7-12`) drops only + **top-level** keys and never reaches nested `errors[].input`. This design adds + a **third** such site (`--input-env`), raising the priority but not changing + the fix, which needs its own plan. +- **Packaging / PyPI publication**, blocked by the direct-reference `asyncssh` + fork (`pyproject.toml:10`). The shim decision depends on the outcome and should + be revisited when the OCI release contract lands. +- **Multi-node scalar env rendering** — see decision (19). +- **Terragrunt-level integration tests in this repo** — decision (14). + The new `e2e` tier is **tofu-level, not Terragrunt-level**, and the two must + not be confused. The `e2e` tier drives `tofu` directly with `TF_VAR_tunstrap` + set by a shim, proving the genuinely novel path: *providers reach a real + cluster through a tunstrap tunnel*. Terragrunt-level testing would additionally + need terragrunt in the image, a state backend, `dependency` units and mock + outputs, in order to re-prove only Terragrunt's own behaviour — which is + already captured as measured facts 1–8 and belongs in + `docs/recipe_terragrunt.md`, not in a test. **Still out of scope:** the + `extra_arguments.env_vars` wiring, the `dependency.*` resolution order, and the + `terraform_binary` hook itself. +- **The ruff 0.16 gate breakage** — recorded under "Verification gates"; + pre-existing, needs a version pin and a docs decision, not this spec. +- **Migrating the consumer repo.** This spec specifies the tunstrap side and the + recipe; the `consumer-repo/garuda` edit is a consumer-repo change. +- **Symmetric projection for fetched files.** `--output-var` projects + `kube_targets` (drops the kube credentials) but passes + `fetch_files[*].content_b64` through verbatim. The asymmetry is documented + and deliberate for now (see `docs/recipe_terragrunt.md`): fetched files are + operator-requested and `FetchedFile` has no on-disk `path`, so dropping + `content_b64` would lose data. The end-state is symmetry with kube targets — + materialize fetched files under the session dir at mode `0o600`, add a `path` + field to `FetchedFile`, force that materialization in `run` (as is already + done for kubeconfigs), and *then* drop `content_b64` from the projection. + That is a schema addition plus a new forced-materialization rule, + deliberately not done in a security fix at the tail of a 64-commit branch. diff --git a/docs/specs/2026-08-03-run-env-io-decision-history.md b/docs/specs/2026-08-03-run-env-io-decision-history.md new file mode 100644 index 0000000..6658f9b --- /dev/null +++ b/docs/specs/2026-08-03-run-env-io-decision-history.md @@ -0,0 +1,132 @@ +# Decision history: `run` env I/O and the tofu proxy + +- Date: 2026-08-03 +- Supersedes: `docs/superpowers/plans/2026-07-31-run-env-io.md` and + `docs/superpowers/plans/2026-08-01-e2e-tier.md` (removed). Those were + per-task execution scaffolding for `feature/run-env-io` — step-by-step + instructions, predicted command output, expected test counts — none of which + has forward value once the branch landed, and some of which had already gone + stale against the tree it shipped beside (see "Why this document exists" + below). The full task-by-task record, if it is ever needed, is the branch's + git history (86 commits ending at PR #13); this document is not a + replacement for `git log`, it is the distillation a maintainer actually + needs. +- Companion design doc (kept as-is, not superseded): + `docs/specs/2026-07-31-run-env-io-and-tofu-proxy-design.md`. +- Companion consumer-facing doc (kept as-is): `docs/recipe_terragrunt.md` — it + already carries most of the measured Terragrunt facts and the shim/entry-point + design trade in durable, tested prose. This document does not repeat that + content; it points at it and adds only what is *not* documented anywhere + else durable. + +## Why this document exists + +The two plan documents it replaces were committed intermediary planning +artifacts — the org rule is that these must never land in the tree. They had +also drifted: `2026-07-31-run-env-io.md` states as a hard global constraint +*"Do not touch the `ruff>=0.8` pin"*, while this same branch changed that pin +to `ruff>=0.16,<0.17` (`pyproject.toml:23`, commit `e57ebd1` and later). The +plan also recorded now-false baselines ("252 passed", "29 passed" — the +branch's own work moved both) and a local-machine fact ("local dev interpreter +is Python 3.14.4") that has no bearing on anyone else's checkout. None of that +is safe to leave as the record of what was decided and why. + +## Provider-cache finding (not published elsewhere) + +**Cold `tofu init`: 7.65 s. Warm plugin cache *without* a `.terraform.lock.hcl`: +8.17–8.27 s — slower than cold. Warm cache *with* a lock file: 0.226 s.** + +Measured twice independently (e2e-tier tasks 3.3 and 6.3), same shape both +times. The dominant cost is **registry version resolution** +(`Finding hashicorp/helm versions matching "~> 2.17"`), which reruns on every +`init` when the module ships no lock file; the plugin cache only removes the +provider *download*, which was never the bottleneck here. `TF_PLUGIN_CACHE_DIR` +alone is not the win it looks like — it has to be paired with a committed +`.terraform.lock.hcl` to actually collapse init time. + +This is enforced and re-stated at `tests/e2e/conftest.py`'s `tofu_plugin_cache` +fixture docstring, which is the one place code and doc were briefly out of +sync: an earlier version of that docstring claimed the cache alone multiplies +only a cheap "warm init (~1-2s)", which is false and was the exact claim a +reviewer cited to dispute this measurement before the docstring was corrected. +Trust the measured numbers above over any restated version of "the cache helps" +that does not name a lock file. + +## Design decisions, and the ones that were reversed + +Everything in this section has a durable home in code or in +`docs/recipe_terragrunt.md`; the entries below are short pointers plus the one +line of "why" a maintainer needs before opening the source, not a duplicate of +the full reasoning. + +- **The tofu shim moved from a copied consumer-repo shell file to an in-package + console entry point (`tunstrap_tofu`, `tunstrap/tofu_proxy.py`).** This + consciously reverses the original design's "keep Terraform vocabulary out of + tunstrap" principle. Full trade, including the timing measurements that + forced the shell-shim's existence in the first place (shell fast path 2.1 ms; + bare Python 17.3 ms; Python + `import tunstrap.cli` 225 ms, ~184 ms of which + is the import; shipped entry point end-to-end 24.6 ms; `import tunstrap` + 67.3→17.5 ms after making `__version__` lazy via PEP 562, `dd62372`): see + `tunstrap/tofu_proxy.py`'s module docstring and + `docs/recipe_terragrunt.md`, "Why a console script (now)". +- **The bypass predicate is a deny-list (`init`, `version`, no-subcommand), not + a cluster allow-list.** An earlier version (`2425fb6`) shipped an allow-list + of `{plan,apply,destroy,refresh,import,console}` and was rejected on review + as Critical: `TUNSTRAP_INPUT` only exists for commands the consumer + deliberately listed in Terragrunt's own `commands`, so an allow-list is dead + code except in the one case where someone opted a command in on purpose — + exactly the case it silently broke. `b891d0d` restored the deny-list. See + the `_BYPASS_COMMANDS` comment in `tunstrap/tofu_proxy.py`. +- **`--output-var` projects through an allow-list (`RunKubeTarget`), not a + deny-list.** `extra="ignore"` means a field added to `KubeTargetOutput` later + is dropped from this channel until someone adds it here on purpose; a + deny-list leaks each new field by default. This is the fix for a CRITICAL + security defect (`083b36b`, `23d81ad`): `--output-var` previously put the + whole `OutputSchema` — including `client_key_data` and `content_b64` (a full + kubeconfig) — into a Terraform variable, which OpenTofu persists into the + plan file; `sensitive = true` would **not** have been enough, since it + suppresses rendering but leaves the value in the plan file itself. See + `RunKubeTarget`'s docstring in `tunstrap/schemas.py`. +- **`fetch_files[*].content_b64` is deliberately *not* projected the same + way — it rides the `--output-var` channel unprojected.** The asymmetry is + intentional, not an oversight: kube credentials were tunstrap's own material, + injected unasked, with a lossless on-disk alternative (`path`) already + present, so dropping them cost nothing; a fetched file is opt-in *twice* + (`--fetch` and `--output-var`), is the operator's own content, and + `FetchedFile` has no `path` field, so dropping `content_b64` would be a + silent, unrecoverable breakage of any consumer reading it. Silently + discarding requested data is worse than persisting data someone asked to + export. See `docs/recipe_terragrunt.md`, "Fetched files are exported + verbatim, not projected", and the design spec's "Out of scope" section for + the follow-up debt this leaves (give `FetchedFile` a `path`, materialize it + at `0600`, then drop `content_b64`). +- **The `--input-env` payload variable is popped from the child's environment + before anything is injected**, because `tofu` hands its environment to every + provider plugin, `external` data source and `local-exec` provisioner, and the + payload's `ssh_pkey` is an SSH private key. See `_build_child_env`'s + docstring in `tunstrap/cli.py`. + +## Traps for anyone editing tunstrap's own shim/recipe assets + +Not consumer-facing — these bit the branch itself and have no other durable +home: + +- **Editing a shim file in place with an in-process edit tool relaxes its mode + to `0775`.** Git tracks only the owner execute bit, so it reports **no diff** + and `git checkout` will **not** restore it. Only an exact `st_mode & 0o777 == + 0o755` test (`tests/e2e/test_shim.py`) catches this. Any future in-place shim + edit needs an explicit `chmod 0755` afterwards, checked, not assumed. +- **A unit that forgets `include "root"` renders an empty `terraform_binary` + and falls back silently to plain `tofu`** — no error, just the module's inert + branch reached later. Documented for consumers in + `docs/recipe_terragrunt.md`, "Failure modes you will hit"; repeated here + because it is easy to lose sight of while editing the recipe itself, where + the silent-fallback shape is not obvious from the diff. + +## Scope note: the three pre-existing plan documents + +`docs/superpowers/plans/2026-05-30-kube-targets.md`, +`2026-06-24-session-reuse-task-a.md` and `2026-06-25-cli-run-modes.md` are +untouched by this branch and were already on `main` before it started. This +compression pass deliberately does **not** touch them — see +`.superpowers/sdd/artifact-compression-report.md` for the reasoning. diff --git a/docs/specs/2026-08-07-issue15-kube-identity-decisions.md b/docs/specs/2026-08-07-issue15-kube-identity-decisions.md new file mode 100644 index 0000000..1787d95 --- /dev/null +++ b/docs/specs/2026-08-07-issue15-kube-identity-decisions.md @@ -0,0 +1,1195 @@ +# Decision history: kubeconfig-as-identity delivery (issue #15) + +> **Redaction/repoint note (2026-08-10):** Repointed provider evidence to its +> committed spec and described the spike notes as unpublished. + +- Date: 2026-08-07 (revised same day, iteration 3: entries 10-13 record the + unified-output-contract pivot; entry 9 is marked superseded rather than + rewritten — an ADR is a history, decisions get superseded in place, not + erased) +- Companion design doc (kept as-is, not superseded): + `docs/specs/2026-08-07-issue15-kube-identity-delivery-design.md`. +- Companion evidence, not repeated here: untracked implementation-spike notes + (six prototype variants against the full unit suite) and + `docs/specs/2026-08-10-issue15-provider-env-precedence.md` (live-probed + OpenTofu provider precedence). +- Ticket: [AlexMKX/tunstrap#15](https://github.com/AlexMKX/tunstrap/issues/15), + a handoff superseding most of #14. + +This document is one entry per decision: context, alternatives considered +(with the spike's own measured numbers where an alternative was actually +prototyped, not just discussed), the decision, and its consequences. It does +not restate the design doc's contract prose — see the companion doc for that. + +## 1. Rename placement: standalone `rename_identities(doc, node, target)` + +**Context.** The materialized kubeconfig's cluster/user/context identities +need renaming to `tunstrap--` before serialization. Three +placements were prototyped in the spike, each as an isolated branch against +the same `feature/run-env-io` base, each run against the full 475-test unit +suite. + +**Alternatives considered** (spike findings, "Part 2"): + +| Placement | Diff size | Unit tests broken | Note | +|---|---|---|---| +| V1a — inline in `run_kube_targets`, mutating `KubeconfigView` in place before `patch_view` | +28/-0 | 0/475 | Minimal, but only testable through the SSH-orchestration fakes `run_kube_targets` needs | +| V1b — inside `dump_kubeconfig` (optional `node`/`target` kwargs, mutates `view` as a side effect of dumping) | +35/-3 | 0/475 (kwargs kept optional; making them required — closer to a "the serializer owns identity" design — would break `test_kube_patch.py`'s three no-rename calls, a real but trivial pin) | Couples "serialize to bytes" with "mutate identity"; also the shape the ticket's own imprecise phrasing about `dump_kubeconfig` invited — see the design doc's correction note | +| **V1c — standalone `rename_identities(doc, node, target) -> str`, no `KubeconfigView` dependency** | +42/-2 | 0/475 | **Chosen** | + +**Decision.** V1c. It operates on the raw parsed `dict` alone (resolves +`current-context` itself), so it is unit-testable with a bare fixture dict — +no `KubeconfigView`, no `run_kube_targets`, no SSH fakes required to exercise +it in isolation. It keeps `dump_kubeconfig` a pure serializer (matches the +design doc's correction that server-address patching is `patch_view`'s job, +not the serializer's — extending the serializer's responsibility further in +the opposite direction, per V1b, would be the wrong direction to move it in). +It also matches the ticket's own proposed signature verbatim. + +**Consequences.** One new top-level function + `__all__` export in `kube.py`, +one call site in `run_kube_targets` between `patch_view` and +`dump_kubeconfig`. `KubeTargetOutput` is built from the function's return +value (the shared new name) instead of `view.cluster_name`/`view.context_name` +post-parse. No signature change to `dump_kubeconfig`, `patch_view`, or +`parse_kubeconfig`. + +## 2. `render_kube_env` split out of `render_env` + +**Context.** `render_env`'s single node-count guard (`envrender.py:26-30`) +covered three unrelated things: the node-ambiguous `TUNSTRAP__*` +scalars, the node-ambiguous per-kube-target `TUNSTRAP__*` scalars, and +the `KUBECONFIG` colon-joined list, which is not node-ambiguous — it is +already a path list and `render_env` already colon-joins it. + +**Alternatives considered.** The only alternative discussed (not prototyped +separately, since it is the null option) was leaving `render_env` as one +function and adding a `multi_node: bool` escape-hatch parameter that skips the +guard for callers that only want the kube lines. Rejected without prototyping: +it reintroduces exactly the "one function doing two things gated by a flag" +shape the split exists to remove, and it would make the scalar-channel +guarantee ("still requires exactly one node") a run-time parameter instead of +an structural property of which function you call. + +**Decision.** Extract `render_kube_env(output) -> dict[str, str]`, callable +for any node count including zero and multi-node. `render_env` keeps its +existing single-node contract unchanged and delegates its kube-line +construction to `render_kube_env` for the single-node case. **[Editorial fix, +iteration 6]** Single-node output is **not** byte-identical to before the +split, as this entry originally claimed — precisely: `KUBECONFIG`'s *value* +(the colon-joined path list) is unchanged, but the conditional cardinality +contract (entry 3, below) this same split enables also *grows the key set* +for the single-file case (adding `KUBE_CONFIG_PATH` alongside `KUBECONFIG`, +which pre-split `render_env` never exported). The design doc carries the +same correction. + +**Evidence.** Spike Axis 2: +30/-5 lines in `envrender.py`, 0/475 unit tests +broken (pure, behavior-preserving refactor for the single-node case), and +manually verified live that `render_kube_env` correctly colon-joins across +**two** nodes' kube targets with `len(connections) == 2`, while `render_env` +on the same input still raises `MultiNodeEnvUnsupported` (spike findings, +"Part 2", Axis 2 row and the inline verification transcript). + +**Consequences.** `MultiNodeEnvUnsupported`'s scope narrows from "the whole +kube-carrying export" to "the scalar channel only" — see decision 8 +(breaking-change policy) for why this is accepted rather than treated as a +regression. `cli.py` needs a new call site to actually invoke +`render_kube_env` for the multi-node case — see decision 6. + +**[Superseded in part by entry 13, iteration 3.]** `render_kube_env` itself +— the function this entry extracts — is **not** superseded and ships +unchanged (kube part, U4). What *is* superseded: `render_env`, the function +it was extracted *from*, is later deleted in its entirety once the +unified-output pivot removes the scalar channel it existed to produce (entry +10). `MultiNodeEnvUnsupported` accordingly narrows all the way to zero raise +sites and is removed too (entry 13), rather than staying narrowed-to-scalars +as this entry originally concluded. + +## 3. Env export: conditional cardinality, not the superset + +**Context.** The ticket's work item 3 asked to prototype exporting +`KUBE_CONFIG_PATH` and `KUBE_CONFIG_PATHS` alongside `KUBECONFIG` as a +superset, explicitly deferring the final choice to a parallel +provider-verification effort ("Whether they also honour plain `KUBECONFIG` is +being verified by another agent in parallel"). + +**Alternatives considered.** + +- **Superset, always** (spike Axis 3, as literally prototyped): export all + three unconditionally. Evidence: +10/-2 lines, exactly one test break — + `test_predicted_env_keys_matches_render_env` — and only when + `predicted_env_keys` isn't updated in the same commit (confirmed by + deliberately reverting only that half and re-running; 1 failure, clear diff, + 0 once both changed together). **Rejected once the provider findings landed + post-spike**: `docs/specs/2026-08-10-issue15-provider-env-precedence.md` + shows `KUBE_CONFIG_PATH` wins over `KUBE_CONFIG_PATHS` when both are set, + live-confirmed for both `hashicorp/kubernetes` v2.38.0 and + `hashicorp/helm` v2.17.0. Exporting both unconditionally the instant a + *second* kube target is materialized would silently shadow every cluster but + the one named by `KUBE_CONFIG_PATH` — the collision-class failure this whole + design exists to prevent, recreated one layer down in the env contract + itself. +- **`KUBE_CONFIG_PATH` + `KUBECONFIG` only, drop `KUBE_CONFIG_PATHS` + entirely**: considered and rejected, because it has no way to express more + than one materialized file to the providers at all — a real regression for + any consumer with two or more kube targets on one or more nodes, which the + multi-node kube channel (decision 2) exists specifically to support. + +**Decision.** Conditional on the number of materialized kubeconfig files +(one per kube target, summed across the whole envelope): + +- 0 files → nothing exported. +- exactly 1 file → `KUBECONFIG` + `KUBE_CONFIG_PATH`, both pointing at the one + file; `KUBE_CONFIG_PATHS` **not** exported. +- ≥ 2 files → `KUBECONFIG` + `KUBE_CONFIG_PATHS`, both the same colon-joined + list; `KUBE_CONFIG_PATH` **must not** be exported (it would win over the + list per the measured provider precedence, hiding every cluster but the + first). + +**Consequences.** `predicted_env_keys` must model the *same* cardinality +condition — not just "kube_targets is non-empty" as it does today — and the +anti-drift guard test is extended with cases for both cardinalities. This is +strictly more surface than the superset would have needed (two branches +instead of one flat set), but it is the only shape that does not have a +silent-shadowing failure mode once the provider precedence fact is known. + +**[Superseded in part by entry 16, iteration 6 (R11).]** The claim that +`predicted_env_keys` should model "the *same*" (i.e. exact) cardinality +condition is wrong for `predicted_env_keys` specifically — it must instead +*over-approximate* conservatively, because it runs pre-spawn against input +cardinality, which can shrink by the time output cardinality is known (an +optional node/target can fail). `render_kube_env`'s own export logic (the +actual, output-side computation this entry establishes) is unaffected and +stays exact — only the *predictor* becomes conservative. See entry 16. + +## 4. Naming scheme: `tunstrap--`, no configurable prefix + +**Context.** The identity strings need a scheme that is unique across nodes +and targets without operator configuration. + +**Alternatives considered.** A configurable prefix (e.g. `--context-prefix`) +was the natural next reach — it would let two independent tunstrap +invocations avoid colliding even if `node`/`target` names happened to repeat +across them. Not prototyped: rejected on the org rule cited directly in the +ticket ("avoid excessive configurability") before implementation, on the +grounds that the node name already solves the concrete scenario a prefix +would be reached for (merging two separate tunstrap runs whose `node` dict +keys already have to differ or the merge was already ill-defined for other +reasons). + +**Decision.** Fixed `tunstrap--` for cluster, user and context +alike (one shared name across all three, not three independently-derived +strings) — no prefix option, no per-field naming variation. + +**Consequences.** Uniqueness is a corollary of `NodeInput`/`kube_targets` +dict-key validation (`_validate_identifier_key`, `schemas.py:14-22`) rather +than a property this feature has to separately maintain — no new uniqueness +check is needed anywhere in the rename path. A future request for a +configurable prefix is a new decision, not an oversight in this one. + +**[Superseded in part by entry 15, iteration 6 (R10).]** "No new uniqueness +check is needed anywhere in the rename path" is **false**: dict-key +validation only proves `node` and `target` are each individually valid +identifiers, not that the hyphen-joined `tunstrap--` string is +unique across different `(node, target)` pairs — `_FETCH_FILES_KEY_RE` +permits internal hyphens, so `(node="a-b", target="c")` and `(node="a", +target="b-c")` both join to `tunstrap-a-b-c`. A real validation-time +collision check is added; see entry 15 for the alternatives considered and +why detection (not structural prevention) was chosen. + +## 5. Rename scope: the active current-context triple only + +**[Corrected iteration 6 (R14) — read in conjunction with the note at the end +of this entry, not in isolation.]** + +**Context.** A materialized kubeconfig may contain more than the +current-context's cluster/user/context — `ignored_contexts` (the *collection* +of skipped-context names) is computed in `parse_kubeconfig` at +`kube.py:179-183`; the *warning* for each is actually logged where that list +is consumed, in `run_kube_targets` at `kube.py:330-337`, not at the +collection site itself — an earlier revision of this entry (and the design +doc) cited the wrong function for the warning. + +**Alternatives considered.** Renaming (or pruning) every context/cluster/user +in the document, not just the current one, was considered and rejected for +this change. Reasons: (a) it changes the module's pre-existing, documented, +tested contract that non-current entries are left byte-stable +(`kube.py:1-8`), which is out of this ticket's stated scope; (b) the practical +collision surface is the current-context triple, since k3s and kind — the two +shapes this codebase actually targets — both ship single-context +kubeconfigs; (c) it is real additional work (deciding prune vs. rename for +entries no consumer will ever reach through `current-context`) that the +ticket's own "one cluster per kube_target" framing does not ask for. + +**Decision.** Rename only the current-context's cluster, user and context +entries, **but update every reference to them, including references from +ignored (non-current) contexts** — corrected, see below. Every entry neither +part of nor referencing the active triple stays untouched. + +**Consequences.** Accepted, documented residual risk: two materialized files +whose *non-current* contexts happen to collide are not protected by this +change. If that ever becomes a real incident rather than a theoretical one, +the fix is pruning ignored entries at materialization time — a separate, +larger change, explicitly out of scope here (see the design doc's "Out of +scope" section). + +**[Corrected, iteration 6 — R14.]** An earlier revision of this decision's +"Decision" line read "Leave every other entry in the document untouched," +full stop. That is incomplete in a way that produces a real defect: a +kubeconfig can legitimately have a *non-current* (ignored) context whose own +`context.cluster`/`context.user` reference the **same** cluster/user entry +the current context also uses (two contexts sharing one cluster with +different users is an ordinary shape). If the shared cluster/user entry is +renamed but the ignored context's reference to it is left pointing at the +*old* name, that reference now dangles — it names an entry that no longer +exists anywhere in the document under that name, which is strictly worse +than the pre-rename state. The rename must therefore walk **every** reference +to the renamed cluster/user, not just the current context's own — "leave +every other entry untouched" is now read as "leave every entry that neither +*is* nor *references* the active triple untouched," a narrower and correct +claim. See the design doc's "Rename scope" section (iteration 6) and the +plan's Task 1 for the concrete fix and its regression test. + +## 6. `cli.py` wiring is in scope for this ticket + +**Context.** The spike's own open question asked whether wiring `run`'s +`_build_child_env` to actually call the new multi-node-safe `render_kube_env` +was part of this ticket or a follow-up, since the spike itself only proved the +export *function* works multi-node — it never wired a call site, to keep the +spike's diff isolated to `envrender.py`/`kube.py`. + +**Decision (ruling, not re-litigated here).** In scope. Without it, a +multi-node `run` invocation with kube targets never actually emits +`KUBECONFIG`/`KUBE_CONFIG_PATH(S)` for the child process — the multi-node kube +channel would be dead code, reachable only by unit tests calling +`render_kube_env` directly, never by an actual `tunstrap run` invocation. + +**Consequences.** `_build_child_env` (`cli.py:365-407`) needs a second, +independent call — `render_kube_env(output)` merged into `child_env` whenever +any node carries `kube_targets`, regardless of the existing `inject_scalars` +gate. This is new code beyond what the spike prototyped; see the +implementation plan for the concrete change and its test. + +## 7. `suppress_kubeconfig` extends to all three exported kube env vars + +> **[Issue #14 fix, iteration 9 — correction annotation, this entry's decision +> is superseded, not rewritten in place.]** The "Decision" below shipped and +> was falsified: it suppressed `KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` through +> `tunstrap_tofu`, which is Mode A's only delivery channel through that exact, +> documented entry point — not a fallback path for it. Measured by the +> reporter against a real tunnel: through `tunstrap_tofu` all three names came +> back unset, and a provider block following Mode A's own item 1 (only +> `config_context` set) failed against the inert `localhost:80` loopback. This +> entry's "Context"/"Alternatives considered"/"Decision"/"Consequences" below +> are kept verbatim as the historical record of what iteration 6 actually +> shipped (the same annotate-don't-rewrite discipline entry 14 applied to +> entry 11); see entry 20 for the current, narrowed, superseding decision. + +**Context.** Not raised by the ticket or by any ruling — found while +specifying the env-export contract (decision 3). `tunstrap_tofu` sets +`suppress_kubeconfig=True` so a broken `TF_VAR_tunstrap` → `config_path` chain +fails loudly rather than silently reaching the cluster through a +still-present `KUBECONFIG` (`tofu_proxy.py:138-155`, `cli.py:388-392`). **[Editorial +fix, iteration 6 — narrowed]** The provider findings (decision 3's evidence) +show that guard has been inert **only for the two providers' own Go +configuration chain** — neither provider's `initializeConfiguration()`/ +`newKubeConfig()` ever read plain `KUBECONFIG`. An earlier revision of this +entry said "inert all along" without that qualifier, which overstated the +claim: the same suppression was, and remains, load-bearing for a *different* +audience the whole time — `tofu`'s children include `local-exec` +provisioners and `external` data sources that can shell out to the +`kubectl`/`helm` **CLIs** directly, both of which do honour plain +`KUBECONFIG`. It becomes genuinely load-bearing **for the provider-native +chain specifically, for the first time**, the moment this design starts +exporting `KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS`, which the providers do +read. + +**Alternatives considered.** Leaving `suppress_kubeconfig` as-is (dropping +only `KUBECONFIG`) was the default until this was noticed; not viable once +stated plainly, since it would silently defeat the one property the proxy's +own docstring claims to guarantee. + +**Decision.** `suppress_kubeconfig` drops all three names — +`KUBECONFIG`, `KUBE_CONFIG_PATH`, `KUBE_CONFIG_PATHS` — both inherited and +injected, whenever set. + +**Consequences.** One small, mechanical change to `_build_child_env` +(`cli.py:394-404`); no change to `tunstrap_tofu`'s own call site +(`suppress_kubeconfig=True` already requests "suppress the kube env", its +meaning just becomes complete). See the plan for the concrete diff and its +test (`tests/unit/test_cli_run_output_var.py` or a new focused test asserting +all three names are absent from the child env under +`suppress_kubeconfig=True` with a multi-file payload). + +## 8. Breaking-change policy: deliberate, no compatibility shim + +**Context.** The rename changes the value of `KubeTargetOutput.context_name`/ +`.cluster_name` for every consumer of the materialized kubeconfig or the +`--output-var` payload; narrowing `MultiNodeEnvUnsupported`'s scope changes a +documented, tested exception contract. + +**Alternatives considered.** A compatibility flag (e.g. +`daemon.rename_kube_identities: bool`, defaulting to the old behaviour) was +considered and rejected without prototyping. The org rule the ticket itself +cites is explicit: no backward compatibility unless instructed. A flag would +also mean two code paths to test and maintain for a rename whose only +consumers are documented, internal (the recipe this design also ships) and +not yet depended on by any released version. + +**Decision.** Breaking, deliberately, with no flag and no fallback: + +- Upstream context/cluster/user names in the materialized kubeconfig always + change to `tunstrap--`. +- `KubeTargetOutput.context_name`/`.cluster_name` always report the new + names. +- `MultiNodeEnvUnsupported`'s contract narrows to the scalar channel only, + matching what its own docstring already claimed. + +**Consequences.** Any external consumer relying on the upstream cluster's own +context name surviving verbatim through tunstrap breaks with this change, +with no opt-out. This is accepted per the org rule and stated explicitly here +so it is not mistaken for an oversight during review. + +**[Partially superseded by entry 13, iteration 3.]** The rename bullets (first +two) stand unchanged. The third bullet — "`MultiNodeEnvUnsupported`'s contract +narrows to the scalar channel only" — is superseded: entry 13 removes the +class entirely rather than leaving it narrowed. The **policy** this entry +establishes (breaking, deliberately, no compatibility shim) is what entry 13 +applies to justify the further deletion; the specific narrowing outcome is +what changed, not the policy behind it. + +## 9. Kube channel fires independently of node count — a pre-existing test's contract is deliberately inverted + +**[Superseded by entry 13, iteration 3 — kept below verbatim as the historical +record, not rewritten.]** This entry's *conclusion* (kube channel fires on +`kube_targets` presence, not node count) still holds and is in fact easier to +satisfy under the pivot. What is superseded is the *mechanism* it specified +to get there — the two-branch `_build_child_env` (`inject_scalars=True` → +`render_env` delegating to `render_kube_env`; `inject_scalars=False` → +`render_kube_env` directly) — because `render_env` and `inject_scalars` are +both removed by the pivot (entries 10/13), leaving one unconditional +`render_kube_env(output)` call with no branch at all. The test this entry +retargets is retargeted *again* under the pivot, for a different reason (the +scalar-leak assertion it still carried no longer makes sense once scalars do +not exist as a concept to leak) — see entry 13 and the plan's **Task 5 Step 2** +(corrected pointer, iteration 4: the cli.py wiring and scalar-removal work +this entry's mechanism affects both live in Task 5, not Task 4, under the +plan's iteration-3 task renumbering — Task 4 is the unified-output shape +task, a pure-function step with no `_build_child_env` changes at all). + +**Context.** Decision 6 established that `cli.py` wiring for the multi-node +kube channel is in scope. Working out that wiring's exact trigger condition +(§6's own text, and the design doc's "`cli.py` wiring is in scope" section) +surfaced a sharper rule than "fires when `inject_scalars` is false": **the +kube channel fires whenever `kube_targets` are present in the output, full +stop — independent of node count and independent of why `inject_scalars` +happens to be false.** `inject_scalars` decides which function computes the +keys (`render_env`, which delegates, vs. `render_kube_env` directly); it never +decides whether they get computed. + +This directly contradicts a **pre-existing, deliberately-written** test: +`tests/unit/test_cli_run_output_var.py::test_multi_node_suppression_uses_input_count` +(added under the pre-#15 `run` env I/O design) asserts, among other things, +`"KUBECONFIG" not in FakePopen.last_env` for a two-input-node run whose one +surviving output connection carries a materialized kube target. That +assertion encoded the *old* contract — "multi-node input ⇒ no kube env at +all" — which decisions 2 and 6 above deliberately supersede. + +**Alternatives considered.** Leaving the old assertion in place and adding a +node-count exception to the new rule (e.g. "kube channel fires on +`kube_targets` presence, except when the *input* had more than one node and +only one survived") was considered and rejected: it reintroduces exactly the +kind of node-count-conditional kube-channel logic this whole design exists to +remove, for the sole purpose of keeping one old assertion green, and it would +leave the multi-node kube channel just as reachable as before, since a +survivor-of-multiple-optional-nodes shape is not distinguishable from a +"real" multi-node output at the type level. + +**Decision.** The old assertion is wrong under the new contract and is +retargeted, not preserved: `test_multi_node_suppression_uses_input_count` is +renamed to `test_multi_node_suppresses_scalars_but_exports_kube_channel` and +its `KUBECONFIG`-absence assertion is flipped to a `KUBECONFIG`/ +`KUBE_CONFIG_PATH`-presence assertion (exactly one file survives in this +test's payload). The test's other half — that the `TUNSTRAP_*` scalars stay +suppressed, decided by the *input* node count and not `len(out.connections)` +— is unchanged and remains the one place that half of the contract is +falsifiable; only the kube-channel half of the old assertion was ever wrong +under the new design. See the plan's iteration-2 Task 4 Step 2 for this +retarget as it landed at the time (historical citation, not re-resolved here +— the pivot's iteration-3 task renumbering moved the equivalent *area* of +work, and the *second* retarget this test undergoes, to the current plan's +Task 5; see entry 13 and its own corrected pointer above). + +**Consequences.** Anyone reading `git blame` on that test past this point +sees a deliberate contract inversion, not a silent weakening — which is why +it is recorded here by name rather than only in the plan's own commit +message. No other test in the suite encoded the old "no kube env for +multi-node, at all" contract (confirmed by grep across `tests/unit/` for +`KUBECONFIG` assertions during this revision), so this is the only retarget +this decision requires. + +--- + +## Iteration 3: the unified-output-contract pivot + +Entries 10-13 record a user-directed design pivot, not a discovery made while +implementing entries 1-9. Where a ruling is given rather than derived, that is +stated plainly rather than reverse-engineered into a false "alternatives +considered." + +## 10. Unified node-qualified output contract replaces the flat scalar channel + +**Context.** The pre-pivot design (entries 1-9) kept the `TUNSTRAP__*` +scalar channel for single-node output and added a parallel, node-agnostic +kube channel (`render_kube_env`) alongside it. The user's pivot rejects that +two-channel shape entirely: the *entire* consumer-facing output — ports, kube +references, session metadata — becomes one unified, node-qualified JSON +structure, replacing the scalar channel outright rather than extending it. + +**Alternatives considered.** + +- **Extend the scalars with a node dimension** (e.g. + `TUNSTRAP___PORT`, or a separate `TUNSTRAP_NODES` listing + key). Rejected by the user's own reasoning, encoded here rather than + re-derived: this is two mechanisms doing the same job — a flat `KEY=VALUE` + scalar space and a structured JSON value both trying to represent a + hierarchy — when the domain has one natural, unambiguous representation + (nested keys) that scalars cannot express without inventing a second + encoding scheme layered on top of environment-variable naming rules + (`_key()`'s `[^A-Z0-9]` → `_` sanitisation already loses information for + non-trivial names; stacking a node segment on top compounds that). Scalars + are also the wrong abstraction for the general case: `fetch_files` content + and multi-field kube references (`path`/`context`/`endpoint`) do not fit a + single scalar value at all — the pre-pivot design already routed those + through the structured `--output-var` channel instead, which is the + existing proof that the domain wants a structure, not more scalars. +- **Status quo: keep both the scalar channel (single-node) and the separate + kube channel (node-agnostic), unified only within `--output-var`'s own + existing projection.** This is entries 1-9's actual shipped design. + Rejected by the pivot: it leaves three channels (scalars, kube env, + `--output-var`) each with a different node-count contract, which is exactly + the kind of multi-mechanism-for-one-need shape the first alternative was + also rejected for, just already partially built rather than newly proposed. + +**Decision.** One unified structure (design doc, "The unified output +contract", shape sketch) replaces the scalar channel outright. +`TUNSTRAP__*` and the per-kube-target `TUNSTRAP__*` scalars are +deleted, not deprecated-with-a-flag (decision 8's breaking-change policy +extends to this too — see entry 13). `TUNSTRAP_SESSION_DIR`/`TUNSTRAP_PID` +survive as two of three non-target-scoped scalars, kept for a real +bootstrapping need (locating the payload from a shell context that has not +parsed anything), not for backward compatibility. **[iteration 4 addition]** +A **third** survivor, `TUNSTRAP_OUTPUT_FILE` (the materialized JSON path), +was added while tracing `render_env`'s deletion through `start --output env` +— dropping to only two survivors leaves that mode with no way to tell a +plain-`remote_targets` shell consumer where a forwarded port landed, a real +functional regression the design doc's "The scalar channel is removed" +section now records as a judgment call, not a silent narrowing. + +**Consequences.** `render_env` (the scalar-producing function) is deleted. +`render_output_var`'s internals change to build the new shape (signature +unchanged: still `OutputSchema -> str`). A new `render_unified_output` +function/model pair is needed (design doc shape). Every test asserting a +`TUNSTRAP__*` key breaks and must be deleted or retargeted, not +patched around — see the plan. + +## 11. Materialization-primary + var-as-convenience + explicit stability contract + +**[Corrected in part by entry 14, iteration 6 (R9) — read together, not in +isolation.]** This entry's core conclusion — materialization is primary, +both channels ship, the reasoning cited to findings #1/#2/#6 — stands. What +is corrected: the specific **mechanism** this entry originally described for +the var form ("the var is a locator and convenience path... the var only to +locate the file") is retracted by entry 14 as unsound (a three-model +red-team review found the locator pattern does not actually buy plan-safety +— see entry 14's alternatives-considered for the full reasoning). Read this +entry's "Decision"/"Consequences" below as describing the *what* (two +channels, materialization primary); read entry 14 for the corrected *how* +(three independent modes, no locator). + +**Context.** U2/U6: the unified structure is delivered both as the +`--output-var` value and as a materialized JSON file, and the user decided +materialization is primary. This directly touches ticket #15's own framing +("connection data should stop travelling through Terraform input variables") +and the pre-pivot recipe's "no connection data in input variables" condition, +because the var form of the unified structure **does** carry live connection +data (host:port strings) — an honest tension, not a technicality. + +**Alternatives considered.** + +- **Var-only delivery** (the pre-pivot `--output-var` shape, just reshaped): + rejected by the user's explicit ruling (U2) that materialization is + primary, on the strength of findings #1 and #6 (below) — a var-only design + has no plan-safe path for a consumer who reuses a saved plan across a + tunstrap restart. +- **Materialization-only delivery** (drop `--output-var` entirely): would + satisfy the ticket's stricter "nothing live enters Terraform" framing most + completely, and was the closest fit to ticket #15's own words. Rejected: + U2 explicitly keeps the var form ("delivered BOTH as... AND materialized"), + and dropping it removes the only channel through which HCL can discover + *anything* without already knowing a session path — Terraform config can + only see env vars that are `TF_VAR_*`-mapped or read by a provider's own + Go code (like `KUBE_CONFIG_PATH`); a plain shell env var pointing at a file + path is invisible to HCL entirely unless also injected as a `TF_VAR_*`. + Materialization-only would need a *different* bootstrap mechanism the user + did not ask for. + +**Decision.** Both channels ship, materialization primary: + +- `--output-var NAME` still injects the full unified JSON as a string (var + form, convenience/bootstrap). +- `run` unconditionally materializes the same JSON to + `/tunnel-data/output.json` (new; not gated by `--output-var`). +- **Reasoning, cited to the ticket's own findings, restated in full here per + the instruction that this ADR entry carry it:** + - **Finding #1** (provider configuration IS re-evaluated at apply — a + `file()` read inside a provider block picks up a post-plan change) is + *why* the materialized file is plan-safe: reading it via `file()` inside + the provider config block re-reads current content at apply time. + - **Finding #6** (binding a connection value to a `var.` trips "Mismatch + between input and plan variable value" on a saved plan — the ticket's own + negative control) is *why* the var form is not plan-safe: its decoded + values are frozen into the plan at `plan` time, and a tunstrap restart + before `apply` risks the mismatch. + - **Finding #2** (outputs freeze silently — `file()` through an output + returns the plan-time value at apply, no error) belongs in the stability + contract text, not just the reasoning here, because it is the trap that + defeats finding #1's plan-safety guarantee if the materialized file is + read through an intermediate `output` rather than directly at the point + of use — see the design doc's "Stability contract" subsection, which + states this as an explicit consumer-facing rule, not just an internal + rationale. + +**U6 reconciliation, recorded here as the decision's own rationale (also +stated in the design doc for the consumer-facing read).** **[Corrected, +iteration 6 — R12: an earlier revision of this paragraph scoped the +reconciliation too narrowly, by kind of *data* rather than kind of +*channel*.]** Ticket #15's "nothing live enters Terraform" holds in full, +unconditionally, only for the **kube env channel** +(`KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS`) — no variable, no file, ever. It is +**superseded** for both ports *and* kube **references** (`path`/`context`/ +`endpoint`, non-credential per U4) once a consumer binds `--output-var`: an +earlier revision claimed the kube framing was "untouched," which is only +true for the env channel — the moment a consumer reads +`nodes..kube..path` (or `context`/`endpoint`) out of +`var.tunstrap`, that is connection data travelling through a Terraform input +variable, exactly what the ticket wanted stopped, whether or not the field +is itself a credential. For ports specifically, no env-native path exists at +all — a generic TCP endpoint is not a Terraform-provider convention the way +`KUBE_CONFIG_PATH` is — so a `host:port` value has nowhere to go except into +HCL as a value, one way or another. Given that constraint, +materialization-primary + var-as-convenience + the explicit stability +contract (entry 14's corrected mechanism) is the closest available +approximation to the ticket's stricter framing that a live TCP endpoint can +actually achieve, and it is adopted as superseding the ticket's stricter +framing **for the unified structure's var form — ports and kube references +both — while the kube env channel's full compliance is untouched.** + +**Consequences.** A new materialization writer is needed (plan). The recipe +gains a stability-contract section a consumer must read before choosing +which form to bind to a resource. This is real new surface area (a second +delivery path with its own failure mode) that a var-only or +materialization-only design would not have had — accepted as the cost of +satisfying both U2's explicit requirement and a genuine plan-safety need +neither alternative covers alone. + +## 12. "через js" is read as JSON/jsondecode, not literal JavaScript — recorded assumption + +**Context.** U5: the user's instruction for consumer-side transformation used +the phrase "через js." This stack (Terragrunt, OpenTofu, HCL) has no +JavaScript runtime anywhere in the consumer chain. + +**Alternatives considered.** Silently interpreting the phrase as "JSON" and +moving on (no explicit record) was the default temptation; rejected per the +standing instruction to record interpretations rather than silently apply +them, and because a genuinely ambiguous phrase deserves a durable record of +which reading was taken, so a reviewer who meant something else (e.g. a +literal `local-exec` calling `node`) can catch the mismatch cheaply instead +of discovering it after implementation. + +**Decision.** "через js" is read as **JSON**, consumed via HCL's `jsondecode` +function inside `locals`, exactly the mechanism the pre-pivot `--output-var` +recipe already used. No JavaScript runtime, no `local-exec` invoking `node`, +no new runtime dependency anywhere in the design. + +**Consequences.** If this reading is wrong, it is wrong in a single, +clearly-labelled place (design doc "Consumer-side transformation", this +entry) rather than baked silently into fifteen sentences of recipe prose — +cheap to correct if a future reviewer disagrees. + +## 13. `MultiNodeEnvUnsupported` and `inject_scalars` are removed, not narrowed further + +**Context.** Entries 2 and 9 narrowed `MultiNodeEnvUnsupported` to "the +scalar channel only" and built a two-branch `_build_child_env` keyed on +`inject_scalars`. The pivot's own stated semantics (design doc, requirement +text: "the `inject_scalars` gate semantics change: unified output is emitted +regardless of node count") removes the scalar channel that both of these +existed to gate. + +**Alternatives considered.** Keeping `MultiNodeEnvUnsupported` as an unused, +unraisable class "for future use" was considered and rejected: every +remaining channel (unified output, kube env) is either structurally +node-safe (nested keys) or already node-count-agnostic by design (the kube +channel), so there is no remaining scenario that class could ever describe. +An exception class with zero reachable raise sites is exactly the kind of +dead code `vulture`'s gate exists to catch, and per the org's no-backward- +compatibility rule there is no reason to keep it as a courtesy. + +**Decision.** + +- `MultiNodeEnvUnsupported` is deleted: the class, its `_EXIT_CODES` entry, + and both raise sites (`render_env`'s internal guard — moot anyway since + `render_env` itself is deleted; and `cli.py:640`'s pre-spawn + multi-node-without-`--output-var` gate, which is removed because + materialization now covers multi-node unconditionally, so the thing that + gate used to force an opt-in for no longer needs one). +- `inject_scalars` (the boolean, its `len(schema.nodes) == 1` computation at + `cli.py:648`, and its threading through `_run_child`/`_supervise_child`/ + `_build_child_env`) is deleted. Nothing left needs to know the node count + to decide what to compute — `render_kube_env` and `render_unified_output` + are both called unconditionally. +- `_build_child_env` collapses to: always call `render_kube_env(output)`, + always build+inject the unified structure per `--output-var`'s presence, + always materialize it for `run`. No node-count branch anywhere in this + function. + +**Consequences.** This is a bigger ripple than entry 9's retarget: every test +asserting `inject_scalars`'s value, mocking it, or asserting +`MultiNodeEnvUnsupported`'s exit code (1) needs deletion or a rewrite to the +new "multi-node succeeds unconditionally" behaviour — including entry 9's own +retargeted test, `test_multi_node_suppresses_scalars_but_exports_kube_channel`, +which is retargeted *again* (its "no `TUNSTRAP_*` scalars leak" assertion no +longer describes a real guard once there is no scalar-producing code path +left to leak from) — see the plan's **Task 5's grep-driven blast-radius +enumeration** for the concrete, exhaustive list (iteration 4: this ripple was +first accounted for case-by-case across three review rounds before being +fixed systemically — see "Iteration 4" note below). + +**Not everything in this ripple is a deletion.** `test_predicted_env_keys_ +matches_render_env` (the anti-drift guard between `predicted_env_keys` and +the actual injected-key computation) is **retargeted, not deleted** — the +two-independent-implementations problem it guards against does not go away +just because one of the two implementations (`render_env`) is replaced by +another (`_build_child_env`'s own hardcoded-plus-`render_kube_env` logic); +see the design doc's "Anti-drift guard extension" subsection, iteration-4 +addendum, for why the guard survives in re-scoped form. This was itself a +drill-caught defect in an earlier revision of this plan, which had deleted +the guard on the (false) premise that only one implementation remained. +**[Further evolved by entry 16, iteration 6 — R11.]** The re-scoped, +single-equality form this entry describes is itself superseded once +`predicted_env_keys` becomes a conservative over-approximator rather than an +exact predictor (entry 16): exact equality can no longer hold in general, so +the guard splits into a formula test (exact equality against a +hand-computed expected set) plus a safety-envelope test (`actual ⊆ +predicted`, driven by a cardinality-shrink case). See entry 16 for the full +reasoning — this entry's framing of "retargeted, not deleted" is the +conclusion that still holds; its specific single-equality mechanism does +not. + +`cli.py:640`'s removal also means the exit-code table in the pre-#15 design +spec (`docs/specs/2026-07-31-run-env-io-and-tofu-proxy-design.md`, "Error +handling") has one fewer row for multi-node input; that spec is left as-is +per its own "kept as-is, not superseded" status (this ADR's header) — the +row simply no longer reflects current behaviour, which is normal for a design +doc describing a point-in-time decision that a later ADR entry overrides, +and is recorded here rather than edited into that older, closed doc. + +**Iteration 4 note — systemic fix, not a fourth case-by-case patch.** The +first three review rounds each caught this same defect class in a different +single spot (a test here, a stale reference there). Iteration 4's fix is a +full grep-driven enumeration of every symbol/shape the pivot removes, across +`tunstrap/`, `tests/unit`, `tests/integration`, `tests/e2e`, and `docs/`, with +an explicit disposition per hit, made the authoritative blast-radius record +inside the plan's Task 5 rather than trusted to be found again by inspection. +See the plan for the table; it is not duplicated here. + +--- + +## Iteration 6: three-model red-team review corrections + +Entries 14-18 record sarge's rulings on a consolidated 12-finding red-team +review (three independent models). Where a ruling is given rather than +derived, that is stated plainly, matching the discipline entries 10-13 +already established for user-directed decisions. + +## 14. The var-locator pattern is unsound and retracted; three independent delivery modes replace it + +> **[R16, iteration 7 — correction annotation, this entry's decision is +> further superseded, not rewritten in place.]** Mode 2 below (the +> literal, caller-pinned `--session-dir` file) is itself retracted by +> iteration 7's user-confirmed direction: the session root stays ephemeral +> unconditionally, and `TUNSTRAP_OUTPUT_FILE` — an env-carried locator, not +> a pinned path — replaces it. Delivery collapses from three modes to two. +> This entry's "Decision"/"Consequences" below are kept verbatim as the +> historical record of what iteration 6 actually shipped (the same +> annotate-don't-rewrite discipline this entry itself applied to entry 11); +> see entry 19 for the current, superseding decision. + +**Context.** Entry 11 shipped a hybrid delivery mechanism: inject +`var.tunstrap` and materialize a file, with the recommended plan-safe +pattern being "read `var.tunstrap` only to locate the file +(`jsondecode(var.tunstrap).session.session_dir`), then `file()` the located +path." A three-model red-team review (findings #1, #5) found this unsound. + +**Alternatives considered.** + +- **The locator pattern itself** (entry 11's original mechanism): rejected + for three independent, compounding reasons: (1) finding #1 measured + content-change tolerance at a **stable** path, but the locator pattern put + a **changing** value (`var.tunstrap`'s full JSON, different every + invocation) into a Terraform variable — OpenTofu's plan-variable + consistency check (finding #6) compares the **whole bound value** of a + root-module variable between `plan` and `apply`, not just the sub-fields an + expression reads, so reading only `session.session_dir` from it does not + narrow the exposure at all; (2) this is the same finding #6 firing, + restated — the locator is exactly as exposed as binding the ports + directly; (3) the **default** session directory (auto-minted via + `tempfile.mkdtemp` when no `--session-dir` is given, `cli.py:427`) is + deleted by teardown, so even ignoring (1)/(2), a locator pointing at it + would frequently reference a directory that no longer exists by the time a + later, separate `apply` tried to read it. +- **Materialization-only, drop the var entirely**: re-considered here (entry + 11 already rejected it, reasoning unchanged) — still rejected, since a + plain shell env var pointing at a file path is invisible to HCL unless also + `TF_VAR_*`-mapped, and dropping the var removes the only bootstrap + mechanism HCL has for anything it does not already know a literal path to. +- **Keep the var, drop the locator recommendation, downgrade the var to + one-shot-only, and make the file mode's plan-safety depend on an + *operator-chosen* literal path rather than anything decoded from the + var**: **adopted** — this is the only option that is honest about what + finding #6 actually measures (the whole-variable comparison) rather than + trying to work around it with an indirection that does not change what is + compared. + +**Decision.** Three genuinely independent delivery modes, none locating +another: + +1. Kube env channel — unchanged, plan-safe unconditionally, no var, no file. +2. Unified file at a **literal, caller-pinned** path — plan-safe **only** + when the caller supplies a stable `--session-dir` (verified against + `session.py`: a caller-supplied root is `generated=False`; cleanup on + that path removes only `tunnel-data/`, never the root) and the consumer's + HCL hardcodes that same path as a literal, never derived from + `var.tunstrap`. +3. `--output-var` (var form) — one-shot `plan && apply` in the same + invocation only. No saved-plan-reuse exemption of any kind, including via + a locator — corrected from entry 11's original recommendation. + +**Consequences.** Every example in the design doc and the recipe using +`var.tunstrap_session_dir` (or any locator pattern) is deleted, not adapted — +there is no variant of the locator that survives this correction. The +recipe's Mode B (design doc, "Documentation") is rewritten to show the +literal-path pattern exclusively for the plan-safe case, with the var form +demoted to explicitly one-shot. See the design doc's "Delivery" and +"Stability contract" subsections (rewritten, iteration 6) for the shipped +contract, and entry 11 above (annotated, not rewritten) for the decision +this corrects. + +## 15. Naming collision detection: validation-time check, not structural prevention + +**Context.** Entry 4 claimed the `tunstrap--` join was unique +by construction. False: `_FETCH_FILES_KEY_RE` (`schemas.py:11`) permits +internal hyphens in both `node` and `target`, and the join itself uses a +hyphen, so `(node="a-b", target="c")` and `(node="a", target="b-c")` both +render `tunstrap-a-b-c`. This is a different defect class from the mandatory +k3s-style collision test (entry 4 area / design doc "Testing contract"): +that test proves *upstream* kubeconfig names colliding is fixed by the +rename; this defect is tunstrap's *own* scheme colliding with itself, +independent of any upstream content. + +**Alternatives considered.** + +- **Change the join separator** (e.g. a character neither `node` nor + `target` can contain) to prevent the collision structurally rather than + detect it: rejected as a larger, unrequested change — it alters the + user-visible naming scheme itself (`tunstrap--`'s exact + rendered form), which nothing in the ticket or the pivot asked to change, + for a defect a validation check closes just as completely. +- **Tighten `_FETCH_FILES_KEY_RE` to forbid hyphens in `node`/`target` + entirely**: rejected — this is a shared regex used for `fetch_files`, + `kube_targets`, and node keys generally (`schemas.py:11`, `_validate_ + identifier_key`); narrowing it to solve a kube-identity-naming problem + would remove a legitimate character from every other identifier in the + schema for an unrelated reason, and does not fully solve the problem + either (two *node* names differing only in an internal hyphen could still + collide against two different *target* names symmetrically). +- **Validation-time collision check across every `(node, target)` pair**: + **adopted** — computed once, at `InputSchema` validation, before any SSH + connection is attempted; rejects the payload with an error naming the + exact colliding pairs. + +**Decision.** Add a collision check at schema-validation time: for every +`(node, target)` pair across all nodes' `kube_targets`, compute the rendered +`tunstrap--` name; if any two pairs render the same string, +reject the whole payload with an error identifying both colliding pairs by +name. The hyphen join itself is kept unchanged. + +**Consequences.** A new unit test drives exactly the `(a-b, c)` vs. `(a, +b-c)` pair (design doc, "Testing contract," R10/R14 section) — not covered +by the existing mandatory k3s-style test, which must not be assumed to also +exercise this defect class. See the plan for the concrete validator and its +test. + +## 16. `predicted_env_keys` becomes a conservative superset predictor; the anti-drift guard becomes two-part + +**Context.** Entry 3 had `predicted_env_keys` model the *exact* same +cardinality-conditional rule `render_kube_env`'s actual export uses. A +red-team finding, logic-verified, shows this under-reserves: `predicted_env_ +keys` runs pre-spawn against the *input* schema's declared cardinality, but +the *actual* materialized cardinality can be **smaller** — an optional +(`required: false`) node or kube target can fail without failing the run +(`manager.py:99-107` already builds successful-only `connections`). Two +kube targets declared, one optional node fails → one file actually +materializes → the real export uses the `==1` branch (`KUBE_CONFIG_PATH`), +but the exact predictor would have predicted the `≥2` branch +(`KUBE_CONFIG_PATHS` only) and **not reserved `KUBE_CONFIG_PATH`** — a +`--output-var KUBE_CONFIG_PATH` would then pass the pre-spawn collision +check and be **silently overwritten** by the real export. + +**Alternatives considered.** + +- **Exact cardinality prediction** (entry 3's original design): rejected, per + the failure mode above — it under-reserves whenever cardinality shrinks + between input and output, which is a normal, expected outcome of the + `required: false` feature this codebase already has, not an edge case. +- **Compute the predictor from a live probe of what will actually + materialize** (e.g. attempt each kube target's fetch before validating): + rejected — `predicted_env_keys` is explicitly a **pre-spawn**, no-SSH-yet + check (its whole purpose is rejecting a bad `--output-var` NAME before a + daemon exists); making it probe live connectivity would defeat that + purpose and reintroduce the exact daemon-orphan risk window the pre-#15 + design's "Cleanup must own the whole post-spawn window" invariant was + written to close. +- **Reserve conservatively: whenever any node declares `kube_targets` at + all, reserve all three kube names, regardless of exact count**: **adopted** + — deliberately over-reserves; the asymmetry is the point. Over-reserving + can only reject *more* `--output-var` names than strictly necessary (a + cheap, immediately visible usage error); under-reserving risks a silent + post-spawn collision, which is the exact failure this check exists to + prevent. + +**Decision.** `predicted_env_keys` reserves `{KUBECONFIG, KUBE_CONFIG_PATH, +KUBE_CONFIG_PATHS}` whenever any node's input schema declares +`kube_targets`, unconditional on exact count. `render_kube_env`'s actual +export (entry 3) is unaffected and stays exact, cardinality-conditional — +only the predictor changes. + +**The anti-drift guard becomes two independent tests, not one equality** +(superseding entry 13's single-equality retarget, see the note there): + +1. A **formula test** (exact equality, unit-test style): `predicted_env_ + keys(schema)` equals a hand-computed expected set for a representative + schema, proving the conservative *formula* is implemented correctly. +2. A **safety-envelope test** (subset, the actual anti-drift property): + `set(actual injected keys) ⊆ predicted_env_keys(schema)`, driven by a + **cardinality-shrink** scenario (two kube targets declared, one optional + node fails, one file materializes) — the case that would falsify a + predictor that got the direction of the conservatism backwards. + +**Consequences.** `predicted_env_keys`' own unit tests gain a case proving +the conservative reservation fires on *any* `kube_targets` presence, not +just above some count threshold. The design doc's "Anti-drift guard +extension" and "Env-export contract" sections carry the same correction; the +plan's Task 3 (formula) and Task 5 (safety-envelope) guard literals are +rewritten accordingly. + +## 17. Materialization writer: true atomic replace, not mode-fixed-at-creation alone + +**Context.** Entry 11 (and the design doc, prior to iteration 6) described +the materialization write as reusing "the atomic-secure-write primitive," +`SessionDir._write_file` (`session.py:132`, `os.open(path, +O_CREAT|O_WRONLY|O_TRUNC, 0o600)`). A red-team finding: `O_TRUNC` + write in +place is **not** atomic — a reader (a consumer's `file()` call racing a +`run` restart that rewrites the same pinned path, entry 14's mode 2) can +observe a truncated-but-not-yet-rewritten file mid-write. `_write_file`'s +real, load-bearing property is **mode-fixed-at-creation** (no separate +`chmod`, no window of broader-than-`0600` permissions) — a different +property from atomicity, conflated by the word "atomic" in earlier text. + +**Alternatives considered.** + +- **Keep `O_TRUNC` + write in place** (matching the existing kube-file + primitive exactly): rejected — the kube-file primitive never needed true + atomicity, because nothing reads a kube-target file mid-write the way a + `file()` call racing a `run` restart against the *same pinned path* + (entry 14's mode 2, which did not exist for kube files pre-pivot) could. + The unified-output file's own delivery contract creates the race this + primitive was never exposed to before. +- **Temp file + `os.replace()` (true atomic rename)**: **adopted** — create + the temp file in the same directory with `os.open(tmp, O_CREAT|O_WRONLY| + O_EXCL, 0o600)` (mode still fixed at creation, `O_EXCL` only guards a + colliding temp name), write the content, then `os.replace(tmp, final)` — a + single filesystem rename, atomic on the same filesystem, so a reader can + never observe a partial write. + +**Decision.** The writer combines both properties: mode-fixed-at-creation +(inherited from the existing primitive's approach) **and** true atomicity +(the `os.replace` step, which the existing primitive alone does not +provide). **Process constraint, stated explicitly:** this writer runs in the +CLI **parent** process (`run_command`), which holds no live `SessionDir` +instance (kube materialization happens daemon/worker-side, inside the +process that does own one) — the unified structure is a pure transformation +of the already-complete `OutputSchema` the parent already has, so no daemon +round-trip is needed to write it. `SessionDir._write_file` is reused +directly only if refactored to be callable without a live instance; +otherwise the primitive (not necessarily the same function object) is +replicated inline in `cli.py`. The design doc's "reusing" language is +corrected to reflect this either/or, not a flat claim of code reuse. + +**Consequences.** New code (`os.replace`-based atomic write), not a pure +reuse of `SessionDir._write_file` as earlier text implied. The design doc's +"Materialization write mechanism" subsection (new, iteration 6) and the +plan's Task 5 carry the concrete implementation. `SessionDir._write_file`'s +own description, wherever it appears, is corrected to drop "atomic" and say +"mode-fixed-at-creation" instead — a real property, just not this one. + +**Stdin-mode guard, also recorded here.** A stdin-supplied `InputSchema`'s +`daemon.materialize` is the caller's own explicit statement, and `start` +(unlike `run`) does not force it true (`cli.py:160-174`). Under the +now-unconditional `render_kube_env` call (entries 10/13), a declared but +unmaterialized kube target (`path is None`) makes `render_kube_env` raise +`ValueError` — existing, unchanged behaviour, but newly reachable from +`start --output env`'s stdin-payload path since that call is now +unconditional. The plan must guard this explicitly (force materialization +for that path, or map the `ValueError` to a typed, user-facing error) rather +than let it surface as a bare traceback. + +## 18. Relationship to #14: re-adopting fixes 1 and 4 for non-kube data, deferring fix 3 + +> **[R16, iteration 7 — correction annotation.]** The "Decision" below +> re-adopts #14 fix 1 (pin the session/state root) as an opt-in +> precondition. **That re-adoption is retracted by iteration 7**: fix 1 is +> no longer re-adopted in any form — the user's own constraint (session dir +> stays mandatory *lifecycle* infrastructure, but is not a consumer-facing +> pinning mechanism) settles this the way the ticket itself originally +> argued. Fix 4 (materialized file + `file()`) is still re-adopted, but its +> shape changes: located via the env-carried `TUNSTRAP_OUTPUT_FILE`, not a +> pinned path. Fix 3's deferral is unaffected. Kept verbatim below per the +> annotate-don't-rewrite discipline; see entry 19 for the superseding +> decision. + +**Context.** Ticket #15 explicitly supersedes most of #14. This pivot's +non-kube (port) delivery mechanism (entry 14, mode 2) is, in substance, two +of #14's own original fixes — worth recording as a deliberate re-adoption, +not silently reinventing a third mechanism that happens to look similar. + +**Alternatives considered.** + +- **Invent a new mechanism distinct from anything #14 proposed**: rejected — + there is no reason to; #14's fix 1 (pin the session/state root) and fix 4 + (materialized file + `file()`) are exactly the plan-safe mechanism finding + #1 measures, and #15's own rejection of them was scoped to kube + specifically (an env-native alternative existed there), not to data in + general. +- **Treat #14 fix 3 (warn on a saved-plan-capturing invocation, e.g. + `-out=`, while non-plan-safe delivery is in use) as in scope for #15**: + rejected — it is a genuinely separate feature (parsing the child's own + command line for Terraform-specific flags inside generic `run`), which the + pre-#15 design deliberately confined to `tunstrap_tofu` alone rather than + adding to `run` itself; taking it on here would re-litigate that + confinement as a side effect of an unrelated pivot. + +**Decision.** + +- **#14 fix 1** (pin the session/state root): re-adopted as an **opt-in + precondition** — a caller-supplied, stable `--session-dir` — not a + default. #15's rejection of pinning-by-default holds for kube (env-native + alternative exists) and does not extend to ports (no env-native + alternative exists at all). +- **#14 fix 4** (materialized file + `file()`): re-adopted for the same + reason — the only plan-safe mechanism available once fix 1 provides a + stable path. +- **#14 fix 3** (CLI warning on `-out=`-style saved-plan capture): **out of + scope for #15, deferred to #14.** The stability contract and the recipe's + explicit warnings cover the risk in documentation. + +**Consequences.** The design doc's "Relationship to #14" subsection (new, +iteration 6) is the durable record a future #14 implementer reads before +picking fix 3 back up; it also appears in "Out of scope" so an implementer +of *this* ticket sees the deferral as deliberate, not as a gap. + +## 19. Unified env-native materialization contract: content on disk, paths in env — supersedes entries 14/18's pinned-path delivery and the pre-#15 fetch_files var-carriage decision + +**Context.** Iteration 7 is the user's own confirmed direction after the +red-team round, plus one added constraint. Two things about the shipped +iteration-6 design were unsatisfying on reflection: (1) entry 14's mode 2 +re-purchased plan-safety for ports by re-adopting #14 fix 1 (a pinned +`--session-dir`) — exactly the mechanism ticket #15's own text explicitly +rejected ("session root can stay ephemeral; only the path to the kubeconfig +has to be stable, and that is supplied through the environment"), re-adopted +anyway on the grounds that ports have no env-native alternative; (2) the +pre-#15 design's decision to let `fetch_files[*].content_b64` ride +`--output-var` unprojected, carried forward unexamined through every +iteration of this design, put arbitrary remote file content into a +Terraform variable — and therefore into any saved plan file — by default, +for any consumer who bound `--output-var` at all, independent of whether +they read `content_b64` themselves. + +**Alternatives considered.** + +- **Keep entry 14's three-mode design (mode 2's pinned path) as-is**: + rejected — the user's own instruction settles this directly, and on + reflection the pinned-path re-adoption was solving ports' plan-safety + problem by quietly reintroducing the exact mechanism the ticket rejected, + just relabelled as "opt-in." Retaining it would leave the design + permanently answering "does the ticket's rejection of fix 1 hold?" with + "yes, unless you want ports to be plan-safe," which is not an honest + reading of a rejection the ticket stated without that carve-out. +- **Keep `fetch_files[*].content_b64` riding the var form inline, documented + with a warning** (the pre-#15 decision, carried through entries 1-18 + unexamined): rejected — a warning is weaker than removing the exposure + entirely, and removing it is cheap: the daemon already owns the session + dir and already materializes kubeconfigs the same way (entry 3), so + extending the identical mechanism to fetched files is not new + infrastructure, only a new call site of infrastructure that already + exists and is already trusted for credential-bearing kube data. +- **[Adopted, with the user's added constraint folded in.]** Content lives + on disk, under the (still-ephemeral) session dir, mode `0600`, via the + atomic-replace primitive (entry 17): kubeconfigs (already so, entry 3), + `output.json` (already so, entry 17), and now fetch_files. Env carries + only paths/locators — `KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` (unchanged), + `TUNSTRAP_OUTPUT_FILE` generalized from a `start`-only bootstrap scalar + (entry 13) into the primary locator for `run` too, plus the session + scalars. `--output-var` survives, narrowed to its one genuinely remaining + job: a bridge for bare `tofu`, which has no `get_env(...)`-equivalent and + cannot read the process environment from HCL at all without a variable + binding. **User's added constraint, folded in rather than treated as a + separate decision**: the session dir itself does not become optional or + disappear just because it stops being a consumer-facing plan-safety + mechanism — it remains required *process lifecycle infrastructure* + (`daemon.pid`, `session.lock`, `stop`/recovery), a different concern from + whether a consumer's HCL may assume its path is stable across + invocations. + +**Decision.** + +1. Delivery collapses from entry 14's three modes to two: Mode A (kube + env-native, unchanged) and Mode B (`TUNSTRAP_OUTPUT_FILE` → the unified + manifest file, `get_env(...)`/`file()`/`jsondecode`/`try()`), with + `--output-var` as Mode B's narrow bare-`tofu` bridge, not an + independently-documented third mode. +2. `fetch_files` entries in every consumer-facing channel become + `{path, size, sha256}` (or `{error}`), never `content_b64` — the daemon + materializes fetched bytes to `tunnel-data/-` (mode + `0600`, atomic replace, mirroring entry 3's kube materialization exactly). + `FetchedFile.content_b64` itself is not removed from the model — it stays + internal plumbing between the SSH fetch and the on-disk write, exactly as + `KubeTargetOutput.content_b64` already does for kube (entry 3); `start`'s + raw default JSON stdout is unaffected, per the existing scope carve-out. +3. Entry 14's mode 2 (pinned `--session-dir` + literal HCL path) is dropped; + entry 18's re-adoption of #14 fix 1 is retracted (fix 4 survives, + reshaped to the env-carried locator). +4. The session dir stays mandatory, ephemeral, lifecycle infrastructure — + this does not change; only its role as a *consumer-facing* mechanism is + retracted. + +**Consequences.** Plan-safety across a tunstrap restart for ports and +`fetch_files` is gone — it existed only via the now-dropped pinned mode; a +consumer needing it has no supported mechanism beyond re-running `plan` +within the same tunstrap invocation. Kube's plan-safety is untouched (its +env-native channel never depended on any of this). The "never `--fetch` +secrets with `--output-var`" warning (entries carried since the pre-#15 +design) is resolved **as a class**: content no longer rides any +consumer-facing channel, so the warning is retracted, not restated, in the +recipe. Every `docs/recipe_terragrunt.md` example using a literal pinned +path or `content_b64` breaks and is rewritten (design doc, "Documentation"); +every test asserting `content_b64` presence in a consumer-facing envelope +(`--output-var`, materialized file) is retargeted to assert its absence and +a `path` field instead — the plan's Task 5 blast-radius table carries the +full enumeration. Entries 14 and 18 are annotated above, not rewritten, per +this document's own established discipline (entry 14's own annotation of +entry 11). + +## 20. `suppress_kubeconfig` narrowed: drop only the injected `KUBECONFIG`, never `KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` — corrects entry 7 + +**Context.** Issue #14 (a comment on the closed #15 handoff) reported that +Mode A — the plan-safe, env-native kube delivery this design shipped, and the +recipe's own documented pattern for it — cannot work through `tunstrap_tofu`, +which the same recipe tells consumers to use as `terraform_binary`. Measured +by the reporter: two nodes, one k3s target each, a real tunnel, `tofu` +replaced by a stub printing its own environment. Through `tunstrap_tofu` +(`suppress_kubeconfig=True`) all three of `KUBECONFIG`, `KUBE_CONFIG_PATH`, +`KUBE_CONFIG_PATHS` came back unset; through plain `tunstrap run` the channel +was correct (colon-joined paths, `KUBE_CONFIG_PATHS` plural for two files, +contexts renamed to `tunstrap--`). A provider block following +Mode A's own worked example (only `config_context` set, per the design doc's +"Mode A" section item 1) therefore resolves no kubeconfig through the proxy +and fails against the inert `localhost:80` loopback. Fails loudly, not +silently — but Mode A is unusable through the one documented entry point for +it. + +The root cause is entry 7's own reasoning, not an accident: entry 7 +deliberately widened `suppress_kubeconfig` from "drop `KUBECONFIG`" to "drop +all three," reasoning that once this design started exporting +`KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` — the names the providers' own Go +config chains actually read — the guard would become "load-bearing for that +provider-native audience too, for the first time," and would be hollow if it +kept exempting them. That reasoning inverts what the guard is for. Entry 7's +own evidence (the provider findings) already established that providers never +read plain `KUBECONFIG` at all; they read `KUBE_CONFIG_PATH`/ +`KUBE_CONFIG_PATHS` directly. Those two are not a *fallback* channel a broken +`config_path` chain could silently reach through — for Mode A they are *the* +channel, by design, reached deliberately and unconditionally by `run`, +proxied or not. Suppressing them does not stop a silent wrong answer; it +removes the only right answer Mode A has through `tunstrap_tofu`. + +**Alternatives considered.** + +- **Keep entry 7's decision as shipped (drop all three)**: rejected — directly + falsified by the measurement above; this is the defect being fixed, not a + live option. +- **Pop-before-inject ordering** — drop the three names from the copied + parent environment, then call `render_kube_env`, and drop nothing + afterward: rejected. This produces the same broken result by a different + route: with `suppress_kubeconfig=True` and no post-injection pop, the + `render_kube_env` call happening *after* the pre-injection pop would still + place a fresh `KUBECONFIG` into the child env (single-file cardinality) — + restoring exactly the injected `KUBECONFIG` this guard exists to remove. + Ordering by itself does not narrow *which* names are protected; it only + changes *when* the removal happens, and the removal still has to be + keyed by name, not by inherited-vs-injected origin alone, to keep the + provider channel while dropping the `KUBECONFIG` fallback. +- **[Adopted.]** Narrow the suppression by key, and split inherited vs + injected handling, as two independent rules: + 1. Always, on both the plain and the proxied path: drop *inherited* + `KUBECONFIG`/`KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` from the copied + parent environment **before** `render_kube_env` injects — a stray + operator environment must never contribute to the child's kube channel, + regardless of `suppress_kubeconfig`. + 2. Only when `suppress_kubeconfig` is set: drop the *injected* `KUBECONFIG` + afterward, keeping `KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` intact. + `render_kube_env`'s own conditional-cardinality contract (entry 3) is + unchanged — one file exports `KUBECONFIG`+`KUBE_CONFIG_PATH`; two or + more export `KUBECONFIG`+`KUBE_CONFIG_PATHS`, never both PATH forms at + once. + +**Decision.** `suppress_kubeconfig` drops only the injected `KUBECONFIG`. +Inherited `KUBECONFIG`/`KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` are always +dropped before injection, unconditionally on both paths — this part of entry +7's original intent (never let an operator's ambient kubeconfig leak through) +is preserved and, if anything, made more precise: it now also covers the +plain `run` path, which entry 7 did not distinguish. The anti-fallback +property this guard exists for — a broken `TF_VAR_tunstrap` → `config_path` +chain (Mode B) must not silently reach the cluster through `KUBECONFIG` — is +still exactly satisfied, since providers never read `KUBECONFIG` and the +audience that does (`kubectl`/`helm` CLI invocations and `local-exec` +provisioners inside `tofu`) is unaffected by keeping `KUBE_CONFIG_PATH`/ +`KUBE_CONFIG_PATHS` present. + +**Consequences.** `tunstrap/cli.py`'s `_build_child_env` gains an +unconditional inherited-name scrub ahead of `render_kube_env`, and its +`suppress_kubeconfig` block narrows from a three-name loop to a single +`KUBECONFIG` pop; its docstring is rewritten to state this contract exactly, +replacing the "drops all three, inherited and injected" claim entry 7 +introduced. `docs/specs/2026-08-07-issue15-kube-identity-delivery-design.md`'s +"Interaction with the tofu proxy's `suppress_kubeconfig`" subsection carries +the same correction, in place, with its own falsified conclusion marked and +kept for the historical record rather than deleted. +`docs/recipe_terragrunt.md` is corrected in both directions: the "How the +proxy works" section states plainly that only `KUBECONFIG` is suppressed, and +the Mode A section states plainly that Mode A works through `tunstrap_tofu`. +Four properties are pinned by new or retargeted unit tests in +`tests/unit/test_cli_run_output_var.py` and `tests/unit/test_tofu_proxy.py`: +the proxy path keeps the provider channel; the proxy path drops the injected +`KUBECONFIG`; the plain path keeps the full, unfiltered channel; and an +inherited value of all three is dropped on both paths, including when there +are no kube targets at all to inject a replacement over it. diff --git a/docs/specs/2026-08-07-issue15-kube-identity-delivery-design.md b/docs/specs/2026-08-07-issue15-kube-identity-delivery-design.md new file mode 100644 index 0000000..f033bae --- /dev/null +++ b/docs/specs/2026-08-07-issue15-kube-identity-delivery-design.md @@ -0,0 +1,1532 @@ +# Kubeconfig-as-identity delivery: deterministic contexts + multi-node kube channel + +> **Redaction/repoint note (2026-08-10):** Repointed provider evidence to its +> committed spec, replaced a local worktree path with a placeholder, and marked +> finding #4 as an unpublished-spike measurement because no automated test +> covers its saved-plan mutation scenario. + +- Status: design, awaiting review +- Date: 2026-08-07 (revised same day, iteration 3: the unified-output-contract + pivot, marked **[PIVOT]** at each affected section below) +- Scope: **[kube part, unchanged by the pivot]** rename the materialized + kubeconfig's cluster/user/context identities deterministically per + `(node, target)`; export the OpenTofu-provider-facing kube env vars + (`KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS`) conditionally on how many + kubeconfig files were materialized. **[PIVOT, iteration 3]** Replace + tunstrap's *entire* consumer-facing output — ports, kube references, + session metadata — with one unified, node-qualified structure, delivered + both as an `--output-var` value and as a materialized JSON file, entirely + superseding the flat `TUNSTRAP__*` scalar channel (which is + removed, not extended with a node dimension). Document the one recipe both + parts enable. Ticket: [AlexMKX/tunstrap#15](https://github.com/AlexMKX/tunstrap/issues/15) + (handoff, supersedes most of #14). Target branch: `feature/run-env-io` + (PR #13). +- Measurement basis: the ticket's own six OpenTofu v1.12.5 findings (2026-08-06/ + 07, **not re-derived here**); the provider-behaviour verification in + `docs/specs/2026-08-10-issue15-provider-env-precedence.md` (OpenTofu v1.12.5, + `hashicorp/kubernetes` v2.38.0, `hashicorp/helm` v2.17.0, live-probed against + a real `kind` cluster, 2026-08-07); and untracked implementation-spike notes + (six prototype variants, each run against the full `tests/unit` suite, + 2026-08-07). The provider result is repeated in the committed spec. +- Code citations (`kube.py:NNN`, `envrender.py:NNN`, `cli.py:NNN`) are against + `e5ed15d`, the tip of `feature/run-env-io` at the time of writing (== the + spike's `spike/issue15-variants` base commit). +- Reference implementation: `variant/combined` in the scratch worktree + `` — reviewed, + 475/475 pre-existing unit tests pass plus one new regression test (476/476). + **Cherry-pick precisely, not wholesale — corrected iteration 6.** The spike + is safe to cherry-pick for exactly two things: `rename_identities` (V1c) + and the `render_kube_env` split (Axis 2). **Its Axis 3 (the unconditional + superset env export) is *rejected*, not adopted** — an earlier revision of + this note credited the spike with "the conditional cardinality contract," + which is false: the spike never prototyped the conditional contract this + design actually ships (that is a post-spike, iteration-1 design decision, + built *in reaction to* the spike's Axis 3 and the provider findings, not + cherry-picked from it). Do not copy the spike's `render_kube_env` body + verbatim for its env-export tail; only the node-count-agnostic path + collection is reusable, the export-key selection is not. **`cli.py` + wiring was never in the spike at all** — the spike's own findings document + raised it only as an open question (open question 1: whether wiring + `render_kube_env` into `run` was in scope), never as a prototyped variant; + every line of `_build_child_env`'s wiring in this design and the plan is + new work, not a cherry-pick. **Scope note, otherwise unchanged: the spike + predates the iteration-3 pivot and covers the kube part only** — nothing + in it prototypes the unified output contract below, and the spike's + `render_env`-delegation mechanism for wiring `render_kube_env` into `run` + (iteration 2's two-branch `_build_child_env`, itself new work built after + the spike, not from it) is superseded by a simpler unconditional call once + the scalar channel it branched around is removed — see "The unified + output contract" and the plan's Task 5. + +## Problem + +Connection data currently travels through two channels that were never +designed for what the ticket calls the actual shape of the domain: + +1. **Terraform input variables**, which OpenTofu persists into the plan file — + any live value bound there (a private key, a materialized kubeconfig) + becomes durable, pipeline-archived state (design spec + `2026-07-31-run-env-io-and-tofu-proxy-design.md`, `RunKubeTarget`'s + allow-list rationale). +2. **The `TUNSTRAP_*` scalar channel**, which has no node dimension: + `render_env` raises `MultiNodeEnvUnsupported` outright for `len(nodes) != 1` + (`envrender.py:26-30`), because `TUNSTRAP__*` has no way to + disambiguate two nodes sharing a target name. **[PIVOT, iteration 3]** The + fix adopted below is not "add a node dimension to the scalars" — a flat + `KEY=VALUE` shape has no natural place to put one without inventing a + second encoding scheme inside the key name. The scalar channel is instead + **removed outright** and replaced by a structure whose node dimension is + just a normal nested key, because that is what a node dimension actually + is. See "The unified output contract" below. + +Two framing corrections drive the fix. The bolded lead phrase in each bullet +is close to the ticket's own words; **the explanatory sentence after it is +this design's own re-reading, not a ticket quotation** — an earlier revision +of this section claimed both bullets were "from the ticket verbatim" in +full, which overstated how much of the surrounding prose is actually the +ticket's own, corrected here: + +- **Multi-node is the base path, not an edge case.** [Design's own + elaboration, not the ticket's words:] the single-node assumption in + `render_env` was never a deliberate design choice — it is an artefact of + the scalar channel's own limitation, wrongly generalized to the whole kube + delivery mechanism. +- **Kubeconfig contexts are the natural addressing mechanism, and tunstrap was + not using them.** [Design's own elaboration:] a kubeconfig set with one + context per target addresses any number of nodes/targets and has no + node-dimension problem — *if* the context + names are actually distinct. Today they are not: `kube.py:99` sets + `context_name=current` from the source document's own `current-context` + verbatim, and `dump_kubeconfig` serializes that same document unchanged + (see "Correction to the ticket text" below for the precise division of + labour). Two k3s targets — which both ship `current-context: default` — thus + collide irreducibly on merge; this is the expected case, not an edge case + (see "The collision trap", below). + +The same defect exists beyond OpenTofu: `kubectl --context`, helmfile, ArgoCD +and anything else consuming a materialized kubeconfig sees whatever name the +upstream cluster happened to pick. This is a general contract fix to the +materialized kubeconfig's identity, not a Terraform patch — the env-export +contract (below) is the one part that is Terraform/OpenTofu-shaped, because it +exists to feed a provider. + +## The deterministic-naming contract + +Naming scheme for **context, cluster and user alike**: + +``` +tunstrap-- +``` + +- **NOT unique by construction — a real collision surface, closed by an + explicit check [R10, corrected iteration 6].** An earlier revision of this + design claimed the join was unique because `node`/`target` are validated + identifiers. That claim is false: `_FETCH_FILES_KEY_RE` + (`schemas.py:11`, `^[a-zA-Z_][a-zA-Z0-9_-]*$`) permits internal hyphens, and + the render itself joins with a hyphen (`tunstrap--`), so two + **different** `(node, target)` pairs can render the **identical** string: + `(node="a-b", target="c")` and `(node="a", target="b-c")` both produce + `tunstrap-a-b-c`. This is a real, not theoretical, collision, and the + mandatory k3s-style regression test (below) does **not** cover it — that + test proves *upstream*-name collisions are fixed by the rename; this is a + different defect class entirely (tunstrap's *own* naming scheme colliding + with itself, upstream names never entering the picture). **Fix: a + validation-time collision check** across every `(node, target)` pair in the + whole payload (all nodes × each node's `kube_targets`), computed at schema + validation — before any SSH connection is attempted — rejecting the + payload with an error naming the exact colliding pairs if any two joined + names coincide. The hyphen join itself is kept (changing the separator is + a larger, unrequested change); the fix detects and rejects the collision + rather than structurally preventing it. +- **Node-qualified**, so it stays unique across multiple nodes *for a given + join*, closing the exact gap `render_env`'s node-blindness left open — this + property is real and unaffected by the join-collision defect above, which + is about two *different* joins coinciding, not about the node dimension + itself being absent. +- **Fixed `tunstrap-` prefix**, giving tunstrap's own contexts **conventional, + probabilistic namespacing** against whatever else a consumer's kubeconfig + set already carries — not a guarantee. **Residual risk, documented:** a + consumer-owned context already literally named `tunstrap--` + for the same `(node, target)` pair collides on merge (accepted — an + operator choosing that exact name is choosing to alias tunstrap's own + scheme); two **independent** tunstrap runs whose node/target names happen + to coincide also collide (accepted per the no-configurable-prefix decision + below — the node name is assumed unique *within* one run's payload, not + across unrelated runs an operator chooses to merge). +- **No configurable prefix.** Org rule: avoid excessive configurability. The + node name already solves the "merge two separate tunstrap runs" scenario a + configurable prefix would otherwise be reached for, for the common case + where the operator controls both runs' node names; it does not solve two + runs an operator merges without also controlling their node-naming + overlap, which is the residual risk stated above. + +All three identity strings (cluster name, user name, context name) get the +**same** rendered value — there is no reason for them to diverge, and a single +shared name is what a `kubectl config get-contexts` or `--context` invocation +actually needs to be unambiguous. + +### Rename scope: the active triple only + +Only the entries the current-context actually resolves to are renamed — +**but every reference to them must be updated, including references that +live inside otherwise-ignored entries [R14, corrected iteration 6]:** + +- the `contexts[]` entry named `doc["current-context"]`, plus its + `context.cluster` / `context.user` references; +- the `clusters[]` entry that reference resolves to; +- the `users[]` entry that reference resolves to; +- `doc["current-context"]` itself; +- **any *other* `contexts[]` entry (one already reported via + `ignored_contexts`, since it is not the current context) whose own + `context.cluster` or `context.user` happens to reference the *same* + cluster/user name being renamed** — a kubeconfig can legitimately have two + contexts sharing one cluster or user entry (e.g. two contexts against the + same cluster with different users). Leaving such a reference unrenamed + while the entry it points at *is* renamed produces a dangling reference: + the ignored context would name a cluster/user that no longer exists under + that name anywhere in the document, which is strictly worse than the + pre-rename state (a mis-typed context that fails immediately if selected, + rather than an odd-but-working one). + +**Every entry that is neither part of nor referencing the active triple +remains byte-stable, unrenamed** — a narrower, correct claim than an earlier +revision's "every other context/cluster/user... is left byte-stable," which +did not distinguish "genuinely unrelated to the active triple" from +"unrelated as far as being the *current* context, but still pointing at the +same cluster/user by name." The warning for skipped contexts is emitted in +`run_kube_targets` (`kube.py:330-337`, not `parse_kubeconfig`'s +`_ignored_contexts` collection helper at `kube.py:179-183`, which only +*computes* the list — the warning itself is logged where that list is +consumed). This matches the module's pre-existing, documented contract: +*"One kube_target maps to exactly one cluster: the kubeconfig's +current-context. Other contexts/clusters are ignored and left byte-stable in +the patched output"* (`kube.py:1-8`) — read now as "left byte-stable" meaning +"not independently re-targeted," not "guaranteed to still reference their +original names once a shared entry is renamed." The rename does not change +that contract's scope, it only fixes the one triple tunstrap already claims +ownership of, correctly this time. + +**Accepted residual risk, documented, not solved here:** if an upstream +kubeconfig carries *other* (non-current) contexts that also collide across two +materialized files, that merge exposure is not addressed by this change. This +is accepted under the ticket's own "one cluster per kube_target" framing, and +is the normal case in practice — k3s and kind both ship single-context +kubeconfigs. If it becomes a real problem, the fix is pruning ignored entries +entirely at materialization time, which is a larger, separate change (see +"Open questions" in the spike findings). + +### Where the rename happens + +`rename_identities(doc, node, target) -> str` — a standalone **deterministic +in-place transformation** in `tunstrap/kube.py` (not a pure function in the +strict sense: it mutates `doc`, the same ruamel document `parse_kubeconfig` +returned, rather than returning a new one — "standalone" and "deterministic" +are the properties that actually matter here, not side-effect-freedom). It +operates on the raw parsed document alone: it resolves +`doc["current-context"]` itself, so it needs no `KubeconfigView` and can be +unit-tested with a bare dict, independent of `parse_kubeconfig`, +`run_kube_targets`, or any SSH-driven orchestration. It returns the new name +(shared by cluster, user and context), and the caller — `run_kube_targets`, +between `patch_view` and `dump_kubeconfig` — updates `KubeTargetOutput` from +that return value. + +This was one of three placements prototyped and measured (spike findings, +"Part 2 — variant comparison"; ADR entry "Rename placement"); the alternatives +(rename inline inside `run_kube_targets` mutating `KubeconfigView` in place; or +rename as a side effect of `dump_kubeconfig`) both work with zero test +breakage too, but neither is independently unit-testable without going +through the fuller orchestration, and the `dump_kubeconfig` placement couples +"serialize to bytes" with "mutate identity" — see the correction note below +for why that coupling is specifically worth avoiding here. + +### Correction to the ticket text + +The ticket states: *"`dump_kubeconfig` serialises that same document with only +the server address patched."* This describes the **combined effect** of +`patch_view` followed by `dump_kubeconfig` in sequence, not a responsibility of +`dump_kubeconfig` itself. Precisely: + +- `patch_view` (`kube.py:243-270`) is what rewrites `server:`, sets + `tls-server-name` or the insecure pair — on the current-context cluster + only. +- `dump_kubeconfig` (`kube.py:273-277`) is a **pure serializer**: it does not + patch anything, it YAML-dumps `view.doc` exactly as it finds it. + +This spec's rename call site sits between the two (`patch_view` → *rename* → +`dump_kubeconfig`), and `dump_kubeconfig` gains no new responsibility — it +stays a pure serializer, which is also why the rename is a standalone function +rather than a `dump_kubeconfig` parameter (the placement the spike prototyped +and rejected for exactly this reason). + +## The unified output contract [PIVOT, iteration 3] + +**This section is the overarching decision this design was revised around; it +supersedes the "scalar channel stays single-node" framing everywhere else in +this document.** User decision (encoded, not re-litigated here; ADR entries +10-13 carry the alternatives-considered detail). + +### Shape + +The entire consumer-facing output — ports, kube references, session metadata +— is one JSON structure, node-qualified by construction (the node dimension +is a nested key, not an encoding problem): + +```json +{ + "session": { + "session_dir": "/run/tunstrap/abc123", + "pid": 4711, + "started_at": "2026-08-07T00:00:00Z", + "warnings": [] + }, + "nodes": { + "node1": { + "ports": { + "service1": "127.0.0.1:5432" + }, + "kube": { + "k3s": { + "path": "/run/tunstrap/abc123/tunnel-data/node1-k3s", + "context": "tunstrap-node1-k3s", + "endpoint": "https://127.0.0.1:41111" + } + }, + "fetch_files": { + "hosts": {"path": "/run/tunstrap/abc123/tunnel-data/node1-hosts", "size": 6, "sha256": "..."} + } + } + } +} +``` + +- **Ports**: a plain `"host:port"` string per target — the minimal, + consumer-friendly shape the user's own sketch names (`node1 { service1: + hostport }`). +- **Kube**: a small object per kube target — `{path, context, endpoint}` — + never credentials, never file content (U4; the existing + `RunKubeTarget`/`KubeTargetOutput` credential-scrubbing already established + in the pre-#15 design is preserved, just reshaped). `context` is the + post-rename `tunstrap--` name from the kube part above, so a + consumer that wants to address the cluster by context rather than by + `config_path` can (`kubectl --context "$(jq -r ...)"`, or a provider's + `config_context` field with `config_path` also set). +- **`fetch_files`** — **[R16, supersedes this bullet's pre-iteration-7 text, + which is retracted, not extended]** no longer rides through unprojected. + The pre-#15 design's own choice to let `content_b64` ride the var form + verbatim (`docs/specs/2026-07-31-run-env-io-and-tofu-proxy-design.md`, + decision history's `fetch_files[*].content_b64` entry) is superseded: R16's + core principle — *content on disk, paths in env* — extends to fetched files + the same way it already applied to kubeconfigs. The daemon (which already + owns the session dir and already materializes kube files there) writes + each fetched file's bytes to `tunnel-data/-` (mode `0600`, + the same atomic-replace primitive as "Materialization write mechanism" + below) and the consumer-facing entry is exactly `{path, size, sha256}` — no + `content_b64` anywhere in it, mirroring `UnifiedKubeRef`'s own + `{path, context, endpoint}` narrowing (U4, above) rather than being a new + pattern. A failed fetch still projects `{"error": "..."}`, unchanged. See + "Fetched-file materialization [R16, new]" below for the mechanism and + "Compatibility" for why this is a breaking change stated plainly, not a + silent narrowing. + +**Judgment call, not literally specified by the user's sketch:** the root +object has exactly two reserved top-level keys, `session` and `nodes`, rather +than putting node names directly at the document root. A flat root (node +names as literal top-level keys, matching the sketch most literally) was +considered and rejected: `node` names are operator-controlled identifiers up +to 64 characters matching `^[a-zA-Z_][a-zA-Z0-9_-]*$` (`schemas.py:14-22`) — +an operator is free to name a node `session` or `nodes`, which would collide +with the reserved top-level keys themselves in a flat root, with no +validation catching it (a node literally named `warnings` would only collide +if `warnings` were also hoisted to the document root — it is not, in the +adopted shape, since `warnings` lives nested under `session`; the flat-root +collision example is `session`/`nodes` colliding with themselves, not +`warnings`). Two reserved keys eliminate that collision by construction, at +the cost of one extra nesting level from the literal sketch. `session_dir` +and `pid` are kept inside `session`, alongside `warnings`, purely for +grouping and symmetry with the rest of the document (every piece of +non-node-scoped metadata lives in one place) — not for a second collision +reason, since neither `session_dir` nor `pid` would themselves collide with +anything at a bare top level. + +### Delivery: two independent modes [R16, iteration 7 — supersedes R9's three-mode design] + +**[R16] Iteration 7's user-confirmed direction retracts R9's mode 2 (the +literal-pinned-`--session-dir` file), collapsing delivery from three modes to +two.** R9's own reasoning for modes 1 and 3 below is unchanged and restated +here, not re-litigated; only mode 2 is gone, and `TUNSTRAP_OUTPUT_FILE` +(previously a `start`-only bootstrapping scalar, see "The scalar channel is +removed" below) is generalized into mode 2's replacement — the **primary**, +env-carried locator for the unified manifest, for `run` as well as `start +--output env`. This is the direction the user confirmed after the red-team +round: **content lives on disk under the (ephemeral) session dir, only paths +travel through the environment** — never a pinned, operator-chosen root the +consumer's HCL has to independently know in advance. + +R9's original three-mode framing (superseded by the two-mode list below — +not reproduced verbatim here; ADR entry 14 carries the original text so the +retraction reads as a decision, not a silent deletion) is retracted for the +reason the user's own instruction states directly: ticket #15 explicitly +rejected #14 fix 1 ("session root can stay +ephemeral; only the path to the kubeconfig has to be stable, and that is +supplied through the environment") — R9's mode 2 **was** fix 1, re-adopted +for ports against the ticket's own explicit rejection, on the grounds that +ports have no provider-native env path the way kube does. Iteration 7 +retracts that re-adoption instead of continuing to defend it: root stays +ephemeral, unconditionally, and ports' plan-safety story is a genuine loss +(see "Stability contract," "what is lost," below) rather than being +purchased via a pinned path. See "Relationship to #14 [R15]" below for the +corrected, no-longer-re-adopting-fix-1 text, and ADR entries 14/18/19 in the +decision history. + +**Iteration 4's design is retracted below, not extended.** It treated the +unified structure as delivered by one hybrid mechanism — inject `var.tunstrap` +and *also* materialize a file, with the recommended plan-safe pattern being +"read `var.tunstrap` only to locate the file, then `file()` the located +path." A three-model red-team review found this unsound for three +independent, compounding reasons (findings #1/#5): + +1. **Finding #1 measured content-change tolerance at a *stable* path.** The + locator pattern instead put a *changing* value — `var.tunstrap`, whose + full JSON differs on every invocation (fresh `pid`, `started_at`, ephemeral + local ports, a fresh kube `path`) — into a Terraform variable and called + that "safe" because only one field of it (`session.session_dir`) happened + to be read. That does not help: OpenTofu's plan-variable consistency check + (finding #6) compares the **whole bound value** of a root-module variable + between `plan` time and `apply` time, not just the sub-fields an + expression happens to reference. A locator built from `var.tunstrap` trips + finding #6 on any saved-plan reuse exactly as readily as binding the ports + directly would — the indirection buys nothing. +2. **Finding #6 fires on the *whole* variable, confirmed by (1).** +3. **The default session directory does not survive to be located.** Without + a caller-supplied `--session-dir`, `run` auto-mints an ephemeral root via + `tempfile.mkdtemp` (`cli.py:427`, `_mint_session_dir`) and teardown deletes + it. Even setting aside (1)/(2) entirely, a locator pointing at that default + root would usually be pointing at a directory that no longer exists by the + time a later, separate `apply` invocation tried to read it. + +**[R16] Revised contract: two genuinely independent delivery modes.** Mode +1 is unchanged from R9. Mode 2 replaces R9's modes 2 and 3 combined: the +env-carried `TUNSTRAP_OUTPUT_FILE` locator (a real env var, not a Terraform +variable) is now the *only* way a consumer reaches the unified manifest, for +both a plain-shell reader and an HCL one — `--output-var` survives only as a +narrower fallback for a caller that cannot read the process environment at +all (bare `tofu`, see below), not as a second, independently-named mode: + +1. **Kube env channel** (`KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS`) — unchanged, + primary for kube, plan-safe per findings #1/#3 (see "Env-export contract" + below), no variable and no file read anywhere in the path at all. +2. **`TUNSTRAP_OUTPUT_FILE` → the unified manifest file** — the session dir + stays ephemeral, unconditionally (no `--session-dir` precondition, no + caller-pinned path); `run` (and `start --output env`) exports + `TUNSTRAP_OUTPUT_FILE=/tunnel-data/output.json` as a plain + process env var and the consumer reads it via `get_env(...)` (Terragrunt) + or `os.environ[...]` (a plain shell/Python child) — never a literal path + baked into the consumer's own config, because the path is fresh, ephemeral, + and different on every invocation by design. This is **one-shot, + `plan && apply` within the same tunstrap invocation only** — the same + restriction R9's mode 3 stated for the var form, now stated for the file + form too, because the file itself is deleted at `stop`/teardown alongside + the rest of `tunnel-data/` (see "session dir as lifecycle infrastructure," + below): there is nothing left to `file()` on a later, separate `apply` + against a saved plan from a prior `run`. Ports and `fetch_files` have no + plan-safe-across-restart story left at all — the honest loss R9's mode 2 + used to paper over (see "Stability contract," "what is lost"). + ```hcl + locals { + tunnel = try( + jsondecode(file(get_env("TUNSTRAP_OUTPUT_FILE"))), + { nodes = {} }, + ) + } + ``` +3. **`--output-var NAME` (var form) — bridge for bare `tofu` only**, which + cannot read `get_env(...)` (a Terragrunt function) or, for that matter, + any process env var directly inside HCL at all without a variable + binding. Same one-shot restriction as mode 2, for the same reason + (finding #6: OpenTofu's plan-variable consistency check compares the + variable's whole bound value between `plan` and `apply`, and there is no + locator exemption — reading only a sub-field does not narrow the + exposure). Carries the lightweight manifest described in "The scalar + channel is removed," below — ports as `host:port` strings, kube as + `{path, context, endpoint}`, fetch_files as `{path, size, sha256}`, no + `content_b64` anywhere (R16). + +**No variable, anywhere, locates the materialized file.** `TUNSTRAP_OUTPUT_FILE` +is a plain env var, read by `get_env(...)`/`os.environ`, never by decoding +`var.tunstrap`/`TF_VAR_tunstrap` to extract a path — that indirection is +exactly what R9's finding #1 analysis (above) showed does not help, and +nothing about R16 changes that specific finding. + +### Materialization write mechanism [R13, corrected iteration 6] + +The mode-2 file (`/tunnel-data/output.json`, mode `0600`, +cleaned up on `stop`/atexit alongside the kube materialized files — same +directory, same lifecycle, since `fetch_files` content can carry arbitrary +remote file content and deserves the same handling) must be written as a +**true atomic replace**, not merely mode-fixed-at-creation: + +- Create a temp file in the same directory with + `os.open(tmp_path, O_CREAT | O_WRONLY | O_EXCL, 0o600)` (the `O_EXCL` + guards against a colliding temp name, not a security property — the mode + is already fixed at creation, as with the existing kube-file primitive). +- Write the full JSON content to it. +- `os.replace(tmp_path, final_path)` — a single filesystem rename, atomic on + the same filesystem, so a reader can never observe a partially-written + `output.json`. **`O_TRUNC` + write in place, used by the existing kube-file + primitive, is *not* atomic** — a reader can observe a + truncated-but-not-yet-rewritten file mid-write. **[R16, iteration 8 — + rationale re-grounded]** An earlier revision justified this by a consumer's + `file()` call racing a `run` restart that rewrites the *same pinned path* — + that race no longer exists under R16 (there is no pinned path; mode 2's + `TUNSTRAP_OUTPUT_FILE` names a fresh, per-invocation ephemeral path, and the + write completes before the child is even spawned, so nothing can be reading + the file concurrently with this process writing it under the current + design). The requirement **stays** — it is strictly safer than `O_TRUNC` + and costs nothing — but on grounds that do not depend on a race the design + no longer has: (1) **torn-read prevention on crash mid-write** — if the + writing process is killed between opening and finishing the write, `O_TRUNC` + leaves a truncated file at the final path with no signal anything is wrong, + where `os.replace` leaves either the old complete file or the new complete + one, never a partial one; (2) **defense-in-depth** against any future + change that reintroduces a stable/reusable path (this design already + retracted one such mechanism once, R16's own retraction of R9's mode 2 — + the atomic-replace property should not need re-deriving if that ever + happens again); (3) **consistency between writers** — every `run` mints a + fresh, ephemeral session dir (`tempfile.mkdtemp`), so in the current design + no two invocations' `tunnel-data/` paths ever collide and the fetched-file + writer (below) has exactly the same no-current-race property `output.json` + does, for the same reason. Sharing one atomic-replace primitive between both + writers is a maintainability argument, not a second race-prevention + argument dressed up as one: one primitive to reason about instead of two, + and reason (1) above (crash mid-write) applies identically to both. This is + new work, not present in the primitive this design otherwise reuses. +- **Process constraint, stated explicitly:** this writer runs in the CLI + **parent** process (`run_command`, `cli.py`), which holds **no + `SessionDir` instance** — materialization of the kube files happens + worker/daemon-side, inside the process that already owns a `SessionDir`, + but the unified structure is a pure transformation of the already-complete + `OutputSchema` the parent already has, so writing it parent-side needs no + daemon round-trip. Reusing `SessionDir._write_file` (`session.py:132`) + directly is only possible if it is refactored into something callable + without a live `SessionDir` instance (or the parent is given one purely for + this write, which the design does not otherwise need); if that refactor is + not straightforward, the primitive above is replicated inline in `cli.py` + instead — the plan permits either, this spec's "reusing" language should be + read as "reusing the *primitive* (atomic, mode-fixed-at-creation write)," not + necessarily reusing the *same function object* — an earlier revision's flat + claim of reuse did not make this distinction. +- **`SessionDir._write_file`'s own real property, restated precisely:** it is + **mode-fixed-at-creation** (`os.open(..., O_CREAT|O_WRONLY|O_TRUNC, 0o600)` + — no separate `chmod`, no window of broader permissions), **not + "atomic"** in the sense that matters here (`O_TRUNC` overwrites in place, + visible mid-write to a concurrent reader) — an earlier revision described + it as "the atomic-secure-write primitive," which conflated the two + properties. This design's materialization write needs *both* + mode-fixed-at-creation (still true of the temp file above) *and* true + atomicity (the `os.replace` step, which `_write_file` alone does not + provide). **[R16, iteration 8 — rationale re-grounded]** The kube-file + primitive's own gap was originally justified by a `file()` call racing a + `run` restart against the *same pinned path* — that specific race is + retired under R16 (no pinned path survives; see the note above this list). + The gap is real for a different, still-live reason: torn-read prevention if + the writing process is killed mid-write (a truncated-but-not-rewritten file + at the final path is indistinguishable from a valid empty/short one to a + naive reader, where `os.replace` guarantees the reader only ever sees a + complete old or complete new file), plus defense-in-depth against any + future change that reintroduces a stable/reusable path. `output.json`'s + writer needs both properties for the same reasons stated there, not because + of the retired race. +- **Stdin-mode guard, flagged not silently assumed:** a stdin-supplied + `InputSchema` payload's `daemon.materialize` is the caller's own explicit + statement and `start` (unlike `run`) leaves it alone rather than forcing it + true (`cli.py:160-174`, `"a stdin payload's daemon.materialize is the + caller's own statement and is left alone"`). Under the now-unconditional + `render_kube_env` call, a kube target that was declared but never + materialized (`materialize: false` in the stdin payload) has `path is + None`, and `render_kube_env` raises `ValueError` for exactly that case + (`envrender.py`, existing behaviour, unchanged by this design). This is + fine for `run` (which always forces `materialize = True`, so the case + cannot occur), but `start`'s `--output env` path can reach it with an + operator-supplied stdin payload that explicitly disables materialization + while still declaring `kube_targets` — the plan must guard this + (materialization forced or the `ValueError` mapped to a typed, + user-facing error) rather than let an unconditional call surface a bare + `ValueError` traceback. + +### Fetched-file materialization [R16, new] + +Mirrors the kube-file precedent exactly, not a new pattern: `FetchedFile` +(`schemas.py:292-313`) gains a `path: str | None = None` field, the same +shape `KubeTargetOutput.path` already has alongside its own `content_b64` +(`schemas.py:317-336`). The daemon-side step that already materializes kube +files (worker/daemon process, holds a live `SessionDir`) gains a parallel +step: for each successful `FetchedFile` a node's `fetch_files` produced, +base64-decode `content_b64` and write the raw bytes to +`tunnel-data/-` (mode `0600`, the same atomic-replace +primitive as `output.json`'s writer — temp file + `O_EXCL` + `os.replace`, +not `_write_file`'s mode-fixed-but-not-atomic `O_TRUNC`, for the same reason: +a consumer's `file()` call inside a provider/data block could race a `run` +restart rewriting the same node-qualified filename), then set `.path` +accordingly. A failed fetch (`FetchedFile.error` set) materializes nothing +and projects `{"error": ...}` unchanged. `content_b64` itself is **not** +removed from the `FetchedFile` model — it stays as internal daemon-side +plumbing between the SSH fetch and the on-disk write, exactly as +`KubeTargetOutput.content_b64` already does for kube; only the +**consumer-facing projection** (`render_unified_output`'s `fetch_files` entry, +and by extension the materialized file's and `--output-var`'s content) drops +it, per U4's already-established narrowing pattern for kube. `start`'s raw +default JSON stdout (the "complete envelope," unchanged scope per +"Compatibility," below) continues to show both `content_b64` and `path` on +`FetchedFile`, exactly as it already shows both on `KubeTargetOutput` today — +no new carve-out, the existing one already covers this symmetrically. + +### Session dir: ephemeral, but not optional — lifecycle infrastructure [R16, user constraint] + +**Stated explicitly per the user's own constraint, not left implicit.** +Retracting R9's mode 2 (above) removes the session dir's role as a +*consumer-facing* plan-safety mechanism, but the session dir itself does not +disappear and does not become any less mandatory. It remains required +**process lifecycle infrastructure**, unrelated to Terraform/consumer +concerns: `daemon.pid`, `session.lock`, and everything `tunstrap stop +--session-dir ` and crash-recovery depend on to find and signal a +running daemon. Nothing about R16 changes any of that — `TUNSTRAP_SESSION_DIR` +and `TUNSTRAP_PID` stay exported exactly as before (they are session +*lifecycle* metadata, not a consumer-facing locator repurposed by R16), and +the existing `stop`/recovery guidance is unaffected. The only thing that +changed is what a *consumer's HCL* is allowed to assume about the directory's +path being stable across invocations — nothing changed about tunstrap's own +internal need for the directory to exist and be addressable while a session +is live. + +### Stability contract (explicit) [R16, iteration 7 — supersedes R9's version] + +- **Kube env channel: always plan-safe, unconditionally.** No caveat — no + variable, no file, findings #1/#3. Unchanged by R16. +- **`TUNSTRAP_OUTPUT_FILE` (mode 2) and `--output-var` (mode 3): both + one-shot `plan && apply` only, unconditionally.** No saved-plan reuse + across a tunstrap restart for either — the file is deleted at + teardown/`stop` alongside the rest of `tunnel-data/` (the session dir stays + ephemeral, per the subsection above), and the var form was already + one-shot-only under R9 (finding #6, no locator exemption). **This is a real + narrowing from R9**: R9's mode 2 (the literal-pinned-`--session-dir` file) + was plan-safe across restarts, unconditionally, given its precondition; + that precondition — and the plan-safety it bought — is retracted along with + it. +- **What is lost, stated plainly, not glossed over [R16.7]:** plan-safety + across a tunstrap restart for **ports and `fetch_files`** is gone entirely + — it existed only via R9's now-retracted pinned mode. A consumer needing a + saved plan to `apply` cleanly against fresh ports or fetched-file content + after a `run` restart has no supported mechanism under this design; the + only remaining recourse is re-running `plan` in the same tunstrap + invocation that produced the current `output.json`. **Kube stays plan-safe** + via the env-native channel (mode 1), which R16 does not touch — this loss + is specific to the data that has no provider-native env equivalent. +- Finding #2 still applies unchanged to mode 2: **outputs freeze silently** — + `file()` read *through an output* (or through any value only computed once + at plan time and never re-touched) returns the plan-time content at apply, + with **no error**. Read `get_env("TUNSTRAP_OUTPUT_FILE")`/`file()` directly + inside the provider/resource config block that consumes it, never through + an intermediate `output` block. +- **Q3's resource-attribute warning still applies unchanged**: binding + live data to a *resource* attribute (not a provider config block) produces + `Error: Provider produced inconsistent final plan`, confirmed for + `hashicorp/kubernetes` v2.38.0. The provider-config-block placement is the + only supported shape for the kube path and for any unified-structure value + read at apply time, in both modes 2 and 3. + +### Reconciliation with the ticket's "nothing live enters Terraform" framing [U6, restated iteration 6 — R12; ports bullet corrected iteration 7 — R16] + +**Stated honestly, not glossed over, and corrected from an earlier revision +that scoped this reconciliation too narrowly.** Ticket #15's own framing says +"connection data should stop travelling through Terraform input variables," +and the pre-pivot recipe's three delivery conditions included "no connection +data enters an input variable." The reconciliation below is scoped by *kind +of channel*, not just *kind of data* as an earlier revision put it — because +kube itself now has **two** channels, and they do not have the same +relationship to the ticket's framing: + +- **Kube env channel** (mode 1 above): the ticket's "nothing live enters + Terraform" holds in full, unconditionally — no variable, no file, ever. +- **Kube references carried inside the unified structure** (mode 2's file or + mode 3's var — `path`/`context`/`endpoint` per kube target, non-credential + per U4, but still *connection data* in the literal sense): **the ticket's + framing is superseded here too, not just for ports.** When a consumer binds + `--output-var` and reads `nodes..kube..path` (or `context` or + `endpoint`) from it, that is connection data travelling through a Terraform + input variable — exactly what the ticket wanted to stop — even though none + of those three fields is a credential. A consumer who needs the ticket's + strict guarantee for kube must use the env channel exclusively (Mode A in + "Documentation" below) and never bind any of `--output-var`'s `kube.*` + fields to a resource; choosing to use `--output-var` for kube at all is + choosing to accept the superseded framing, the same choice a consumer + reading ports from it already makes. +- **Ports**: no env-native path exists for a generic TCP endpoint the way + `KUBE_CONFIG_PATH` exists for the Kubernetes provider convention — nothing + about `host:port` is any provider's own configuration vocabulary. + **[R16, corrected iteration 7]** An earlier revision of this bullet + claimed a "genuine third option" — a literal, operator-pinned file path + (R9's mode 2) — as ports' plan-safety story, re-adopting #14 fix 1. That + re-adoption is **retracted**: the user's own instruction, and the ticket's + own explicit rejection of fix 1 ("session root can stay ephemeral; only the + path to the kubeconfig has to be stable"), settle this the other way. Ports + read the same env-carried `TUNSTRAP_OUTPUT_FILE` locator kube's + non-env-native connection data would use (mode 2, "Delivery" above) — but + **one-shot only**, since the session root stays ephemeral and the file it + names does not survive a tunstrap restart. There is no remaining mechanism + that buys ports plan-safety *across a restart* the way kube's env-native + channel does — see "Stability contract," "what is lost," above, and + "Relationship to #14" immediately below for the corrected fix-1 + disposition. + +This explicitly **supersedes the ticket's stricter framing for the unified +structure's var form — both ports and kube references carried in it — while +leaving the kube env channel's full compliance untouched.** See ADR entry 11 +for this reasoning recorded as a decision with its own alternatives +considered. + +### Relationship to #14 [R15, new; fix-1 disposition corrected iteration 7 — R16] + +Ticket #15 explicitly supersedes most of #14. An earlier revision of this +section (R15) re-adopted two of #14's original fixes for non-kube delivery; +**iteration 7 (R16) corrects that: fix 1 is no longer re-adopted, only fix +4 is, and fix 4's own shape changes** (an env-carried locator, not a pinned +path) — stated here so the correction reads as a decision, not scope creep +from a superseded ticket, and cross-referenced from ADR entries 14 and 18 so +the three documents cannot silently re-diverge on this point: + +- **#14 fix 1 (pin the session/state root) — [R16] NO LONGER re-adopted, in + either form.** An earlier revision (R15) re-adopted it as an opt-in + precondition (a caller-supplied, stable `--session-dir`) specifically to + give ports a plan-safe-across-restart story. The user's confirmed direction + after the red-team round retracts that: the session root stays ephemeral + unconditionally, matching the ticket's own explicit rejection of fix 1 + ("session root can stay ephemeral; only the path to the kubeconfig has to + be stable, and that is supplied through the environment") — which R15's + re-adoption had, on reflection, only honored for kube while quietly + reintroducing the exact thing the ticket rejected for everything else. This + is not cost-free: see "Stability contract," "what is lost," above. +- **#14 fix 4 (materialized file + `file()`) — re-adopted, reshaped.** The + mechanism (finding #1 measured it as plan-safe) survives, but **not** via a + pinned path anymore: the file is located by the env-carried + `TUNSTRAP_OUTPUT_FILE` (mode 2, "Delivery" above), one-shot within a single + tunstrap invocation, never a stable path the consumer's HCL independently + hardcodes. This is a narrower re-adoption than R15's — it buys plan-safety + within one invocation, not across a restart — but it is real, and it is + what "content on disk, paths in env" (R16's core principle) means + concretely for ports and `fetch_files`, which have no env-native provider + path the way kube does. +- **#14 fix 3 (warn when the child's invocation captures a saved plan, e.g. + a `-out=` flag, while non-plan-safe delivery is in use)** — **explicitly + out of scope for #15, deferred to #14** (also listed in "Out of scope" + below so an implementer sees it as a deliberate deferral, not a gap). This + design's stability contract and the recipe's explicit warnings (below) + cover the risk in documentation; a runtime CLI warning would require + tunstrap to parse its own child command line for Terraform-specific flags + like `-out=`, which is exactly the kind of Terraform-vocabulary-inside- + generic-`run` trade the pre-#15 design deliberately confined to + `tunstrap_tofu` alone (`docs/specs/2026-07-31-run-env-io-and-tofu-proxy- + design.md`, "Shipping the shim") rather than adding to `run` itself. That + confinement is orthogonal to this pivot and not re-litigated here; fix 3 + stays #14's remaining scope. + +### Consumer-side transformation [U5] + +The consumer parses the unified JSON with `jsondecode` and reshapes it in +HCL `locals` into whatever their own tooling needs (a Terragrunt `inputs` +map, a set of `provider` blocks, etc.) — the same pattern the pre-pivot +recipe already used for the (smaller) `--output-var` payload. + +**Assumption recorded, not silently interpreted:** the user's instruction +used the phrase "через js" ("via js"). This stack has no JavaScript runtime +anywhere in its consumer chain (Terragrunt/OpenTofu, both Go binaries, HCL +configuration language) — there is no `js`/`node` step between tunstrap's +output and the consumer's config. This is read as **JSON** delivery consumed +via HCL's `jsondecode` function, not literal JavaScript execution, and that +interpretation is recorded here explicitly per the instruction to record +assumptions rather than guess silently. + +### The scalar channel is removed, not extended + +Every remaining reference to `render_env`, `TUNSTRAP__*`, +`inject_scalars`, or `MultiNodeEnvUnsupported` below describes what is +**removed**, not a surviving single-node-only contract. **Three** scalars +survive, deliberately, because they are session metadata rather than +``-scoped connection data and because they solve a real bootstrapping +need (locating the var/materialized-file payload from a plain shell context +that hasn't parsed anything yet): `TUNSTRAP_SESSION_DIR`, `TUNSTRAP_PID`, and +**`TUNSTRAP_OUTPUT_FILE`** (new — judgment call, not literally named by any +of U1-U6, found while tracing the consequence of removing `render_env` from +`start --output env`'s call site; see below). Every other +`TUNSTRAP__*` key (`_HOST`, `_PORT`, `_ENDPOINT`, and the +per-kube-target `_KUBECONFIG`/`_ENDPOINT` pair) is deleted outright, along +with the `render_env` function that produced them, `predicted_env_keys`' +per-target enumeration, and every raise site of `MultiNodeEnvUnsupported` +(the class itself is removed — see below). + +**Why a third survivor.** `start --output env`'s exported lines shrink +correspondingly to the same three-survivors-plus-kube-channel shape (it calls +`render_env` too, at a second call site — `cli.py:206` — deleting the +function without touching this call site breaks `start` outright, not just a +test; see the plan). Doing that naively (dropping to only +`TUNSTRAP_SESSION_DIR`/`TUNSTRAP_PID`) leaves `start --output env` with **no +way at all** to tell a plain shell consumer (not an HCL consumer — `start` +has no Terraform-shaped output channel, unlike `run`) where a plain +`remote_targets` port landed, which is a real functional regression, not a +cosmetic one: `TUNSTRAP_WEB_PORT` used to be the only thing `--output env` +existed to provide for that case. `TUNSTRAP_OUTPUT_FILE` — the absolute path +to the materialized `/tunnel-data/output.json` (see "Delivery," +above; **[R13, corrected iteration 6]** only `start --output env` gains +materialization, mirroring `run`'s new unconditional write — `start`'s other +modes are untouched, see "Compatibility" below) — restores that: a shell +consumer of `--output env` (or of `run`'s child +environment, for a non-Terraform child) does +`jq .nodes.web.ports.service1 "$TUNSTRAP_OUTPUT_FILE"` instead of reading a +now-nonexistent scalar. This is safe to expose as a plain env var (unlike +`session_dir` alone driving an HCL `file()` call) precisely *because* the +shell/non-Terraform consumer this scalar serves can read arbitrary env vars — +the "only `TF_VAR_*`-mapped names are visible to HCL" constraint that shaped +the var-vs-materialization design above applies to Terraform config, not to +this scalar's actual audience. + +`MultiNodeEnvUnsupported`'s disposition: **removed entirely, class and all**. +Its only purpose was guarding the scalar channel's node-count collision; with +the scalar channel gone, no code path can raise it. The pre-spawn gate at +`cli.py:640` (`len(schema.nodes) != 1 and output_var is None` → exit 1) is +removed with it: multi-node input no longer needs an explicit +`--output-var` opt-in, because the unified structure is materialized +unconditionally regardless of node count or of whether `--output-var` was +passed — "unified output is emitted regardless of node count" is the pivot's +own stated semantics for the `inject_scalars` gate, and once that gate no +longer decides *whether* an alternate channel exists (materialization always +does), the gate that used to force choosing one has nothing left to protect +against. + +## Multi-node kube channel [kube part, unchanged by the pivot — U4] + +`render_env` currently has one node-count guard covering three unrelated +things at once: the `TUNSTRAP__*` scalars, the per-kube-target +`TUNSTRAP__{KUBECONFIG,ENDPOINT}` scalars, and the plain `KUBECONFIG` +colon-joined list (`envrender.py:24-60`). Only the first two actually have a +node-dimension problem — a target named `k3s` on two different nodes +genuinely collides in `TUNSTRAP_K3S_PORT`. The `KUBECONFIG` line does not: +it is *already* a path list, and `render_env` *already* colon-joins it +(`envrender.py:56-59`) — the only thing stopping it from working across nodes +is the early-return that raises before it is ever built. **This subsection +describes the kube channel's own contract, which the pivot does not change; +only its wiring into `_build_child_env` simplifies, per the rewritten "`cli.py` +wiring" subsection below, once the scalar channel it used to branch around no +longer exists.** + +**Split**, per the spike's Axis 2: + +- `render_kube_env(output: OutputSchema) -> dict[str, str]` — new. No + node-count guard. Iterates every node's `kube_targets`, in order, collecting + one materialized `path` per kube target across the **whole** envelope (not + one node), and builds the conditional env-export contract below from that + flat path list. Callable for any node count, including zero (returns `{}`) + and multi-node. +- `render_env(output: OutputSchema) -> dict[str, str]` — unchanged contract + **at the point this split lands** (Task 3 in the plan). Still requires + `len(output.connections) == 1` and still raises `MultiNodeEnvUnsupported` + otherwise (`MultiNodeEnvUnsupported`'s own docstring, `exceptions.py:80-87`, + already states the reason precisely: *"has no node dimension"* — a claim +this change does not touch). For the single-node case, `render_env` now +delegates its kube-path-list line(s) to `render_kube_env`. **[Editorial fix, +iteration 6]** This is **not** "reproducing the previous combined behaviour +exactly," as an earlier revision put it — precisely: `KUBECONFIG`'s *value* +(the colon-joined path list) is unchanged, but the *key set* grows, by +design, per the conditional cardinality contract this same split introduces +(e.g. a single materialized kube target now also exports `KUBE_CONFIG_PATH` +alongside `KUBECONFIG`, which the pre-split `render_env` never did). The ADR +carries the same correction (decision 2). **[PIVOT correction]** `render_env` + itself is **not** part of "kube part, unchanged by the pivot" — only + `render_kube_env`, the function this bullet's sibling describes, survives. + `render_env` and `MultiNodeEnvUnsupported` are both deleted once the + unified output contract lands (see "The scalar channel is removed" above); + this bullet describes their contract as it stands in the plan's Task 3, + before Task 5 removes them, not the shipped end state. + +`predicted_env_keys` (`envrender.py:83-112`) — the pre-spawn predictor used to +reject a colliding `--output-var` NAME before a daemon exists — gains the same +conditional logic in lockstep (see env-export contract below); the anti-drift +guard test (`test_predicted_env_keys_matches_render_env`, +`test_envrender.py:96-126`) is **extended, never weakened**, to assert the +predictor and `render_env` agree exactly for both the one-file and +two-or-more-file cases. **[PIVOT time-scope note, matching the correction +above]** This paragraph describes the pairing as it stands at the plan's +Task 3 (`predicted_env_keys` vs. `render_env`) — accurate at that point, not +the shipped end state. Once Task 5 deletes `render_env`, the guard's *other +half* changes, not its existence: the anti-drift property (two independent +"what will `run` inject" implementations must agree) still matters and the +guard is **re-scoped, not deleted** — the pair it compares becomes +`predicted_env_keys(schema)` vs. the actual key set `_build_child_env` +injects for a corresponding `OutputSchema`, since that is the pair capable of +silently diverging once `render_env` is gone. See "The scalar channel is +removed" above and the plan's Task 5 for the concrete rewritten test. + +### `cli.py` wiring is in scope [simplified under the pivot] + +**Iteration 2 shipped a two-branch `_build_child_env`** (`inject_scalars=True` +→ `render_env`, which delegated to `render_kube_env`; `inject_scalars=False` +→ `render_kube_env` directly), built to satisfy the ruling that "the kube +channel's trigger condition is `kube_targets` presence, not node count and +not `inject_scalars`'s value" while `render_env`'s scalar half still existed +as something to branch around. + +**Under the pivot that branch collapses.** `render_env` (the scalar-emitting +function) is deleted outright (see "The scalar channel is removed" above), so +there is nothing left to delegate from and nothing left to branch on. +`_build_child_env` calls `render_kube_env(output)` **unconditionally**, every +time, regardless of node count and regardless of whether the unified output +is also being materialized/injected in the same call — the iteration-2 +ruling's requirement ("kube channel fires on `kube_targets` presence, not +node count") is now satisfied trivially, by construction, because there is no +other function it could have been routed through instead. This is a genuine +simplification the pivot buys, not a new requirement: one function, one call +site, no condition on it beyond `render_kube_env`'s own internal +"`kube_targets` empty → return `{}`" check. + +**[Editorial fix, iteration 6 — the wiring description below was imprecise; +corrected to match what the plan actually implements.]** Two more pieces of +wiring exist alongside the unconditional `render_kube_env` call above, and +they are **not** the same call site, nor both unconditional: + +- `render_output_var(output) -> str` — the function's name and signature are + unchanged from the pre-pivot design (still `OutputSchema -> str`, still + the value injected under `--output-var`); only its *body* changes, to build + the unified structure via a new function, `render_unified_output(output) + -> dict[str, Any]`, and serialize that instead of the old + `RunKubeTarget`-based projection. `_build_child_env` calls + `render_output_var` **only when `output_var is not None`** — this is + unchanged from the pre-pivot contract (no `--output-var` flag, no var + injected) and is **conditional**, not unconditional. +- **Materialization is a separate, unconditional call, in a different + function.** It does not live inside `_build_child_env` at all: `run_command` + (`cli.py`) calls `render_output_var(output)` a **second** time (or a shared + helper that also calls `render_unified_output`), unconditionally, in its + success path, and writes the result to + `/tunnel-data/output.json` — independent of whether + `--output-var` was passed and independent of node count. See "The unified + output contract," "Delivery," mode 2 above for the full write-mechanism + description (R13), and the plan's Task 5 for the concrete call sites. + +Without this wiring the multi-node kube channel and the unified output are +dead code reachable only by unit tests calling the render functions +directly. + +## Env-export contract (superset rejected; conditional adopted) [kube part, unchanged by the pivot — U4] + +`docs/specs/2026-08-10-issue15-provider-env-precedence.md` (live-probed, +source-cited against `hashicorp/kubernetes` v2.38.0 and `hashicorp/helm` +v2.17.0) settles the question the ticket's work item 3 left open: + +- **Plain `KUBECONFIG` is not read by either provider — evidence differs in + strength per provider, split here rather than conflated [editorial fix, + iteration 6]:** + - `hashicorp/kubernetes`: source evidence (`kubernetes/provider.go`'s + `initializeConfiguration()`, no `KUBECONFIG` read) **and** the stronger + live negative control — setting `KUBECONFIG` to a **valid** kubeconfig + file still fails (`dial tcp 127.0.0.1:80: connect: connection refused`, + the provider's zero-value default), proving the provider never reads it + regardless of the value's validity. + - `hashicorp/helm`: source evidence (`helm/structure_kubeconfig.go`'s + `newKubeConfig()`, same absence of a `KUBECONFIG` read) **plus** a live + negative control that used a deliberately **wrong** path + (`KUBECONFIG=/definitely/wrong`, failing with "no configuration has been + provided") — weaker than kubernetes' valid-file negative control on its + own (an invalid path failing does not by itself rule out a partial read), + so the "not read at all" conclusion for helm rests more heavily on the + source reading than on this transcript alone. +- **Provider resolution order**: configured `config_path` (whose own default + reads env `KUBE_CONFIG_PATH`) → configured `config_paths` → env + `KUBE_CONFIG_PATHS` (colon-split via `filepath.SplitList`) — confirmed by + source reading for both providers. +- **`KUBE_CONFIG_PATH` wins over `KUBE_CONFIG_PATHS` when both are set** — + confirmed live for **both** providers, each with its own valid-file + transcript (`kubernetes` data source read; `helm_release` apply): with both + set, only the file named by `KUBE_CONFIG_PATH` is reachable; a cluster + reachable only through the `KUBE_CONFIG_PATHS` list is invisible. + +That last fact is why the spike's Axis 3 prototype — "export the superset, +`KUBECONFIG` + `KUBE_CONFIG_PATH` + `KUBE_CONFIG_PATHS`, always" — is +**rejected**, not adopted. Exporting `KUBE_CONFIG_PATH` unconditionally +alongside `KUBE_CONFIG_PATHS` would silently shadow every cluster but the +first the instant a second kube target is materialized — exactly the failure +mode this whole design exists to prevent, just moved one layer down. + +**Adopted contract, conditional on how many files were materialized** (one +materialized file per kube target, so this is a cardinality condition on +`render_kube_env`'s collected path list): + +| Materialized kube files | `KUBECONFIG` | `KUBE_CONFIG_PATH` | `KUBE_CONFIG_PATHS` | +|---|---|---|---| +| 0 | not exported | not exported | not exported | +| exactly 1 | `` | `` | **not exported** | +| ≥ 2 | `::...` (colon-joined) | **not exported** | `::...` (colon-joined) | + +- `KUBECONFIG` is exported whenever any files exist, always as the full + colon-joined list — it is the kubectl/Helm-CLI convention, unaffected by the + provider precedence problem (no provider here reads it), and a human running + `kubectl` by hand still benefits from the complete list. +- `KUBE_CONFIG_PATH` is exported **only** for the single-file case, where its + precedence-winning behaviour is exactly the desired outcome (there is only + one file to reach, so "wins over `KUBE_CONFIG_PATHS`" is moot). +- `KUBE_CONFIG_PATHS` is exported **only** for the two-or-more case, and + `KUBE_CONFIG_PATH` **must not** be exported alongside it — the whole point + of the condition. + +**`predicted_env_keys` must NOT model the exact cardinality condition — it +must over-approximate it, conservatively [R11, corrected iteration 6].** An +earlier revision of this design had `predicted_env_keys` compute the *input* +schema's exact `kube_targets` count and apply the same one-vs-two-or-more +conditional `_kube_channel_keys` logic the actual export uses. That is +wrong: `predicted_env_keys` runs **pre-spawn**, against the *input* schema, +before any node has connected — but the *actual* materialized count can be +**smaller** than the input count, because an optional (`required: false`) +node or kube target can fail without failing the run (`manager.py:99-107` +already builds `connections` from successful nodes only). Concretely: two +kube targets declared in the input (→ predicted the `≥2` branch, +`KUBE_CONFIG_PATHS` only) but one optional node fails at connect time (→ only +one file actually materializes, and the real export uses the `==1` branch, +`KUBE_CONFIG_PATH`). If `predicted_env_keys` had predicted the `≥2` branch's +key set, it would **not** have reserved `KUBE_CONFIG_PATH` — a +`--output-var KUBE_CONFIG_PATH` would then pass the pre-spawn collision check +and get **silently overwritten** by the real, one-file export at +`_build_child_env` time. This is exactly the collision the pre-spawn check +exists to prevent, defeated by predicting from the wrong (optimistic) side of +a value that can only shrink, never grow, between input and output. + +**Fix: reserve conservatively.** Whenever **any** node in the input schema +declares `kube_targets` (regardless of exact count, regardless of how many +of those targets are `required`), `predicted_env_keys` reserves **all three** +kube env names — `KUBECONFIG`, `KUBE_CONFIG_PATH`, `KUBE_CONFIG_PATHS` — not +the exact cardinality-conditional subset. This is deliberately a superset of +what will usually actually be injected; that asymmetry is the whole point — +over-reserving can only reject *more* `--output-var` names than strictly +necessary (a false-positive usage error, cheap and immediately visible), +while under-reserving risks a silent post-spawn collision (the failure mode +this check exists to prevent). Under the scalar-channel removal above, +`predicted_env_keys` also loses its entire per-target/per-node scalar +enumeration and its `len(schema.nodes) == 1` branch — it collapses to: + +``` +{"TUNSTRAP_SESSION_DIR", "TUNSTRAP_PID", "TUNSTRAP_OUTPUT_FILE"} + | ({"KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"} + if any(node.kube_targets for node in schema.nodes.values()) + else set()) +``` + +unconditionally on node count, matching `_build_child_env`'s own +unconditional `render_kube_env` call above (which itself stays exact, +cardinality-conditional, computed from the *actual* materialized output — +only the *predictor* becomes conservative, not the actual export). Its +remaining job — reject an `--output-var` NAME that collides with an injected +key — is otherwise unchanged, and the guard verifying the relationship +between the two is **preserved, not deleted, and now two-part** (an earlier +revision's "extended, never weakened"/"re-scoped" framing evolves into this, +below) — see "Anti-drift guard extension." + +### Interaction with the tofu proxy's `suppress_kubeconfig` + +**Not covered by any ruling; identified while writing this contract, flagged +here rather than silently folded in.** `tunstrap_tofu` sets +`suppress_kubeconfig=True` (`tofu_proxy.py:155`) specifically so that a broken +`TF_VAR_tunstrap` → `config_path` wiring fails loudly instead of silently +still reaching the cluster through an inherited/injected `KUBECONFIG` +(`cli.py:388-392`). **[Editorial fix, iteration 6 — narrowed]** The provider +findings above show plain `KUBECONFIG` was never read by either provider's +**own Go configuration chain** — so the guard was inert **for that specific +purpose** (stopping `KUBECONFIG` from silently reconfiguring the +`kubernetes`/`helm` providers themselves), not "inert all along" in general, +as an earlier revision overstated. The same suppression is, and always was, +load-bearing for a different, real audience: `KUBECONFIG` is the +kubectl/Helm-**CLI** convention, and `tofu`'s children include `local-exec` +provisioners and `external` data sources, which can shell out to `kubectl` +or the `helm` CLI directly — both of which *do* honour plain `KUBECONFIG`. +Suppressing it was never protecting a nonexistent fallback in general; it was +(and is) protecting exactly those two provider-native config chains, while +already correctly protecting kubectl/Helm-CLI-invoking children the whole +time. + +**[Issue #14 fix, iteration 9 — the paragraph below is falsified; kept for +the historical record of what iteration 6 actually shipped, not restated as +current.]** Iteration 6 went on to conclude: "Once this design ships +`KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` — the vars the providers' own Go +chains actually do read — the guard becomes load-bearing for that +provider-native audience too, for the first time, and `_build_child_env`'s +`suppress_kubeconfig` handling must drop **all three** exported names, not +just `KUBECONFIG`." That is backwards: this same subsection already +established that providers never read plain `KUBECONFIG` — they read +`KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` directly, which is exactly Mode A's +delivery channel through the proxy, not a fallback for it. Dropping those +two through `suppress_kubeconfig` does not close a silent-fallback gap; it +deletes Mode A's only channel through `tunstrap_tofu`, the documented +`terraform_binary` entry point — measured against a real tunnel by the +issue #14 report: through `tunstrap_tofu` all three names came back unset, +and a provider block following Mode A's own item 1 (only `config_context` +set) failed against the inert `localhost:80` loopback. + +**Corrected contract.** `suppress_kubeconfig` drops only the *injected* +`KUBECONFIG` — never `KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS`, so Mode A keeps +working through `tunstrap_tofu` exactly as documented. Two further +guarantees hold unconditionally, on both the plain and the proxied path, +independent of `suppress_kubeconfig`: an *inherited* +`KUBECONFIG`/`KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` from the parent +environment is always dropped before `render_kube_env` injects, so a stray +operator environment can never contribute to the child's kube channel +either. See `docs/specs/2026-08-07-issue15-kube-identity-decisions.md` +entry 20 for the full record, including the alternative considered and +rejected (pop-before-inject ordering) and why. + +## Compatibility + +Breaking, deliberately — org rule, no backward compatibility unless +instructed: + +- Upstream context/cluster/user names in the materialized kubeconfig change + from whatever the source cluster used to `tunstrap--`. +- `KubeTargetOutput.context_name` and `.cluster_name` report the **new** + names, not the upstream ones — both fields are plain `str` with no + validation tying them to the source document (`schemas.py:317-336`), so + this requires no schema change, only a different value at construction + time. +- **[Editorial fix, iteration 6]** `RunKubeTarget` (`schemas.py:339-372`) + does **not** "carry the same fields through unchanged" into the unified + structure, as an earlier revision claimed — that class is **deleted** + under the pivot (its allow-list job is now done by explicit-keyword + construction inside `render_unified_output`, see the plan). Of its seven + fields, only `context_name` survives into the consumer channel, renamed to + `context`; `cluster_name`, `local_port`, `tls_server_name`, and + `certificate_authority_data` are **dropped** — a real, intentional breaking + narrowing beyond the pre-#15 credential fix, not an oversight (design + rationale: U4 scopes the unified kube entry to `{path, context, endpoint}` + references only). A consumer reading any of the four dropped fields out of + the old `--output-var` payload breaks. +- **[Editorial fix, iteration 6]** The superset env export the ticket's work + item 3 asked to prototype as a placeholder — final choice deferred to the + parallel provider-behaviour verification (paraphrased, not a ticket + quotation; an earlier revision rendered this in quotation marks as if it + were verbatim) — is explicitly **not** the shipped contract; see + "Env-export contract" above. +- **[PIVOT, iteration 3]** The entire `TUNSTRAP__*` scalar channel is + removed, not extended — every `run`/`start --output env` consumer reading + `TUNSTRAP__PORT` or similar breaks outright, with no compatibility + shim. **Three** survivors, not two (corrected, iteration 4): + `TUNSTRAP_SESSION_DIR`, `TUNSTRAP_PID`, `TUNSTRAP_OUTPUT_FILE` (session + metadata, not target-scoped). +- **[R11, iteration 6]** `KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` export + behaviour changes for multi-node: pre-pivot, the kube env channel was + gated by the same single-node guard as the scalars and so was never + injected for multi-node input at all; post-pivot it is injected + unconditionally on node count (`render_kube_env` has no node-count guard — + see "Multi-node kube channel" above), so a multi-node `run` now exports + `KUBECONFIG`/`KUBE_CONFIG_PATH(S)` where it previously exported nothing + kube-related. `predicted_env_keys` reserves all three names conservatively + whenever *any* node declares `kube_targets`, regardless of how many + actually materialize — see "Env-export contract," `predicted_env_keys` + paragraph. +- **[PIVOT, iteration 3]** `--output-var NAME`'s injected JSON shape changes + from the raw `OutputSchema`-minus-kube-credentials projection to the + unified structure above — any consumer parsing the old flat + `connections..ports.` shape breaks; the new shape is + `nodes..ports.` (a string, not an int) plus the reorganized + `kube`/`fetch_files`/`session` keys. +- **[PIVOT, iteration 3]** `MultiNodeEnvUnsupported` is removed entirely + (class, `_EXIT_CODES` entry, and both raise sites — `render_env`'s internal + guard and `cli.py:640`'s pre-spawn multi-node-without-`--output-var` gate). + A multi-node `run` without `--output-var` is no longer a usage error: the + unified structure is materialized unconditionally, so multi-node input + always has a channel. +- **[PIVOT, iteration 3]** `run` now unconditionally writes + `/tunnel-data/output.json` — new on-disk artefact, new content + in a directory whose kube-only contents were previously the sole thing + present. **[R13, corrected iteration 6]** `start`'s **default** JSON stdout + gains no such artefact and is otherwise unaffected by this design — only + `start --output env` materializes, matching `run`, and only because it + shares `run`'s new `--output env` export shape (see "The scalar channel is + removed" above). +- **[Editorial fix, iteration 6]** Scope carve-out, stated explicitly: the + unified contract covers `run`'s `--output-var`, `run`'s materialization, + and `start --output env`'s export lines. **`start`'s default/raw JSON + stdout envelope (no `--output` flag, or `--output json`) is unchanged by + this design** — a deliberate scope judgment call (plan, Self-Review), not + an oversight: it remains the pre-#15 "complete envelope" contract for + session-management tooling, a different audience than the consumer-facing + channels this pivot reshapes. +- **[R16, iteration 7 — supersedes the iteration-6 bullet above, retracted, + not extended]** `fetch_files` content is **no longer** plan-file-durable + via the var form at all — the pre-#15 design's own choice to let + `content_b64` ride `--output-var` unprojected (decision history's + `fetch_files[*].content_b64` entry, cited by the retracted bullet this + replaces) is superseded. Fetched bytes are now materialized to + `tunnel-data/-` (mode `0600`) exactly like kubeconfigs + already were, and the consumer-facing projection — both the + `TUNSTRAP_OUTPUT_FILE` manifest and `--output-var` — carries only + `{path, size, sha256}` (or `{error}`), never `content_b64`. **This resolves + the "never fetch secrets with `--output-var`" warning as a class, not case + by case**: since content never rides the var (or the manifest) at all, a + consumer using `--fetch` to retrieve a secret can no longer leak it into a + saved Terraform plan file via that channel, regardless of whether + `--output-var` is bound. This is breaking versus the pre-#15 fetch-files + design, stated plainly: any consumer decoding `fetch_files..content_b64` + out of the old envelope breaks and must instead read the file at + `fetch_files..path`. `start`'s raw default JSON stdout keeps showing + `content_b64` unchanged (see "Fetched-file materialization" above, + mirroring the existing kube carve-out) — this narrowing is specific to the + consumer-facing channels R16 reshapes, not to `FetchedFile` itself. + +## Testing contract + +### The collision trap — mandatory, unit-level + +k3s ships `current-context: default`, `cluster: default`, `user: default` — +**two k3s targets collide on the exact upstream names verbatim**; this is the +expected case the rename exists to fix, not an edge case. **A kind-based test +proves nothing here**: kind's context is `kind-`, already +unique, so a kind-only regression test would pass unchanged even with the +rename entirely absent. The mandatory regression test therefore uses two fake +upstream kubeconfigs whose context/cluster/user names are **identical** +(k3s-style), not kind-style — see the untracked spike prototype and +`variant/combined`'s `tests/unit/test_issue15_context_collision.py`. It drives +`run_kube_targets` twice (two different `node_name`s, same k3s-style fixture +content) and asserts: + +- the two `KubeTargetOutput.context_name`/`.cluster_name` values differ, and + match `tunstrap--kube` exactly; +- the rename reaches the **serialized** document (`content_b64`), not just the + extracted fields — a consumer parsing the materialized file, not + `KubeTargetOutput`, is what actually merges kubeconfigs. + +Confirmed RED against the unmodified `feature/run-env-io` tip (both outputs +report `context_name == "default"`) and GREEN under `variant/combined` +(spike findings, "Part 3"). + +### Two more mandatory unit tests, distinct defect classes [R10, R14, new] + +**Neither is covered by the collision trap above** — restated because both +are easy to mistake for "the same test, differently framed" and neither is: + +- **R10 — tunstrap's own naming scheme colliding with itself**, independent + of any upstream kubeconfig content: two different `(node, target)` pairs, + e.g. `(node="a-b", target="c")` and `(node="a", target="b-c")`, both + render `tunstrap-a-b-c`. Driven at schema-validation time (no SSH, no + kubeconfig fixture needed at all — this is a pure `InputSchema` + validation test), asserting the payload is rejected with an error naming + both colliding pairs. +- **R14 — a dangling reference inside an *ignored* context after rename.** + A fixture with two contexts sharing one cluster entry: the current context + (renamed) and a non-current, ignored context whose own `context.cluster` + reference names the *same* cluster. Assert that after `rename_identities` + runs, the ignored context's `cluster`/`user` references have been updated + to the new name too — not left pointing at a cluster/user entry that no + longer exists under its old name anywhere in the document. + +### `e2e` coverage — optional, with rationale + +Kind-based `e2e` coverage of this feature is **not required** to land the +fix, for the reason above: kind's own context naming already makes the +collision unreachable, so an `e2e` test added naively would be decorative. +**If** `e2e` coverage is added, it must first rewrite the two kind clusters' +materialized kubeconfig identities to a **shared** name (e.g. force both to +`current-context: default` / `cluster: default` / `user: default`, matching +the k3s shape) before feeding them to `tunstrap start`/`run` — otherwise the +test exercises kind's own uniqueness, not tunstrap's rename. This is real +extra fixture work (a kubeconfig-identity rewrite step ahead of the existing +`kube_rig`/`node_kubeconfig` fixtures in `tests/e2e/conftest.py`), which is +why it is marked optional rather than mandatory in the plan — the unit-level +regression test above already exercises the real defect precisely and does +not need a live cluster to do so. + +**[Editorial fix, iteration 6 — disambiguated from a different e2e change +this design also requires]** This optionality is about **collision-specific** +e2e coverage only. It does not extend to the e2e tier's existing +`nodes..kube..path` shape migration (`tests/e2e/module/main.tf` +and its dependent test files), which **is** mandatory — the shape change +ships with this design regardless, and a `try()` swallowing the shape +mismatch into an empty `config_path` would fail silently rather than loudly +if that migration were skipped, which is exactly the risk profile that makes +it non-optional. See the plan's Task 6 for the mandatory migration and Task 7 +for where the two are kept distinct in the gate pass. + +### Anti-drift guard extension + +`test_predicted_env_keys_matches_render_env` (`test_envrender.py:96-126`) +must be extended with cases for both the exactly-one-file and +two-or-more-file conditions of the env-export contract above — the guard is +**extended, never weakened**. **[Editorial fix, iteration 6]** The concern +this guard addresses (two independent implementations of "what keys get +injected" silently diverging) is this codebase's own anti-drift discipline, +established by the spike/this design's own review process — not, as an +earlier revision misattributed it, "the ticket's own stated concern" (the +ticket never mentions this guard or this failure mode at all). Confirmed in +the spike: reverting only the `predicted_env_keys` half of the Axis-3 +superset change (not the conditional version specified here, but the same +class of change) produced exactly one failure, with a clear diff (`Extra +items in the right set: 'KUBE_CONFIG_PATH', 'KUBE_CONFIG_PATHS'`) — +confirming the guard fires correctly and is not a false pin. + +**[R11, corrected iteration 6 — the guard is two-part, not a single equality]** +Two prior corrections to this guard are both superseded by R11's conservative +predictor (above), which changes what "agree" even means between the two +implementations: + +- **Iteration 4** deleted the guard outright on the false premise that only + one implementation of the injected-key set remained after `render_env`'s + removal — wrong; `_build_child_env` and `predicted_env_keys` are still two + independent implementations. +- **Iteration 5** retargeted it to a single full-set-equality assertion, + `predicted_env_keys(schema) == set(actual injected keys)` — this was + correct *only* as long as `predicted_env_keys` computed the exact + cardinality-conditional key set. R11 makes `predicted_env_keys` + deliberately **conservative** (reserves all three kube names whenever any + `kube_targets` exist, regardless of exact count), so exact equality can no + longer hold in the general case — a schema with exactly one kube target + that materializes cleanly now predicts `{KUBECONFIG, KUBE_CONFIG_PATH, + KUBE_CONFIG_PATHS}` (conservative) while the actual export is + `{KUBECONFIG, KUBE_CONFIG_PATH}` (exact, per the `==1` branch) — genuinely + unequal, correctly so. + +**The guard is now two independent tests, not one:** + +1. **Formula test (exact equality, unchanged in spirit from iteration 5):** + for a fixed, representative schema, `predicted_env_keys(schema)` equals a + hand-computed expected set reflecting the conservative rule exactly (e.g. + any `kube_targets` present → all three kube names, plus the three + survivors) — this proves the *formula* is implemented correctly, and is a + normal unit test, not a drift guard between two independent + implementations. +2. **Safety-envelope test (subset, new, the actual anti-drift guard):** + `set(actual injected keys from _build_child_env(out)) ⊆ + predicted_env_keys(schema)`, driven by a **cardinality-shrink** case — an + input schema declaring two kube targets (one on an optional node that + fails), producing an `OutputSchema` with only one kube target + materialized. This is the property that actually matters for the + pre-spawn collision check: predicted must always cover whatever actually + gets injected, in every direction cardinality can move between input and + output, and only a shrink case can falsify a formula that got the + direction of the conservatism backwards. + +Both tests live together in `tests/unit/test_envrender.py`; see the plan's +Task 3 (formula) and Task 5 (safety-envelope, since it needs +`_build_child_env` from Task 5) for the concrete literals. This remains a +**standing ruling (R1: the guard is extended, never weakened)** — R11 +changes *what* the guard asserts, not whether one exists. + +### Unified output contract tests [PIVOT, new territory] + +Nothing in the spike prototypes the unified structure, its materialization, +or the removal of `render_env`/`predicted_env_keys`' scalar half — these are +new tests, not spike cherry-picks. At minimum: `render_unified_output` +produces the shape above for a multi-node, multi-kube-target `OutputSchema` +(including **[R16]** the `fetch_files` **projection** — `{path, size, +sha256}`, no `content_b64`, not a passthrough — and the +two-reserved-top-level-keys namespacing); materialization writes +`/tunnel-data/output.json` +at mode `0600` and it is valid JSON matching the injected `--output-var` +payload byte-for-byte; a multi-node `run` **without** `--output-var` now +succeeds (was: exit 1 `MultiNodeEnvUnsupported`) and the materialized file +still exists; `predicted_env_keys` no longer enumerates per-target scalar +keys and the anti-drift guard is re-scoped to the surviving +`{TUNSTRAP_SESSION_DIR, TUNSTRAP_PID, TUNSTRAP_OUTPUT_FILE} ∪ kube-channel` +set, compared against `_build_child_env`'s actual output rather than against +`render_env` (which no longer exists) — see "Anti-drift guard extension" +above. **A full grep-driven enumeration of every pre-existing test, fixture, +and shipped artifact this removal breaks — across unit, integration, e2e and +the recipe doc — is the authoritative blast-radius table in the plan's +Task 5**, not repeated here; this spec states the contract, the plan states +every concrete consequence of shipping it. + +## Documentation (work item 4) [R12, rewritten as two explicit consumer modes; Mode B rewritten iteration 7 — R16] + +`docs/recipe_terragrunt.md` gains **two explicit, named consumer modes**, not +a single blended recipe — an earlier revision's "kube-only" + +"unified-output" split by *feature area* is replaced by a split by +*compliance level*, because that is the axis a reader actually has to choose +on (per U6's restated reconciliation, above): does this consumer need the +ticket's strict "nothing live enters Terraform" guarantee, or is +materialization-primary/var-convenience acceptable? Write both modes in one +coherent document (a real consumer may use Mode A for kube and Mode B for +ports in the same module), but never present Mode B as satisfying Mode A's +guarantee. + +**Mode A — env-native kube (satisfies the ticket's strict contract):** + +1. `KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` from tunstrap's own process + environment (never a `var.`-bound value, never a file read in HCL at all) + **plus a literal `config_context = "tunstrap--"` per + provider alias** — the ticket's own central pattern (finding #3), shown + with a **two-alias worked example** (two `kubernetes`/`helm` provider + blocks, one per target, each with its own literal `config_context`, + sharing the same `KUBE_CONFIG_PATHS` list) citing findings #3 and #5 by + number. `config_path`/`config_paths` need not be set explicitly at all in + this mode — the env vars alone resolve them per the provider's own + `EnvDefaultFunc`. + ```hcl + provider "kubernetes" { + alias = "node1_k3s" + config_context = "tunstrap-node1-k3s" # literal -- see warning below + } + provider "kubernetes" { + alias = "node2_k3s" + config_context = "tunstrap-node2-k3s" + } + ``` +2. **Warning, explicit:** never derive `config_context`'s value from + `var.tunstrap` or any other live/decoded data — it must be a literal + string in the config, matching the deterministic naming scheme exactly + (`tunstrap--`, "The deterministic-naming contract," above). + Deriving it live would reintroduce a variable-bound value for data that + has an env-native, fully static alternative, defeating the point of Mode + A. + +**Mode B — unified-file convenience (ports + kube references; does *not* +satisfy the ticket's strict contract, stated plainly, not glossed over):** + +3. **[R16, iteration 7 — this item's HCL is rewritten, not just re-worded]** + **The shape**, with a worked HCL example using the env-carried + `TUNSTRAP_OUTPUT_FILE` locator — **never** a literal, operator-pinned path + (R9's mode 2, now retracted) and never `var.tunstrap_session_dir` or any + variable-derived locator; an earlier revision of this recipe used the + unsound variable-derived form, a later one used the now-retracted pinned + form, both corrected here: + ```hcl + locals { + tunnel = try( + jsondecode(file(get_env("TUNSTRAP_OUTPUT_FILE"))), + { nodes = {} }, + ) + } + + provider "kubernetes" { + config_path = local.tunnel.nodes.node1.kube.k3s.path + } + ``` + `get_env(...)` is Terragrunt's own function for reading the parent + process's environment into HCL — this is exactly the bridge tunstrap's + `run` (or `start --output env`) sets up by exporting + `TUNSTRAP_OUTPUT_FILE` before spawning the child. No caller-supplied + `--session-dir`, no operator-agreed literal path: `run` mints an ephemeral + session dir the same way it always has, and every invocation's own child + sees that invocation's own fresh path via the env var, never a stale one. + Read directly inside the `locals` block that feeds the provider config — + never through an `output`, per the stability contract's finding-#2 + warning. +4. **Ports lose their integer form in this shape** (a `"host:port"` string, + not a bare port number) — show the HCL extraction idiom explicitly, one + canonical form, not left to the reader to invent: + ```hcl + locals { + service1_port = split(":", local.tunnel.nodes.node1.ports.service1)[1] + } + ``` +5. **[R16, iteration 7 — corrected: item 3 is no longer plan-safe across a + restart]** **The stability contract**, restated plainly and matching + "Stability contract" above word-for-word on the load-bearing claims: Mode + B via `TUNSTRAP_OUTPUT_FILE` (item 3) **and** Mode B via `--output-var` + (`var.tunstrap`/`TF_VAR_tunstrap`) are **both one-shot `plan && apply` + only** — no saved-plan reuse across a tunstrap restart, for either, since + the session dir stays ephemeral (an earlier revision of this item claimed + item 3 was unconditionally plan-safe given a `--session-dir` precondition + — that precondition is retracted along with R9's mode 2, see "Relationship + to #14," above). State this as plainly as: *"Neither Mode B form survives + a tunstrap restart. If you need a saved plan to `apply` cleanly against + fresh ports or fetched-file content, re-run `plan` in the same tunstrap + invocation instead — there is no supported way to pin either form's + locator to a stable path across invocations."* +6. **The `jsondecode`-not-JavaScript note** (U5): the recipe consumes JSON + via HCL's `jsondecode`, there is no JS runtime in this stack, and the + recipe should say so in one sentence to preempt the same question this + design had to resolve as an explicit assumption. +7. **[R16, iteration 7 — this warning is retracted, not carried forward]** + The "never `--fetch` secrets with `--output-var`" warning two earlier + revisions of this recipe stated (one dropped it, the next restored it) is + **resolved as a class**, not restated: `fetch_files` content no longer + rides any consumer-facing channel at all — `--output-var` carries only + `{path, size, sha256}` (see "Compatibility," above). The recipe should say + this plainly instead of warning against something that can no longer + happen: *"Fetched file content never enters a Terraform variable or plan + file — only its path, size, and checksum do. Read the file itself at + `fetch_files..path` if you need its contents."* + +**Measured-facts list, corrected to cite all six of the ticket's own +findings [editorial fix, iteration 6 — an earlier revision's list, despite +its own header claiming "the ticket's own six findings," actually cited +only four]:** + +- **#1** — provider configuration **is** re-evaluated at apply: a `file()` + read inside a provider block picks up a post-plan change (Mode B's basis). +- **#2** — outputs **freeze silently**: `file()` read through an output + returns the plan-time value at apply, with no error — the nastiest failure + mode, name it as such. +- **#3** — **[was missing]** per-alias `config_context` works with an + env-supplied kubeconfig path: two aliases, literal `config_context` each, + sharing one `KUBECONFIG`/`KUBE_CONFIG_PATHS` list, each reach their own + cluster — Mode A's basis, cited with the two-alias worked example above. +- **#4** — **[was missing]** plan-safe end to end, measured live in the + unpublished #15 spike (no automated test covers this saved-plan mutation + scenario): plan with + one set of ports, mutate only the kubeconfig, apply the *saved* plan → the + alias uses the mutated value, zero "Mismatch between input and plan + variable value" — this is the e2e-level confirmation that Mode A's + env-native path really is plan-safe across a saved-plan reuse, not just a + theoretical consequence of finding #1. +- **#5** — `KUBE_CONFIG_PATHS` is colon-separated on Linux (comma silently + falls back to `localhost:80`). +- **#6** — a live value bound to a `var.` **does** trip "Mismatch between + input and plan variable value" on a saved plan — the negative control + behind the one-shot-only rule for Mode B's variable form (R9's original + finding, restated for the file form too under R16 since the file itself no + longer survives a restart either). +- A live value bound to a **resource attribute** (not a provider config + block) produces `Error: Provider produced inconsistent final plan` — + confirmed for `hashicorp/kubernetes` v2.38.0 in this design's own + provider-findings probe (Q3), reproducing the ticket's #14 claim on + current provider versions. The recipe must show the provider-block + placement as the only supported shape in both modes and name this failure + mode explicitly as what happens if a reader tries the resource-attribute + shape instead. + +## Out of scope + +- Pruning ignored (non-current) contexts/clusters/users from the materialized + document — accepted residual risk, see "Rename scope" above. +- A configurable identity-naming prefix — explicitly rejected by the ticket's + own org-rule citation. +- Any change to `KubeParseError`, `parse_kubeconfig`'s section-parser split, + or `patch_view`'s server/TLS rewriting — all untouched by this design and + still pinned by `tests/unit/test_kube_parse.py`, + `tests/unit/test_kube_parse_invariants.py`, and + `tests/unit/test_kube_patch.py` respectively. +- **[PIVOT]** Literal JavaScript execution or a JS runtime in the consumer + chain — "через js" is interpreted as JSON delivery consumed via HCL's + `jsondecode`, per the recorded assumption above; introducing an actual JS + step (e.g. a `local-exec` calling `node`) is not part of this design and + was never asked for beyond that phrase. +- **[PIVOT]** A configurable materialized-output filename/location — fixed at + `/tunnel-data/output.json`, matching the fixed-naming + philosophy the kube identity contract already established (decision 4); + not separately requested, so not built. +- **[R15, new]** **#14 fix 3 — a CLI-level warning when the child's tofu + invocation captures a saved plan (e.g. `-out=`) while non-plan-safe + delivery is in use.** Deliberately deferred to #14, not forgotten: this + design's stability contract and the recipe's explicit warnings cover the + risk in documentation; a runtime warning would require `run` to parse its + own child's command line for Terraform-specific flags, which the pre-#15 + design deliberately confined to `tunstrap_tofu` alone, not generic `run` — + see "Relationship to #14," above, for the full reasoning. +- **[R10, new]** A different join separator (replacing the hyphen in + `tunstrap--`) to eliminate the naming-collision surface + structurally instead of detecting it — a larger, unrequested change; the + validation-time collision check (above) is the shipped fix. + +## Pointers + +- `docs/specs/2026-08-10-issue15-provider-env-precedence.md` — committed + provider-precedence evidence behind the env-export contract above. +- The untracked issue #15 spike notes contained the six-variant comparison and + regression-test prototype; they are not public reference material. +- `docs/specs/2026-08-07-issue15-kube-identity-decisions.md` — the + decision-history companion to this design, one entry per decision with + alternatives considered and consequences. +- `docs/superpowers/plans/2026-08-07-issue15-kube-identity.md` — the + implementation plan, cherry-picking from `variant/combined`. diff --git a/docs/specs/2026-08-10-issue15-provider-env-precedence.md b/docs/specs/2026-08-10-issue15-provider-env-precedence.md new file mode 100644 index 0000000..00a5b91 --- /dev/null +++ b/docs/specs/2026-08-10-issue15-provider-env-precedence.md @@ -0,0 +1,46 @@ +# Provider environment precedence for kubeconfig paths (issue #15) + +- Date measured: 2026-08-07 +- Probe runtime: OpenTofu v1.12.5; kind v0.30.0; `hashicorp/kubernetes` + v2.38.0; `hashicorp/helm` v2.17.0. +- Scope: provider environment resolution relevant to tunstrap's kube channel. + +## Measured result + +For both providers, plain `KUBECONFIG` alone did not configure the provider. +`KUBE_CONFIG_PATH` configured a single kubeconfig, and `KUBE_CONFIG_PATHS` +configured a list. When both provider-specific variables were present, +`KUBE_CONFIG_PATH` won over `KUBE_CONFIG_PATHS`. + +The Kubernetes probe read `data.kubernetes_namespace.system`; with only +`KUBECONFIG` it tried `127.0.0.1:80` and failed, while either provider-specific +variable read `kube-system` successfully. The Helm probe similarly failed with +only `KUBECONFIG`; it applied successfully with either provider-specific +variable. With an invalid `KUBE_CONFIG_PATH` and a valid +`KUBE_CONFIG_PATHS`, Helm failed trying the invalid single path. + +## Decision supported by this result + +`render_kube_env` exports `KUBECONFIG` plus exactly one provider-specific +variable: `KUBE_CONFIG_PATH` for one materialized file, or +`KUBE_CONFIG_PATHS` for two or more. It must not export both provider-specific +variables for multiple files, because the single-path variable would hide the +list. + +The path-list separator was measured as colon on Linux. This is an observation +from the stated runtime, not a cross-platform claim. + +## Source-level corroboration + +The measured behavior is consistent with the Kubernetes provider's documented +configuration precedence and Helm's nested Kubernetes configuration, but this +spec does not independently verify a source-level resolution order. The tagged +source trees are [Kubernetes v2.38.0](https://github.com/hashicorp/terraform-provider-kubernetes/tree/v2.38.0) +and [Helm v2.17.0](https://github.com/hashicorp/terraform-provider-helm/tree/v2.17.0). + +## Related measurement: resource attributes (Q3) + +With `hashicorp/kubernetes` v2.38.0, changing a file-derived +`kubernetes_config_map_v1.data["value"]` between planning and applying a saved +plan produced `Provider produced inconsistent final plan`. This result applies +to a resource attribute, not a provider configuration block. diff --git a/docs/superpowers/plans/2026-05-30-kube-targets.md b/docs/superpowers/plans/2026-05-30-kube-targets.md index 3de8c16..9eb3b57 100644 --- a/docs/superpowers/plans/2026-05-30-kube-targets.md +++ b/docs/superpowers/plans/2026-05-30-kube-targets.md @@ -1,5 +1,8 @@ # Kube-targets Implementation Plan +> **Redaction/repoint note (2026-08-10):** Replaced ignored scratch-file paths +> with an accurate description of their untracked location; no plan step changed. + > **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:** Add a self-contained "kube mode" to `tunstrap` so it reads a remote kubeconfig, forwards the apiserver port, probes the serving-cert SAN for `tls-server-name`, patches `server:`, and returns ready-to-use kubeconfig fields — eliminating the HCL-side reconstruction. @@ -43,7 +46,7 @@ ### Task 0.1: Record baseline test counts **Files:** -- Create: `docs/artifacts/2026-05-30-kube-targets-baseline.md` +- Record the baseline in the untracked artifacts directory. - [ ] **Step 1: Run the unit suite and capture counts** @@ -62,7 +65,7 @@ Expected: all clean on the untouched baseline. - [ ] **Step 3: Write the baseline artifact** -Create `docs/artifacts/2026-05-30-kube-targets-baseline.md` with the recorded unit count, gate statuses, and the date. (This file is gitignored by design — do not attempt to commit it.) +Record the unit count, gate statuses, and date in the gitignored artifacts directory; do not attempt to commit that scratch file. - [ ] **Step 4: No commit** @@ -2678,7 +2681,7 @@ git commit -m "docs: README kube mode, materialization, host-key, migration" ### Task 9.1: Full verification sweep **Files:** -- Create/Update: `docs/artifacts/2026-05-30-kube-targets-baseline.md` (final counts) +- Create/update the untracked baseline record with final counts. - [ ] **Step 1: Run the entire test + gate matrix** diff --git a/docs/superpowers/plans/2026-08-07-issue15-kube-identity.md b/docs/superpowers/plans/2026-08-07-issue15-kube-identity.md new file mode 100644 index 0000000..a306b7b --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-issue15-kube-identity.md @@ -0,0 +1,2656 @@ +# Kubeconfig-as-identity delivery (issue #15) Implementation Plan + +> **Redaction/repoint note (2026-08-10):** Repointed provider evidence to its +> committed spec and replaced local/ignored references with accurate placeholders. + +> **Status: historical record, NOT the executable plan.** This file carries the +> full 8-iteration decision history inline. Implementers execute +> `docs/superpowers/plans/2026-08-08-issue15-kube-identity-clean.md` +> (the consolidated final state); apply any future correction THERE, and treat +> this file as frozen. + +> **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. + +**Revised (iteration 3): the unified-output-contract pivot.** Tasks 1-3 below +(kube identity rename, mandatory collision test, kube env conditional +contract) are **unchanged by the pivot** — U4, kube part stands as written, +cherry-pick from the spike exactly as before. **Task 4 is entirely new** +(unified output shape + `render_unified_output`, pure-function work). **Task 5 +is entirely new and absorbs iteration 2's old Task 4** (multi-node kube +wiring): the old two-branch `_build_child_env` is superseded by one +unconditional `render_kube_env` call, per decision history entry 13, and +Task 5 additionally wires materialization and deletes `render_env`, +`MultiNodeEnvUnsupported`, and `inject_scalars` in the same commit, since all +three are the same edit site. The recipe task (renumbered Task 6, was Task 5) +gains new content; the gate-pass task is renumbered Task 7 (was Task 6) with +no content change beyond fixed cross-references. + +**Revised (iteration 6): a three-model red-team review of the pivot found 12 +consolidated findings, all addressed in place — task numbering is unchanged +from iteration 4/5, only task *content*.** Highlights: Task 1 gains a naming +collision check (R10) and a dangling-reference fix (R14); Task 3's +`predicted_env_keys` ships the **conservative** superset formula directly, +not the exact-cardinality one an earlier revision had it compute (R11 — no +"Task 5 fixes it again" deferral needed here, the correct formula lands the +first time); Task 5's materialization writer is a true atomic replace, not +`_write_file` reused as-is (R13), and gains a stdin-mode `--output env` +guard; Task 6's recipe is two explicit consumer modes (env-native kube / +unified-file convenience), not a single blended one, and drops the unsound +`var.tunstrap_session_dir` locator pattern entirely (R9, R12) — decision +history entries 14-18 carry the full reasoning for each. + +**Revised (iteration 7): the user's confirmed post-red-team direction plus +one added constraint (R16), superseding parts of R9/R12/R13/R15 — task +numbering unchanged, only task content.** Core principle: **content on disk, +paths in env.** Delivery collapses from three modes to two — R9's mode 2 +(the literal, caller-pinned `--session-dir` file) is retracted; the +`--output-var` bridge survives only for bare `tofu`. `TUNSTRAP_OUTPUT_FILE` +generalizes into the primary, env-carried locator for `run` (not just `start +--output env`), read via `get_env(...)`/`file()`, never a pinned path. +`fetch_files` content_b64 is removed from every consumer-facing channel: the +daemon materializes fetched bytes to `tunnel-data/-` +(mirroring the kube precedent exactly) and projects `{path, size, sha256}` +instead — Task 5 gains this mechanism plus a dedicated `content_b64` +blast-radius enumeration. **User's constraint, encoded explicitly:** the +session dir stays mandatory *lifecycle* infrastructure (`daemon.pid`, +`session.lock`, `stop`/recovery) — only its role as a *consumer-facing* +locator is retracted, not the directory itself. See decision history entry +19 (new) and the correction annotations on entries 14 and 18. + +**Goal:** Rename the materialized kubeconfig's cluster/user/context to +`tunstrap--` (deterministic, per-target); make the `KUBECONFIG` +export multi-node-safe; export the OpenTofu-provider-facing env vars under the +conditional cardinality contract (never the naive superset) — all three +unchanged by the pivot. **New:** replace tunstrap's entire consumer-facing +output with one unified, node-qualified JSON structure, delivered as both an +`--output-var` value and a materialized session-dir file (materialization +primary); remove the `TUNSTRAP__*` scalar channel, +`MultiNodeEnvUnsupported`, and `inject_scalars` entirely; document the +resulting recipe (kube-only guidance unchanged, unified-output guidance new). + +**Spec:** `docs/specs/2026-08-07-issue15-kube-identity-delivery-design.md`. +**Decision history:** `docs/specs/2026-08-07-issue15-kube-identity-decisions.md` +(entries 10-13 are the pivot; entry 9 is marked superseded, not deleted). +**Reference implementation:** `variant/combined` in the scratch worktree +`` — reviewed, +475/475 pre-existing unit tests pass plus one new regression test (476/476). +**Cherry-pick the `kube.py` rename change from it as-is; the `envrender.py` +change needs its body replaced** — the spike's Axis 3 prototype (superset +export) is superseded by the conditional contract below (design doc §"Env- +export contract", decision-history #3). **Scope note, iteration 3:** the +spike predates the pivot and covers the kube part only (Tasks 1-3 below); +nothing in it prototypes the unified output contract, materialization, or the +scalar-channel removal (Tasks 5-6) — those are new code with no spike +reference to cherry-pick from. Do not re-derive the six OpenTofu findings or +the provider-precedence findings; the latter is now committed in the provider-precedence spec. + +**Target branch:** `feature/run-env-io` (PR #13). Every task below assumes a +checkout of that branch (not the spike worktree) as the working tree; the +spike worktree is a read-only reference, never committed from directly. + +**Tech stack:** Python 3.10+, Pydantic v2, Click, ruamel.yaml, pytest + +pytest-asyncio. Use `.venv/bin/{pytest,ruff,black,mypy,pylint,vulture}`. +Integration/e2e tiers need Docker (+ `kind`/`kubectl`/`tofu` for e2e) on +`PATH`; see `tests/README.md` for env flags. + +--- + +## File Structure + +| File | Responsibility | +|------|----------------| +| `tunstrap/kube.py` | New `rename_identities(doc, node, target) -> str`; one call site in `run_kube_targets` between `patch_view` and `dump_kubeconfig`. **[unchanged by the pivot]** | +| `tunstrap/envrender.py` | New `render_kube_env(output) -> dict[str,str]` (conditional cardinality contract, **unchanged by the pivot**) + a shared `_kube_channel_keys(count)` helper reused by `predicted_env_keys`. **[PIVOT]** New `render_unified_output(output) -> dict[str, Any]` (the unified structure) and `UnifiedOutput`/`UnifiedNode`/`UnifiedKubeRef` models (or equivalent — see Task 5); `render_output_var`'s body rewritten to serialize the unified structure instead of the old `RunKubeTarget` projection; `render_env` and `predicted_env_keys`' scalar half **deleted**. | +| `tunstrap/schemas.py` | **[PIVOT]** New `UnifiedOutput`/`UnifiedNode`/`UnifiedKubeRef` Pydantic models (or placed in `envrender.py` — Task 5 picks one and states why). | +| `tunstrap/exceptions.py` | **[PIVOT]** `MultiNodeEnvUnsupported` and its `_EXIT_CODES` entry **deleted**. | +| `tunstrap/cli.py` | `_build_child_env`: **[PIVOT, supersedes iteration 2's two-branch design]** unconditional `render_kube_env(output)` call (no `inject_scalars` branch — the branch and the flag are both deleted); extend `suppress_kubeconfig` to drop all three kube env names (unchanged by the pivot); **[PIVOT]** new unconditional materialization write for `run`; `cli.py:640`'s pre-spawn multi-node-without-`--output-var` gate **deleted**. | +| `tests/unit/test_kube_rename.py` | New. Direct unit tests for `rename_identities` as a pure function. **[unchanged by the pivot]** | +| `tests/unit/test_kube_identity_collision.py` | New. The mandatory k3s-style collision regression test (promoted from the spike's throwaway `test_issue15_context_collision.py`, renamed to match this repo's non-issue-numbered test naming convention). **[unchanged by the pivot]** | +| `tests/unit/test_kube_run.py` | Extend: `context_name`/`cluster_name` now assert the renamed value, not just "non-empty output". **[unchanged by the pivot]** | +| `tests/unit/test_envrender.py` | Extend: `render_kube_env` cardinality cases (0/1/≥2 files, unchanged by the pivot, Task 3); `predicted_env_keys` cardinality cases (rewritten scope, **Task 5**). **[PIVOT]** New: `render_unified_output` shape tests (Task 4); every `render_env`-dependent test deleted by name, incl. the Task-3-era `test_render_kube_env_works_for_multi_node_while_render_env_still_rejects`; anti-drift guard **retargeted, not deleted** (Task 5). | +| `tests/unit/test_cli_run_output_var.py` | Extend/rewrite: multi-node `run` with kube_targets gets the kube channel and the unified output in the child env, **with or without** `--output-var`; `suppress_kubeconfig` drops all three kube names; **[PIVOT]** every `TUNSTRAP__*`-scalar or `MultiNodeEnvUnsupported`-exit-code assertion deleted or rewritten (Task 5). | +| `tests/unit/test_cli_run_output_var_projection.py` | **[PIVOT]** Retargeted, not left alone (missed twice before iteration 4) — the credential-scrubbing pin for the kube reference; shape moves to `nodes.*.kube.*`, expected field set narrows to `UnifiedKubeRef`'s three fields, one test deleted outright (Task 5). | +| `tests/unit/test_cli_run_materialize.py` | **[PIVOT]** New. Materialization writer tests (session-dir file, mode, content, `TUNSTRAP_OUTPUT_FILE`) (Task 5). | +| `tests/unit/test_cli_run.py`, `test_cli_run_input_env_scrub.py`, `test_cli_runner.py`, `test_cli_run_postspawn.py` | **[PIVOT]** Each retargets one pre-existing `TUNSTRAP__*` or `MultiNodeEnvUnsupported` assertion — full list in Task 5's blast-radius table (Task 5). | +| `tests/unit/test_exceptions.py`, `tests/unit/test_tofu_proxy.py` | **[PIVOT]** Three `MultiNodeEnvUnsupported` cases deleted; two docstrings updated to drop `inject_scalars` framing (Task 5). | +| `tunstrap/session.py` | **[PIVOT, R13]** Referenced, not necessarily modified — `_write_file`'s mode-fixed-at-creation property (not "atomic": no rename step) is either factored into a shared atomic-replace (temp file + `os.replace`) helper both kube materialization and `output.json` use, or the atomic-replace primitive is replicated inline in `cli.py` if that refactor isn't clean (Task 5). | +| `tunstrap/schemas.py` (naming collision) | **[PIVOT, R10]** New `InputSchema`-level `model_validator` rejecting a payload where two `(node, target)` pairs render the same `tunstrap--` string (Task 1). | +| `tests/integration/test_run_env_io.py`, `test_cli_modes.py` | **[PIVOT]** Retargeted for the same shape/scalar removal, proven against the real console script (Task 5 Step 7). | +| `tests/e2e/module/main.tf`, `rig.py`, `test_tofu_providers.py`, `test_terragrunt_apply.py` | **[PIVOT]** Retargeted to `nodes.*.kube.*.path` (Task 6 Step 0) — mandatory, not the optional collision-specific e2e coverage. | +| `docs/recipe_terragrunt.md` | Recipe section per the design doc's "Documentation" item — kube-only content unchanged **except its pre-existing `connections.*` shape, fixed in Task 6 Step 0 before new content is added**; **[PIVOT]** new unified-output-consumption + stability-contract + jsondecode-note content (Task 6 Steps 1-2). | + +--- + +### Task 1: `rename_identities` + call site in `kube.py` + naming collision check + +**Files:** +- Modify: `tunstrap/kube.py` (rename_identities, R14 dangling-reference fix), + `tunstrap/schemas.py` (new naming-collision validator, R10) +- Test: `tests/unit/test_kube_rename.py` (new), `tests/unit/test_kube_run.py` + (extend), `tests/unit/test_schemas_kube.py` or a new + `tests/unit/test_schemas_kube_naming_collision.py` (R10's collision test) + +- [ ] **Step 1: Write failing tests** + +`tests/unit/test_kube_rename.py`: + +```python +"""rename_identities: deterministic tunstrap-- identity rename. + +Validates: the current-context's cluster/user/context are all renamed to the +same tunstrap-- string; non-current entries are untouched; +current-context itself is updated; the return value is that shared name. +Code: tunstrap/kube.py::rename_identities +Assertion: post-call doc state matches exactly, including the untouched +ignored entries; the returned name equals every renamed field. +Method: build a minimal ruamel-shaped dict (plain dicts are sufficient; the +function only calls .get/[]/isinstance) with two contexts, call the function, +inspect doc afterwards. +""" + +from __future__ import annotations + +import pytest + +from tunstrap.kube import rename_identities + +pytestmark = pytest.mark.unit + + +def _doc() -> dict[str, object]: + return { + "current-context": "default", + "contexts": [ + {"name": "default", "context": {"cluster": "default", "user": "default"}}, + {"name": "other", "context": {"cluster": "other-c", "user": "other-u"}}, + ], + "clusters": [ + {"name": "default", "cluster": {"server": "https://127.0.0.1:1"}}, + {"name": "other-c", "cluster": {"server": "https://127.0.0.1:2"}}, + ], + "users": [ + {"name": "default", "user": {}}, + {"name": "other-u", "user": {}}, + ], + } + + +def test_renames_current_context_cluster_and_user_to_shared_name() -> None: + """All three identity fields get the same tunstrap-- value.""" + doc = _doc() + new_name = rename_identities(doc, "node-a", "kube") + assert new_name == "tunstrap-node-a-kube" + assert doc["current-context"] == "tunstrap-node-a-kube" + ctx = doc["contexts"][0] + assert ctx["name"] == "tunstrap-node-a-kube" + assert ctx["context"]["cluster"] == "tunstrap-node-a-kube" + assert ctx["context"]["user"] == "tunstrap-node-a-kube" + assert doc["clusters"][0]["name"] == "tunstrap-node-a-kube" + assert doc["users"][0]["name"] == "tunstrap-node-a-kube" + + +def test_ignored_entries_are_left_untouched() -> None: + """Non-current context/cluster/user entries survive byte-stable.""" + doc = _doc() + rename_identities(doc, "node-a", "kube") + assert doc["contexts"][1] == { + "name": "other", + "context": {"cluster": "other-c", "user": "other-u"}, + } + assert doc["clusters"][1]["name"] == "other-c" + assert doc["users"][1]["name"] == "other-u" + + +def test_two_nodes_same_upstream_names_get_distinct_results() -> None: + """The exact k3s-style collision case: same input, different node -> different name.""" + assert rename_identities(_doc(), "a", "kube") != rename_identities(_doc(), "b", "kube") + + +def test_ignored_context_sharing_the_active_cluster_keeps_a_valid_reference() -> None: + """[R14] A non-current context that references the SAME cluster/user the + active triple uses must have that reference updated too, or it dangles -- + naming a cluster/user that no longer exists anywhere in the document + under its old name. The ignored context's own `name` is untouched (it is + not renamed itself, only its cluster/user references are); only entries + that neither ARE nor REFERENCE the active triple stay fully byte-stable.""" + doc: dict[str, object] = { + "current-context": "default", + "contexts": [ + {"name": "default", "context": {"cluster": "default", "user": "default"}}, + # Shares the SAME cluster/user as the active context, under a + # different context name -- a legitimate, ordinary kubeconfig shape. + {"name": "staging", "context": {"cluster": "default", "user": "default"}}, + ], + "clusters": [{"name": "default", "cluster": {"server": "https://127.0.0.1:1"}}], + "users": [{"name": "default", "user": {}}], + } + new_name = rename_identities(doc, "node-a", "kube") + staging_ctx = doc["contexts"][1] + assert staging_ctx["name"] == "staging" # the ignored context's own name is untouched + assert staging_ctx["context"]["cluster"] == new_name # its reference is NOT left dangling + assert staging_ctx["context"]["user"] == new_name + # And the referenced entries genuinely exist under the new name. + assert doc["clusters"][0]["name"] == new_name + assert doc["users"][0]["name"] == new_name +``` + +Add to `tests/unit/test_kube_run.py` (extends the existing +`test_run_kube_target_success`): + +```python +@pytest.mark.asyncio +async def test_run_kube_target_reports_renamed_identity(monkeypatch: pytest.MonkeyPatch) -> None: + """KubeTargetOutput.context_name/cluster_name are tunstrap--, not upstream.""" + monkeypatch.setattr( + "tunstrap.kube.sans_from_cert", + lambda _der: (["dev-kube-1", "192.0.2.11"], []), + ) + conn = _FakeConn((FIXTURES / "single_internal_ip.yaml").read_bytes()) + outputs, _, _ = await run_kube_targets( + conn, + {"k3s": KubeTarget.model_validate({"kubeconfig_path": "/etc/k3s.yaml"})}, + connect_timeout=5, + probe=_probe_ok, + node_name="edge", + ) + out = outputs["k3s"] + assert out.context_name == "tunstrap-edge-k3s" + assert out.cluster_name == "tunstrap-edge-k3s" +``` + +- [ ] **Step 2: Run to verify failure** + +`.venv/bin/pytest tests/unit/test_kube_rename.py tests/unit/test_kube_run.py -v` +Expected: `test_kube_rename.py` fails on import (`rename_identities` missing); +the new `test_kube_run.py` case fails because `context_name`/`cluster_name` +still equal the fixture's upstream `"production"`. + +- [ ] **Step 3: Implement `rename_identities` in `kube.py`** + +Add after `patch_view`, before `dump_kubeconfig` (cherry-pick from +`variant/combined`, `tunstrap/kube.py`, with the docstring's "V1c" spike +framing dropped — that context belongs in the decision history, not the +shipped code — and `__all__` gains `"rename_identities"`): + +```python +def rename_identities(doc: dict[str, object], node: str, target: str) -> str: + """Rename the current-context's cluster/user/context to a deterministic name. + + ``tunstrap--`` for cluster, user and context alike — see + docs/specs/2026-08-07-issue15-kube-identity-delivery-design.md. Operates on + the raw parsed document alone; the current-context's own name is enough to + find every entry that needs renaming. + + [R14] Every *other* context's cluster/user REFERENCES are also updated if + they name the same cluster/user being renamed here -- a kubeconfig can + legitimately have two contexts sharing one cluster or user entry, and + leaving such a reference unrenamed while the entry it points at IS renamed + would dangle it. Only entries that neither are, nor reference, the active + triple are left untouched; other contexts' own `name` fields are never + renamed, only their `cluster`/`user` reference fields when they match. + + Returns the new name (shared by cluster, user and context alike). + """ + new_name = f"tunstrap-{node}-{target}" + current = doc.get("current-context") + assert isinstance(current, str) + contexts_raw = doc.get("contexts") + contexts: list[object] = contexts_raw if isinstance(contexts_raw, list) else [] + ctx_entry = _find_named(contexts, current) + assert ctx_entry is not None + ctx_body = ctx_entry["context"] + assert isinstance(ctx_body, dict) + old_cluster = ctx_body["cluster"] + old_user = ctx_body["user"] + assert isinstance(old_cluster, str) + assert isinstance(old_user, str) + + ctx_entry["name"] = new_name + ctx_body["cluster"] = new_name + ctx_body["user"] = new_name + + cluster_entry = _find_named(doc.get("clusters") or [], old_cluster) + assert cluster_entry is not None + cluster_entry["name"] = new_name + + user_entry = _find_named(doc.get("users") or [], old_user) + assert user_entry is not None + user_entry["name"] = new_name + + doc["current-context"] = new_name + + # [R14] Sweep every OTHER context for a reference to the cluster/user + # entries just renamed. This context's own `name` is not touched -- it is + # not becoming the current context, only its dangling reference is fixed. + for entry in contexts: + if entry is ctx_entry or not isinstance(entry, dict): + continue + other_body = entry.get("context") + if not isinstance(other_body, dict): + continue + if other_body.get("cluster") == old_cluster: + other_body["cluster"] = new_name + if other_body.get("user") == old_user: + other_body["user"] = new_name + + return new_name +``` + +Wire the call site in `run_kube_targets` (replace the `patch_view` → +`dump_kubeconfig` → `KubeTargetOutput` block): + +```python + patch_view(view, local_port=local_port, tls_server_name=tls_name, insecure=insecure) + assert isinstance(view.doc, dict) # parse_kubeconfig guaranteed this + new_identity = rename_identities(view.doc, node_name, name) + patched = dump_kubeconfig(view) + outputs[name] = KubeTargetOutput( + cluster_name=new_identity, + context_name=new_identity, +``` + +**Do not carry over the spike's `# type: ignore[arg-type]`** on this call — +`view.doc` is typed `object` on `KubeconfigView` (dataclass field, `kube.py:56`) +and `rename_identities` wants `dict[str, object]`; use the explicit +`assert isinstance(view.doc, dict)` above instead, matching the pattern +`patch_view` already uses two lines earlier (`kube.py:257`) — this keeps +`mypy --strict` clean without a suppression. + +- [ ] **Step 4: Run to verify pass** + +`.venv/bin/pytest tests/unit/test_kube_rename.py tests/unit/test_kube_run.py tests/unit/test_kube_patch.py tests/unit/test_kube_parse.py tests/unit/test_kube_parse_invariants.py -v` +Expected: all pass. (The last three files are the ones the spike confirmed +are unaffected — this run is the regression check that confirms it stayed +true after cherry-picking, not a re-derivation.) + +- [ ] **Step 5: Commit** + +```bash +git add tunstrap/kube.py tests/unit/test_kube_rename.py tests/unit/test_kube_run.py +git commit -m "feat(kube): rename current-context identity to tunstrap-- (#15)" +``` + +- [ ] **Step 6: [R10] Write the failing naming-collision test** + +New file `tests/unit/test_schemas_kube_naming_collision.py`: + +```python +"""[R10] tunstrap-- is NOT unique by construction. + +_FETCH_FILES_KEY_RE (schemas.py:11) permits internal hyphens in node/target +identifiers, and the join itself uses a hyphen, so two DIFFERENT (node, +target) pairs can render the SAME string: (node="a-b", target="c") and +(node="a", target="b-c") both produce "tunstrap-a-b-c". This is a distinct +defect class from the mandatory k3s-style collision test (Task 2) -- that +test proves upstream kubeconfig names colliding is fixed by the rename; this +test proves tunstrap's OWN naming scheme does not collide with itself, +independent of any kubeconfig content at all. + +Code: tunstrap/schemas.py (new validator, InputSchema level) +Method: construct an InputSchema with exactly the a-b/c vs a/b-c pair and +assert validation rejects it, naming both colliding pairs. +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from tunstrap.schemas import InputSchema + +pytestmark = pytest.mark.unit + + +def test_naming_join_collision_across_nodes_is_rejected() -> None: + """(node='a-b', target='c') and (node='a', target='b-c') both join to + tunstrap-a-b-c -- reject the whole payload, naming both colliding pairs.""" + with pytest.raises(ValidationError) as excinfo: + InputSchema.model_validate( + { + "nodes": { + "a-b": { + "host": "h1", "user": "u", "ssh_password": "p", + "kube_targets": {"c": {"kubeconfig_path": "/etc/x.yaml"}}, + }, + "a": { + "host": "h2", "user": "u", "ssh_password": "p", + "kube_targets": {"b-c": {"kubeconfig_path": "/etc/y.yaml"}}, + }, + } + } + ) + message = str(excinfo.value) + assert "tunstrap-a-b-c" in message + assert "a-b" in message and "c" in message # first colliding pair + assert "a" in message and "b-c" in message # second colliding pair + + +def test_non_colliding_hyphenated_names_are_accepted() -> None: + """Anti-vacuity: hyphens alone don't trigger the check -- only an actual join collision does.""" + InputSchema.model_validate( + { + "nodes": { + "node-one": { + "host": "h1", "user": "u", "ssh_password": "p", + "kube_targets": {"kube-a": {"kubeconfig_path": "/etc/x.yaml"}}, + }, + "node-two": { + "host": "h2", "user": "u", "ssh_password": "p", + "kube_targets": {"kube-b": {"kubeconfig_path": "/etc/y.yaml"}}, + }, + } + } + ) +``` + +- [ ] **Step 7: Run to verify failure** + +`.venv/bin/pytest tests/unit/test_schemas_kube_naming_collision.py -v` +Expected: FAIL — no such validator exists yet; both tests currently pass +`InputSchema.model_validate` without complaint (the first test's payload is +wrongly accepted today). + +- [ ] **Step 8: Implement the collision check in `schemas.py`** + +Add an `InputSchema`-level `model_validator(mode="after")` (alongside the +existing `_validate_auth` field validator, `schemas.py:278-289`) — this must +run at the `InputSchema` level, not per-`NodeInput`, since the collision is +cross-node: + +```python +@model_validator(mode="after") +def _validate_kube_identity_names_are_unique(self) -> InputSchema: + """[R10] tunstrap-- is not unique by construction (hyphens + are legal in both node and target names); reject a payload where two + different (node, target) pairs join to the same rendered identity.""" + seen: dict[str, tuple[str, str]] = {} + for node_name, node in self.nodes.items(): + for target_name in node.kube_targets or {}: + joined = f"tunstrap-{node_name}-{target_name}" + if joined in seen: + other_node, other_target = seen[joined] + raise ValueError( + f"kube identity name collision: ({node_name!r}, {target_name!r}) " + f"and ({other_node!r}, {other_target!r}) both render {joined!r}" + ) + seen[joined] = (node_name, target_name) + return self +``` + +Place this near `InputSchema`'s existing `_validate_auth` validator so both +cross-node checks live together. Note this validator is intentionally at +`InputSchema` level (has access to every node), not on `NodeInput` +(single-node scope, cannot see the collision) — do not move it there even +though `kube_targets` is a `NodeInput` field. + +- [ ] **Step 9: Run to verify pass, then commit** + +`.venv/bin/pytest tests/unit/test_schemas_kube_naming_collision.py tests/unit/test_schemas.py tests/unit/test_schemas_kube.py -v` +Expected: all pass. + +```bash +git add tunstrap/schemas.py tests/unit/test_schemas_kube_naming_collision.py +git commit -m "feat(schemas): reject tunstrap-- naming collisions (#15, R10)" +``` + +--- + +### Task 2: The mandatory collision regression test + +**Files:** +- Create: `tests/unit/test_kube_identity_collision.py` + +This is the trap the design doc's testing contract calls out by name: it must +land, unmodified in substance, regardless of how Task 1 was implemented. + +- [ ] **Step 1: Copy the spike's prototype under a repo-convention file name** + +Copy `tests/unit/test_issue15_context_collision.py` from the spike worktree +(` `, branch +`variant/combined`) to `tests/unit/test_kube_identity_collision.py` in this +checkout — **only the file is renamed** (this repo's other test files never +carry an issue number, e.g. `test_kube_run.py`, `test_envrender.py`). **Keep +the test function name unchanged**, +`test_two_k3s_style_targets_get_distinct_deterministic_identities` — it is +already descriptive and needs no rename. Drop the module docstring's "spike" +framing, replacing it with a plain description (content is otherwise correct +verbatim; see the untracked spike's Part 3 for the exact source). + +- [ ] **Step 2: Run to verify it is GREEN after Task 1** + +`.venv/bin/pytest tests/unit/test_kube_identity_collision.py -v` +Expected: PASS. (It was confirmed RED against the unmodified branch and GREEN +under `variant/combined` during the spike; this run confirms the same is true +against this checkout's own Task 1 implementation, not a re-derivation of the +spike's own finding.) + +- [ ] **Step 3: Commit** + +```bash +git add tests/unit/test_kube_identity_collision.py +git commit -m "test(kube): regression test for the k3s upstream-name collision trap (#15)" +``` + +--- + +### Task 3: `render_kube_env` + the conditional env-export contract + +**Files:** +- Modify: `tunstrap/envrender.py` +- Test: `tests/unit/test_envrender.py` + +- [ ] **Step 1: Write failing tests** + +Add to `tests/unit/test_envrender.py` (uses the existing `_kube_out` helper): + +```python +def test_render_kube_env_zero_files_returns_empty() -> None: + """No kube_targets anywhere -> no keys at all.""" + out = OutputSchema( + connections={"h": NodeOutput(ports={"db": 1})}, + pid=1, session_dir="/s", started_at="now", + ) + assert render_kube_env(out) == {} + + +def test_render_kube_env_one_file_sets_path_not_paths() -> None: + """Exactly one materialized file: KUBECONFIG + KUBE_CONFIG_PATH, no _PATHS.""" + out = OutputSchema( + connections={"h": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7000, "/s/k3s")})}, + pid=1, session_dir="/s", started_at="now", + ) + env = render_kube_env(out) + assert env == {"KUBECONFIG": "/s/k3s", "KUBE_CONFIG_PATH": "/s/k3s"} + assert "KUBE_CONFIG_PATHS" not in env + + +def test_render_kube_env_two_files_sets_paths_not_path() -> None: + """Two materialized files (could be one node, two targets, or two nodes): + KUBECONFIG + KUBE_CONFIG_PATHS, no _PATH -- KUBE_CONFIG_PATH would win over + KUBE_CONFIG_PATHS per the measured provider precedence and hide the second + cluster.""" + out = OutputSchema( + connections={ + "a": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7000, "/s/a-k3s")}), + "b": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7001, "/s/b-k3s")}), + }, + pid=1, session_dir="/s", started_at="now", + ) + env = render_kube_env(out) + assert env == {"KUBECONFIG": "/s/a-k3s:/s/b-k3s", "KUBE_CONFIG_PATHS": "/s/a-k3s:/s/b-k3s"} + assert "KUBE_CONFIG_PATH" not in env + + +def test_render_kube_env_works_for_multi_node_while_render_env_still_rejects() -> None: + """The exact split render_env's own docstring claims: kube channel is + node-count-agnostic, scalar channel is not.""" + out = OutputSchema( + connections={ + "a": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7000, "/s/a-k3s")}), + "b": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7001, "/s/b-k3s")}), + }, + pid=1, session_dir="/s", started_at="now", + ) + assert render_kube_env(out) # does not raise + with pytest.raises(MultiNodeEnvUnsupported): + render_env(out) + + +def test_predicted_env_keys_reserves_all_three_for_one_kube_target() -> None: + """[R11] predicted_env_keys is a CONSERVATIVE predictor, not exact: it + reserves all three kube names whenever ANY kube_targets are declared, + regardless of exact count -- input cardinality can shrink by output time + (an optional node/target can fail), so predicting the exact one-file + branch here would under-reserve KUBE_CONFIG_PATHS for a schema that + later, at runtime, actually produces >=2 files. render_kube_env's own + export (tested above) stays exact -- only the predictor is conservative.""" + schema = InputSchema.model_validate( + { + "nodes": { + "node": { + "host": "h.example.net", "user": "u", "ssh_password": "p", + "kube_targets": {"k3s": {"kubeconfig_path": "/etc/k3s.yaml"}}, + } + } + } + ) + keys = predicted_env_keys(schema) + assert {"KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"} <= keys + + +def test_predicted_env_keys_reserves_all_three_for_two_kube_targets_one_node() -> None: + """[R11] Same conservative reservation for the >=2 case -- the point is + that BOTH cardinalities reserve identically (all three), which is what + makes the predictor a safe over-approximation rather than a second exact + implementation of _kube_channel_keys.""" + schema = InputSchema.model_validate( + { + "nodes": { + "node": { + "host": "h.example.net", "user": "u", "ssh_password": "p", + "kube_targets": { + "a": {"kubeconfig_path": "/etc/a.yaml"}, + "b": {"kubeconfig_path": "/etc/b.yaml"}, + }, + } + } + } + ) + keys = predicted_env_keys(schema) + assert {"KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"} <= keys +``` + +**[R11] The anti-drift guard is two-part, not a single equality against +`render_kube_env`/`render_env`** — a red-team finding, logic-verified, shows +the naive "predict the exact cardinality branch" design under-reserves: +`predicted_env_keys` runs pre-spawn against *input* cardinality, but an +optional (`required: false`) node or kube target can fail without failing +the run, so *output* cardinality can be smaller than what was declared. Two +kube targets declared (predicting the `≥2` branch, `KUBE_CONFIG_PATHS` only) +but one optional node fails at connect time → only one file actually +materializes → the real export uses the `==1` branch (`KUBE_CONFIG_PATH`) — +which the exact predictor never reserved. A `--output-var KUBE_CONFIG_PATH` +would then pass the pre-spawn collision check and be **silently +overwritten** by the real export. Fix: `predicted_env_keys` reserves **all +three** kube names whenever *any* `kube_targets` are declared, not the exact +per-count subset (implemented below); this is deliberately a superset of +what usually gets injected, and over-reserving is the safe direction (a +false-positive usage error, cheap and visible) versus under-reserving +(a silent post-spawn collision). The anti-drift concern this guard encodes +is this codebase's own discipline (spike/design review process), not a +concern stated by the ticket itself — an earlier revision of this note +misattributed it to "the ticket's own review process," corrected here. Add +both new tests above (formula-correctness style, not drift-guard style — +see decision history entry 16 for the full two-part-guard design); the +drift-guard half (`actual ⊆ predicted`, driven by a cardinality-shrink case) +is added in Task 5 once `_build_child_env` exists to compute "actual" from. + +- [ ] **Step 2: Run to verify failure** + +`.venv/bin/pytest tests/unit/test_envrender.py -v` +Expected: FAIL — `render_kube_env` missing; `predicted_env_keys` still uses +the unconditional `KUBECONFIG`-only rule. + +- [ ] **Step 3: Implement in `envrender.py`** + +Add a shared cardinality helper (used by both `render_kube_env` and +`predicted_env_keys`, so the two cannot independently drift on this rule — +this is stronger than the spike's structure, which computed the export dict +inline in `render_kube_env` with no shared helper): + +```python +def _kube_channel_keys(count: int) -> set[str]: + """Names of the kube-channel env keys the conditional contract exports. + + 0 files: nothing. Exactly 1: KUBECONFIG + KUBE_CONFIG_PATH. >=2: + KUBECONFIG + KUBE_CONFIG_PATHS. KUBE_CONFIG_PATH and KUBE_CONFIG_PATHS are + never both present -- KUBE_CONFIG_PATH wins over KUBE_CONFIG_PATHS per the + measured OpenTofu kubernetes/helm provider precedence (docs/specs/ + 2026-08-10-issue15-provider-env-precedence.md), so exporting both once a + second file exists would silently hide every cluster but the first. + """ + if count == 0: + return set() + if count == 1: + return {"KUBECONFIG", "KUBE_CONFIG_PATH"} + return {"KUBECONFIG", "KUBE_CONFIG_PATHS"} + + +def render_kube_env(output: OutputSchema) -> dict[str, str]: + """Build the node-count-agnostic kube channel: KUBECONFIG plus the + OpenTofu-provider-facing var the conditional contract picks. + + Unlike the ``TUNSTRAP__*`` scalars, this channel has no node + dimension: it collects one materialized path per kube_target across every + node (not just a single one), so it is safe to call for any node count. + """ + kube_paths: list[str] = [] + for node in output.connections.values(): + for kname, target in node.kube_targets.items(): + if target.path is None: + raise ValueError(f"kube target {kname!r} not materialized; cannot set KUBECONFIG") + kube_paths.append(target.path) + if not kube_paths: + return {} + joined = ":".join(kube_paths) + return {key: joined for key in _kube_channel_keys(len(kube_paths))} +``` + +Replace `render_env`'s inline kube-path block (unchanged single-node contract, +delegating to `render_kube_env` — **note:** `render_env` itself is still +alive at this point in the plan; Task 3 lands before Task 5 deletes it +entirely, so this edit is a normal in-place change here, not a preview of the +deletion). **This also deletes the now-unused `kube_paths: list[str] = []` +accumulator declaration at `envrender.py:49`** — the block below never +appends to it (that accumulation moved into `render_kube_env` above), and +leaving the declaration in place would fail `ruff check` (unused variable) at +Task 7's gate: + +```python + for kname, target in node.kube_targets.items(): + base = _key(kname) + if target.path is None: + raise ValueError(f"kube target {kname!r} not materialized; cannot set KUBECONFIG") + put(f"TUNSTRAP_{base}_KUBECONFIG", target.path) + put(f"TUNSTRAP_{base}_ENDPOINT", target.endpoint) + + for key, value in render_kube_env(output).items(): + put(key, value) + return env +``` + +**[R11] Update `predicted_env_keys` to reserve conservatively, not exactly.** +Unlike `render_kube_env`'s own export (which correctly uses +`_kube_channel_keys(exact_count)` because it runs *after* real materialization +and knows the true count), `predicted_env_keys` runs pre-spawn against the +*input* schema, before any node has connected — so it must not assume the +declared cardinality will survive to output time. Whenever *any* node +declares `kube_targets` at all, reserve **all three** kube names +unconditionally, regardless of exact count: + +```python +def predicted_env_keys(schema: InputSchema) -> set[str]: + keys: set[str] = set() + if len(schema.nodes) == 1: + (node,) = schema.nodes.values() + keys.update({"TUNSTRAP_SESSION_DIR", "TUNSTRAP_PID"}) + for tname in node.remote_targets: + base = _key(tname) + keys.update( + {f"TUNSTRAP_{base}_HOST", f"TUNSTRAP_{base}_PORT", f"TUNSTRAP_{base}_ENDPOINT"} + ) + for kname in node.kube_targets or {}: + base = _key(kname) + keys.update({f"TUNSTRAP_{base}_KUBECONFIG", f"TUNSTRAP_{base}_ENDPOINT"}) + if any(node.kube_targets for node in schema.nodes.values()): + keys.update({"KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"}) + return keys +``` + +**Note, still valid:** Task 5 replaces this function's body again — not to +fix the cardinality logic (already correct here, conservative from the +start, so no re-fix needed), but because the entire `TUNSTRAP_*` scalar +half this version still computes (the `if len(schema.nodes) == 1:` block) +is deleted once the scalar channel itself is removed. The conservative +kube-reservation line above is what Task 5's later rewrite keeps unchanged +(it only adds the three survivor scalars in its place of the deleted +per-target block) — see Task 5 Step 4. + +Note the docstring's "Multi-node input injects no scalars at all, so the +answer there is the empty set" claim (`envrender.py:83-93`, pre-change) is now +**false** for the kube-channel keys specifically and must be corrected in the +same edit — it stays true only for the `TUNSTRAP_*` scalar keys. + +- [ ] **Step 4: Run to verify pass** + +`.venv/bin/pytest tests/unit/test_envrender.py -v` +Expected: all pass, including every pre-existing case (single-node +`KUBECONFIG` behaviour is byte-identical to before this task for the +one-kube-target case, since `_kube_channel_keys(1)` includes `KUBECONFIG` +exactly as the old unconditional `put("KUBECONFIG", ...)` did). + +- [ ] **Step 5: Commit** + +```bash +git add tunstrap/envrender.py tests/unit/test_envrender.py +git commit -m "feat(envrender): multi-node kube channel + conditional KUBE_CONFIG_PATH(S) export (#15)" +``` + +--- + +### Task 4: The unified output contract — shape + `render_unified_output` [PIVOT, new] + +Pure-function work only: no `cli.py` wiring yet (Task 5), no materialization +yet (Task 5). This task makes the shape exist and be correctly built from an +`OutputSchema`; Task 5 makes anything call it. + +**Files:** +- Modify: `tunstrap/schemas.py` (new models), `tunstrap/envrender.py` (new + `render_unified_output`, `render_output_var` body rewritten) +- Test: `tests/unit/test_envrender.py` (new cases), `tests/unit/test_schemas.py` + or a new `tests/unit/test_schemas_unified.py` (model tests) + +- [ ] **Step 1: Write failing tests** + +Add to `tests/unit/test_envrender.py`: + +```python +def test_render_unified_output_shape() -> None: + """[R16] Ports become 'host:port' strings; kube becomes + {path,context,endpoint} references; fetch_files becomes + {path,size,sha256} -- NOT a content_b64 passthrough, corrected from an + earlier revision of this test that asserted the opposite ("content_b64 + IS allowed via fetch_files"); two reserved top-level keys.""" + out = OutputSchema( + connections={ + "node1": NodeOutput( + ports={"service1": 5432}, + kube_targets={ + "k3s": _kube_out_full( + 7000, "/s/tunnel-data/node1-k3s", context="tunstrap-node1-k3s" + ) + }, + fetch_files={ + # [R16] .path is set here because materialization (Task 5) + # runs before render_unified_output ever sees this object -- + # the daemon writes the bytes and sets .path, exactly as it + # already does for KubeTargetOutput.path today. + "hosts": FetchedFile( + content_b64="aG9zdHM=", size=6, sha256="ab" * 32, + path="/s/tunnel-data/node1-hosts", + ) + }, + ) + }, + pid=42, + session_dir="/s", + started_at="2026-08-07T00:00:00Z", + ) + unified = render_unified_output(out) + assert unified["session"] == { + "session_dir": "/s", + "pid": 42, + "started_at": "2026-08-07T00:00:00Z", + "warnings": [], + } + node = unified["nodes"]["node1"] + assert node["ports"] == {"service1": "127.0.0.1:5432"} + assert node["kube"]["k3s"] == { + "path": "/s/tunnel-data/node1-k3s", + "context": "tunstrap-node1-k3s", + "endpoint": "https://127.0.0.1:7000", + } + # [R16] {path, size, sha256} exactly -- no content_b64 in the projection. + assert node["fetch_files"]["hosts"] == { + "path": "/s/tunnel-data/node1-hosts", "size": 6, "sha256": "ab" * 32, + } + # Nothing that could carry raw content -- kube credentials AND fetched + # file content_b64 -- ever appears anywhere in the shape. [R16] content_b64 + # joins this leak check; it is no longer a sanctioned exception. + dumped = json.dumps(unified) + for leaked in ("client_certificate_data", "client_key_data", "content_b64"): + assert leaked not in dumped + + +def test_render_unified_output_multi_node() -> None: + """Node dimension is a nested key: two nodes, two independent bodies.""" + out = OutputSchema( + connections={ + "a": NodeOutput(ports={"db": 1}), + "b": NodeOutput(ports={"db": 2}), + }, + pid=1, session_dir="/s", started_at="now", + ) + unified = render_unified_output(out) + assert set(unified["nodes"]) == {"a", "b"} + assert unified["nodes"]["a"]["ports"]["db"] == "127.0.0.1:1" + assert unified["nodes"]["b"]["ports"]["db"] == "127.0.0.1:2" + + +def test_render_output_var_serializes_the_unified_shape() -> None: + """render_output_var's return value decodes to the same shape render_unified_output builds.""" + out = OutputSchema( + connections={"h": NodeOutput(ports={"db": 1})}, + pid=1, session_dir="/s", started_at="now", + ) + decoded = json.loads(render_output_var(out)) + assert decoded == render_unified_output(out) +``` + +(`_kube_out_full` is a small extension of the file's existing `_kube_out` +helper that also accepts a `context` kwarg — add it alongside `_kube_out`, +do not change `_kube_out`'s existing signature, since Task 3's tests still +use it unchanged.) + +- [ ] **Step 2: Run to verify failure** + +`.venv/bin/pytest tests/unit/test_envrender.py -k "unified" -v` +Expected: FAIL — `render_unified_output` missing; `render_output_var` still +returns the old `RunKubeTarget`-projection shape. + +- [ ] **Step 3: Implement** + +Add models to `tunstrap/schemas.py` (placed there, not `envrender.py`, +matching the existing convention that `schemas.py` is "Single source of JSON +shape" per its own module docstring, `schemas.py:1`): + +```python +class UnifiedKubeRef(BaseModel): + """Kube reference in the unified output: never credentials, never content.""" + + model_config = ConfigDict(extra="forbid") + + path: str | None + context: str + endpoint: str + + +class UnifiedSession(BaseModel): + """Session metadata block of the unified output.""" + + model_config = ConfigDict(extra="forbid") + + session_dir: str + pid: int + started_at: str + warnings: list[TunnelWarning] = Field(default_factory=list) + + +class UnifiedFetchRef(BaseModel): + """[R16] Fetched-file reference in the unified output: never content_b64, + mirroring UnifiedKubeRef's own credential/content narrowing. Success and + error are mutually exclusive, matching FetchedFile's own xor -- but this + model has no validator enforcing it, because render_unified_output (below) + is the only place that constructs one, from an already-validated + FetchedFile, per exactly the same explicit-keyword-construction pattern + UnifiedKubeRef already uses instead of a second runtime check.""" + + model_config = ConfigDict(extra="forbid") + + path: str | None = None + size: int | None = None + sha256: str | None = None + error: str | None = None + + +class UnifiedNode(BaseModel): + """One node's body in the unified output: ports, kube refs, fetch_files.""" + + model_config = ConfigDict(extra="forbid") + + ports: dict[str, str] = Field(default_factory=dict) + kube: dict[str, UnifiedKubeRef] = Field(default_factory=dict) + fetch_files: dict[str, UnifiedFetchRef] = Field(default_factory=dict) + + +class UnifiedOutput(BaseModel): + """The entire consumer-facing output: two reserved top-level keys.""" + + model_config = ConfigDict(extra="forbid") + + session: UnifiedSession + nodes: dict[str, UnifiedNode] +``` + +Add to `tunstrap/envrender.py`: + +```python +def render_unified_output(output: OutputSchema) -> dict[str, Any]: + """Build the unified, node-qualified structure (design doc, "Unified + output contract"). Ports become 'host:port' strings; kube becomes + {path, context, endpoint} references (never credentials, never content, + per RunKubeTarget's pre-existing allow-list — this reshapes, not + reprojects). [R16] fetch_files becomes {path, size, sha256} (or + {error}) -- NOT a passthrough. An earlier revision of this function (and + this docstring) carried forward the pre-#15 design's decision to let + fetch_files ride unprojected, including content_b64; R16 retracts that + for the same reason U4 already narrowed kube: content must not enter a + Terraform variable or the materialized file, only its path/metadata may. + Callers must ensure fetch_files entries are already materialized (.path + set) before calling this -- see Task 5's fetched-file materialization + step, which runs upstream of this function, the same ordering + KubeTargetOutput.path already requires today. + """ + nodes: dict[str, object] = {} + for node_name, node in output.connections.items(): + kube = { + kname: UnifiedKubeRef( + path=target.path, context=target.context_name, endpoint=target.endpoint + ).model_dump() + for kname, target in node.kube_targets.items() + } + ports = {tname: f"127.0.0.1:{port}" for tname, port in node.ports.items()} + fetch_files = { + fname: ( + UnifiedFetchRef(error=f.error).model_dump(exclude_none=True) + if f.error is not None + else UnifiedFetchRef(path=f.path, size=f.size, sha256=f.sha256) + .model_dump(exclude_none=True) + ) + for fname, f in node.fetch_files.items() + } + nodes[node_name] = UnifiedNode( + ports=ports, kube=kube, fetch_files=fetch_files + ).model_dump() + session = UnifiedSession( + session_dir=output.session_dir, + pid=output.pid, + started_at=output.started_at, + warnings=output.warnings, + ).model_dump(mode="json") + return {"session": session, "nodes": nodes} +``` + +**`exclude_none=True`, deliberately**: without it, a success entry would +serialize `{"path": ..., "size": ..., "sha256": ..., "error": null}` — a +stray `"error": null` in every successful fetch, not matching the design +doc's shape (`{"path", "size", "sha256"}` exactly, no fourth key) or the +error-branch shape (`{"error"}` exactly, not `{"path": null, "size": null, +"sha256": null, "error": ...}`). This mirrors why `UnifiedKubeRef` does not +need the same treatment: none of its three fields is ever optional/`None` +in a materialized `KubeTargetOutput`. + +Replace `render_output_var`'s body (keep the signature, `OutputSchema -> str` +— no `cli.py` call-site change needed): + +```python +def render_output_var(output: OutputSchema) -> str: + """Serialise the unified structure for ``--output-var``. + + Delivers the same content the materialized file carries (Task 5) — see + docs/specs/2026-08-07-issue15-kube-identity-delivery-design.md, "The + unified output contract", for the delivery/stability contract governing + which of the two a plan-safe consumer should actually bind to a resource. + """ + return json.dumps(render_unified_output(output), separators=(",", ":")) +``` + +Delete the old `RunKubeTarget`-based body (the `payload = output.model_dump +(mode="json")` / per-node `RunKubeTarget.model_validate` loop) — it is fully +replaced, not kept as a fallback. + +**`RunKubeTarget` disposition:** now unused by `render_output_var`. Check with +`vulture` (Task 7) whether anything else still imports it; if not, delete the +class from `schemas.py` too — its whole purpose (an allow-list projection for +this exact channel) is now served by `UnifiedKubeRef`, and keeping an unused +allow-list model around is exactly the kind of drift a reviewer would flag on +sight. + +- [ ] **Step 4: Run to verify pass** + +`.venv/bin/pytest tests/unit/test_envrender.py -v` +Expected: all pass. The **old** `render_output_var` shape tests in +`tests/unit/test_cli_run_output_var.py` (e.g. +`test_output_var_carries_the_whole_envelope_minus_kube_credentials`) now +fail, expectedly — they pin the old `connections..ports.` +(int) shape; Task 5 retargets them alongside the rest of that file's +scalar-removal changes. Do not fix them here; note the expected failures and +move on, matching this plan's own established discipline of one clean +commit per concern rather than a half-finished retarget. + +- [ ] **Step 5: Commit** + +```bash +git add tunstrap/schemas.py tunstrap/envrender.py tests/unit/test_envrender.py +git commit -m "feat(envrender): unified node-qualified output contract, shape only (#15)" +``` + +--- + +### Task 5: Materialize the unified output; remove the scalar channel [PIVOT, new + rewrite; R16, iteration 7 adds fetch-file materialization] + +**This is the big ripple task.** It does six things in one coherent change, +because they are the same edit site (`_build_child_env` and its callers) or +its direct sibling (the daemon-side materialization step): +(a) wires `render_unified_output`/`render_output_var` into `run` and adds +unconditional materialization (using the repo's existing secure-write +primitive, not a write-then-chmod sequence); (b) collapses the kube-channel +call to unconditional (supersedes iteration 2's two-branch design, decision +history entry 13); (c) deletes `render_env`, `MultiNodeEnvUnsupported`, and +`inject_scalars`; (d) **re-scopes** (not deletes) the `predicted_env_keys` +anti-drift guard to compare against `_build_child_env`'s actual output, since +`render_env` is no longer there to compare against; (e) retargets every +pre-existing test, fixture, and shipped artifact this removal breaks, across +every tier — enumerated exhaustively below, not case-by-case; (f) **[R16, +new]** materializes `fetch_files` content to `tunnel-data/-` +the same way kube files already are, removing `content_b64` from every +consumer-facing projection — see "Fetched-file materialization" below, its +own dedicated blast-radius enumeration. + +**Iteration-4 note, read before starting this task.** The first three review +rounds each caught this task's blast radius incompletely, one spot at a time +— a test here, a stale reference there, an entire test *file* +(`test_cli_run_output_var_projection.py`) missed twice, integration and e2e +fixtures never looked at. The table below is a full grep-driven enumeration +across `tunstrap/`, `tests/unit`, `tests/integration`, `tests/e2e`, and +`docs/`, done once, systemically, specifically so this task cannot be landed +piecemeal again. **Do not treat this table as a starting point to extend by +inspection — treat it as complete; if the implementer finds something it +missed, that is itself a signal to re-run the greps below, not to patch the +one spot found.** + +Re-derivable with (or equivalent): + +```bash +grep -rn 'TUNSTRAP_[A-Z0-9_]*' tunstrap/ tests/ docs/ \ + --include='*.py' --include='*.md' --include='*.tf' \ + | grep -vE 'TUNSTRAP_SESSION_DIR|TUNSTRAP_PID|TUNSTRAP_OUTPUT_FILE|TUNSTRAP_INPUT|TUNSTRAP_E2E_REQUIRE_ALL|TUNSTRAP_TOKEN' +grep -rn 'MultiNodeEnvUnsupported\|inject_scalars\|render_env(' tunstrap/ tests/ --include='*.py' +grep -rn 'connections\.' tests/ docs/ --include='*.py' --include='*.md' --include='*.tf' +grep -rn '\["connections"\]\|\.connections\[' tests/ --include='*.py' +``` + +### Blast-radius table (authoritative; every hit below has a disposition) + +**Unit tier:** + +| File:line | Old shape/symbol | Disposition | +|---|---|---| +| `test_cli_run.py:91` | `FakePopen.last_env["TUNSTRAP_DB_PORT"] == "5432"` | Retarget: assert `TUNSTRAP_SESSION_DIR`/`TUNSTRAP_PID`/`TUNSTRAP_OUTPUT_FILE` present, `TUNSTRAP_DB_PORT` absent. | +| `test_cli_run_input_env_scrub.py:156` | `env["TUNSTRAP_DB_PORT"] == "5432"`, "the injected scalars must survive the scrub" | Retarget: assert `TUNSTRAP_SESSION_DIR` survives the scrub instead; same docstring claim, different scalar. | +| `test_cli_run_input_env_scrub.py:174` (found beyond the drill's list) | `json.loads(env[VAR])["pid"] == 99` | Retarget: `json.loads(env[VAR])["session"]["pid"] == 99` — `pid` moved under the unified structure's `session` key. | +| `test_cli_runner.py:392` (+docstring at ~360) | `"export TUNSTRAP_DB_PORT='5432'" in res.output` — existing `start --output env` pin | **Fix the existing assertion**, not just "add a test": replace with the new three-survivors-plus-kube-channel export set; drop `TUNSTRAP_DB_PORT`/`TUNSTRAP_WEB_PORT`-style lines from any fixture the test builds. | +| `test_cli_run_postspawn.py:955,993` (`test_lone_optional_node_failure_keeps_its_own_exit_code`) | Asserts `error["error"] == "MultiNodeEnvUnsupported"` for a lone optional node's failure (`connections == {}` trips `render_env`'s `!= 1` guard today) | Retarget completely, new behaviour is the opposite: `_build_child_env` no longer branches on connection count at all, so this now **succeeds** (exit 0). Rename to `test_lone_optional_node_failure_still_succeeds_with_only_a_warning`; assert exit 0, `session.warnings` (via `--output-var`) carries the "edge" failure, teardown ran exactly once. | +| `test_cli_run_output_var.py` (multiple) | See Task 5 Step 2's existing per-test list below — **unchanged by this iteration's fix**, already correct from iteration 3. | Retarget/delete per the existing list (kept). | +| `test_cli_run_output_var_projection.py` (**whole file, missed in iterations 1-3**) | `RunKubeTarget` import; `["connections"]["node"]["kube_targets"]["k3s"]`; `decoded["pid"]`/`["session_dir"]`/`["started_at"]`/`["connections"]["node"]["ports"]` | See dedicated sub-section below — this is a security-critical file (credential-scrubbing pin) and needs care, not a one-line note. | +| `test_envrender.py:4` | `from tunstrap.exceptions import MultiNodeEnvUnsupported` | Delete the import — `ruff` F401 once every user of it in this file is gone. | +| `test_envrender.py` (Task-3-era, `render_env`-dependent) | `test_render_ports_and_session`, `test_render_kube_sets_kubeconfig`, `test_render_kube_not_materialized_raises`, `test_render_requires_single_node_zero`, `test_render_requires_single_node_two`, **and explicitly `test_render_kube_env_works_for_multi_node_while_render_env_still_rejects`** (added by Task 3 itself, plan line ~360 — the general clause below missed naming this one by name in earlier iterations) | Delete all six — each asserts on `render_env`, which no longer exists. | +| `test_envrender.py::test_predicted_env_keys_matches_render_env` | Compares `predicted_env_keys` against `render_env`'s output | **Retarget, not delete** (the major fix this iteration exists to make) — see "Anti-drift guard" sub-section below. | +| `test_envrender.py` (predicted_env_keys shape) | `test_predicted_env_keys_no_kube_omits_kubeconfig`, `test_predicted_env_keys_multi_node_is_empty` | Delete — both pin the old per-target scalar enumeration / the "multi-node is empty" claim, which is false under the new unconditional `{session scalars} ∪ kube-channel` contract; superseded by Task 5's own new `test_predicted_env_keys_is_session_scalars_plus_kube_channel` (below, update its expected set to include `TUNSTRAP_OUTPUT_FILE`) and `test_predicted_env_keys_no_kube_is_just_the_two_survivors` (rename: three survivors now). | +| `test_exceptions.py:87-90` | `test_multinode_env_unsupported_is_a_tunstrap_error`-shaped subclass test | Delete. | +| `test_exceptions.py:94-99` | Exit-code + envelope test constructing `MultiNodeEnvUnsupported(...)` | Delete. | +| `test_exceptions.py:107-114` | `_EXIT_CODES[MultiNodeEnvUnsupported] == 1` table test | Delete. All **three** cases named explicitly — "whatever case pins the exit code" undercounted them twice already. | +| `test_tofu_proxy.py:375,386,397,399` | Docstrings framing the pop in terms of `inject_scalars`/`render_env` | Update docstrings only (mechanism note, not an assertion change) — both `test_tunnelled_suppresses_kubeconfig_in_child_env` and `test_tunnelled_drops_an_inherited_kubeconfig_in_the_multi_node_case`; already flagged in Task 5's earlier draft, kept here for completeness of the table. | + +**Integration tier (Task 7 Step 3's "no changes needed" claim was false — corrected here and in Task 7):** + +| File:line | Old shape/symbol | Disposition | +|---|---|---| +| `test_run_env_io.py:49-50` (`_PROBE_SINGLE`) | `os.environ["TUNSTRAP_WEB_PORT"]` | Retarget: probe reads `json.load(open(os.environ["TUNSTRAP_OUTPUT_FILE"]))["nodes"]["hub"]["ports"]["web"]` (a `"host:port"` string; `.rsplit(":", 1)[1]` for the port) instead. | +| `test_run_env_io.py` `_PROBE_MULTI` (same region, `envelope["connections"]`) | `envelope["connections"][name]["ports"]["web"]` | Retarget to `envelope["nodes"][name]["ports"]["web"]` (string, parse as above). | +| `test_run_env_io.py` `_PROBE_MULTI` leak check | `k.startswith("TUNSTRAP_") and k != "TUNSTRAP_INPUT"` → now **wrongly** flags the three sanctioned survivors as leaks | Retarget: exclude `TUNSTRAP_SESSION_DIR`, `TUNSTRAP_PID`, `TUNSTRAP_OUTPUT_FILE` too. | +| `test_run_env_io.py:173-193` (`test_multi_node_without_output_var_is_exit_1`) | Asserts exit 1 + `MultiNodeEnvUnsupported`, `not session_dir.exists()` | **Retarget completely — the exact behaviour this task inverts.** Rename to `test_multi_node_without_output_var_now_succeeds`; assert exit 0, no `MultiNodeEnvUnsupported` anywhere in stderr (stderr may be empty), teardown ran. Materialization's *content* is not re-verified here — that is `test_cli_run_materialize.py`'s job at unit level; this integration test's remaining job is confirming the real console script also allows the case, not re-proving the file's shape. | +| `test_cli_modes.py:111-138` | `start --output env`'s `TUNSTRAP_WEB_PORT`/`TUNSTRAP_WEB_ENDPOINT`; `run`'s child probe reading `os.environ['TUNSTRAP_WEB_PORT']` directly | Retarget both (two separate tests in this range): the `start --output env` test asserts `TUNSTRAP_SESSION_DIR`/`TUNSTRAP_OUTPUT_FILE` present, `TUNSTRAP_WEB_PORT`/`_ENDPOINT` absent, and derives the port via `json.load(open(env["TUNSTRAP_OUTPUT_FILE"]))["nodes"][...]["ports"]["web"]`; the `run` child probe (inline Python string) rewrites to read `TUNSTRAP_OUTPUT_FILE` the same way instead of `TUNSTRAP_WEB_PORT` directly. | + +**E2E tier + shipped artifacts (in scope — this ships with the work; deferring is not an option, per the ruling, because the failure mode is silent: `try()` around `jsondecode` swallows the shape mismatch into an empty `config_path` and the resulting error is a confusing provider message, not an obvious test failure):** + +| File:line | Old shape/symbol | Disposition | +|---|---|---| +| `tests/e2e/module/main.tf:27-28` | `try(jsondecode(var.tunstrap), { connections = {} })`; `local.tunnel.connections.node.kube_targets.k3s.path` | Retarget: `{ nodes = {} }`; `local.tunnel.nodes.node.kube.k3s.path`. Task 6 (extended). | +| `docs/recipe_terragrunt.md:287-288` | Same `tunnel`/`kubepath` locals, mirroring `main.tf` | Retarget identically. Task 6. | +| `docs/recipe_terragrunt.md:~329` | Prose: "`path` comes from... `connections.*.kube_targets.*.path`" | Retarget prose to `nodes.*.kube.*.path`. Task 6. | +| `docs/recipe_terragrunt.md:~407` | Prose: "the module picks the node out of `connections[]`" | Retarget to `nodes[]`; also correct the surrounding paragraph's claim that multi-node suppresses the scalar/`KUBECONFIG` channel — under the pivot the kube channel is unconditional and the "TUNSTRAP_* env... not injected" framing is stale. Task 6. | +| `docs/recipe_terragrunt.md:~509` | "What is proven" section, `--output-var` → `TF_VAR_tunstrap` → `try(jsondecode(...))` → `config_path` chain description | Mechanism description stays accurate; no shape-specific text to fix beyond confirming it still reads correctly once the two locals above change. Verify only. | +| `tests/e2e/rig.py:171` | Docstring: "`module/main.tf` decodes `connections.node.kube_targets.k3s.path`" | Retarget docstring text to `nodes.node.kube.k3s.path`. | +| `tests/e2e/test_tofu_providers.py:154` | `envelope["connections"]["node"]["kube_targets"]["k3s"]["path"]` | Retarget to `envelope["nodes"]["node"]["kube"]["k3s"]["path"]`. | +| `tests/e2e/test_tofu_providers.py:251-254` | Fake envelope dict literal: `{"connections": {"node": {"ports": {}, "kube_targets": {"k3s": {...}}}}}` | Retarget the literal to `{"nodes": {"node": {"ports": {}, "kube": {"k3s": {"path": ..., "context": ..., "endpoint": ...}}}}}` — align field names with `UnifiedKubeRef` (drop any fields beyond `path`/`context`/`endpoint` the old literal happened to include). | +| `tests/e2e/test_terragrunt_apply.py:339,425` | `envelope["connections"]["node"]["kube_targets"]["k3s"]["path"]` (×2, apply and tunnelled-output cases) | Retarget both to `envelope["nodes"]["node"]["kube"]["k3s"]["path"]`. | +| `tests/e2e/test_rig.py:278` | `envelope["connections"]["node"]["kube_targets"]["k3s"]` | **Unaffected, disposition = out of scope, stated explicitly, not silently skipped:** this reads `tunstrap start`'s **raw stdout JSON** (`OutputSchema.model_dump_json()`-shaped), not the `--output-var`/materialized unified channel. The pivot's scope is `run`'s consumer-facing channels (`--output-var`, materialization) and `start --output env`; `start`'s default/`--output json` stdout — documented since the pre-#15 design as "the complete envelope," a separate contract for session-management tooling, not consumer transformation — is deliberately untouched. Judgment call, recorded here since it narrows the blast radius meaningfully; if a reviewer wants `start`'s raw JSON unified too, that is a new decision, not an oversight. | +| `tests/e2e/test_recipe_terragrunt.py:259,322` | Recipe↔module drift guard (textual block comparison) | **Unaffected in mechanism.** The guard's compared *content* changes automatically once `main.tf` and the recipe are both updated to the `nodes.*` shape in Task 6 — no separate code change to the guard itself. Task 6's own steps must keep it green (run it as part of Task 6's verification, not just Task 7's). | + +### `test_cli_run_output_var_projection.py` — dedicated retarget (security-critical, missed twice before this iteration) + +This file pins the credential-scrubbing property for the projected kube +reference — it must not be weakened while being reshaped. All four tests +retarget or delete, **not** left alone: + +- `test_output_var_never_carries_kube_private_key_material` — retarget the + shape lookup: `json.loads(env["TF_VAR_tunstrap"])["nodes"]["node"]["kube"]["k3s"]` + instead of `["connections"]["node"]["kube_targets"]["k3s"]`. The + absence assertions (`client_key_data`/`client_certificate_data`/ + `content_b64` not in `target`) are unaffected in spirit, but note **the + field set is now smaller than before for a different reason too** — see + the next test. +- `test_output_var_keeps_every_field_the_consumer_chain_reads` — the + anti-vacuity pair. **The expected dict shrinks further than credential + removal alone**: `UnifiedKubeRef` carries exactly `{path, context, + endpoint}` (Task 4's model) — `cluster_name`, `local_port`, + `tls_server_name`, and `certificate_authority_data` (all present in the old + `RunKubeTarget` projection, none of them credentials) are **also** gone + under the unified shape, because the design narrows to references only + (design doc, U4). Retarget the expected dict to exactly + `{"path": KUBE_PATH, "context": "probe-context", "endpoint": + "https://127.0.0.1:41111"}`. This is a real, intentional narrowing beyond + the credential fix — call it out in the retargeted test's docstring so a + future reader does not mistake it for scope creep. +- `test_output_var_projection_leaves_the_rest_of_the_envelope_intact` — + retarget: `decoded["pid"]` → `decoded["session"]["pid"]`, + `decoded["session_dir"]` → `decoded["session"]["session_dir"]`, + `decoded["started_at"]` → `decoded["session"]["started_at"]`, + `decoded["connections"]["node"]["ports"]` → + `decoded["nodes"]["node"]["ports"]` — **and note the value shape changed + too**: `{"db": 5432}` (int) becomes `{"db": "127.0.0.1:5432"}` (string). +- `test_projection_is_an_allow_list_so_a_new_secret_field_cannot_leak` — + **delete, not retarget.** This test validated `RunKubeTarget.model_validate` + directly, exercising `extra="ignore"`'s fail-closed behaviour against an + untrusted dict. `RunKubeTarget` is deleted (Task 4's disposition); its + replacement, `render_unified_output`, never calls `.model_validate()` on + untrusted kube data at all — it constructs `UnifiedKubeRef(path=..., + context=..., endpoint=...)` with three explicit keyword arguments, so a + hypothetical field added to `KubeTargetOutput` later cannot leak through + without someone editing that constructor call by hand. The allow-list + property now holds **by construction**, not by validating against a model, + so there is nothing left for a `model_validate`-shaped test to exercise + differently from `test_output_var_keeps_every_field_the_consumer_chain_ + reads`'s own exact-equality assertion, which already proves the same + property end-to-end. Confirm this by re-reading `render_unified_output`'s + body (Task 4) before deleting — the property must actually hold, not just + be asserted to hold by this note. + +### Fetched-file materialization + `content_b64` blast-radius enumeration [R16, new] + +**New mechanism, same precedent as kube.** `FetchedFile` (`schemas.py:292-313`) +gains `path: str | None = None`, mirroring `KubeTargetOutput.path` +(`schemas.py:317-336`) exactly. Wherever kube materialization currently runs +daemon/worker-side (the same call site the "Materialization write mechanism" +design-doc section and this task's `output.json` writer both point at — +confirm the exact function before implementing, do not assume it is +`manager.py:start_all_and_build_output` without checking), add a parallel +step: for each successful `FetchedFile` a node's `fetch_files` produced, +base64-decode `content_b64` and write the raw bytes to +`tunnel-data/-` using the **same atomic-replace primitive** +as `output.json` (temp file + `O_EXCL` + `os.replace`, not `_write_file`'s +`O_TRUNC` — see "Materialization write mechanism," design doc), then set +`.path`. A failed fetch (`.error` set) materializes nothing. `content_b64` +itself is **not** deleted from `FetchedFile` — it stays internal plumbing, +same as kube's own `content_b64`. The projection itself (`{path, size, +sha256}`/`{error}`, no `content_b64`) is **not** a separate function here — +it is `render_unified_output`'s own `fetch_files` construction via the new +`UnifiedFetchRef` model, given in full in Task 4's `render_unified_output` +body (above); this materialization step is what makes `.path` non-`None` by +the time that function runs, the same ordering `KubeTargetOutput.path` +already requires and Task 4's own docstring now states explicitly. + +**`content_b64` grep enumeration, repo-wide, every hit dispositioned** (per +this plan's established discipline — a table, not a promise to look later). +**[R16, iteration 8 — methodology correction.]** An earlier revision of this +enumeration ran the grep as `tunstrap/ tests/ --include='*.py'` only, +dropping the `docs/` tier and `--include='*.md'`/`'*.tf'` that iteration 4 +established for the *original* blast-radius table (top of Task 5) and that +this R16-specific enumeration should have inherited rather than narrowing. +Consequence: `docs/recipe_terragrunt.md`'s own shipped "Fetched files are +exported verbatim, not projected" subsection — which argues the *opposite* +of R16 in prose — went unfound by a whole review round. Re-run at the +established scope, not the narrowed one: + +```bash +grep -rn 'content_b64' tunstrap/ tests/ docs/ --include='*.py' --include='*.md' --include='*.tf' +``` + +**Kube-internal — unaffected by R16, listed to prove they were checked, not +missed:** `KubeTargetOutput.content_b64` (`schemas.py:335`) is a different +field entirely (the patched kubeconfig's own content, unrelated to +`fetch_files`) and is untouched by this ruling. Every hit below reads or +constructs *that* field, not `FetchedFile`'s: `test_kube_run.py:111`, +`test_envrender.py:20`, `test_output_kube.py:35`, `test_tofu_proxy.py:351`, +`test_kube_targets.py:91,147` (integration — reads `start`'s raw stdout JSON, +already out of scope per the existing carve-out), and the **absence** +assertions for kube's own `content_b64` in +`test_cli_run_output_var.py:256,281` and +`test_cli_run_output_var_projection.py:10,72,91,190,249` (these already +correctly assert kube's `content_b64` is *not* in the projected shape — +nothing to change). + +**`fetch_files`-related — in scope, retarget:** + +| File:line | Old shape/behaviour | Disposition | +|---|---|---| +| `test_manager_fetch.py:91` (`test_fetch_files_results_populate_node_output`) | Docstring "Fetcher results land in `NodeOutput.fetch_files` unchanged"; fixture `FetchedFile(content_b64="YQ==", size=1, sha256="ca97")`, no `path` | **False under R16** — a materialization step now runs after the fetch. Retarget: assert `out.connections["a"].fetch_files["kubeconfig"].path` is set to the expected `tunnel-data/a-kubeconfig` location and its on-disk bytes match `base64.b64decode("YQ==")`; `content_b64` still present on the object (internal plumbing, unchanged) but the test's point moves to `path`. Rename to drop "unchanged" from the docstring. | +| `test_fetcher_unit.py:101,111` | `fetcher.fetch_files()`'s own unit test, asserts `ff.content_b64` set on success | **Unaffected** — this is the SSH-fetch-to-memory layer, upstream of the new daemon-side materialization step; `fetcher.py` itself is not changed by R16, only its caller gains a new step after it. | +| `test_fetch_files.py:67,119,216` (integration) | `base64.b64decode(ff["content_b64"])` reading the raw `start` stdout envelope | **Unaffected in mechanism** (raw stdout stays the "complete envelope," existing carve-out) **but verify against the correct channel**: if any of these three actually assert against `--output-var`/materialized output rather than raw `start` JSON, that specific assertion retargets to read `ff["path"]` + a direct file read instead — confirm which channel each of the three actually exercises before deciding no change is needed; do not assume all three are raw-stdout without checking. | +| `test_fetch_security.py:49,52,69,87-89` (integration) | Proves fetched content_b64 "appears on stdout only, never on stderr" — i.e. accepts it riding *some* channel, checks which | **Retarget the property proved, not just the assertion syntax.** R16 makes a stronger claim possible: fetched content should appear **nowhere** in `--output-var`/the materialized manifest, only in the `0600` on-disk file. Rewrite to assert (a) `content_b64`/the raw fetched bytes do not appear anywhere in `TF_VAR_tunstrap`, the materialized `output.json`, stdout, or stderr; (b) the file at the reported `path` exists, is mode `0600`, and its bytes match the source. This is a **stronger** security property than the test proved before, not a weaker one — call that out in the retargeted test's docstring. | +| `test_cli_run_output_var.py:83` (`_RICH_PAYLOAD`) | `"fetch_files": {"hosts": {"content_b64": "aG9zdHM=", "size": 6, "sha256": "ab" * 32}}` | Retarget the fixture to `{"hosts": {"path": "/s/tunnel-data/node-hosts", "size": 6, "sha256": "ab" * 32}}` — this fixture feeds `test_output_var_keeps_every_field_the_consumer_chain_reads`-style field-preservation tests (the R16 instruction's explicit callout); any downstream assertion reading `fetch_files.hosts.content_b64` from the decoded var retargets to `.path`. | + +**`docs/` tier — the rows the narrowed grep missed, added here:** + +| File:line | Old shape/behaviour | Disposition | +|---|---|---| +| `docs/recipe_terragrunt.md:344` | Kube-drop list: "and **drops** `client_key_data`... `content_b64`... `client_certificate_data`" | **Verify only, no rewrite.** This states kube's `content_b64` is dropped from `TF_VAR_tunstrap`'s kube projection — still true under R16 (unrelated field, U4's narrowing was already in force). Confirmed accurate as written. | +| `docs/recipe_terragrunt.md:361-364` | "`tunstrap start` is not affected: it writes the complete envelope to stdout... without `--materialize` its `content_b64` is the only way to obtain the kubeconfig at all." | **Verify only, no rewrite.** Matches the existing, unchanged scope carve-out (design doc, "Compatibility") — `start`'s raw default JSON stdout is untouched by R16, for kube and (per "Fetched-file materialization," design doc) for `fetch_files` alike. Confirmed accurate. | +| `docs/recipe_terragrunt.md:366-388` (whole subsection, "### Fetched files are exported verbatim, not projected") | Argues the **opposite** of R16: "Every `fetch_files` entry keeps its `content_b64` whole"; "`FetchedFile` has no `path` (`schemas.py:292`), so dropping `content_b64` would be a silent, unrecoverable breakage"; "tunstrap fetches into the envelope (`content_b64`), not onto disk"; the materialize-then-drop end-state "is recorded in the spec's Out of scope" (false — the issue15 design's "Out of scope" does not list it; this premise is now simply wrong, not aspirational) | **REWRITE — the whole subsection.** See Task 6's new step, below, for the replacement text. This is the finding the narrowed grep missed. | +| `tests/e2e/module/main.tf:13` | Comment: "the kube target's `client_key_data`, `client_certificate_data` and `content_b64` are dropped" | **Unaffected by R16** (kube-only, unrelated field) — already inside the region Task 6 Step 0's existing shape-migration row for this file covers for the unrelated `connections.*`→`nodes.*` rename; no R16-specific change. | +| Untracked characterization harness | Fetch/kube fixtures using `content_b64` | **Out of scope, stated explicitly.** It is not part of the test suite, shipped code, or consumer documentation. | +| `docs/specs/2026-05-20-feature-fetch-files-design.md`, `docs/specs/2026-07-31-run-env-io-and-tofu-proxy-design.md`, `docs/specs/2026-08-03-run-env-io-decision-history.md`, `docs/specs/2026-05-30-kube-targets-design.md`, `docs/superpowers/plans/2026-06-25-cli-run-modes.md`, `docs/superpowers/plans/2026-05-30-kube-targets.md` | Pre-#15 design/decision/plan documents for already-shipped tickets (#14 and earlier), predating this ticket by weeks | **Out of scope, historical record — cited, never edited**, matching this plan's own established treatment of the pre-#15 decision history everywhere else in this document (e.g. "the pre-#15 decision history's `fetch_files[*].content_b64` entry," cited by name, never rewritten). Editing a completed ticket's own historical spec to match a later ticket's decision would falsify the historical record of what that ticket actually shipped. | +| Untracked superseded owner-tracking design | — | **Out of scope** — historical scratch material, not live. | +| Untracked issue #15 spike notes | Kube-only `content_b64` hits (patched-kubeconfig content in the collision-test prototype) | **Unaffected by R16** (kube, not `fetch_files`) and a frozen historical spike snapshot. | +| `test_output_schema.py:25,32,46,63` | `FetchedFile(content_b64=...)` construction, xor-validation tests | **Unaffected** — these test `FetchedFile`'s own model validation (`content_b64`/`error` xor), which is unchanged; only a new optional `path` field is added, not a change to this xor. Add one new case: `path` defaults to `None`, is not part of the xor, and can be set independently after construction (mirrors `KubeTargetOutput.path`'s own test coverage, if any — check for a precedent test to mirror rather than inventing a new assertion style). | + +**Schema note:** `FetchedFile`'s xor validator (`schemas.py:303-314`) does not +need new logic for `path` — it is a plain optional field set post-construction +by the new materialization step, the same relationship `KubeTargetOutput.path` +already has to that model's own required fields. Confirm this against the +actual `KubeTargetOutput` definition before implementing, not assumed from +this note alone. + +**`predicted_env_keys`/anti-drift guard: checked, unaffected by R16.** +`TUNSTRAP_OUTPUT_FILE` was already one of the three unconditional survivors +`_build_child_env` injects and `predicted_env_keys` reserves for **`run`**, +not only for `start --output env` (see "The scalar channel is removed," +design doc, and this task's own `predicted_env_keys` rewrite above) — R16's +"generalize `TUNSTRAP_OUTPUT_FILE` to `run`" instruction describes its +*role* changing (from a secondary convenience scalar to the *primary* +consumer-facing locator, now that R9's mode 2 is gone), not its *export +set membership*, which was already unconditional. No change to +`predicted_env_keys`'s formula, `_build_child_env`'s injection, or either +half of the two-part anti-drift guard (R11) is needed for R16 — verified, +not silently assumed. `fetch_files`'s own keys never touched env at all +(pre- or post-R16), so the guard's key set is untouched by the materialization +change above too. + +### Anti-drift guard — retargeted, not deleted, and now two-part (R11) + +**Standing ruling R1: the guard is extended, never weakened. An earlier +revision of this task deleted `test_predicted_env_keys_matches_render_env` +on the false premise that only one implementation of the injected-key set +remained after this task's rewrite. That premise is wrong**: after Task 5, +there are still **two independent implementations** of "what keys will `run` +inject" — `_build_child_env` (hardcodes `TUNSTRAP_SESSION_DIR`/ +`TUNSTRAP_PID`/`TUNSTRAP_OUTPUT_FILE`, merges `render_kube_env`'s output) and +`predicted_env_keys` (Task 3's conservative formula: the three survivors, +plus all three kube names whenever any `kube_targets` are declared). If +these two silently diverge, the pre-spawn `--output-var` collision check +(`_validate_output_var`, `cli.py:311-324` — confirm the exact line against +the checked-out file) under-rejects: a NAME that collides with a key +`_build_child_env` actually injects would sail through validation and then +genuinely collide post-spawn. + +**[R11 — a second, iteration-6 correction to this same guard.]** A *later* +revision retargeted the guard to a single `predicted_env_keys(schema) == +set(actual)` full-equality assertion. That was only valid while +`predicted_env_keys` computed the *exact* cardinality-conditional key set. +Task 3 now makes `predicted_env_keys` deliberately **conservative** (reserves +all three kube names whenever any `kube_targets` exist, regardless of exact +count), so **exact equality can no longer hold in general** — a schema with +exactly one kube target that materializes cleanly now predicts all three +kube names (conservative) while the actual export has only two (`KUBECONFIG` ++ `KUBE_CONFIG_PATH`, the exact `==1` branch) — genuinely, correctly unequal. +**The guard splits into two independent tests:** + +1. **Formula test** (exact equality, unit-test style — proves the + *conservative formula itself* is implemented correctly; this is the + already-written `test_predicted_env_keys_reserves_all_three_for_one_ + kube_target` / `..._two_kube_targets_one_node` pair from Task 3, not + repeated here). +2. **Safety-envelope test** (subset, the actual anti-drift guard — proves + the conservative reservation still covers whatever *actually* gets + injected, even when cardinality shrinks between input and output): + +```python +def test_predicted_env_keys_covers_actual_injected_keys_under_cardinality_shrink( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """[R11] Safety-envelope half of the two-part anti-drift guard: predicted + must be a superset of actual, driven by the exact scenario that falsifies + a predictor that got the conservatism backwards -- two kube targets + DECLARED (one on an optional node that fails), only ONE materializes. A + NAME colliding with a key _build_child_env actually injects, but which + predicted_env_keys failed to reserve, would sail through the pre-spawn + collision check and then genuinely collide post-spawn.""" + from tunstrap import cli as cli_mod + from tunstrap.cli import _build_child_env + + # _build_child_env starts from dict(os.environ) (cli.py:394), so without + # isolating it first, `set(actual)` is the whole ambient environment + # (PATH, HOME, ...) and any comparison against it is meaningless in any + # real process. Isolate BEFORE calling it, not after: subtracting + # os.environ back out (`set(actual) - set(os.environ)`) is NOT an + # acceptable substitute -- a key that is both inherited AND injected (an + # operator-set KUBECONFIG, or a NAME matching --output-var) would be + # subtracted away too, silently under-checking exactly the collision + # this guard exists to catch. + monkeypatch.setattr(cli_mod.os, "environ", {}) + + # Input: two kube targets declared, on two nodes -- one optional and about + # to fail. predicted_env_keys sees only this schema. + schema = InputSchema.model_validate( + { + "nodes": { + "a": { + "host": "h1", "user": "u", "ssh_password": "p", + "kube_targets": {"k3s": {"kubeconfig_path": "/etc/k3s.yaml"}}, + }, + "b": { + "host": "h2", "user": "u", "ssh_password": "p", "required": False, + "kube_targets": {"k3s": {"kubeconfig_path": "/etc/k3s.yaml"}}, + }, + } + } + ) + # Output: node "b" failed (required: false), only node "a"'s kube target + # actually materialized -- output cardinality (1) SHRANK below input + # cardinality (2). This is the real _build_child_env sees post-spawn. + out = OutputSchema( + connections={ + "a": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7000, "/run/s/tunnel-data/k3s")}), + }, + pid=1, session_dir="/run/s", started_at="now", + warnings=[TunnelWarning(node="b", error="optional node refused the forward")], + ) + actual = _build_child_env(out, output_var=None, input_env=None) + # Subset, not equality: predicted (conservative, computed from input + # cardinality 2) legitimately claims MORE than actual (exact, computed + # from output cardinality 1) -- that asymmetry is the whole point. + assert set(actual) <= predicted_env_keys(schema) + # Anti-vacuity: KUBE_CONFIG_PATHS specifically must be in the prediction + # even though it is NOT in the actual export (the >=2 branch never fires + # here) -- this is the exact key an exact-cardinality predictor would + # have wrongly omitted. + assert "KUBE_CONFIG_PATHS" in predicted_env_keys(schema) + assert "KUBE_CONFIG_PATHS" not in actual +``` + +This test needs `_build_child_env` (Task 5), so it is added here, in Task 5, +alongside `_build_child_env`'s own implementation — not in Task 3, where +`predicted_env_keys`'s formula lands but `_build_child_env` does not yet +exist. Task 3's own two formula tests (above) are sufficient at that point; +this safety-envelope test is the piece that specifically needs both sides to +exist simultaneously. + +**Files** (updated for the full blast radius; the earlier draft of this task +covered only the first three rows): +- Modify: `tunstrap/cli.py`, `tunstrap/envrender.py` (delete `render_env`, + retarget the anti-drift guard's sibling code), `tunstrap/exceptions.py` + (delete `MultiNodeEnvUnsupported`) +- Test (unit): `tests/unit/test_cli_run_output_var.py`, + `tests/unit/test_cli_run_output_var_projection.py`, + `tests/unit/test_cli_run_materialize.py` (new), + `tests/unit/test_cli_run.py`, `tests/unit/test_cli_run_input_env_scrub.py`, + `tests/unit/test_cli_runner.py`, `tests/unit/test_cli_run_postspawn.py`, + `tests/unit/test_envrender.py`, `tests/unit/test_exceptions.py`, + `tests/unit/test_tofu_proxy.py` (docstrings only) +- Test (integration): `tests/integration/test_run_env_io.py`, + `tests/integration/test_cli_modes.py` +- Test/artifact (e2e, if the e2e tier is exercised — Task 6 owns the actual + edits since they land alongside the recipe, but they are enumerated here + because they are this task's blast radius, not new scope): + `tests/e2e/module/main.tf`, `tests/e2e/rig.py`, + `tests/e2e/test_tofu_providers.py`, `tests/e2e/test_terragrunt_apply.py` + +- [ ] **Step 1: Write failing tests** + +New file `tests/unit/test_cli_run_materialize.py`: + +```python +"""run's unified-output materialization: /tunnel-data/output.json. + +Validates: run always writes the unified structure to a deterministic path, +mode 0600, regardless of --output-var or node count; the file's content +equals render_unified_output's output for the same OutputSchema. +Code: tunstrap/cli.py (materialization call site) +Method: CliRunner + spawn_daemon/Popen/_teardown_run monkeypatched, as in +test_cli_run_output_var.py; read the file back after invoke(). +""" + +from __future__ import annotations + +import json +import stat +from pathlib import Path +from typing import Any + +import pytest +from click.testing import CliRunner + +from tests.unit.conftest import cleaning_teardown +from tunstrap import cli as cli_mod +from tunstrap.cli import main +from tunstrap.envrender import render_unified_output +from tunstrap.schemas import OutputSchema + +pytestmark = pytest.mark.unit + + +def test_run_materializes_output_json(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A single-node run writes tunnel-data/output.json, mode 0600, matching content.""" + session_dir = tmp_path / "session" + session_dir.mkdir() + payload = { + "connections": {"h": {"ports": {"db": 5432}, "fetch_files": {}, "kube_targets": {}}}, + "pid": 99, "session_dir": str(session_dir), "started_at": "2026-08-07T00:00:00Z", + } + monkeypatch.setattr( + cli_mod, "spawn_daemon", + lambda schema, session_dir=None, *, input_env=None: {"kind": "success", "payload": payload}, + ) + monkeypatch.setattr(cli_mod.subprocess, "Popen", _FakePopen) + monkeypatch.setattr(cli_mod, "_teardown_run", cleaning_teardown) + monkeypatch.setenv("TUNSTRAP_INPUT", json.dumps({"nodes": {"node": { + "host": "h", "user": "u", "ssh_password": "p", "remote_targets": {"db": "127.0.0.1:5432"}, + }}})) + result = CliRunner().invoke( + main, ["run", "--input-env", "TUNSTRAP_INPUT", "--", "true"] + ) + assert result.exit_code == 0, result.stderr + materialized = session_dir / "tunnel-data" / "output.json" + assert materialized.exists() + assert stat.S_IMODE(materialized.stat().st_mode) == 0o600 + out = OutputSchema.model_validate(payload) + assert json.loads(materialized.read_text()) == render_unified_output(out) + assert _FakePopen.last_env is not None + assert _FakePopen.last_env["TUNSTRAP_OUTPUT_FILE"] == str(materialized) + + +class _FakePopen: + last_env: dict[str, str] | None = None + returncode = 0 + + def __init__(self, cmd: list[str], env: dict[str, str] | None = None) -> None: + _FakePopen.last_env = env + + def wait(self) -> int: + return 0 + + def send_signal(self, _signum: int) -> None: + pass +``` + +Add to `tests/unit/test_cli_run_output_var.py`: + +```python +def test_multi_node_run_succeeds_without_output_var( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any] +) -> None: + """Multi-node input with NO --output-var now succeeds -- was exit 1 + MultiNodeEnvUnsupported before this task; materialization covers + multi-node unconditionally so the opt-in gate has nothing left to force.""" + survivor_a = {"ports": {}, "fetch_files": {}, "kube_targets": {"k3s": _RICH_KUBE}} + other_kube = dict(_RICH_KUBE, path="/s/tunnel-data/node-b-k3s") + survivor_b = {"ports": {}, "fetch_files": {}, "kube_targets": {"k3s": other_kube}} + spawn[0]( + { + "kind": "success", + "payload": { + "connections": {"a": survivor_a, "b": survivor_b}, + "pid": 99, "session_dir": "/s", "started_at": "2026-08-07T00:00:00Z", + }, + } + ) + monkeypatch.setenv(VAR, _payload({"a": _node(), "b": _node()})) + result = CliRunner().invoke(main, ["run", "--input-env", VAR, "--", "true"]) + assert result.exit_code == 0, result.stderr + assert FakePopen.last_env is not None + joined = "/s/tunnel-data/node-k3s:/s/tunnel-data/node-b-k3s" + assert FakePopen.last_env["KUBECONFIG"] == joined + assert FakePopen.last_env["KUBE_CONFIG_PATHS"] == joined + assert "KUBE_CONFIG_PATH" not in FakePopen.last_env + assert FakePopen.last_env["TUNSTRAP_SESSION_DIR"] == "/s" + assert FakePopen.last_env["TUNSTRAP_PID"] == "99" + assert FakePopen.last_env["TUNSTRAP_OUTPUT_FILE"] == "/s/tunnel-data/output.json" + + +def test_suppress_kubeconfig_drops_all_three_kube_env_names( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any] +) -> None: + """suppress_kubeconfig (the tunstrap_tofu proxy's guard) must drop + KUBE_CONFIG_PATH/_PATHS too, not just KUBECONFIG -- see decision history + #7: those are the names the providers actually read.""" + from tunstrap.cli import _build_child_env + from tunstrap.schemas import OutputSchema + + out = OutputSchema.model_validate( + { + "connections": {"h": {"ports": {}, "kube_targets": {"k3s": _RICH_KUBE}}}, + "pid": 1, "session_dir": "/s", "started_at": "now", + } + ) + env = _build_child_env(out, output_var=None, input_env=None, suppress_kubeconfig=True) + assert "KUBECONFIG" not in env + assert "KUBE_CONFIG_PATH" not in env + assert "KUBE_CONFIG_PATHS" not in env +``` + +- [ ] **Step 2: Retarget every pre-existing test pinning the removed machinery** + +**`tests/unit/test_cli_run_output_var.py`** — this file's whole premise (the +scalar/`--output-var` interaction) partly no longer exists. Retarget or +delete: + +- `test_collision_with_injected_scalar_is_usage_error` — pins + `--output-var TUNSTRAP_DB_PORT` colliding with an injected scalar. + `TUNSTRAP_DB_PORT` is no longer ever injected (no scalars), so this + collision can no longer occur. **Delete this test**, not retarget — there + is no equivalent behaviour to assert once the collision class it tested is + gone. +- `test_non_colliding_tunstrap_prefixed_name_is_accepted` — asserts a + `TUNSTRAP_`-prefixed `--output-var` NAME is accepted because only *some* + `TUNSTRAP_` keys are protected. Retarget: rename to + `test_tunstrap_prefixed_output_var_name_is_accepted` and drop the "only + some are protected" framing from the docstring — under the new contract no + `TUNSTRAP__*` key exists to be protected from at all; the test's + only remaining job is confirming `--output-var TUNSTRAP_ANYTHING` is not + specially rejected just for the prefix. +- `test_multi_node_without_output_var_is_exit_1_pre_spawn` — pins the exact + behaviour Step 1's new `test_multi_node_run_succeeds_without_output_var` + inverts. **Delete this test**; it is superseded by the new one, not + retargetable (the assertion is the literal opposite). +- `test_multi_node_with_output_var_reaches_spawn` — still valid in spirit + (multi-node + `--output-var` reaches `spawn_daemon`) but its docstring + ("until it lands render_env would still reject a two-node envelope + post-spawn") describes removed code. Update the docstring only; the + assertions are unaffected (it never inspects env content, only that + `spawn_daemon` was reached). +- `test_output_var_carries_the_whole_envelope_minus_kube_credentials` — pins + the **old** shape (`connections..ports.` as an int, + `RunKubeTarget`'s exact field set). **Retarget in place**: rename to + `test_output_var_carries_the_unified_structure_minus_kube_credentials`, + replace `_RICH_PAYLOAD`'s expected-shape assertions with the unified + shape's (`nodes.node.ports.db == "127.0.0.1:5432"`, + `nodes.node.kube.k3s == {"path": ..., "context": ..., "endpoint": ...}`, + `nodes.node.fetch_files.hosts.sha256 == ...`), keep the credential-absence + assertions (`client_certificate_data`/`client_key_data`/`content_b64` still + must not appear anywhere in the decoded payload) — that property is + unchanged, only the container shape is. +- `test_single_node_keeps_scalars_alongside_output_var` — pins + `TUNSTRAP_DB_PORT`/`TUNSTRAP_DB_ENDPOINT` in the child env. **Delete**; no + scalars survive to keep "alongside" anything except + `TUNSTRAP_SESSION_DIR`/`TUNSTRAP_PID`, already covered by + `test_child_env_without_output_var_is_unchanged` below. +- `test_multi_node_injects_output_var_and_no_scalars` — retarget: the + "no scalars" half is now trivially true (nothing produces them), so the + test's remaining job is confirming the unified structure carries both + nodes correctly; update its body to decode `render_unified_output`'s shape + (`nodes` keyed by `"a"`/`"b"`) instead of the old `OutputSchema.connections` + shape, keep the `leaked` scalar-absence assertion (still a real guard + against a regression that reintroduces target-scoped scalars). +- `test_multi_node_suppression_uses_input_count` (**already retargeted once**, + in iteration 2, to `test_multi_node_suppresses_scalars_but_exports_kube_channel`) + — retarget **again**: its `leaked = [...TUNSTRAP_...]; assert leaked == []` + assertion no longer describes a real guard (there is no + `inject_scalars`/input-node-count decision left to get wrong — the kube + channel is unconditional by construction after this task, so there is + nothing left to falsify). Rename to + `test_optional_node_failure_does_not_affect_kube_channel_or_unified_output` + and rewrite the body to assert: the kube channel still fires for the one + surviving connection (`KUBECONFIG`/`KUBE_CONFIG_PATH` present), and the + unified structure (if `--output-var` given) reflects only the surviving + node (`"b"` absent from `nodes`, its failure visible in + `session.warnings`). Drop the `leaked` assertion entirely — nothing + produces `TUNSTRAP_`-prefixed target scalars anymore, so asserting their + absence is now asserting a tautology, not a guard. +- `test_child_env_without_output_var_is_unchanged` — retarget: the expected + `injected` dict shrinks to exactly `{"TUNSTRAP_SESSION_DIR": "/s", + "TUNSTRAP_PID": "99", "TUNSTRAP_OUTPUT_FILE": "/s/tunnel-data/output.json"}` + (drop `TUNSTRAP_DB_HOST`/`_PORT`/`_ENDPOINT` from the expected dict; this + node has no kube_targets in its fixture, so no kube keys are expected + either). Docstring updated to say "the three survivors, session metadata + only." Widen the `injected` filter (`k.startswith(("TUNSTRAP_", + "KUBECONFIG"))`) is already broad enough to catch `TUNSTRAP_OUTPUT_FILE` + automatically — no filter change needed, only the expected dict. + +**`tests/unit/test_envrender.py`** — delete every `render_env`-specific test +**by name** (the blast-radius table above lists these; restated here as the +concrete instruction): `test_render_ports_and_session`, +`test_render_kube_sets_kubeconfig`, `test_render_kube_not_materialized_raises`, +`test_render_requires_single_node_zero`, `test_render_requires_single_node_two`, +and **`test_render_kube_env_works_for_multi_node_while_render_env_still_rejects`** +(added by Task 3 itself — do not miss this one, it was undercounted in an +earlier revision of this plan). Also delete the now-unused module-level +`from tunstrap.exceptions import MultiNodeEnvUnsupported` import +(`test_envrender.py:4` — `ruff` F401 once nothing in the file uses it) and +`test_predicted_env_keys_no_kube_omits_kubeconfig`, +`test_predicted_env_keys_multi_node_is_empty` (both pin the old per-target +enumeration / the old "multi-node predicts nothing" claim, superseded below). + +**Do NOT delete `test_predicted_env_keys_matches_render_env`.** An earlier +revision of this plan deleted it on the false premise that only one +implementation of the injected-key set remained after this task — false, see +"Anti-drift guard — retargeted, not deleted, and now two-part (R11)" above, +which is the actual, correct disposition and supersedes this paragraph if the +two ever disagree. **[R11]** Retarget it in place to +`test_predicted_env_keys_covers_actual_injected_keys_under_cardinality_ +shrink` (the safety-envelope half; code given in full above) — not to a +single full-equality test named `..._matches_actual_injected_keys`, which was +this same paragraph's own iteration-4/5 name and is stale now that +`predicted_env_keys` is conservative rather than exact (see R11). Add it +here, in `test_envrender.py`, not as a new file. + +Replace the two deleted "shape" tests with: + +```python +def test_predicted_env_keys_is_session_scalars_plus_kube_channel() -> None: + """[R11] predicted_env_keys collapses to the three survivors + the + CONSERVATIVE kube channel (all three names, not just the >=2 branch that + this input's exact declared cardinality would exactly hit) -- there is no + other injected key left, and the formula does not vary by exact count.""" + schema = InputSchema.model_validate( + { + "nodes": { + "a": { + "host": "h", "user": "u", "ssh_password": "p", + "kube_targets": {"k3s": {"kubeconfig_path": "/etc/k3s.yaml"}}, + }, + "b": { + "host": "h2", "user": "u", "ssh_password": "p", + "kube_targets": {"k4s": {"kubeconfig_path": "/etc/k4s.yaml"}}, + }, + } + } + ) + assert predicted_env_keys(schema) == { + "TUNSTRAP_SESSION_DIR", "TUNSTRAP_PID", "TUNSTRAP_OUTPUT_FILE", + "KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS", + } + + +def test_predicted_env_keys_no_kube_is_just_the_three_survivors() -> None: + schema = InputSchema.model_validate( + {"nodes": {"a": {"host": "h", "user": "u", "ssh_password": "p", + "remote_targets": {"db": "127.0.0.1:1"}}}} + ) + assert predicted_env_keys(schema) == { + "TUNSTRAP_SESSION_DIR", "TUNSTRAP_PID", "TUNSTRAP_OUTPUT_FILE", + } +``` + +`format_exports`'s own test (`test_format_exports_quotes_safely`) is +unaffected — it takes a plain `dict[str, str]`, not an `OutputSchema`. + +**`tests/unit/test_exceptions.py`**: delete **all three** `MultiNodeEnvUnsupported` +cases by name, not "whatever case pins the exit code" (an earlier revision of +this plan undercounted these twice): the subclass check (`:87-90`, +`issubclass(MultiNodeEnvUnsupported, TunstrapError)`), the exit-code + +envelope test (`:94-99`, constructs an instance and checks +`to_error_output()["error"]`), and the `_EXIT_CODES` table test (`:107-114`, +`_EXIT_CODES[MultiNodeEnvUnsupported] == 1`). + +**`tests/unit/test_tofu_proxy.py`**: the two `suppress_kubeconfig`-related +tests (`test_tunnelled_suppresses_kubeconfig_in_child_env`, +`test_tunnelled_drops_an_inherited_kubeconfig_in_the_multi_node_case`) keep +their assertions unchanged (still correct: `KUBECONFIG` still must not leak) +but their fixtures currently rely on single-node-vs-multi-node framing in +their docstrings ("For single-node the post-injection pop already removes +KUBECONFIG... For multi-node... inject_scalars=False") — update both +docstrings to drop the `inject_scalars` framing entirely (there is no +branch left to describe; one unconditional pop covers every case, as +iteration 2's side note already anticipated). + +- [ ] **Step 3: Run to verify failure** + +`.venv/bin/pytest tests/unit/test_cli_run_output_var.py tests/unit/test_cli_run_output_var_projection.py tests/unit/test_cli_run_materialize.py tests/unit/test_cli_run.py tests/unit/test_cli_run_input_env_scrub.py tests/unit/test_cli_runner.py tests/unit/test_cli_run_postspawn.py tests/unit/test_envrender.py tests/unit/test_exceptions.py -v` +Expected: FAIL across the board — `render_env`/`MultiNodeEnvUnsupported`/ +`inject_scalars` still exist and behave the old way; `_build_child_env` still +requires `inject_scalars` and injects only two survivors, not three; no +materialization call site exists yet; `start --output env` still emits +per-target scalars. + +- [ ] **Step 4: Implement** + +`tunstrap/exceptions.py`: delete the `MultiNodeEnvUnsupported` class and its +`_EXIT_CODES` entry. + +`tunstrap/envrender.py`: delete `render_env` in its entirety, and the +now-unused `from tunstrap.exceptions import MultiNodeEnvUnsupported` import +(`envrender.py:13`, mirroring the same fix in `test_envrender.py:4`). Rewrite +`predicted_env_keys`: + +```python +def predicted_env_keys(schema: InputSchema) -> set[str]: + """Env keys ``run`` will inject for this *input* schema, unconditional on + node count: the three session scalars, plus -- [R11] conservatively, not + per the exact _kube_channel_keys(count) branch -- all three kube names + whenever any node declares kube_targets at all. Input cardinality can + shrink by output time (an optional node/target can fail without failing + the run), so predicting the exact branch would under-reserve; see the + "Anti-drift guard" section for the cardinality-shrink case this guards + against. Used pre-spawn to reject a colliding --output-var NAME before a + daemon exists. + """ + keys = {"TUNSTRAP_SESSION_DIR", "TUNSTRAP_PID", "TUNSTRAP_OUTPUT_FILE"} + if any(node.kube_targets for node in schema.nodes.values()): + keys |= {"KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"} + return keys +``` + +This is the same conservative rule Task 3 already gave `predicted_env_keys` +(above, in Task 3 Step 4) — Task 5 does not re-derive it, it only drops the +scalar half's `if len(schema.nodes) == 1:` per-target block per that task's +own "Note, still valid" callout. An earlier draft of this Task 5 rewrite +re-introduced `_kube_channel_keys(total_kube)` (the exact per-count branch) +here by mistake, which would have silently reverted the R11 fix for every +caller that hits this later body instead of Task 3's; fixed in place. + +`tunstrap/session.py`: confirm `SessionDir._write_file`'s exact signature +(`session.py:132` per the design doc's citation) before Step 4 item 4 below. +**[R13] It is not a drop-in reuse** — `_write_file` is mode-fixed-at-creation +but not atomic (`O_TRUNC`, no rename step), while materialization needs true +atomicity too (temp file + `os.replace`); check whether `_write_file` can be +refactored into a shared atomic-replace helper both call sites use, or +whether the primitive is replicated inline in `cli.py` instead — see item 4. + +`tunstrap/cli.py`: + +1. **Remove the `inject_scalars` parameter from all three places that thread + it, named explicitly (an earlier revision of this plan hedged with + "whichever of these two names is correct" — both exist, and a third does + too):** + - `_build_child_env` (`cli.py:365-372`, the parameter declaration) — + remove the parameter and its `if inject_scalars:` branch (`cli.py:399`). + - `_run_child` (`cli.py:466-474`, parameter; `cli.py:486`, passed through + to `_build_child_env`). + - `_supervise_child` (`cli.py:513-521`, parameter; `cli.py:543`, passed + through to `_run_child`). + - `run_command` (`cli.py:648`, `inject_scalars = len(schema.nodes) == 1` + — delete the line entirely; `cli.py:702`, the keyword argument passed + to `_supervise_child` — delete it from the call). + Confirm all four sites against the checked-out file rather than trusting + these line numbers verbatim — they are a reading of the pre-iteration-4 + tree and may have shifted by the time Tasks 1-4 land ahead of this one. +2. Remove the `cli.py:640` pre-spawn block: + ```python + if output_var is None and len(schema.nodes) != 1: + raise MultiNodeEnvUnsupported(...) + ``` + entirely — multi-node without `--output-var` is no longer rejected. +3. Rewrite `_build_child_env`: + +```python +def _build_child_env( + output: OutputSchema, + *, + output_var: str | None, + input_env: str | None, + suppress_kubeconfig: bool = False, +) -> dict[str, str]: + child_env = dict(os.environ) + if input_env is not None: + child_env.pop(input_env, None) + child_env["TUNSTRAP_SESSION_DIR"] = output.session_dir + child_env["TUNSTRAP_PID"] = str(output.pid) + child_env["TUNSTRAP_OUTPUT_FILE"] = _materialized_output_path(output.session_dir) + child_env.update(render_kube_env(output)) + if suppress_kubeconfig: + for key in ("KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"): + child_env.pop(key, None) + if output_var is not None: + child_env[output_var] = render_output_var(output) + return child_env + + +def _materialized_output_path(session_dir: str) -> str: + """The deterministic path materialize_output writes to; shared so + _build_child_env's TUNSTRAP_OUTPUT_FILE and the actual writer never + independently compute a different path for the same file.""" + return str(Path(session_dir) / "tunnel-data" / "output.json") +``` + + No branch, no `inject_scalars` parameter anywhere in the call chain. +4. **[R13] Materialization writer — true atomic replace, not + write-then-chmod, and not `O_TRUNC` alone.** `SessionDir._write_file`'s + real property is **mode-fixed-at-creation** (`session.py:132`, + `os.open(path, O_CREAT | O_WRONLY | O_TRUNC, 0o600)`, no separate + `chmod`) — **not** "atomic" in the sense that matters here: `O_TRUNC` + overwrites the file *in place*, visible mid-write to a concurrent reader. + `Path.write_text()` + `.chmod(0o600)` is even worse (a real, + umask-dependent `0644` window before `chmod` closes it). Neither is + sufficient on its own: this write needs *both* mode-fixed-at-creation + *and* true atomicity. **[R16, iteration 8 — rationale re-grounded, not + just retargeted.]** An earlier revision justified the atomicity + requirement by a `file()` call in a consumer's HCL racing a `run` restart + rewriting the *same pinned path* — that race is retired under R16 (design + doc, "Delivery," mode 2 is gone; `TUNSTRAP_OUTPUT_FILE` names a fresh + per-invocation path, written before the child spawns, so nothing reads it + concurrently with this write today). The requirement **stays** — strictly + safer than `O_TRUNC`, costs nothing — on grounds that do not depend on + that retired race: (1) torn-read prevention if this process is killed + mid-write (a truncated file at the final path is indistinguishable from a + valid short one to a naive reader; `os.replace` guarantees only a complete + old or complete new file is ever observable); (2) defense-in-depth against + any future change that reintroduces a stable/reusable path; (3) the + fetched-file materialization writer (Task 5's "Fetched-file + materialization" subsection, below) shares this exact primitive, so one + atomic-replace helper is reasoned about once, not twice. Use a temp file + + rename: + +```python + materialized_path = _materialized_output_path(output.session_dir) + tunnel_data_dir = Path(materialized_path).parent + tunnel_data_dir.mkdir(parents=True, exist_ok=True) + tmp_path = tunnel_data_dir / f".output.json.{os.getpid()}.tmp" + fd = os.open(tmp_path, os.O_CREAT | os.O_WRONLY | os.O_EXCL, 0o600) + try: + os.write(fd, render_output_var(output).encode()) + finally: + os.close(fd) + os.replace(tmp_path, materialized_path) +``` + + `O_EXCL` on the temp file guards against a colliding temp name (the mode + is already fixed at creation, same as the existing primitive); `os.replace` + is the atomic step — a single filesystem rename, so a reader can never + observe a partial write. If `SessionDir._write_file` can be refactored + into something callable without a live `SessionDir` instance (this + writer runs in the CLI **parent** process, `run_command`, which holds no + `SessionDir` — kube materialization happens daemon/worker-side, inside + the process that does own one), factor the atomic-replace primitive + above into a small shared helper in `session.py` both call sites use; + otherwise replicate it in `cli.py` as shown — do not describe this as + "reusing `_write_file`" if the code is not actually shared, since the + temp-file + `os.replace` step is new work `_write_file` does not + currently do at all. + + Placed in `run_command`'s success path — the same place `_build_child_env` + is already called, inside the `try` that owns teardown (design spec + `2026-07-31-run-env-io-and-tofu-proxy-design.md`'s "Cleanup must own the + whole post-spawn window" invariant applies here too: writing this file is + new work in that same protected window, so it must go inside the existing + `try`, not before it) — unconditionally (regardless of `--output-var`, + regardless of node count). **Confirm the exact `run_command` call site + against the checked-out `cli.py`** — the pre-#15 design's line numbers for + this function have already drifted once (`cli.py:302-308` in that design's + own text vs. later citations in this plan at `cli.py:640`/`cli.py:648`), + so re-resolve by reading the function, not by trusting a stale citation. +5. **`start_command`'s `--output env` mode** (`cli.py:204-206`, + `sys.stdout.write(format_exports(render_env(out)))`) is the **other** + caller of `render_env` — deleting the function without touching this call + site breaks `start` outright (`NameError`), not just a stale test. `start` + also now materializes under `--output env` (only) — mirroring `run`'s + conditional materialization: it already + forces `daemon.materialize` under `--output env`, per `cli.py:191`'s + `force_materialize=(output_fmt == "env")`, so the kube files already land + on disk; extend that to also write `output.json` via the same + `_materialized_output_path`/secure-write helper from item 4). Update the + `--output env` branch to build the same three-survivors-plus-kube-channel + mapping `_build_child_env` now uses: + ```python + if kind == "success" and output_fmt == "env": + out = OutputSchema.model_validate(message["payload"]) + _write_materialized_output(out) # same helper as item 4 + env = { + "TUNSTRAP_SESSION_DIR": out.session_dir, + "TUNSTRAP_PID": str(out.pid), + "TUNSTRAP_OUTPUT_FILE": _materialized_output_path(out.session_dir), + } + env.update(render_kube_env(out)) + sys.stdout.write(format_exports(env)) + ``` + Fix the existing test pinning this mode (`test_cli_runner.py:392`, see the + blast-radius table) in place — do not just add a new test alongside a + stale one. + + **[R13] Stdin-mode guard — a real reachable failure, not a theoretical + one.** `--output env` forces `daemon.materialize = True` only for **flag + mode** (`build_flag_schema`'s `force_materialize=(output_fmt == "env")`, + `cli.py:191`); a **stdin**-supplied payload's own `daemon.materialize` is + the caller's explicit statement and `_pick_start_input_schema` leaves it + alone (`cli.py:160-174`, docstring: *"a stdin payload's daemon.materialize + is the caller's own statement and is left alone"*). A stdin payload that + declares `kube_targets` with `materialize: false` under `--output env` + therefore reaches the now-unconditional `render_kube_env(out)` call with + `target.path is None` for that target, which raises a bare `ValueError` — + an ugly traceback, not a typed error. **Fix, before wiring the unconditional + call above:** either (a) force `daemon.materialize = True` for the stdin + path too when `output_fmt == "env"`, matching flag mode's own precedent + (simplest, and consistent — `--output env` needs materialized kube paths + regardless of input channel), or (b) catch `ValueError` around the + `render_kube_env` call in this branch and re-raise as a typed + `TunstrapError` subclass with a clear message. **Choose (a)** unless a + reviewer specifically wants materialization to stay an operator opt-out + even under `--output env` — it is the smaller change and matches the + existing flag-mode precedent exactly. Add a unit test: stdin payload, + `daemon.materialize: false`, `kube_targets` declared, `--output env` → + either exit 0 with the kube path materialized (if (a)), or a typed error + (if (b)) — never a bare `ValueError` traceback. + +- [ ] **Step 5: Run to verify pass, then the full suite** + +`.venv/bin/pytest tests/unit/test_cli_run_output_var.py tests/unit/test_cli_run_output_var_projection.py tests/unit/test_cli_run_materialize.py tests/unit/test_cli_run.py tests/unit/test_cli_run_input_env_scrub.py tests/unit/test_cli_runner.py tests/unit/test_cli_run_postspawn.py tests/unit/test_envrender.py tests/unit/test_exceptions.py tests/unit/test_tofu_proxy.py -v` +Expected: **all pass, and only after every row of the blast-radius table +above has actually been applied** — this is not "full pass" as a hope, it is +"full pass" as the definition of this step being done; a partial pass with a +handful of still-red tests means a table row was skipped, not that the row +was optional. + +`.venv/bin/pytest tests/unit -q` +Expected: full pass. Read the actual count; do not compare against any +number recorded in this plan or the spike findings — both predate this +task's deletions and retargets. + +- [ ] **Step 6: Commit** + +```bash +git add tunstrap/cli.py tunstrap/envrender.py tunstrap/exceptions.py tunstrap/session.py \ + tests/unit/test_cli_run_output_var.py tests/unit/test_cli_run_output_var_projection.py \ + tests/unit/test_cli_run_materialize.py tests/unit/test_cli_run.py \ + tests/unit/test_cli_run_input_env_scrub.py tests/unit/test_cli_runner.py \ + tests/unit/test_cli_run_postspawn.py tests/unit/test_envrender.py \ + tests/unit/test_exceptions.py tests/unit/test_tofu_proxy.py +git commit -m "feat: unified output materialization; remove TUNSTRAP_* scalars, MultiNodeEnvUnsupported, inject_scalars (#15)" +``` + +**Note:** `tunstrap/session.py` is only in this commit if Step 4 item 4 added +a small shared secure-write helper there; omit it if `SessionDir._write_file` +was reusable as-is. + +- [ ] **Step 7: Integration retargets** + +The blast-radius table's integration rows are their own step, not folded +into Task 7's gate pass — they are behavioural retargets (TDD-shaped: they +can fail against the old code and must pass against the new), not a +verification-only pass. + +**Files:** +- Test: `tests/integration/test_run_env_io.py`, `tests/integration/test_cli_modes.py` + +Apply every integration row from the blast-radius table above: +`_PROBE_SINGLE`/`_PROBE_MULTI` read `TUNSTRAP_OUTPUT_FILE` instead of +`TUNSTRAP_WEB_PORT`; the "no scalar leak" check excludes the three sanctioned +survivors; `test_multi_node_without_output_var_is_exit_1` is renamed and +inverted to `test_multi_node_without_output_var_now_succeeds`; +`test_cli_modes.py`'s `start --output env` test and `run`'s child probe both +retarget to `TUNSTRAP_OUTPUT_FILE`. Run (requires Docker, per +`tests/README.md`): + +```bash +PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/integration -m integration -q -k "run_env_io or cli_modes" +``` + +Expected: FAIL before the retargets (old assertions against new behaviour), +PASS after. Commit: + +```bash +git add tests/integration/test_run_env_io.py tests/integration/test_cli_modes.py +git commit -m "test(integration): retarget env-shape assertions for the unified output contract (#15)" +``` + +--- + +### Task 6: Recipe documentation + e2e artifact shape migration (work item 4, extended by the pivot) + +**This task also carries the e2e-tier and shipped-recipe rows of Task 5's +blast-radius table** — they land here, not in Task 5, because they are the +same textual shape migration as the new recipe content this task writes, and +`test_recipe_terragrunt.py`'s drift guard requires the recipe and +`tests/e2e/module/main.tf` to move together or it fails by design. + +**Files:** +- Modify: `docs/recipe_terragrunt.md`, `tests/e2e/module/main.tf`, + `tests/e2e/rig.py`, `tests/e2e/test_tofu_providers.py`, + `tests/e2e/test_terragrunt_apply.py` + +- [ ] **Step 0: Fix the recipe's pre-existing `connections.*` shape (before adding new content)** + +The recipe already contains working HCL/prose in the old shape, predating +this pivot — fix these **in place** before Step 1/2 add anything new, so the +document is never left in a self-contradictory state (old shape in one +section, new shape in another): + +- `docs/recipe_terragrunt.md:287-288` — the `tunnel`/`kubepath` locals: + `try(jsondecode(var.tunstrap), { connections = {} })` → + `try(jsondecode(var.tunstrap), { nodes = {} })`; + `local.tunnel.connections.node.kube_targets.k3s.path` → + `local.tunnel.nodes.node.kube.k3s.path`. +- `docs/recipe_terragrunt.md:~329` — prose point 3, "`path` comes from the + materialized file... `connections.*.kube_targets.*.path`" → retarget to + `nodes.*.kube.*.path`. +- `docs/recipe_terragrunt.md:~407` — "The input variable is scrubbed" + section: "the module picks the node out of `connections[]`" → + `nodes[]`; also correct the surrounding paragraph's claim that + multi-node input suppresses the scalar/`KUBECONFIG` channel entirely — that + was true pre-pivot and is false now (the kube channel is unconditional; + only the `TUNSTRAP__*` scalars, which no longer exist as a concept, + were ever suppressed for multi-node). +- `docs/recipe_terragrunt.md:~509` — "What is proven" section: verify only, + no shape-specific text to change (the `--output-var` → `TF_VAR_tunstrap` → + `jsondecode` → `config_path` chain description stays accurate once the two + locals above change). + +**`tests/e2e/module/main.tf:27-28`** — the exact chain the e2e tier proves, +mirroring the recipe: `try(jsondecode(var.tunstrap), { connections = {} })` +→ `{ nodes = {} }`; `local.tunnel.connections.node.kube_targets.k3s.path` → +`local.tunnel.nodes.node.kube.k3s.path`. Update the module's own header +comment (`main.tf:1-9`, "The exact chain this tier exists to prove") to match. + +**`tests/e2e/rig.py:171`** — docstring: "`module/main.tf` decodes +`connections.node.kube_targets.k3s.path`" → retarget to `nodes.node.kube. +k3s.path`. + +**`tests/e2e/test_tofu_providers.py:154`** — +`envelope["connections"]["node"]["kube_targets"]["k3s"]["path"]` → +`envelope["nodes"]["node"]["kube"]["k3s"]["path"]`. + +**`tests/e2e/test_tofu_providers.py:251-254`** — the fake envelope dict +literal (`"connections": {"node": {"ports": {}, "kube_targets": {"k3s": +{...}}}}}`) → retarget to `{"nodes": {"node": {"ports": {}, "kube": {"k3s": +{"path": ..., "context": ..., "endpoint": ...}}}}}`, aligning the literal's +field names with `UnifiedKubeRef` (drop any field beyond `path`/`context`/ +`endpoint` the old literal happened to carry — this fixture only needs +enough to exercise the dead-cluster negative-control scenario it drives). + +**`tests/e2e/test_terragrunt_apply.py:339,425`** — +`envelope["connections"]["node"]["kube_targets"]["k3s"]["path"]` (apply and +tunnelled-output cases) → `envelope["nodes"]["node"]["kube"]["k3s"]["path"]` +at both sites. + +**Not touched, disposition recorded (from Task 5's table, restated for +completeness at the point where a reader would otherwise expect to find +them fixed):** `tests/e2e/test_rig.py:278` reads `start`'s raw stdout JSON, +which is out of the pivot's scope (design doc judgment call); `tests/e2e/ +test_recipe_terragrunt.py`'s drift guard needs no code change — its compared +content updates automatically once the steps above land. + +- [ ] **Step 0b: [R16, iteration 8, new] Rewrite `docs/recipe_terragrunt.md:366-388` + — the "Fetched files are exported verbatim, not projected" subsection** + +Found by the widened `content_b64` grep (Task 5's enumeration, "docs tier" +rows) — this shipped subsection currently argues the **opposite** of R16's +shipped behaviour (fetch content stays whole in the envelope, `FetchedFile` +has no `path`, dropping `content_b64` would be "a silent, unrecoverable +breakage"). Fix in place, same "before Step 1/2 add anything new" discipline +as Step 0 above — this is pre-existing content, not new content Step 2 adds. +Replace the entire subsection (heading through the final paragraph ending +"...is recorded in the spec's 'Out of scope'.") with: + +> ### Fetched files are materialized, not carried in the envelope +> +> The projection above (kube) and this one (`fetch_files`) now follow the +> same rule: `run` materializes content to disk under the session dir's +> `tunnel-data/`, mode `0600`, and the consumer-facing envelope carries only +> a reference to it. Each `fetch_files` entry becomes `{path, size, sha256}` +> on success, `{error}` on failure — never `content_b64`. +> +> This supersedes the asymmetry an earlier revision of this document +> described: `FetchedFile` **now has a `path`** (`schemas.py`, extended for +> this ticket), so the "dropping `content_b64` would be a silent, +> unrecoverable breakage" premise that justified keeping content in the +> envelope no longer holds — the lossless on-disk alternative that argument +> said was missing now exists, the same way it already existed for kube. +> +> **The plan-file-persistence risk this asymmetry existed to warn about is +> resolved as a class, not documented around**: since fetched content never +> enters `TF_VAR_tunstrap` or the materialized file at all, `--fetch`ing a +> secret no longer risks it landing in a saved Terraform plan file through +> this channel. Read the file directly at `fetch_files..path` if you +> need its contents. + +Also update the one adjacent sentence this rewrite does not itself replace: +the "One other free-form string rides this channel unprojected: +`warnings[*].error`" paragraph immediately after (line ~390) stays accurate +as written — `warnings[*].error` is unrelated to `fetch_files` — but confirm +after the edit that "One other" still reads correctly given the preceding +subsection no longer describes `fetch_files` as unprojected at all (the +prior subsection's own "unprojected" framing is what "other" was +contrasting against); reword the transition if it no longer parses, do not +leave a dangling "other." + +Verify lines 344 and 361-364 (the kube-drop list and the `start` carve-out, +immediately above this subsection) need **no** edit — both already state +kube-specific `content_b64` facts unaffected by R16, confirmed in Task 5's +enumeration table. + +Run (requires `kind`/`tofu`/`kubectl`/Docker, per `tests/README.md` — +optional locally, mandatory before merge per Task 7 Step 4): + +```bash +PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/e2e -m e2e -q +``` + +Commit this shape-migration half separately from the new recipe content +below, so a reviewer can see "shape rename, no behaviour change" and "new +recipe content" as two distinct, independently reviewable diffs: + +```bash +git add tests/e2e/module/main.tf tests/e2e/rig.py tests/e2e/test_tofu_providers.py \ + tests/e2e/test_terragrunt_apply.py +git commit -m "test(e2e): retarget to the unified output nodes.*.kube.*.path shape (#15)" +``` + +- [ ] **Step 1: Add Mode A — env-native kube [R12, rewritten from "kube-only recipe"]** + +Add a new section (placement: after the existing provider-config example, so +it reads as "and here is the identity-delivery contract that example +depends on") titled around **Mode A: env-native kube (satisfies the +ticket's strict "nothing live enters Terraform" contract)**, per the design +doc's "Documentation" section, Mode A: + +1. `KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` from tunstrap's own process + environment (no `var.`-bound value, no file read in HCL at all for kube) + **plus a literal `config_context = "tunstrap--"` per + provider alias** — a **two-alias worked HCL example**, citing findings #3 + and #5 by number: + ```hcl + provider "kubernetes" { + alias = "node1_k3s" + config_context = "tunstrap-node1-k3s" # literal -- never derived from var.tunstrap + } + provider "kubernetes" { + alias = "node2_k3s" + config_context = "tunstrap-node2-k3s" + } + ``` +2. Explicit warning: never derive `config_context`'s value from + `var.tunstrap` or any decoded data — literal only, matching the + deterministic naming scheme exactly. +3. A short "measured facts a consumer needs" list, restated (not + re-derived) from **all six** of the ticket's own findings and this + design's own provider findings — an earlier revision of this list, despite + its own header claiming all six, cited only four; do not repeat that + miscount: + - **#1** — provider configuration **is** re-evaluated at apply. + - **#2** — outputs **freeze silently** — the worst failure mode, name it + as such. + - **#3** — per-alias `config_context` works with an env-supplied + kubeconfig path (Mode A's own basis, shown in item 1's example). + - **#4** — plan-safe end to end, measured live: plan with one set of + ports, mutate only the kubeconfig, apply the *saved* plan → the alias + uses the mutated value, zero plan-variable mismatch — the e2e-level + confirmation Mode A's env-native path really is plan-safe. + - **#5** — `KUBE_CONFIG_PATHS` is colon-separated (comma silently falls + back to `localhost:80`). + - **#6** — a live value bound to a `var.` **does** trip "Mismatch between + input and plan variable value" on a saved plan (Mode B's one-shot rule, + below, rests on this). + - A live value bound to a **resource attribute** (not a provider config + block) produces `Error: Provider produced inconsistent final plan` — + cite the committed provider-precedence spec's Q3 + result, and show the provider-block placement as the only supported + shape in both Mode A and Mode B. +4. A one-line pointer to the deterministic naming scheme + (`tunstrap--`) and why it matters for anyone piping the + materialized kubeconfig into `kubectl --context` directly instead of + through a provider. + +- [ ] **Step 2: Add Mode B — unified-file convenience [R16, iteration 7 — rewritten again, R12's version retracted]** + +Immediately after Step 1's section (same document — a real consumer may use +Mode A for kube and Mode B for ports in the same module), add a section +titled around **Mode B: unified-file convenience (ports + kube references; +does NOT satisfy the ticket's strict contract — state this plainly)**, per +the design doc's "Documentation" section, Mode B (iteration 7 text). **[R16] +No literal, operator-pinned path and no `var.tunstrap_session_dir`/ +variable-derived locator anywhere in this section** — two earlier revisions +of this recipe each used one of those two unsound patterns in turn (a +locator built from `var.tunstrap`; then a literal pinned `--session-dir` +path); both are retracted, not adapted, per decision history entry 19 (which +itself supersedes entry 14's pinned-path decision): + +5. **The shape**, with a worked HCL example using the env-carried + `TUNSTRAP_OUTPUT_FILE` locator via Terragrunt's `get_env(...)` — no + `--session-dir` precondition, no operator-agreed path, because the + session dir stays ephemeral unconditionally (design doc, "Session dir: + ephemeral, but not optional"): + ```hcl + locals { + tunnel = try( + jsondecode(file(get_env("TUNSTRAP_OUTPUT_FILE"))), + { nodes = {} }, + ) + } + + provider "kubernetes" { + config_path = local.tunnel.nodes.node1.kube.k3s.path + } + ``` + read directly inside the `locals` block that feeds the provider config — + never through an `output`, per the stability contract's finding-#2 + warning. +6. **Ports lose their integer form** (`"host:port"` string) — show the + extraction idiom explicitly: + ```hcl + locals { + service1_port = split(":", local.tunnel.nodes.node1.ports.service1)[1] + } + ``` +7. **[R16] The stability contract**, restated plainly and matching the + design doc's "Stability contract" word-for-word on the load-bearing + claims: **both** Mode B forms — item 5's `TUNSTRAP_OUTPUT_FILE` form and + the `--output-var` (`var.tunstrap`) form — are **one-shot `plan && apply` + only**, no saved-plan reuse across a tunstrap restart for either, no + locator exemption of any kind (the check compares the variable's whole + value; the file itself is deleted at teardown alongside the rest of + `tunnel-data/`). An earlier revision of this item claimed item 5's form + was unconditionally plan-safe given a `--session-dir` precondition — that + precondition and the plan-safety it bought are both retracted; there is + no remaining path-pinning mechanism. State this as plainly as the design + doc does: *"Neither Mode B form survives a tunstrap restart. If you need + a saved plan to apply cleanly against fresh ports or fetched-file content, + re-run plan in the same tunstrap invocation."* Cite findings #1, #2 and #6 + by number. +8. **The `jsondecode`-not-JavaScript note** (U5), one sentence: consumption + is via HCL's `jsondecode`; there is no JS runtime anywhere in this stack. +9. **[R16] The `fetch_files` warning is retracted, not carried forward.** + Two earlier revisions of this recipe disagreed on whether to keep this + warning (one dropped it, the next restored it while reshaping the + payload); iteration 7 resolves it as a class instead of restating it: + fetched content no longer rides `--output-var` or the materialized file at + all — only `{path, size, sha256}` does (design doc, "Fetched-file + materialization" and "Compatibility"). State instead: *"Fetched file + content never enters a Terraform variable or plan file — only its path, + size, and checksum do. Read the file itself at `fetch_files..path` + if you need its contents."* + +Match the existing file's structure (numbered/lettered subsections, HCL code +fences, "Measured Terragrunt facts"-style attribution footers) — read the +file's current shape before writing, do not introduce a new prose style. + +- [ ] **Step 3: Cross-check against the two artifacts, the design doc, and the drift guard** + +Confirm every measured fact restated in both new sections matches the committed +provider-precedence spec, the ticket's +own six findings, and the design doc's "Stability contract" subsection +verbatim — no rewording that could drift from the source transcripts or +introduce a second, subtly different phrasing of the same rule. This is a +manual read-through, not a test. + +Then run the recipe↔module drift guard, which must stay green through both +Step 0's shape migration and this step's new content (it fails loudly, by +design, if the two documents disagree on a shared HCL block): + +```bash +PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/e2e/test_recipe_terragrunt.py -m e2e -q +``` + +- [ ] **Step 4: Commit** + +```bash +git add docs/recipe_terragrunt.md +git commit -m "docs(recipe): kubeconfig-as-identity delivery + unified output + stability contract (#15)" +``` + +--- + +### Task 7: Full gate pass + +**Files:** none (verification only). + +- [ ] **Step 1: Style/type/lint gates** + +```bash +.venv/bin/black --check . +.venv/bin/ruff format --check . +.venv/bin/ruff check . +.venv/bin/pylint tunstrap/ +.venv/bin/vulture tunstrap/ +.venv/bin/mypy --strict tunstrap +``` + +Expected: all clean. `vulture` has no whitelist file to update +(`vulture_whitelist.py` was removed; `min_confidence = 80` in +`pyproject.toml` — if `rename_identities`/`render_kube_env`/ +`render_unified_output` get flagged as unused, that means a call site is +missing, not that a suppression is needed; conversely if `RunKubeTarget`, +`render_env`, or `MultiNodeEnvUnsupported` are still importable from +anywhere, `vulture`/`ruff` catching them as unused is the signal Task 5's +deletions were incomplete). `pylint`'s `fail-under = 9.0` gate applies to +the whole `tunstrap/` package score, not per-file. + +- [ ] **Step 2: Unit suite** + +```bash +.venv/bin/pytest tests/unit -q +``` + +Expected: full pass. **Do not compare the count against any number recorded +in this plan, the spike findings, or earlier iterations of this plan** — the +pivot deletes a meaningful number of pre-existing tests (Task 5's retarget +list) while adding others; both this plan's own earlier "475+N" guidance and +the spike's 476 are stale baselines from before the scalar-channel removal. +Run it and read the real number. + +- [ ] **Step 3: Integration suite** + +```bash +PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/integration -m integration -q +``` + +Expected: full pass — **this claim was false in an earlier revision of this +plan** ("no changes needed — this ticket touches no integration fixtures"), +corrected here: Task 5 Step 7 retargets `test_run_env_io.py` and +`test_cli_modes.py` for the exact same shape/scalar removal as the unit +tier, and this is the tier that proves those retargets hold against the +*real* console script and a real docker rig, not just `CliRunner`. If this +step is reached with those retargets not yet landed, it will fail, correctly +— that failure is not a flake to route around, it is Task 5 Step 7 being +incomplete. + +- [ ] **Step 4: e2e suite** + +**This claim was also false in an earlier revision of this plan** ("run the +tier unmodified as a regression check only"): Task 6 changes +`tests/e2e/module/main.tf`, `rig.py`, `test_tofu_providers.py`, and +`test_terragrunt_apply.py` to the `nodes.*.kube.*.path` shape — those are +real code changes this tier must pass against, not a no-op regression check. + +```bash +PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/e2e -m e2e -q +``` + +Expected: full pass, including `test_recipe_terragrunt.py`'s drift guard +(already run once in Task 6 Step 3; running the full tier here is the final +confirmation nothing else regressed). + +**Separately, and still optional:** the design doc's "`e2e` coverage — +optional, with rationale" describes a *different* piece of work — an +e2e-level collision test proving the kube identity rename, which no task in +this plan adds by default because Task 2's unit-level regression test +already exercises that specific defect precisely. If a reviewer chose to add +it anyway, it would be an extension of Task 2 (rewriting two kind +kubeconfigs to a shared identity before feeding them to `tunstrap start`/ +`run`, per the design doc), not part of this step. Do not conflate the two: +Task 6's shape-migration e2e changes are mandatory and verified by this +step; the collision-specific e2e coverage is optional and, if added, is +Task 2's concern, verified the same way this step already verifies the rest +of the tier. + +- [ ] **Step 5: Final commit / PR** + +No further commit needed if Tasks 1-6 already committed cleanly and gates +pass on the resulting tree. Open or update PR #13 against `feature/run-env-io` +per the ticket's stated target; do not merge (org rule, per prior Phase-A +rulings on this repo — curate and validate, leave the merge decision to the +human reviewer). + +--- + +## Self-Review + +**Spec coverage — kube part (Tasks 1-3, unchanged by the pivot):** +- Ticket work item 1 (patch identity names, all three, cluster+user+context) + → Task 1. ✓ +- Ticket work item 2 (multi-node kube channel) → Task 3. ✓ (its original + "`MultiNodeEnvUnsupported` narrows to scalars" framing is itself + superseded by the pivot — see below, the class is removed entirely, not + narrowed.) +- Ticket work item 3 (export provider-facing variables) → Task 3, per the + conditional contract (R1), **not** the ticket's own placeholder "superset" + suggestion — corrected in light of the provider findings that arrived after + the ticket was written. ✓ +- R1 (conditional, not superset; anti-drift guard extended for both + cardinalities) → Task 3 builds `render_kube_env`'s exact cardinality + contract; Task 5 re-scopes the guard's *other side* (`predicted_env_keys` + vs. `_build_child_env`, once `render_env` is deleted) — but note + `predicted_env_keys` **itself** ships its final, conservative formula + directly in Task 3 (R11, iteration 6), not deferred to Task 5. ✓ +- R3 (active-triple-only rename scope) → Task 1 (`rename_identities`'s scope, + `test_kube_rename.py`'s "ignored entries" case). ✓ +- R4 (naming scheme, no configurable prefix) → Task 1. ✓ +- R6 (correction: `patch_view` owns server-address patching, not + `dump_kubeconfig`) → encoded in Task 1's implementation guidance (no + `dump_kubeconfig` signature change) and in the design doc directly. ✓ +- R7 (mandatory unit collision test; e2e optional with rationale) → Task 2 + (mandatory) + Task 7 Step 4's "separately, and still optional" paragraph + (explicit rationale for skipping the collision-specific e2e coverage by + default, disentangled from Task 6's now-mandatory e2e shape migration — + the two were conflated in an earlier revision of this plan; iteration 4 + keeps them clearly distinct). ✓ +- Not covered by any ruling, found and closed here: `suppress_kubeconfig` + must drop all three kube env names once `KUBE_CONFIG_PATH`/`_PATHS` become + real exported channels → wired in Task 5's `_build_child_env` rewrite + (originally Task 4 in iteration 2; the mechanism moved when the pivot + merged kube-channel wiring into the same edit as scalar removal). ✓ (see + decision history #7.) + +**Spec coverage — the pivot (U1-U6, Tasks 4-6):** +- U1 (unified node-qualified output contract replaces flat scalars) → Task 4 + (shape + `render_unified_output`) + Task 5 (scalar deletion). Decision + history entry 10. ✓ +- U2 (delivery: var AND materialization, materialization primary) → Task 5 + Step 4 (materialization write, unconditional). Decision history entry 11. ✓ +- U3 (scalar channel deprecated/removed, not "stays single-node"; disposition + of `render_env`/`predicted_env_keys`/`MultiNodeEnvUnsupported` worked out) + → Task 5 in full, **now backed by a grep-driven blast-radius table** + (iteration 4) covering unit, integration, e2e and the recipe doc — every + deletion and retarget is enumerated by file:line with a stated disposition, + not reconstructed from a prose list. Decision history entries 10 and 13. ✓ +- U4 (kube part unchanged; unified structure carries only kube references) + → Task 1-3 untouched; Task 4's `UnifiedKubeRef` shape enforces + reference-only fields at the model level (`extra="forbid"`, no credential + field exists to leak, and — confirmed in Task 5's dedicated retarget of + `test_cli_run_output_var_projection.py` — the field set is narrower than + the pre-#15 `RunKubeTarget` projection by design, not by omission: `path`/ + `context`/`endpoint` only). ✓ +- U5 (consumer-side transformation via jsondecode; "через js" recorded as an + assumption) → design doc "Consumer-side transformation"; Task 6 Step 2's + recipe content states the same interpretation; decision history entry 12 + records it as its own decision. ✓ +- U6 (reconciliation: ticket's "nothing live enters Terraform" holds for kube, + superseded for ports by materialization-primary + stability contract) → + design doc's dedicated reconciliation subsection (present in both the + problem framing and the unified-output section) + decision history entry + 11's "U6 reconciliation" paragraph, both present per the DoD's explicit + requirement that this appear in *both* documents. ✓ +- Sarge's ruling this iteration (`inject_scalars` gate semantics change: + unified output emitted regardless of node count) → design doc's rewritten + "`cli.py` wiring is in scope" subsection + Task 5's unconditional + `_build_child_env`; decision history entry 13. ✓ +- R5 (breaking deliberately) → extended by the pivot to cover the scalar + removal and `MultiNodeEnvUnsupported` deletion too, not just the kube + rename — design doc "Compatibility" section, pivot-tagged bullets. ✓ +- R8 (recipe carries the three conditions + the four measured facts) → + Task 6 Step 0 (fixes the recipe's own **pre-existing** `connections.*` + shape so it does not contradict the new content) + Step 1 (kube part, + unchanged) + Step 2 (pivot: shape, stability contract, jsondecode note). ✓ +- Iteration-2 ruling (kube channel fires on `kube_targets` presence, not node + count) → **superseded, not re-satisfied by a new mechanism**: under the + pivot there is no `inject_scalars` branch left to satisfy the ruling + *against*, so it holds trivially (Task 5's unconditional + `render_kube_env` call). The pre-existing test this ruling first + contradicted (originally `test_multi_node_suppression_uses_input_count`, + retargeted once in iteration 2 to + `test_multi_node_suppresses_scalars_but_exports_kube_channel`) is + retargeted a **second** time in Task 5 Step 2, to + `test_optional_node_failure_does_not_affect_kube_channel_or_unified_output`, + because its remaining `leaked == []` assertion stopped describing a real + guard once there was nothing left to leak from. Both retargets are + recorded by name in decision history (entry 9, marked superseded; entry + 13, the current disposition). ✓ + +**Placeholder scan:** No task defers its own code to "TBD" with one +explicitly-flagged exception: Task 5 Step 4's materialization call site asks +the implementer to "confirm the exact `run_command` call site against the +checked-out `cli.py`" rather than citing a line number, because this plan's +own earlier line citations for that function have already drifted once +across iterations (noted inline, Task 5 Step 4) — re-resolving against the +live file is explicitly instructed, not a gap. Every other task either +cherry-picks concrete, reviewed spike code (Task 1's function body, Task 3's +`render_kube_env` skeleton before the cardinality-helper refactor) or +specifies exact replacement logic inline (Task 3's `_kube_channel_keys`, +Task 4's model definitions, Task 5's `_build_child_env` rewrite, Task 5's +retargeted anti-drift guard given in full). Task 6 is documentation plus the +e2e shape migration, both scoped to concrete file:line targets from the +blast-radius table. Task 7 Step 4 is scoped precisely (mandatory e2e shape +migration vs. optional collision coverage, disentangled — iteration 4). + +**Type consistency:** `rename_identities(dict[str,object], str, str) -> str`; +`render_kube_env(OutputSchema) -> dict[str,str]`; +`_kube_channel_keys(int) -> set[str]`; `render_unified_output(OutputSchema) -> +dict[str, Any]`; `render_output_var(OutputSchema) -> str` (signature +unchanged, body rewritten); `predicted_env_keys(InputSchema) -> set[str]` +(return type unchanged, body simplified, now three-survivor-aware); +`_build_child_env(output, *, output_var, input_env, suppress_kubeconfig=False) +-> dict[str,str]` (**`inject_scalars` parameter removed** — every one of the +three call sites that threaded it, `_build_child_env`/`_run_child`/ +`_supervise_child`/`run_command`, updated in the same task, Task 5, named +individually rather than hedged); `_materialized_output_path(str) -> str` +(new, shared between the env-var value and the writer so the two paths +cannot independently drift). ✓ + +**Under-specified for implementation, flagged rather than silently resolved:** +only the exact `run_command` call-site line for the materialization write +(Task 5 Step 4, noted inline — re-resolve against the live file rather than +trust a citation that has already drifted once) and whether `SessionDir. +_write_file` is directly reusable for `output.json` or needs a small sibling +helper (Task 5 Step 4 item 4, both paths given). `start`'s `--output env` +mode, which also called the now-deleted `render_env` and would otherwise +break outright, is explicitly resolved in Task 5 Step 4 item 5 +(three-survivors-plus-kube-channel, matching `_build_child_env`'s own new +shape, plus materialization) — not left as an open question. + +**Iteration-4 summary — the systemic fix this revision exists to make:** a +full grep-driven enumeration (unit + integration + e2e + docs, commands given +at the top of Task 5) replaced three rounds of case-by-case patching. The +anti-drift guard (`test_predicted_env_keys_matches_render_env`) is retargeted, +not deleted — the earlier deletion was itself a drill-caught defect in this +plan, corrected here and cross-referenced from the design doc and decision +history entry 13 so the three documents cannot silently re-diverge on this +point again. + +**Iteration-5 correction:** the iteration-4 retargeted guard literal (at that +point, a single `predicted_env_keys(schema) == set(actual)` full-equality +assertion) was itself defective — `_build_child_env` starts from +`dict(os.environ)`, so the comparison was unconditionally False against the +real ambient environment of any test process. Fixed by isolating `os.environ` +to `{}` via `monkeypatch.setattr(cli_mod.os, "environ", {})` before calling +`_build_child_env`, with an explicit warning against subtracting `os.environ` +back out after the call instead (silently under-checks a key that is both +inherited and injected). + +**Iteration-6 correction (R11) — the single-equality guard itself is now +split into two, per the "Anti-drift guard" section above; read the +iteration-5 warning below in that light, not as still forbidding a subset +check outright.** Iteration-5's warning against "relaxing the assertion to a +subset check" applied to the **exact-cardinality** predictor that existed at +the time — a subset check on top of an exact predictor really would have +been a pure relaxation, hiding a real regression. R11 changes what +`predicted_env_keys` computes (deliberately conservative, not exact), which +changes what "correct" means for the comparison: the safety-envelope half of +the now-two-part guard (Task 5, "Anti-drift guard — retargeted, not deleted, +and now two-part") **is** a subset check, `set(actual) <= +predicted_env_keys(schema)`, and that is *correct*, not a weakening — it is +paired with a separate, still-full-equality formula test (Task 3) that +guards the conservative formula's own correctness. The ambient-environment +isolation fix from iteration 5 carries forward unchanged into the +safety-envelope test's literal (given in full in Task 5, above). + +**Iteration-7 summary (R16) — supersedes parts of R9/R12/R13/R15, does not +touch R1/R10/R11/R14.** The user's confirmed post-red-team direction plus +one added constraint: delivery collapses from R9's three modes to two +(mode 2, the literal-pinned-`--session-dir` file, is retracted — +`TUNSTRAP_OUTPUT_FILE` becomes the primary env-carried locator instead); +`fetch_files` content_b64 is removed from every consumer-facing channel +(materialized to disk, `{path, size, sha256}` projected instead, mirroring +the kube precedent R13's atomic-replace primitive already established); +R15's re-adoption of #14 fix 1 is retracted (fix 4 survives, reshaped). The +user's own constraint — the session dir stays mandatory lifecycle +infrastructure regardless — is encoded as its own design-doc subsection, not +folded silently into the stability contract where it could be missed. R1 +(anti-drift guard), R10 (naming collision), R11 (conservative predictor), +and R14 (dangling context reference) are all independently verified +unaffected by this iteration — checked explicitly (see "`predicted_env_keys`/ +anti-drift guard: checked, unaffected by R16," Task 5), not assumed safe by +omission. Decision history entry 19 (new) records the full +context/alternatives/decision/consequences; entries 14 and 18 carry +correction annotations rather than being rewritten in place, matching this +document's own established annotate-don't-rewrite discipline. + +**Self-Review updates, iteration 7:** +- U2 ("delivery: var AND materialization, materialization primary") — still + holds for the *mechanism* (materialization primary, var secondary); the + *locator* for the materialized side changes from R9's caller-pinned path to + R16's env-carried `TUNSTRAP_OUTPUT_FILE`. Task 5 (mechanism) + Task 6 Step 2 + (recipe) updated; decision history entry 19 records the correction. +- U6 (reconciliation) — the "ports: genuine third option" bullet is corrected + in place (design doc, "Reconciliation," R16-tagged); the option itself + (`file()` at a locatable path) survives, its plan-safety-across-restart + property does not. +- R8 (recipe carries the conditions + measured facts) — Task 6 Step 2's Mode + B content is rewritten a second time (R16), not just re-worded; the + measured-facts list itself (six ticket findings) is unchanged, only which + delivery mechanism each fact is cited in support of. +- R9/R12/R13/R15 — explicitly **not** re-litigated from scratch; R16 is + scoped to exactly what changed (delivery mode count, the fetch_files + projection, the fix-1 re-adoption), stated as corrections layered on top, + per the ticket's own instruction for this iteration. + +**Iteration-8 note — a methodology regression in iteration 7's own +enumeration, found by drill review and corrected here.** Iteration 7's +`content_b64` blast-radius grep (Task 5, "Fetched-file materialization") +was run as `tunstrap/ tests/ --include='*.py'` — narrower than the +`tunstrap/ tests/ docs/ --include='*.py' --include='*.md' --include='*.tf'` +scope iteration 4 established for the *original* blast-radius table at the +top of Task 5, and that this R16-specific enumeration should have inherited +by default rather than re-deriving from scratch. The missed `docs/` tier +cost a whole shipped subsection (`docs/recipe_terragrunt.md:366-388`) +arguing the opposite of what R16 ships — not caught until this iteration's +drill pass. **Standing instruction for any future grep-driven enumeration in +this plan: default to the widest previously-established scope for the same +search term (`docs/` + `.md`/`.tf` included) and narrow only with an +explicit, stated reason, never by silently reusing a shorter command from a +different, earlier context.** Fixed in place: the grep command (Task 5), +the enumeration table (new "docs tier" rows, Task 5), and a new Task 6 Step +0b carrying the actual rewrite. R13's atomic-replace rationale was also +re-grounded this iteration (design doc, two locations; plan, Task 5's +materialization-writer step) — the requirement itself did not change, only +its justification, which had come to rest on a race (`file()` racing a `run` +restart against a pinned path) that R16 itself had already retired one +iteration earlier without anyone circling back to the sentences that cited +it. diff --git a/docs/superpowers/plans/2026-08-08-issue15-kube-identity-clean.md b/docs/superpowers/plans/2026-08-08-issue15-kube-identity-clean.md new file mode 100644 index 0000000..70f6a52 --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-issue15-kube-identity-clean.md @@ -0,0 +1,2323 @@ +# Kubeconfig-as-identity delivery (issue #15) — Implementation Plan + +> **Redaction/repoint note (2026-08-10):** Repointed the provider-evidence +> citation to its committed spec and identified the spike notes as unpublished, +> so this frozen plan does not claim ignored working files are committed. + +> **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. + +**Ticket:** AlexMKX/tunstrap#15 — rework kube delivery: deterministic context +names + a unified, env-native output contract. + +**Target branch:** `feature/run-env-io` (PR #13). Every task below assumes a +checkout of that branch as the working tree. + +**Spec:** `docs/specs/2026-08-07-issue15-kube-identity-delivery-design.md` +**Decision history (ADR, entries 1-19):** +`docs/specs/2026-08-07-issue15-kube-identity-decisions.md` — all historical +rationale (alternatives considered, why each rule is shaped the way it is) +lives there; this plan states what to build, not how the design arrived at it. + +**Spike reference implementation:** branch `variant/combined` in the read-only +scratch worktree `` +(reviewed; 475/475 pre-existing unit tests pass plus one new regression test). +**Cherry-pick the `kube.py` rename change from it as-is. Do NOT cherry-pick its +`envrender.py` change:** the spike's env-export body is a naive superset export +and must be replaced by the conditional cardinality contract given in Task 3. +The spike covers the kube part only (Tasks 1-3); it prototypes nothing of the +unified output contract, materialization, or the scalar-channel removal (Tasks +4-6) — that is new code with no spike reference. Never commit from the spike +worktree. Do not re-derive the six OpenTofu findings or the provider-precedence +findings; the provider result is committed in +`docs/specs/2026-08-10-issue15-provider-env-precedence.md`. The spike notes are +unpublished working artifacts, not committed reference material. + +**Tech stack:** Python 3.10+, Pydantic v2, Click, ruamel.yaml, pytest + +pytest-asyncio. Use `.venv/bin/{pytest,ruff,black,mypy,pylint,vulture}`. +Integration/e2e tiers need Docker (+ `kind`/`kubectl`/`tofu` for e2e) on +`PATH`; see `tests/README.md` for env flags. + +**Standing discipline for this plan:** the blast-radius tables in Task 5 are +grep-driven, authoritative enumerations, not starting points. If you find +something a table missed, **re-run the greps at the stated scope** — do not +patch the single spot you found. Default any new enumeration to the widest +established scope for the same search term (`tunstrap/ tests/ docs/` × +`--include='*.py' --include='*.md' --include='*.tf'`) and narrow only with an +explicit, stated reason. + +--- + +## The contract this plan implements + +**Core principle: content on disk, paths in env.** All content-bearing +artifacts (patched kubeconfigs, the unified manifest, fetched files) are +written under `/tunnel-data/` at mode `0600`; only locators +travel through the environment. + +### Two delivery modes + +1. **Kube: env-native, always plan-safe.** `run` exports + `KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` (plus `KUBECONFIG`) from its own + process environment, and the consumer pins a **literal** + `config_context = "tunstrap--"` per provider alias. No + Terraform variable and no `file()` read anywhere in the kube path, so a + saved plan applies cleanly (findings #1/#3/#4). +2. **Everything else: the unified manifest file, located by + `TUNSTRAP_OUTPUT_FILE`.** `run` (and `start --output env`) exports + `TUNSTRAP_OUTPUT_FILE=/tunnel-data/output.json` as a plain + process env var; the consumer reads it with + `try(jsondecode(file(get_env("TUNSTRAP_OUTPUT_FILE"))), { nodes = {} })`. + The session dir is ephemeral and freshly minted per invocation, and the + file is deleted at teardown/`stop`, so this mode — and the `--output-var` + bridge below — is **one-shot `plan && apply` within a single tunstrap + invocation only**. No saved-plan reuse across a tunstrap restart, no + locator exemption (finding #6 compares the variable's whole bound value). + `--output-var NAME` (`TF_VAR_tunstrap`) survives only as a narrower + fallback for bare `tofu`, which cannot call `get_env(...)`; it carries the + same manifest under the same one-shot rule. + +Never read the manifest through a Terraform `output` block: outputs freeze +silently at plan time (finding #2). Read it directly inside the +provider/`locals` block that consumes it. Binding live data to a *resource* +attribute (rather than a provider config block) produces `Error: Provider +produced inconsistent final plan` — provider-block placement is the only +supported shape (findings artifact, Q3). + +### The unified manifest shape + +```json +{ + "session": { + "session_dir": "/run/tunstrap/abc123", + "pid": 4711, + "started_at": "2026-08-07T00:00:00Z", + "warnings": [] + }, + "nodes": { + "node1": { + "ports": {"service1": "127.0.0.1:5432"}, + "kube": { + "k3s": { + "path": "/run/tunstrap/abc123/tunnel-data/node1-k3s", + "context": "tunstrap-node1-k3s", + "endpoint": "https://127.0.0.1:41111" + } + }, + "fetch_files": { + "hosts": {"path": "/run/tunstrap/abc123/tunnel-data/node1-hosts", "size": 6, "sha256": "..."} + } + } + } +} +``` + +- Exactly two reserved top-level keys, `session` and `nodes` (a flat root + would let an operator-named node collide with them). +- **Ports**: a plain `"host:port"` string per target — no integer form. +- **Kube**: `{path, context, endpoint}` only — never credentials, never file + content. `context` is the post-rename `tunstrap--` name. +- **`fetch_files`**: `{path, size, sha256}` on success, `{error}` on failure — + **never `content_b64`**. The daemon materializes fetched bytes to + `tunnel-data/-`. + +### Env keys `run` injects + +Three survivor scalars, unconditionally: `TUNSTRAP_SESSION_DIR`, +`TUNSTRAP_PID`, `TUNSTRAP_OUTPUT_FILE`. Plus the kube channel when any kube +target materialized. Every `TUNSTRAP__*` key is gone, along with +`render_env`, `inject_scalars`, and `MultiNodeEnvUnsupported`. Multi-node +input no longer requires `--output-var`. + +### Kube env-export cardinality (never the naive superset) + +| Materialized kubeconfig files | Exported keys | +|---|---| +| 0 | *(nothing)* | +| exactly 1 | `KUBECONFIG` + `KUBE_CONFIG_PATH` | +| ≥ 2 | `KUBECONFIG` + `KUBE_CONFIG_PATHS` (**no** `KUBE_CONFIG_PATH`) | + +Values are colon-joined (`:`) — comma silently degrades to `localhost:80` +(finding #5). `KUBE_CONFIG_PATH` wins over `KUBE_CONFIG_PATHS` in the +measured provider precedence, so exporting both once a second file exists +would silently hide every cluster but the first (ADR entry 3). + +--- + +## File Structure + +| File | Responsibility | +|------|----------------| +| `tunstrap/kube.py` | New `rename_identities(doc, node, target) -> str`, sweeping all references incl. non-current contexts; one call site in `run_kube_targets` between `patch_view` and `dump_kubeconfig`. | +| `tunstrap/schemas.py` | New `InputSchema`-level `model_validator` rejecting colliding `tunstrap--` pairs; new `UnifiedOutput`/`UnifiedSession`/`UnifiedNode`/`UnifiedKubeRef`/`UnifiedFetchRef` models; `FetchedFile` gains `path: str \| None = None`; `RunKubeTarget` deleted. | +| `tunstrap/envrender.py` | New `_kube_channel_keys(count)` + `render_kube_env(output)` (conditional cardinality contract); new `render_unified_output(output)`; `render_output_var`'s body rewritten to serialize the unified structure; `predicted_env_keys` rewritten (conservative); `render_env` deleted. | +| `tunstrap/exceptions.py` | `MultiNodeEnvUnsupported` and its `_EXIT_CODES` entry deleted. | +| `tunstrap/cli.py` | `_build_child_env`: unconditional `render_kube_env(output)` call, three survivor scalars, `suppress_kubeconfig` drops all three kube names; new `_materialized_output_path` + atomic-replace materialization writer in `run_command`'s success path; `start --output env` rebuilt on the same mapping; the pre-spawn multi-node-without-`--output-var` gate deleted; `inject_scalars` removed from the whole call chain. | +| `tunstrap/session.py` | Referenced; optionally gains a shared atomic-replace helper (temp file + `os.replace`) if `_write_file` can be refactored to be callable without a live `SessionDir`. | +| `tunstrap/` (daemon/worker materialization site) | New fetched-file materialization step writing `tunnel-data/-` and setting `FetchedFile.path`. | +| `tests/unit/test_kube_rename.py` | New. `rename_identities` as a pure function. | +| `tests/unit/test_schemas_kube_naming_collision.py` | New. The join-collision validator. | +| `tests/unit/test_kube_identity_collision.py` | New. The mandatory k3s-style upstream-name collision regression test. | +| `tests/unit/test_kube_run.py` | Extend: `context_name`/`cluster_name` assert the renamed value. | +| `tests/unit/test_envrender.py` | Extend/rewrite: `render_kube_env` cardinality cases; conservative `predicted_env_keys` cases; the safety-envelope half of the anti-drift guard; `render_unified_output` shape tests; every `render_env`-dependent test deleted by name. | +| `tests/unit/test_cli_run_materialize.py` | New. Materialization writer tests. | +| `tests/unit/test_cli_run_output_var.py`, `test_cli_run_output_var_projection.py`, `test_cli_run.py`, `test_cli_run_input_env_scrub.py`, `test_cli_runner.py`, `test_cli_run_postspawn.py`, `test_exceptions.py`, `test_tofu_proxy.py`, `test_manager_fetch.py`, `test_output_schema.py` | Retargeted per Task 5's blast-radius tables. | +| `tests/integration/test_run_env_io.py`, `test_cli_modes.py` | Retargeted for the shape/scalar removal against the real console script. | +| `tests/e2e/module/main.tf`, `rig.py`, `test_tofu_providers.py`, `test_terragrunt_apply.py` | Retargeted to `nodes.*.kube.*.path` (Task 6). | +| `docs/recipe_terragrunt.md` | Pre-existing `connections.*` shape fixed; the fetched-files subsection rewritten; Mode A + Mode B consumer sections added. | + +--- + +### Task 1: `rename_identities` + call site in `kube.py` + naming-collision check + +**Files:** +- Modify: `tunstrap/kube.py`, `tunstrap/schemas.py` +- Test: `tests/unit/test_kube_rename.py` (new), `tests/unit/test_kube_run.py` + (extend), `tests/unit/test_schemas_kube_naming_collision.py` (new) + +- [ ] **Step 1: Write failing tests** + +`tests/unit/test_kube_rename.py`: + +```python +"""rename_identities: deterministic tunstrap-- identity rename. + +Validates: the current-context's cluster/user/context are all renamed to the +same tunstrap-- string; non-current entries are untouched; +current-context itself is updated; the return value is that shared name. +Code: tunstrap/kube.py::rename_identities +Assertion: post-call doc state matches exactly, including the untouched +ignored entries; the returned name equals every renamed field. +Method: build a minimal ruamel-shaped dict (plain dicts are sufficient; the +function only calls .get/[]/isinstance) with two contexts, call the function, +inspect doc afterwards. +""" + +from __future__ import annotations + +import pytest + +from tunstrap.kube import rename_identities + +pytestmark = pytest.mark.unit + + +def _doc() -> dict[str, object]: + return { + "current-context": "default", + "contexts": [ + {"name": "default", "context": {"cluster": "default", "user": "default"}}, + {"name": "other", "context": {"cluster": "other-c", "user": "other-u"}}, + ], + "clusters": [ + {"name": "default", "cluster": {"server": "https://127.0.0.1:1"}}, + {"name": "other-c", "cluster": {"server": "https://127.0.0.1:2"}}, + ], + "users": [ + {"name": "default", "user": {}}, + {"name": "other-u", "user": {}}, + ], + } + + +def test_renames_current_context_cluster_and_user_to_shared_name() -> None: + """All three identity fields get the same tunstrap-- value.""" + doc = _doc() + new_name = rename_identities(doc, "node-a", "kube") + assert new_name == "tunstrap-node-a-kube" + assert doc["current-context"] == "tunstrap-node-a-kube" + ctx = doc["contexts"][0] + assert ctx["name"] == "tunstrap-node-a-kube" + assert ctx["context"]["cluster"] == "tunstrap-node-a-kube" + assert ctx["context"]["user"] == "tunstrap-node-a-kube" + assert doc["clusters"][0]["name"] == "tunstrap-node-a-kube" + assert doc["users"][0]["name"] == "tunstrap-node-a-kube" + + +def test_ignored_entries_are_left_untouched() -> None: + """Non-current context/cluster/user entries survive byte-stable.""" + doc = _doc() + rename_identities(doc, "node-a", "kube") + assert doc["contexts"][1] == { + "name": "other", + "context": {"cluster": "other-c", "user": "other-u"}, + } + assert doc["clusters"][1]["name"] == "other-c" + assert doc["users"][1]["name"] == "other-u" + + +def test_two_nodes_same_upstream_names_get_distinct_results() -> None: + """The exact k3s-style collision case: same input, different node -> different name.""" + assert rename_identities(_doc(), "a", "kube") != rename_identities(_doc(), "b", "kube") + + +def test_ignored_context_sharing_the_active_cluster_keeps_a_valid_reference() -> None: + """A non-current context that references the SAME cluster/user the active + triple uses must have that reference updated too, or it dangles -- naming + a cluster/user that no longer exists anywhere in the document under its + old name. The ignored context's own `name` is untouched (it is not + renamed itself, only its cluster/user references are); only entries that + neither ARE nor REFERENCE the active triple stay fully byte-stable.""" + doc: dict[str, object] = { + "current-context": "default", + "contexts": [ + {"name": "default", "context": {"cluster": "default", "user": "default"}}, + # Shares the SAME cluster/user as the active context, under a + # different context name -- a legitimate, ordinary kubeconfig shape. + {"name": "staging", "context": {"cluster": "default", "user": "default"}}, + ], + "clusters": [{"name": "default", "cluster": {"server": "https://127.0.0.1:1"}}], + "users": [{"name": "default", "user": {}}], + } + new_name = rename_identities(doc, "node-a", "kube") + staging_ctx = doc["contexts"][1] + assert staging_ctx["name"] == "staging" # the ignored context's own name is untouched + assert staging_ctx["context"]["cluster"] == new_name # its reference is NOT left dangling + assert staging_ctx["context"]["user"] == new_name + # And the referenced entries genuinely exist under the new name. + assert doc["clusters"][0]["name"] == new_name + assert doc["users"][0]["name"] == new_name +``` + +Add to `tests/unit/test_kube_run.py` (extends the existing +`test_run_kube_target_success`): + +```python +@pytest.mark.asyncio +async def test_run_kube_target_reports_renamed_identity(monkeypatch: pytest.MonkeyPatch) -> None: + """KubeTargetOutput.context_name/cluster_name are tunstrap--, not upstream.""" + monkeypatch.setattr( + "tunstrap.kube.sans_from_cert", + lambda _der: (["dev-kube-1", "192.0.2.11"], []), + ) + conn = _FakeConn((FIXTURES / "single_internal_ip.yaml").read_bytes()) + outputs, _, _ = await run_kube_targets( + conn, + {"k3s": KubeTarget.model_validate({"kubeconfig_path": "/etc/k3s.yaml"})}, + connect_timeout=5, + probe=_probe_ok, + node_name="edge", + ) + out = outputs["k3s"] + assert out.context_name == "tunstrap-edge-k3s" + assert out.cluster_name == "tunstrap-edge-k3s" +``` + +- [ ] **Step 2: Run to verify failure** + +`.venv/bin/pytest tests/unit/test_kube_rename.py tests/unit/test_kube_run.py -v` + +Expected: `test_kube_rename.py` fails on import (`rename_identities` missing); +the new `test_kube_run.py` case fails because `context_name`/`cluster_name` +still equal the fixture's upstream `"production"`. + +- [ ] **Step 3: Implement `rename_identities` in `kube.py`** + +Add after `patch_view`, before `dump_kubeconfig` (cherry-pick from +`variant/combined`, `tunstrap/kube.py`, dropping the spike docstring's "V1c" +framing; `__all__` gains `"rename_identities"`): + +```python +def rename_identities(doc: dict[str, object], node: str, target: str) -> str: + """Rename the current-context's cluster/user/context to a deterministic name. + + ``tunstrap--`` for cluster, user and context alike -- see + docs/specs/2026-08-07-issue15-kube-identity-delivery-design.md. Operates on + the raw parsed document alone; the current-context's own name is enough to + find every entry that needs renaming. + + Every *other* context's cluster/user REFERENCES are also updated if they + name the same cluster/user being renamed here -- a kubeconfig can + legitimately have two contexts sharing one cluster or user entry, and + leaving such a reference unrenamed while the entry it points at IS renamed + would dangle it. Only entries that neither are, nor reference, the active + triple are left untouched; other contexts' own `name` fields are never + renamed, only their `cluster`/`user` reference fields when they match. + + Returns the new name (shared by cluster, user and context alike). + """ + new_name = f"tunstrap-{node}-{target}" + current = doc.get("current-context") + assert isinstance(current, str) + contexts_raw = doc.get("contexts") + contexts: list[object] = contexts_raw if isinstance(contexts_raw, list) else [] + ctx_entry = _find_named(contexts, current) + assert ctx_entry is not None + ctx_body = ctx_entry["context"] + assert isinstance(ctx_body, dict) + old_cluster = ctx_body["cluster"] + old_user = ctx_body["user"] + assert isinstance(old_cluster, str) + assert isinstance(old_user, str) + + ctx_entry["name"] = new_name + ctx_body["cluster"] = new_name + ctx_body["user"] = new_name + + cluster_entry = _find_named(doc.get("clusters") or [], old_cluster) + assert cluster_entry is not None + cluster_entry["name"] = new_name + + user_entry = _find_named(doc.get("users") or [], old_user) + assert user_entry is not None + user_entry["name"] = new_name + + doc["current-context"] = new_name + + # Sweep every OTHER context for a reference to the cluster/user entries + # just renamed. That context's own `name` is not touched -- it is not + # becoming the current context, only its dangling reference is fixed. + for entry in contexts: + if entry is ctx_entry or not isinstance(entry, dict): + continue + other_body = entry.get("context") + if not isinstance(other_body, dict): + continue + if other_body.get("cluster") == old_cluster: + other_body["cluster"] = new_name + if other_body.get("user") == old_user: + other_body["user"] = new_name + + return new_name +``` + +Wire the call site in `run_kube_targets` (replace the `patch_view` → +`dump_kubeconfig` → `KubeTargetOutput` block): + +```python + patch_view(view, local_port=local_port, tls_server_name=tls_name, insecure=insecure) + assert isinstance(view.doc, dict) # parse_kubeconfig guaranteed this + new_identity = rename_identities(view.doc, node_name, name) + patched = dump_kubeconfig(view) + outputs[name] = KubeTargetOutput( + cluster_name=new_identity, + context_name=new_identity, +``` + +**Do not carry over the spike's `# type: ignore[arg-type]`** on this call — +`view.doc` is typed `object` on `KubeconfigView` (dataclass field, +`kube.py:56`) and `rename_identities` wants `dict[str, object]`; use the +explicit `assert isinstance(view.doc, dict)` above instead, matching the +pattern `patch_view` already uses two lines earlier (`kube.py:257`). This +keeps `mypy --strict` clean without a suppression. `dump_kubeconfig`'s +signature does not change — `patch_view` owns server-address patching. + +- [ ] **Step 4: Run to verify pass** + +`.venv/bin/pytest tests/unit/test_kube_rename.py tests/unit/test_kube_run.py tests/unit/test_kube_patch.py tests/unit/test_kube_parse.py tests/unit/test_kube_parse_invariants.py -v` + +Expected: all pass. (The last three files are the ones the spike confirmed are +unaffected; this run is the regression check that it stayed true.) + +- [ ] **Step 5: Commit** + +```bash +git add tunstrap/kube.py tests/unit/test_kube_rename.py tests/unit/test_kube_run.py +git commit -m "feat(kube): rename current-context identity to tunstrap-- (#15)" +``` + +- [ ] **Step 6: Write the failing naming-collision test** + +New file `tests/unit/test_schemas_kube_naming_collision.py`: + +```python +"""tunstrap-- is NOT unique by construction. + +_FETCH_FILES_KEY_RE (schemas.py:11) permits internal hyphens in node/target +identifiers, and the join itself uses a hyphen, so two DIFFERENT (node, +target) pairs can render the SAME string: (node="a-b", target="c") and +(node="a", target="b-c") both produce "tunstrap-a-b-c". This is a distinct +defect class from the k3s-style collision test (Task 2) -- that test proves +upstream kubeconfig names colliding is fixed by the rename; this test proves +tunstrap's OWN naming scheme does not collide with itself, independent of any +kubeconfig content at all. + +Code: tunstrap/schemas.py (validator, InputSchema level) +Method: construct an InputSchema with exactly the a-b/c vs a/b-c pair and +assert validation rejects it, naming both colliding pairs. +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from tunstrap.schemas import InputSchema + +pytestmark = pytest.mark.unit + + +def test_naming_join_collision_across_nodes_is_rejected() -> None: + """(node='a-b', target='c') and (node='a', target='b-c') both join to + tunstrap-a-b-c -- reject the whole payload, naming both colliding pairs.""" + with pytest.raises(ValidationError) as excinfo: + InputSchema.model_validate( + { + "nodes": { + "a-b": { + "host": "h1", "user": "u", "ssh_password": "p", + "kube_targets": {"c": {"kubeconfig_path": "/etc/x.yaml"}}, + }, + "a": { + "host": "h2", "user": "u", "ssh_password": "p", + "kube_targets": {"b-c": {"kubeconfig_path": "/etc/y.yaml"}}, + }, + } + } + ) + message = str(excinfo.value) + assert "tunstrap-a-b-c" in message + assert "a-b" in message and "c" in message # first colliding pair + assert "a" in message and "b-c" in message # second colliding pair + + +def test_non_colliding_hyphenated_names_are_accepted() -> None: + """Anti-vacuity: hyphens alone don't trigger the check -- only an actual join collision does.""" + InputSchema.model_validate( + { + "nodes": { + "node-one": { + "host": "h1", "user": "u", "ssh_password": "p", + "kube_targets": {"kube-a": {"kubeconfig_path": "/etc/x.yaml"}}, + }, + "node-two": { + "host": "h2", "user": "u", "ssh_password": "p", + "kube_targets": {"kube-b": {"kubeconfig_path": "/etc/y.yaml"}}, + }, + } + } + ) +``` + +- [ ] **Step 7: Run to verify failure** + +`.venv/bin/pytest tests/unit/test_schemas_kube_naming_collision.py -v` + +Expected: FAIL — no such validator exists yet; the first test's payload is +wrongly accepted today. + +- [ ] **Step 8: Implement the collision check in `schemas.py`** + +Add an `InputSchema`-level `model_validator(mode="after")` alongside the +existing `_validate_auth` field validator (`schemas.py:278-289`) — this must +run at `InputSchema` level, not per-`NodeInput`, since the collision is +cross-node: + +```python +@model_validator(mode="after") +def _validate_kube_identity_names_are_unique(self) -> InputSchema: + """tunstrap-- is not unique by construction (hyphens are + legal in both node and target names); reject a payload where two + different (node, target) pairs join to the same rendered identity.""" + seen: dict[str, tuple[str, str]] = {} + for node_name, node in self.nodes.items(): + for target_name in node.kube_targets or {}: + joined = f"tunstrap-{node_name}-{target_name}" + if joined in seen: + other_node, other_target = seen[joined] + raise ValueError( + f"kube identity name collision: ({node_name!r}, {target_name!r}) " + f"and ({other_node!r}, {other_target!r}) both render {joined!r}" + ) + seen[joined] = (node_name, target_name) + return self +``` + +Place it near `InputSchema`'s existing `_validate_auth` validator so both +cross-node checks live together. Do not move it onto `NodeInput` even though +`kube_targets` is a `NodeInput` field — single-node scope cannot see the +collision. + +- [ ] **Step 9: Run to verify pass, then commit** + +`.venv/bin/pytest tests/unit/test_schemas_kube_naming_collision.py tests/unit/test_schemas.py tests/unit/test_schemas_kube.py -v` + +Expected: all pass. + +```bash +git add tunstrap/schemas.py tests/unit/test_schemas_kube_naming_collision.py +git commit -m "feat(schemas): reject tunstrap-- naming collisions (#15)" +``` + +--- + +### Task 2: The mandatory collision regression test + +**Files:** +- Create: `tests/unit/test_kube_identity_collision.py` + +This is the trap the design doc's testing contract calls out by name: it must +land, unmodified in substance, regardless of how Task 1 was implemented. + +- [ ] **Step 1: Copy the spike's prototype under a repo-convention file name** + +Copy `tests/unit/test_issue15_context_collision.py` from the spike worktree +(``, branch +`variant/combined`) to `tests/unit/test_kube_identity_collision.py` in this +checkout — **only the file is renamed** (this repo's other test files never +carry an issue number, e.g. `test_kube_run.py`, `test_envrender.py`). **Keep +the test function name unchanged**, +`test_two_k3s_style_targets_get_distinct_deterministic_identities` — it is +already descriptive. Drop the module docstring's "spike" framing, replacing it +with a plain description; the content is otherwise correct verbatim (exact +source reproduced in full in +the untracked issue #15 spike notes, "Part 3"). + +- [ ] **Step 2: Run to verify it is GREEN after Task 1** + +`.venv/bin/pytest tests/unit/test_kube_identity_collision.py -v` + +Expected: PASS. (It was confirmed RED against the unmodified branch and GREEN +under `variant/combined`; this run confirms the same holds against this +checkout's own Task 1 implementation.) + +- [ ] **Step 3: Commit** + +```bash +git add tests/unit/test_kube_identity_collision.py +git commit -m "test(kube): regression test for the k3s upstream-name collision trap (#15)" +``` + +--- + +### Task 3: `render_kube_env` + the conditional env-export contract + +**Files:** +- Modify: `tunstrap/envrender.py` +- Test: `tests/unit/test_envrender.py` + +- [ ] **Step 1: Write failing tests** + +Add to `tests/unit/test_envrender.py` (uses the existing `_kube_out` helper): + +```python +def test_render_kube_env_zero_files_returns_empty() -> None: + """No kube_targets anywhere -> no keys at all.""" + out = OutputSchema( + connections={"h": NodeOutput(ports={"db": 1})}, + pid=1, session_dir="/s", started_at="now", + ) + assert render_kube_env(out) == {} + + +def test_render_kube_env_one_file_sets_path_not_paths() -> None: + """Exactly one materialized file: KUBECONFIG + KUBE_CONFIG_PATH, no _PATHS.""" + out = OutputSchema( + connections={"h": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7000, "/s/k3s")})}, + pid=1, session_dir="/s", started_at="now", + ) + env = render_kube_env(out) + assert env == {"KUBECONFIG": "/s/k3s", "KUBE_CONFIG_PATH": "/s/k3s"} + assert "KUBE_CONFIG_PATHS" not in env + + +def test_render_kube_env_two_files_sets_paths_not_path() -> None: + """Two materialized files (could be one node, two targets, or two nodes): + KUBECONFIG + KUBE_CONFIG_PATHS, no _PATH -- KUBE_CONFIG_PATH would win over + KUBE_CONFIG_PATHS per the measured provider precedence and hide the second + cluster.""" + out = OutputSchema( + connections={ + "a": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7000, "/s/a-k3s")}), + "b": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7001, "/s/b-k3s")}), + }, + pid=1, session_dir="/s", started_at="now", + ) + env = render_kube_env(out) + assert env == {"KUBECONFIG": "/s/a-k3s:/s/b-k3s", "KUBE_CONFIG_PATHS": "/s/a-k3s:/s/b-k3s"} + assert "KUBE_CONFIG_PATH" not in env + + +def test_predicted_env_keys_reserves_all_three_for_one_kube_target() -> None: + """predicted_env_keys is a CONSERVATIVE predictor, not exact: it reserves + all three kube names whenever ANY kube_targets are declared, regardless of + exact count -- input cardinality can shrink by output time (an optional + node/target can fail), so predicting the exact one-file branch here would + under-reserve KUBE_CONFIG_PATHS for a schema that later, at runtime, + actually produces >=2 files. render_kube_env's own export (tested above) + stays exact -- only the predictor is conservative.""" + schema = InputSchema.model_validate( + { + "nodes": { + "node": { + "host": "h.example.net", "user": "u", "ssh_password": "p", + "kube_targets": {"k3s": {"kubeconfig_path": "/etc/k3s.yaml"}}, + } + } + } + ) + keys = predicted_env_keys(schema) + assert {"KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"} <= keys + + +def test_predicted_env_keys_reserves_all_three_for_two_kube_targets_one_node() -> None: + """Same conservative reservation for the >=2 case -- the point is that BOTH + cardinalities reserve identically (all three), which is what makes the + predictor a safe over-approximation rather than a second exact + implementation of _kube_channel_keys.""" + schema = InputSchema.model_validate( + { + "nodes": { + "node": { + "host": "h.example.net", "user": "u", "ssh_password": "p", + "kube_targets": { + "a": {"kubeconfig_path": "/etc/a.yaml"}, + "b": {"kubeconfig_path": "/etc/b.yaml"}, + }, + } + } + } + ) + keys = predicted_env_keys(schema) + assert {"KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"} <= keys +``` + +**Why the predictor is conservative (and why the guard is two-part).** +`predicted_env_keys` runs pre-spawn against *input* cardinality, but an +optional (`required: false`) node or kube target can fail without failing the +run, so *output* cardinality can be smaller than what was declared. Two kube +targets declared (which an exact predictor would map to the `≥2` branch, +`KUBE_CONFIG_PATHS` only) but one optional node fails at connect time → only +one file materializes → the real export uses the `==1` branch +(`KUBE_CONFIG_PATH`), which an exact predictor never reserved. A +`--output-var KUBE_CONFIG_PATH` would then pass the pre-spawn collision check +and be **silently overwritten** post-spawn. Hence: reserve **all three** kube +names whenever *any* `kube_targets` are declared. Over-reserving is the safe +direction (a false-positive usage error, cheap and visible) versus +under-reserving (a silent post-spawn collision). The two tests above are the +**formula half** of the anti-drift guard; the **safety-envelope half** +(`actual ⊆ predicted`, driven by a cardinality-shrink case) needs +`_build_child_env` to exist and is therefore added in Task 5. See ADR entry 16. + +- [ ] **Step 2: Run to verify failure** + +`.venv/bin/pytest tests/unit/test_envrender.py -v` + +Expected: FAIL — `render_kube_env` missing; `predicted_env_keys` still uses +the unconditional `KUBECONFIG`-only rule. + +- [ ] **Step 3: Implement in `envrender.py`** + +Add the shared cardinality helper (used by `render_kube_env`, so the export +rule lives in exactly one place): + +```python +def _kube_channel_keys(count: int) -> set[str]: + """Names of the kube-channel env keys the conditional contract exports. + + 0 files: nothing. Exactly 1: KUBECONFIG + KUBE_CONFIG_PATH. >=2: + KUBECONFIG + KUBE_CONFIG_PATHS. KUBE_CONFIG_PATH and KUBE_CONFIG_PATHS are + never both present -- KUBE_CONFIG_PATH wins over KUBE_CONFIG_PATHS per the + measured OpenTofu kubernetes/helm provider precedence (docs/specs/ + 2026-08-10-issue15-provider-env-precedence.md), so exporting both once a + second file exists would silently hide every cluster but the first. + """ + if count == 0: + return set() + if count == 1: + return {"KUBECONFIG", "KUBE_CONFIG_PATH"} + return {"KUBECONFIG", "KUBE_CONFIG_PATHS"} + + +def render_kube_env(output: OutputSchema) -> dict[str, str]: + """Build the node-count-agnostic kube channel: KUBECONFIG plus the + OpenTofu-provider-facing var the conditional contract picks. + + This channel has no node dimension: it collects one materialized path per + kube_target across every node, so it is safe to call for any node count. + """ + kube_paths: list[str] = [] + for node in output.connections.values(): + for kname, target in node.kube_targets.items(): + if target.path is None: + raise ValueError(f"kube target {kname!r} not materialized; cannot set KUBECONFIG") + kube_paths.append(target.path) + if not kube_paths: + return {} + joined = ":".join(kube_paths) + return {key: joined for key in _kube_channel_keys(len(kube_paths))} +``` + +Replace `render_env`'s inline kube-path block so it delegates to +`render_kube_env` (`render_env` is still alive at this point — Task 5 deletes +it). **This also deletes the now-unused `kube_paths: list[str] = []` +accumulator declaration at `envrender.py:49`** — the block below never appends +to it (that accumulation moved into `render_kube_env`), and leaving the +declaration would fail `ruff check` (unused variable) at Task 7's gate: + +```python + for kname, target in node.kube_targets.items(): + base = _key(kname) + if target.path is None: + raise ValueError(f"kube target {kname!r} not materialized; cannot set KUBECONFIG") + put(f"TUNSTRAP_{base}_KUBECONFIG", target.path) + put(f"TUNSTRAP_{base}_ENDPOINT", target.endpoint) + + for key, value in render_kube_env(output).items(): + put(key, value) + return env +``` + +Update `predicted_env_keys` to reserve conservatively. Unlike +`render_kube_env`'s own export (exact, because it runs *after* real +materialization and knows the true count), this runs pre-spawn against the +*input* schema: + +```python +def predicted_env_keys(schema: InputSchema) -> set[str]: + keys: set[str] = set() + if len(schema.nodes) == 1: + (node,) = schema.nodes.values() + keys.update({"TUNSTRAP_SESSION_DIR", "TUNSTRAP_PID"}) + for tname in node.remote_targets: + base = _key(tname) + keys.update( + {f"TUNSTRAP_{base}_HOST", f"TUNSTRAP_{base}_PORT", f"TUNSTRAP_{base}_ENDPOINT"} + ) + for kname in node.kube_targets or {}: + base = _key(kname) + keys.update({f"TUNSTRAP_{base}_KUBECONFIG", f"TUNSTRAP_{base}_ENDPOINT"}) + if any(node.kube_targets for node in schema.nodes.values()): + keys.update({"KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"}) + return keys +``` + +Task 5 rewrites this function's body again — **not** to change the kube +cardinality rule (already final and conservative here), but because the whole +`TUNSTRAP_*` scalar half (`if len(schema.nodes) == 1:`) disappears with the +scalar channel. The conservative kube-reservation line survives that rewrite +verbatim. + +Also correct the docstring claim at `envrender.py:83-93` that "multi-node +input injects no scalars at all, so the answer there is the empty set" — now +false for the kube-channel keys; it stays true only for `TUNSTRAP_*` scalars. + +- [ ] **Step 4: Run to verify pass** + +`.venv/bin/pytest tests/unit/test_envrender.py -v` + +Expected: all pass, including every pre-existing case — single-node +`KUBECONFIG` behaviour is byte-identical for the one-kube-target case, since +`_kube_channel_keys(1)` includes `KUBECONFIG` exactly as the old +unconditional `put("KUBECONFIG", ...)` did. + +- [ ] **Step 5: Commit** + +```bash +git add tunstrap/envrender.py tests/unit/test_envrender.py +git commit -m "feat(envrender): multi-node kube channel + conditional KUBE_CONFIG_PATH(S) export (#15)" +``` + +--- + +### Task 4: The unified output contract — shape + `render_unified_output` + +Pure-function work only: no `cli.py` wiring and no materialization yet (both +Task 5). This task makes the shape exist and be correctly built from an +`OutputSchema`. + +**Files:** +- Modify: `tunstrap/schemas.py` (new models), `tunstrap/envrender.py` + (`render_unified_output`, `render_output_var` body rewritten) +- Test: `tests/unit/test_envrender.py` (new cases) + +- [ ] **Step 1: Write failing tests** + +Add to `tests/unit/test_envrender.py`: + +```python +def test_render_unified_output_shape() -> None: + """Ports become 'host:port' strings; kube becomes {path,context,endpoint} + references; fetch_files becomes {path,size,sha256} -- NOT a content_b64 + passthrough; two reserved top-level keys.""" + out = OutputSchema( + connections={ + "node1": NodeOutput( + ports={"service1": 5432}, + kube_targets={ + "k3s": _kube_out_full( + 7000, "/s/tunnel-data/node1-k3s", context="tunstrap-node1-k3s" + ) + }, + fetch_files={ + # .path is set here because materialization (Task 5) runs + # before render_unified_output ever sees this object -- the + # daemon writes the bytes and sets .path, exactly as it + # already does for KubeTargetOutput.path today. + "hosts": FetchedFile( + content_b64="aG9zdHM=", size=6, sha256="ab" * 32, + path="/s/tunnel-data/node1-hosts", + ) + }, + ) + }, + pid=42, + session_dir="/s", + started_at="2026-08-07T00:00:00Z", + ) + unified = render_unified_output(out) + assert unified["session"] == { + "session_dir": "/s", + "pid": 42, + "started_at": "2026-08-07T00:00:00Z", + "warnings": [], + } + node = unified["nodes"]["node1"] + assert node["ports"] == {"service1": "127.0.0.1:5432"} + assert node["kube"]["k3s"] == { + "path": "/s/tunnel-data/node1-k3s", + "context": "tunstrap-node1-k3s", + "endpoint": "https://127.0.0.1:7000", + } + # {path, size, sha256} exactly -- no content_b64 in the projection. + assert node["fetch_files"]["hosts"] == { + "path": "/s/tunnel-data/node1-hosts", "size": 6, "sha256": "ab" * 32, + } + # Nothing that could carry raw content -- kube credentials AND fetched + # file content_b64 -- ever appears anywhere in the shape. + dumped = json.dumps(unified) + for leaked in ("client_certificate_data", "client_key_data", "content_b64"): + assert leaked not in dumped + + +def test_render_unified_output_multi_node() -> None: + """Node dimension is a nested key: two nodes, two independent bodies.""" + out = OutputSchema( + connections={ + "a": NodeOutput(ports={"db": 1}), + "b": NodeOutput(ports={"db": 2}), + }, + pid=1, session_dir="/s", started_at="now", + ) + unified = render_unified_output(out) + assert set(unified["nodes"]) == {"a", "b"} + assert unified["nodes"]["a"]["ports"]["db"] == "127.0.0.1:1" + assert unified["nodes"]["b"]["ports"]["db"] == "127.0.0.1:2" + + +def test_render_output_var_serializes_the_unified_shape() -> None: + """render_output_var's return value decodes to the same shape render_unified_output builds.""" + out = OutputSchema( + connections={"h": NodeOutput(ports={"db": 1})}, + pid=1, session_dir="/s", started_at="now", + ) + decoded = json.loads(render_output_var(out)) + assert decoded == render_unified_output(out) +``` + +(`_kube_out_full` is a small extension of the file's existing `_kube_out` +helper that also accepts a `context` kwarg — add it alongside `_kube_out`; do +not change `_kube_out`'s signature, Task 3's tests still use it unchanged.) + +- [ ] **Step 2: Run to verify failure** + +`.venv/bin/pytest tests/unit/test_envrender.py -k "unified" -v` + +Expected: FAIL — `render_unified_output` missing; `render_output_var` still +returns the old `RunKubeTarget`-projection shape. + +- [ ] **Step 3: Implement** + +Add models to `tunstrap/schemas.py` (placed there, not `envrender.py`, +matching the existing convention that `schemas.py` is the "Single source of +JSON shape" per its own module docstring, `schemas.py:1`): + +```python +class UnifiedKubeRef(BaseModel): + """Kube reference in the unified output: never credentials, never content.""" + + model_config = ConfigDict(extra="forbid") + + path: str | None + context: str + endpoint: str + + +class UnifiedSession(BaseModel): + """Session metadata block of the unified output.""" + + model_config = ConfigDict(extra="forbid") + + session_dir: str + pid: int + started_at: str + warnings: list[TunnelWarning] = Field(default_factory=list) + + +class UnifiedFetchRef(BaseModel): + """Fetched-file reference in the unified output: never content_b64, + mirroring UnifiedKubeRef's own credential/content narrowing. Success and + error are mutually exclusive, matching FetchedFile's own xor -- but this + model has no validator enforcing it, because render_unified_output (below) + is the only place that constructs one, from an already-validated + FetchedFile, per exactly the same explicit-keyword-construction pattern + UnifiedKubeRef already uses instead of a second runtime check.""" + + model_config = ConfigDict(extra="forbid") + + path: str | None = None + size: int | None = None + sha256: str | None = None + error: str | None = None + + +class UnifiedNode(BaseModel): + """One node's body in the unified output: ports, kube refs, fetch_files.""" + + model_config = ConfigDict(extra="forbid") + + ports: dict[str, str] = Field(default_factory=dict) + kube: dict[str, UnifiedKubeRef] = Field(default_factory=dict) + fetch_files: dict[str, UnifiedFetchRef] = Field(default_factory=dict) + + +class UnifiedOutput(BaseModel): + """The entire consumer-facing output: two reserved top-level keys.""" + + model_config = ConfigDict(extra="forbid") + + session: UnifiedSession + nodes: dict[str, UnifiedNode] +``` + +Add to `tunstrap/envrender.py`: + +```python +def render_unified_output(output: OutputSchema) -> dict[str, Any]: + """Build the unified, node-qualified structure (design doc, "Unified + output contract"). Ports become 'host:port' strings; kube becomes + {path, context, endpoint} references (never credentials, never content); + fetch_files becomes {path, size, sha256} (or {error}) -- NOT a + passthrough: content must not enter a Terraform variable or the + materialized manifest, only its path/metadata may. Callers must ensure + fetch_files entries are already materialized (.path set) before calling + this -- see Task 5's fetched-file materialization step, which runs + upstream of this function, the same ordering KubeTargetOutput.path + already requires today. + """ + nodes: dict[str, object] = {} + for node_name, node in output.connections.items(): + kube = { + kname: UnifiedKubeRef( + path=target.path, context=target.context_name, endpoint=target.endpoint + ).model_dump() + for kname, target in node.kube_targets.items() + } + ports = {tname: f"127.0.0.1:{port}" for tname, port in node.ports.items()} + fetch_files = { + fname: ( + UnifiedFetchRef(error=f.error).model_dump(exclude_none=True) + if f.error is not None + else UnifiedFetchRef(path=f.path, size=f.size, sha256=f.sha256) + .model_dump(exclude_none=True) + ) + for fname, f in node.fetch_files.items() + } + nodes[node_name] = UnifiedNode( + ports=ports, kube=kube, fetch_files=fetch_files + ).model_dump() + session = UnifiedSession( + session_dir=output.session_dir, + pid=output.pid, + started_at=output.started_at, + warnings=output.warnings, + ).model_dump(mode="json") + return {"session": session, "nodes": nodes} +``` + +**`exclude_none=True`, deliberately**: without it a success entry would +serialize `{"path": ..., "size": ..., "sha256": ..., "error": null}` — a stray +`"error": null` in every successful fetch, not matching the contract's shape +(`{"path", "size", "sha256"}` exactly) or the error-branch shape (`{"error"}` +exactly). `UnifiedKubeRef` needs no such treatment: none of its three fields +is ever optional/`None` in a materialized `KubeTargetOutput`. + +Replace `render_output_var`'s body (signature unchanged, `OutputSchema -> str` +— no `cli.py` call-site change needed): + +```python +def render_output_var(output: OutputSchema) -> str: + """Serialise the unified structure for ``--output-var``. + + Delivers the same content the materialized file carries (Task 5) -- see + docs/specs/2026-08-07-issue15-kube-identity-delivery-design.md, "The + unified output contract", for the delivery/stability contract governing + which of the two a plan-safe consumer should actually bind to. + """ + return json.dumps(render_unified_output(output), separators=(",", ":")) +``` + +Delete the old `RunKubeTarget`-based body (the `payload = +output.model_dump(mode="json")` / per-node `RunKubeTarget.model_validate` +loop) — fully replaced, not kept as a fallback. + +**`RunKubeTarget` disposition:** now unused by `render_output_var`. Check with +`vulture` (Task 7) whether anything else still imports it; if not, delete the +class from `schemas.py` too — its whole purpose (an allow-list projection for +this exact channel) is now served by `UnifiedKubeRef`. + +- [ ] **Step 4: Run to verify pass** + +`.venv/bin/pytest tests/unit/test_envrender.py -v` + +Expected: all pass. The **old** `render_output_var` shape tests in +`tests/unit/test_cli_run_output_var.py` (e.g. +`test_output_var_carries_the_whole_envelope_minus_kube_credentials`) now fail, +expectedly — they pin the old `connections..ports.` (int) shape; +Task 5 retargets them alongside the rest of that file's changes. Do not fix +them here; note the expected failures and move on (one clean commit per +concern). + +- [ ] **Step 5: Commit** + +```bash +git add tunstrap/schemas.py tunstrap/envrender.py tests/unit/test_envrender.py +git commit -m "feat(envrender): unified node-qualified output contract, shape only (#15)" +``` + +--- + +### Task 5: Materialize the unified output; remove the scalar channel + +**This is the big ripple task.** It does six things in one coherent change, +because they are the same edit site (`_build_child_env` and its callers) or +its direct sibling (the daemon-side materialization step): + +(a) wires `render_unified_output`/`render_output_var` into `run` and adds +unconditional materialization of `output.json`; +(b) collapses the kube-channel call to unconditional (ADR entry 13); +(c) deletes `render_env`, `MultiNodeEnvUnsupported`, and `inject_scalars`; +(d) **re-scopes** (not deletes) the `predicted_env_keys` anti-drift guard so +it compares against `_build_child_env`'s actual output; +(e) retargets every pre-existing test, fixture, and shipped artifact this +removal breaks, across every tier — enumerated exhaustively below; +(f) materializes `fetch_files` content to `tunnel-data/-` the +same way kube files already are, removing `content_b64` from every +consumer-facing projection. + +**Read before starting.** The tables below are a full grep-driven enumeration +across `tunstrap/`, `tests/unit`, `tests/integration`, `tests/e2e`, and +`docs/`. **Treat them as complete; if you find something they missed, that is +a signal to re-run the greps, not to patch the one spot found.** + +Re-derivable with (or equivalent): + +```bash +grep -rn 'TUNSTRAP_[A-Z0-9_]*' tunstrap/ tests/ docs/ \ + --include='*.py' --include='*.md' --include='*.tf' \ + | grep -vE 'TUNSTRAP_SESSION_DIR|TUNSTRAP_PID|TUNSTRAP_OUTPUT_FILE|TUNSTRAP_INPUT|TUNSTRAP_E2E_REQUIRE_ALL|TUNSTRAP_TOKEN' +grep -rn 'MultiNodeEnvUnsupported\|inject_scalars\|render_env(' tunstrap/ tests/ --include='*.py' +grep -rn 'connections\.' tests/ docs/ --include='*.py' --include='*.md' --include='*.tf' +grep -rn '\["connections"\]\|\.connections\[' tests/ --include='*.py' +grep -rn 'content_b64' tunstrap/ tests/ docs/ --include='*.py' --include='*.md' --include='*.tf' +``` + +#### Blast-radius table — unit tier (authoritative; every hit has a disposition) + +| File:line | Old shape/symbol | Disposition | +|---|---|---| +| `test_cli_run.py:91` | `FakePopen.last_env["TUNSTRAP_DB_PORT"] == "5432"` | Retarget: assert `TUNSTRAP_SESSION_DIR`/`TUNSTRAP_PID`/`TUNSTRAP_OUTPUT_FILE` present, `TUNSTRAP_DB_PORT` absent. | +| `test_cli_run_input_env_scrub.py:156` | `env["TUNSTRAP_DB_PORT"] == "5432"`, "the injected scalars must survive the scrub" | Retarget: assert `TUNSTRAP_SESSION_DIR` survives the scrub instead; same docstring claim, different scalar. | +| `test_cli_run_input_env_scrub.py:174` | `json.loads(env[VAR])["pid"] == 99` | Retarget: `json.loads(env[VAR])["session"]["pid"] == 99` — `pid` moved under the unified structure's `session` key. | +| `test_cli_runner.py:392` (+docstring at ~360) | `"export TUNSTRAP_DB_PORT='5432'" in res.output` — the `start --output env` pin | **Fix the existing assertion**, not just "add a test": replace with the new three-survivors-plus-kube-channel export set; drop `TUNSTRAP_DB_PORT`/`TUNSTRAP_WEB_PORT`-style lines from any fixture the test builds. | +| `test_cli_run_postspawn.py:955,993` (`test_lone_optional_node_failure_keeps_its_own_exit_code`) | Asserts `error["error"] == "MultiNodeEnvUnsupported"` for a lone optional node's failure (`connections == {}` trips `render_env`'s `!= 1` guard) | Retarget completely; the new behaviour is the opposite: `_build_child_env` no longer branches on connection count, so this **succeeds** (exit 0). Rename to `test_lone_optional_node_failure_still_succeeds_with_only_a_warning`; assert exit 0, `session.warnings` (via `--output-var`) carries the "edge" failure, teardown ran exactly once. | +| `test_cli_run_output_var.py` (multiple) | See Step 2's per-test list | Retarget/delete per that list. | +| `test_cli_run_output_var_projection.py` (whole file) | `RunKubeTarget` import; `["connections"]["node"]["kube_targets"]["k3s"]`; `decoded["pid"]`/`["session_dir"]`/`["started_at"]`/`["connections"]["node"]["ports"]` | Security-critical (credential-scrubbing pin) — see the dedicated sub-section below, not a one-line note. | +| `test_envrender.py:4` | `from tunstrap.exceptions import MultiNodeEnvUnsupported` | Delete the import — `ruff` F401 once every user of it in this file is gone. | +| `test_envrender.py` (`render_env`-dependent) | `test_render_ports_and_session`, `test_render_kube_sets_kubeconfig`, `test_render_kube_not_materialized_raises`, `test_render_requires_single_node_zero`, `test_render_requires_single_node_two` | Delete all five — each asserts on `render_env`, which no longer exists. Do not add any new `render_env`-asserting test in Task 3 either; there is nothing left for one to pin. | +| `test_envrender.py::test_predicted_env_keys_matches_render_env` | Compares `predicted_env_keys` against `render_env`'s output | **Retarget, not delete** — see "Anti-drift guard" below. | +| `test_envrender.py` (predicted_env_keys shape) | `test_predicted_env_keys_no_kube_omits_kubeconfig`, `test_predicted_env_keys_multi_node_is_empty` | Delete — both pin the old per-target scalar enumeration / the "multi-node is empty" claim, false under the new unconditional `{session scalars} ∪ kube-channel` contract. Replaced by `test_predicted_env_keys_is_session_scalars_plus_kube_channel` and `test_predicted_env_keys_no_kube_is_just_the_three_survivors` (Step 2). | +| `test_exceptions.py:87-90` | `issubclass(MultiNodeEnvUnsupported, TunstrapError)` subclass test | Delete. | +| `test_exceptions.py:94-99` | Exit-code + envelope test constructing `MultiNodeEnvUnsupported(...)` | Delete. | +| `test_exceptions.py:107-114` | `_EXIT_CODES[MultiNodeEnvUnsupported] == 1` table test | Delete. All **three** cases named explicitly. | +| `test_tofu_proxy.py:375,386,397,399` | Docstrings framing the pop in terms of `inject_scalars`/`render_env` | Update docstrings only (mechanism note, not an assertion change) — both `test_tunnelled_suppresses_kubeconfig_in_child_env` and `test_tunnelled_drops_an_inherited_kubeconfig_in_the_multi_node_case`. | + +#### Blast-radius table — integration tier + +| File:line | Old shape/symbol | Disposition | +|---|---|---| +| `test_run_env_io.py:49-50` (`_PROBE_SINGLE`) | `os.environ["TUNSTRAP_WEB_PORT"]` | Retarget: probe reads `json.load(open(os.environ["TUNSTRAP_OUTPUT_FILE"]))["nodes"]["hub"]["ports"]["web"]` (a `"host:port"` string; `.rsplit(":", 1)[1]` for the port). | +| `test_run_env_io.py` `_PROBE_MULTI` | `envelope["connections"][name]["ports"]["web"]` | Retarget to `envelope["nodes"][name]["ports"]["web"]` (string, parsed as above). | +| `test_run_env_io.py` `_PROBE_MULTI` leak check | `k.startswith("TUNSTRAP_") and k != "TUNSTRAP_INPUT"` — now wrongly flags the three sanctioned survivors as leaks | Retarget: exclude `TUNSTRAP_SESSION_DIR`, `TUNSTRAP_PID`, `TUNSTRAP_OUTPUT_FILE` too. | +| `test_run_env_io.py:173-193` (`test_multi_node_without_output_var_is_exit_1`) | Asserts exit 1 + `MultiNodeEnvUnsupported`, `not session_dir.exists()` | **Retarget completely — the exact behaviour this task inverts.** Rename to `test_multi_node_without_output_var_now_succeeds`; assert exit 0, no `MultiNodeEnvUnsupported` anywhere in stderr (stderr may be empty), teardown ran. Materialization *content* is not re-verified here — that is `test_cli_run_materialize.py`'s job; this test's remaining job is confirming the real console script allows the case. | +| `test_cli_modes.py:111-138` | `start --output env`'s `TUNSTRAP_WEB_PORT`/`TUNSTRAP_WEB_ENDPOINT`; `run`'s child probe reading `os.environ['TUNSTRAP_WEB_PORT']` directly | Retarget both tests in this range: the `start --output env` test asserts `TUNSTRAP_SESSION_DIR`/`TUNSTRAP_OUTPUT_FILE` present, `TUNSTRAP_WEB_PORT`/`_ENDPOINT` absent, and derives the port via `json.load(open(env["TUNSTRAP_OUTPUT_FILE"]))["nodes"][...]["ports"]["web"]`; the `run` child probe (inline Python string) rewrites to read `TUNSTRAP_OUTPUT_FILE` the same way. | + +#### Blast-radius table — e2e tier + shipped artifacts + +In scope, not deferrable: the failure mode is **silent** — `try()` around +`jsondecode` swallows a shape mismatch into an empty `config_path`, and the +resulting error is a confusing provider message, not an obvious test failure. +**The edits themselves land in Task 6** (same textual migration as the recipe, +and the recipe↔module drift guard requires both to move together); they are +enumerated here because they are this task's blast radius. + +| File:line | Old shape/symbol | Disposition | +|---|---|---| +| `tests/e2e/module/main.tf:27-28` | `try(jsondecode(var.tunstrap), { connections = {} })`; `local.tunnel.connections.node.kube_targets.k3s.path` | Retarget: `{ nodes = {} }`; `local.tunnel.nodes.node.kube.k3s.path`. Task 6. | +| `docs/recipe_terragrunt.md:287-288` | Same `tunnel`/`kubepath` locals, mirroring `main.tf` | Retarget identically. Task 6. | +| `docs/recipe_terragrunt.md:~329` | Prose: "`path` comes from... `connections.*.kube_targets.*.path`" | Retarget prose to `nodes.*.kube.*.path`. Task 6. | +| `docs/recipe_terragrunt.md:~407` | Prose: "the module picks the node out of `connections[]`" | Retarget to `nodes[]`; also correct the surrounding paragraph's claim that multi-node suppresses the scalar/`KUBECONFIG` channel — the kube channel is unconditional and the "TUNSTRAP_* env... not injected" framing is stale. Task 6. | +| `docs/recipe_terragrunt.md:~509` | "What is proven" section: `--output-var` → `TF_VAR_tunstrap` → `try(jsondecode(...))` → `config_path` chain | Mechanism description stays accurate; **verify only** once the two locals above change. | +| `tests/e2e/rig.py:171` | Docstring: "`module/main.tf` decodes `connections.node.kube_targets.k3s.path`" | Retarget docstring text to `nodes.node.kube.k3s.path`. | +| `tests/e2e/test_tofu_providers.py:154` | `envelope["connections"]["node"]["kube_targets"]["k3s"]["path"]` | Retarget to `envelope["nodes"]["node"]["kube"]["k3s"]["path"]`. | +| `tests/e2e/test_tofu_providers.py:251-254` | Fake envelope literal `{"connections": {"node": {"ports": {}, "kube_targets": {"k3s": {...}}}}}` | Retarget to `{"nodes": {"node": {"ports": {}, "kube": {"k3s": {"path": ..., "context": ..., "endpoint": ...}}}}}` — align field names with `UnifiedKubeRef`, dropping any field beyond `path`/`context`/`endpoint` the old literal carried. | +| `tests/e2e/test_terragrunt_apply.py:339,425` | `envelope["connections"]["node"]["kube_targets"]["k3s"]["path"]` (apply and tunnelled-output cases) | Retarget both to `envelope["nodes"]["node"]["kube"]["k3s"]["path"]`. | +| `tests/e2e/test_rig.py:278` | `envelope["connections"]["node"]["kube_targets"]["k3s"]` | **Out of scope, stated explicitly, not silently skipped:** this reads `tunstrap start`'s **raw stdout JSON** (`OutputSchema.model_dump_json()`-shaped), not the `--output-var`/materialized unified channel. Scope here is `run`'s consumer-facing channels plus `start --output env`; `start`'s default/`--output json` stdout is a separate contract for session-management tooling and is deliberately untouched. If a reviewer wants it unified too, that is a new decision. | +| `tests/e2e/test_recipe_terragrunt.py:259,322` | Recipe↔module drift guard (textual block comparison) | **Unaffected in mechanism.** The compared *content* changes automatically once `main.tf` and the recipe both move to the `nodes.*` shape. Task 6 must keep it green (run it as part of Task 6, not just Task 7). | + +#### `test_cli_run_output_var_projection.py` — dedicated retarget (security-critical) + +This file pins the credential-scrubbing property for the projected kube +reference — it must not be weakened while being reshaped. All four tests +retarget or delete, **not** left alone: + +- `test_output_var_never_carries_kube_private_key_material` — retarget the + shape lookup: + `json.loads(env["TF_VAR_tunstrap"])["nodes"]["node"]["kube"]["k3s"]` + instead of `["connections"]["node"]["kube_targets"]["k3s"]`. The absence + assertions (`client_key_data`/`client_certificate_data`/`content_b64` not in + `target`) are unaffected in spirit, but the field set is now smaller for a + second reason too — see the next test. +- `test_output_var_keeps_every_field_the_consumer_chain_reads` — the + anti-vacuity pair. **The expected dict shrinks further than credential + removal alone**: `UnifiedKubeRef` carries exactly `{path, context, + endpoint}` — `cluster_name`, `local_port`, `tls_server_name`, and + `certificate_authority_data` (all present in the old `RunKubeTarget` + projection, none of them credentials) are **also** gone, because the design + narrows to references only. Retarget the expected dict to exactly + `{"path": KUBE_PATH, "context": "probe-context", "endpoint": + "https://127.0.0.1:41111"}`. This is a real, intentional narrowing beyond + the credential fix — call it out in the retargeted test's docstring so a + future reader does not mistake it for scope creep. +- `test_output_var_projection_leaves_the_rest_of_the_envelope_intact` — + retarget: `decoded["pid"]` → `decoded["session"]["pid"]`, + `decoded["session_dir"]` → `decoded["session"]["session_dir"]`, + `decoded["started_at"]` → `decoded["session"]["started_at"]`, + `decoded["connections"]["node"]["ports"]` → + `decoded["nodes"]["node"]["ports"]` — **and note the value shape changed + too**: `{"db": 5432}` (int) becomes `{"db": "127.0.0.1:5432"}` (string). +- `test_projection_is_an_allow_list_so_a_new_secret_field_cannot_leak` — + **delete, not retarget.** It validated `RunKubeTarget.model_validate` + directly, exercising `extra="ignore"`'s fail-closed behaviour against an + untrusted dict. `RunKubeTarget` is deleted (Task 4); its replacement, + `render_unified_output`, never calls `.model_validate()` on untrusted kube + data at all — it constructs `UnifiedKubeRef(path=..., context=..., + endpoint=...)` with three explicit keyword arguments, so a hypothetical + field added to `KubeTargetOutput` later cannot leak through without someone + editing that constructor call by hand. The allow-list property now holds + **by construction**, and + `test_output_var_keeps_every_field_the_consumer_chain_reads`'s exact-equality + assertion already proves it end-to-end. **Confirm by re-reading + `render_unified_output`'s body before deleting** — the property must + actually hold, not just be asserted to hold by this note. + +#### Fetched-file materialization + the `content_b64` enumeration + +**New mechanism, same precedent as kube.** `FetchedFile` (`schemas.py:292-313`) +gains `path: str | None = None`, mirroring `KubeTargetOutput.path` +(`schemas.py:317-336`) exactly. Wherever kube materialization currently runs +daemon/worker-side (the same call site the design doc's "Materialization write +mechanism" section points at — **confirm the exact function before +implementing; do not assume it is `manager.py:start_all_and_build_output` +without checking**), add a parallel step: for each successful `FetchedFile` a +node's `fetch_files` produced, base64-decode `content_b64` and write the raw +bytes to `tunnel-data/-` using the **same atomic-replace +primitive** as `output.json` (temp file + `O_EXCL` + `os.replace`, not +`_write_file`'s `O_TRUNC`), then set `.path`. A failed fetch (`.error` set) +materializes nothing. `content_b64` itself is **not** deleted from +`FetchedFile` — it stays internal plumbing, same as kube's own `content_b64`. +The projection is not a separate function: it is `render_unified_output`'s +`fetch_files` construction via `UnifiedFetchRef` (Task 4); this step is what +makes `.path` non-`None` by the time that function runs. + +**Kube-internal `content_b64` hits — unaffected, listed to prove they were +checked, not missed:** `KubeTargetOutput.content_b64` (`schemas.py:335`) is a +different field entirely (the patched kubeconfig's own content, unrelated to +`fetch_files`). Every hit here reads or constructs *that* field: +`test_kube_run.py:111`, `test_envrender.py:20`, `test_output_kube.py:35`, +`test_tofu_proxy.py:351`, `test_kube_targets.py:91,147` (integration — reads +`start`'s raw stdout JSON, already out of scope per the carve-out), and the +**absence** assertions for kube's own `content_b64` in +`test_cli_run_output_var.py:256,281` and +`test_cli_run_output_var_projection.py:10,72,91,190,249` (these already +correctly assert kube's `content_b64` is *not* in the projected shape — +nothing to change). + +**`fetch_files`-related — in scope, retarget:** + +| File:line | Old shape/behaviour | Disposition | +|---|---|---| +| `test_manager_fetch.py:91` (`test_fetch_files_results_populate_node_output`) | Docstring "Fetcher results land in `NodeOutput.fetch_files` unchanged"; fixture `FetchedFile(content_b64="YQ==", size=1, sha256="ca97")`, no `path` | **False once materialization runs.** Retarget: assert `out.connections["a"].fetch_files["kubeconfig"].path` is set to the expected `tunnel-data/a-kubeconfig` location and its on-disk bytes match `base64.b64decode("YQ==")`; `content_b64` still present on the object (internal plumbing) but the test's point moves to `path`. Rename to drop "unchanged" from the docstring. | +| `test_fetcher_unit.py:101,111` | `fetcher.fetch_files()`'s own unit test, asserts `ff.content_b64` set on success | **Unaffected** — the SSH-fetch-to-memory layer, upstream of the new daemon-side materialization step; `fetcher.py` itself is not changed, only its caller gains a step after it. | +| `test_fetch_files.py:67,119,216` (integration) | `base64.b64decode(ff["content_b64"])` reading the raw `start` stdout envelope | **Unaffected in mechanism** (raw stdout stays the "complete envelope") **but verify against the correct channel**: if any of these three actually asserts against `--output-var`/materialized output rather than raw `start` JSON, that assertion retargets to read `ff["path"]` + a direct file read. Confirm which channel each of the three exercises before deciding no change is needed. | +| `test_fetch_security.py:49,52,69,87-89` (integration) | Proves fetched `content_b64` "appears on stdout only, never on stderr" — i.e. accepts it riding *some* channel | **Retarget the property proved, not just the assertion syntax.** Rewrite to assert (a) `content_b64`/the raw fetched bytes appear **nowhere** in `TF_VAR_tunstrap`, the materialized `output.json`, stdout, or stderr; (b) the file at the reported `path` exists, is mode `0600`, and its bytes match the source. This is a **stronger** security property than before — call that out in the retargeted docstring. | +| `test_cli_run_output_var.py:83` (`_RICH_PAYLOAD`) | `"fetch_files": {"hosts": {"content_b64": "aG9zdHM=", "size": 6, "sha256": "ab" * 32}}` | Retarget the fixture to `{"hosts": {"path": "/s/tunnel-data/node-hosts", "size": 6, "sha256": "ab" * 32}}`; any downstream assertion reading `fetch_files.hosts.content_b64` from the decoded var retargets to `.path`. | +| `test_output_schema.py:25,32,46,63` | `FetchedFile(content_b64=...)` construction, xor-validation tests | **Unaffected** — they test `FetchedFile`'s own `content_b64`/`error` xor, unchanged; only a new optional `path` field is added. Add one new case: `path` defaults to `None`, is not part of the xor, and can be set independently after construction (mirror `KubeTargetOutput.path`'s own coverage if a precedent test exists, rather than inventing a new assertion style). | + +**`docs/` tier:** + +| File:line | Old shape/behaviour | Disposition | +|---|---|---| +| `docs/recipe_terragrunt.md:344` | Kube-drop list: "and **drops** `client_key_data`... `content_b64`... `client_certificate_data`" | **Verify only, no rewrite.** States kube's `content_b64` is dropped from `TF_VAR_tunstrap`'s kube projection — still true. | +| `docs/recipe_terragrunt.md:361-364` | "`tunstrap start` is not affected: it writes the complete envelope to stdout... without `--materialize` its `content_b64` is the only way to obtain the kubeconfig at all." | **Verify only, no rewrite.** Matches the unchanged scope carve-out: `start`'s raw default JSON stdout is untouched, for kube and `fetch_files` alike. | +| `docs/recipe_terragrunt.md:366-388` (whole subsection, "### Fetched files are exported verbatim, not projected") | Argues the **opposite** of the shipped behaviour: "Every `fetch_files` entry keeps its `content_b64` whole"; "`FetchedFile` has no `path` (`schemas.py:292`), so dropping `content_b64` would be a silent, unrecoverable breakage"; "tunstrap fetches into the envelope (`content_b64`), not onto disk"; and a false premise that the materialize-then-drop end-state "is recorded in the spec's Out of scope" | **REWRITE — the whole subsection.** Replacement text in Task 6 Step 0b. | +| `tests/e2e/module/main.tf:13` | Comment: "the kube target's `client_key_data`, `client_certificate_data` and `content_b64` are dropped" | **Unaffected** (kube-only field) — already inside the region Task 6 Step 0 edits for the unrelated `connections.*`→`nodes.*` rename. | +| Untracked characterization harness | Fetch/kube fixtures using `content_b64` | **Out of scope, stated explicitly.** It is not part of the test suite, shipped code, or consumer documentation. | +| `docs/specs/2026-05-20-feature-fetch-files-design.md`, `docs/specs/2026-07-31-run-env-io-and-tofu-proxy-design.md`, `docs/specs/2026-08-03-run-env-io-decision-history.md`, `docs/specs/2026-05-30-kube-targets-design.md`, `docs/superpowers/plans/2026-06-25-cli-run-modes.md`, `docs/superpowers/plans/2026-05-30-kube-targets.md` | Pre-#15 design/decision/plan documents for already-shipped tickets (#14 and earlier) | **Out of scope, historical record — cited, never edited.** Editing a completed ticket's own spec to match a later ticket's decision would falsify the record of what that ticket actually shipped. | +| Untracked superseded owner-tracking design | — | **Out of scope** — historical scratch material, not live. | +| Untracked issue #15 spike notes | Kube-only `content_b64` hits (patched-kubeconfig content in the collision-test prototype) | **Unaffected** (kube, not `fetch_files`) and a frozen historical spike snapshot. | + +**Schema note:** `FetchedFile`'s xor validator (`schemas.py:303-314`) needs no +new logic for `path` — it is a plain optional field set post-construction by +the materialization step, the same relationship `KubeTargetOutput.path` +already has to that model's own required fields. Confirm against the actual +`KubeTargetOutput` definition before implementing, not assumed from this note. + +**`predicted_env_keys`/anti-drift guard: checked, unaffected by the +fetched-file change.** `TUNSTRAP_OUTPUT_FILE` is one of the three +unconditional survivors `_build_child_env` injects and `predicted_env_keys` +reserves for **`run`**, not only for `start --output env`. `fetch_files`'s own +keys never touched env at all, so the guard's key set is untouched by the +materialization change — verified, not silently assumed. + +#### Anti-drift guard — retargeted, not deleted, and two-part + +**The guard is extended, never weakened.** After this task there are still +**two independent implementations** of "what keys will `run` inject": +`_build_child_env` (hardcodes `TUNSTRAP_SESSION_DIR`/`TUNSTRAP_PID`/ +`TUNSTRAP_OUTPUT_FILE`, merges `render_kube_env`'s output) and +`predicted_env_keys` (Task 3's conservative formula). If these two silently +diverge, the pre-spawn `--output-var` collision check (`_validate_output_var`, +`cli.py:311-324` — confirm the exact line against the checked-out file) +under-rejects: a NAME that collides with a key `_build_child_env` actually +injects would sail through validation and then genuinely collide post-spawn. + +The guard is **two independent tests**, because `predicted_env_keys` is +deliberately conservative rather than exact, so exact equality against the +actual export cannot hold in general (a schema with one kube target that +materializes cleanly predicts all three kube names while the actual export has +only two — correctly unequal): + +1. **Formula test** (exact equality, unit-test style — proves the conservative + formula itself is implemented correctly): the + `test_predicted_env_keys_reserves_all_three_for_one_kube_target` / + `..._two_kube_targets_one_node` pair already written in Task 3. +2. **Safety-envelope test** (subset — proves the conservative reservation + still covers whatever *actually* gets injected, even when cardinality + shrinks between input and output). A subset check here is *correct*, not a + weakening, precisely because it is paired with (1): + +```python +def test_predicted_env_keys_covers_actual_injected_keys_under_cardinality_shrink( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Safety-envelope half of the two-part anti-drift guard: predicted must be + a superset of actual, driven by the exact scenario that falsifies a + predictor that got the conservatism backwards -- two kube targets + DECLARED (one on an optional node that fails), only ONE materializes. A + NAME colliding with a key _build_child_env actually injects, but which + predicted_env_keys failed to reserve, would sail through the pre-spawn + collision check and then genuinely collide post-spawn.""" + from tunstrap import cli as cli_mod + from tunstrap.cli import _build_child_env + + # _build_child_env starts from dict(os.environ) (cli.py:394), so without + # isolating it first, `set(actual)` is the whole ambient environment + # (PATH, HOME, ...) and any comparison against it is meaningless in any + # real process. Isolate BEFORE calling it, not after: subtracting + # os.environ back out (`set(actual) - set(os.environ)`) is NOT an + # acceptable substitute -- a key that is both inherited AND injected (an + # operator-set KUBECONFIG, or a NAME matching --output-var) would be + # subtracted away too, silently under-checking exactly the collision + # this guard exists to catch. + monkeypatch.setattr(cli_mod.os, "environ", {}) + + # Input: two kube targets declared, on two nodes -- one optional and about + # to fail. predicted_env_keys sees only this schema. + schema = InputSchema.model_validate( + { + "nodes": { + "a": { + "host": "h1", "user": "u", "ssh_password": "p", + "kube_targets": {"k3s": {"kubeconfig_path": "/etc/k3s.yaml"}}, + }, + "b": { + "host": "h2", "user": "u", "ssh_password": "p", "required": False, + "kube_targets": {"k3s": {"kubeconfig_path": "/etc/k3s.yaml"}}, + }, + } + } + ) + # Output: node "b" failed (required: false), only node "a"'s kube target + # actually materialized -- output cardinality (1) SHRANK below input + # cardinality (2). This is the real _build_child_env sees post-spawn. + out = OutputSchema( + connections={ + "a": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7000, "/run/s/tunnel-data/k3s")}), + }, + pid=1, session_dir="/run/s", started_at="now", + warnings=[TunnelWarning(node="b", error="optional node refused the forward")], + ) + actual = _build_child_env(out, output_var=None, input_env=None) + # Subset, not equality: predicted (conservative, computed from input + # cardinality 2) legitimately claims MORE than actual (exact, computed + # from output cardinality 1) -- that asymmetry is the whole point. + assert set(actual) <= predicted_env_keys(schema) + # Anti-vacuity: KUBE_CONFIG_PATHS specifically must be in the prediction + # even though it is NOT in the actual export (the >=2 branch never fires + # here) -- this is the exact key an exact-cardinality predictor would + # have wrongly omitted. + assert "KUBE_CONFIG_PATHS" in predicted_env_keys(schema) + assert "KUBE_CONFIG_PATHS" not in actual +``` + +Add it in `tests/unit/test_envrender.py` (not a new file), here in Task 5, +alongside `_build_child_env`'s own implementation — it is the piece that needs +both sides to exist simultaneously. + +**Files:** +- Modify: `tunstrap/cli.py`, `tunstrap/envrender.py` (delete `render_env`), + `tunstrap/exceptions.py` (delete `MultiNodeEnvUnsupported`), + `tunstrap/schemas.py` (`FetchedFile.path`), the daemon/worker + materialization site, optionally `tunstrap/session.py` +- Test (unit): `tests/unit/test_cli_run_output_var.py`, + `test_cli_run_output_var_projection.py`, `test_cli_run_materialize.py` + (new), `test_cli_run.py`, `test_cli_run_input_env_scrub.py`, + `test_cli_runner.py`, `test_cli_run_postspawn.py`, `test_envrender.py`, + `test_exceptions.py`, `test_tofu_proxy.py` (docstrings only), + `test_manager_fetch.py`, `test_output_schema.py` +- Test (integration): `tests/integration/test_run_env_io.py`, + `test_cli_modes.py`, `test_fetch_files.py` (verify channel), + `test_fetch_security.py` +- Test/artifact (e2e): enumerated above, edited in Task 6 + +- [ ] **Step 1: Write failing tests** + +New file `tests/unit/test_cli_run_materialize.py`: + +```python +"""run's unified-output materialization: /tunnel-data/output.json. + +Validates: run always writes the unified structure to a deterministic path, +mode 0600, regardless of --output-var or node count; the file's content +equals render_unified_output's output for the same OutputSchema. +Code: tunstrap/cli.py (materialization call site) +Method: CliRunner + spawn_daemon/Popen/_teardown_run monkeypatched, as in +test_cli_run_output_var.py; read the file back after invoke(). +""" + +from __future__ import annotations + +import json +import stat +from pathlib import Path +from typing import Any + +import pytest +from click.testing import CliRunner + +from tests.unit.conftest import cleaning_teardown +from tunstrap import cli as cli_mod +from tunstrap.cli import main +from tunstrap.envrender import render_unified_output +from tunstrap.schemas import OutputSchema + +pytestmark = pytest.mark.unit + + +def test_run_materializes_output_json(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A single-node run writes tunnel-data/output.json, mode 0600, matching content.""" + session_dir = tmp_path / "session" + session_dir.mkdir() + payload = { + "connections": {"h": {"ports": {"db": 5432}, "fetch_files": {}, "kube_targets": {}}}, + "pid": 99, "session_dir": str(session_dir), "started_at": "2026-08-07T00:00:00Z", + } + monkeypatch.setattr( + cli_mod, "spawn_daemon", + lambda schema, session_dir=None, *, input_env=None: {"kind": "success", "payload": payload}, + ) + monkeypatch.setattr(cli_mod.subprocess, "Popen", _FakePopen) + monkeypatch.setattr(cli_mod, "_teardown_run", cleaning_teardown) + monkeypatch.setenv("TUNSTRAP_INPUT", json.dumps({"nodes": {"node": { + "host": "h", "user": "u", "ssh_password": "p", "remote_targets": {"db": "127.0.0.1:5432"}, + }}})) + result = CliRunner().invoke( + main, ["run", "--input-env", "TUNSTRAP_INPUT", "--", "true"] + ) + assert result.exit_code == 0, result.stderr + materialized = session_dir / "tunnel-data" / "output.json" + assert materialized.exists() + assert stat.S_IMODE(materialized.stat().st_mode) == 0o600 + out = OutputSchema.model_validate(payload) + assert json.loads(materialized.read_text()) == render_unified_output(out) + assert _FakePopen.last_env is not None + assert _FakePopen.last_env["TUNSTRAP_OUTPUT_FILE"] == str(materialized) + + +class _FakePopen: + last_env: dict[str, str] | None = None + returncode = 0 + + def __init__(self, cmd: list[str], env: dict[str, str] | None = None) -> None: + _FakePopen.last_env = env + + def wait(self) -> int: + return 0 + + def send_signal(self, _signum: int) -> None: + pass +``` + +Add to `tests/unit/test_cli_run_output_var.py`: + +```python +def test_multi_node_run_succeeds_without_output_var( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any] +) -> None: + """Multi-node input with NO --output-var succeeds: materialization covers + multi-node unconditionally, so the opt-in gate has nothing left to force.""" + survivor_a = {"ports": {}, "fetch_files": {}, "kube_targets": {"k3s": _RICH_KUBE}} + other_kube = dict(_RICH_KUBE, path="/s/tunnel-data/node-b-k3s") + survivor_b = {"ports": {}, "fetch_files": {}, "kube_targets": {"k3s": other_kube}} + spawn[0]( + { + "kind": "success", + "payload": { + "connections": {"a": survivor_a, "b": survivor_b}, + "pid": 99, "session_dir": "/s", "started_at": "2026-08-07T00:00:00Z", + }, + } + ) + monkeypatch.setenv(VAR, _payload({"a": _node(), "b": _node()})) + result = CliRunner().invoke(main, ["run", "--input-env", VAR, "--", "true"]) + assert result.exit_code == 0, result.stderr + assert FakePopen.last_env is not None + joined = "/s/tunnel-data/node-k3s:/s/tunnel-data/node-b-k3s" + assert FakePopen.last_env["KUBECONFIG"] == joined + assert FakePopen.last_env["KUBE_CONFIG_PATHS"] == joined + assert "KUBE_CONFIG_PATH" not in FakePopen.last_env + assert FakePopen.last_env["TUNSTRAP_SESSION_DIR"] == "/s" + assert FakePopen.last_env["TUNSTRAP_PID"] == "99" + assert FakePopen.last_env["TUNSTRAP_OUTPUT_FILE"] == "/s/tunnel-data/output.json" + + +def test_suppress_kubeconfig_drops_all_three_kube_env_names( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any] +) -> None: + """suppress_kubeconfig (the tunstrap_tofu proxy's guard) must drop + KUBE_CONFIG_PATH/_PATHS too, not just KUBECONFIG -- those are the names + the providers actually read (ADR entry 7).""" + from tunstrap.cli import _build_child_env + from tunstrap.schemas import OutputSchema + + out = OutputSchema.model_validate( + { + "connections": {"h": {"ports": {}, "kube_targets": {"k3s": _RICH_KUBE}}}, + "pid": 1, "session_dir": "/s", "started_at": "now", + } + ) + env = _build_child_env(out, output_var=None, input_env=None, suppress_kubeconfig=True) + assert "KUBECONFIG" not in env + assert "KUBE_CONFIG_PATH" not in env + assert "KUBE_CONFIG_PATHS" not in env +``` + +Also add the safety-envelope anti-drift test given in full above, to +`tests/unit/test_envrender.py`. + +- [ ] **Step 2: Retarget every pre-existing test pinning the removed machinery** + +**`tests/unit/test_cli_run_output_var.py`** — this file's whole premise (the +scalar/`--output-var` interaction) partly no longer exists: + +- `test_collision_with_injected_scalar_is_usage_error` — pins `--output-var + TUNSTRAP_DB_PORT` colliding with an injected scalar. `TUNSTRAP_DB_PORT` is + never injected now, so the collision cannot occur. **Delete this test** — + there is no equivalent behaviour to assert. +- `test_non_colliding_tunstrap_prefixed_name_is_accepted` — rename to + `test_tunstrap_prefixed_output_var_name_is_accepted` and drop the "only some + are protected" framing from the docstring; its only remaining job is + confirming `--output-var TUNSTRAP_ANYTHING` is not rejected just for the + prefix. +- `test_multi_node_without_output_var_is_exit_1_pre_spawn` — **delete**; + superseded by Step 1's `test_multi_node_run_succeeds_without_output_var`, + not retargetable (the assertion is the literal opposite). +- `test_multi_node_with_output_var_reaches_spawn` — still valid in spirit; + update the docstring only (it describes removed `render_env` behaviour). The + assertions are unaffected — it never inspects env content. +- `test_output_var_carries_the_whole_envelope_minus_kube_credentials` — + **retarget in place**: rename to + `test_output_var_carries_the_unified_structure_minus_kube_credentials`, + replace the expected-shape assertions with the unified shape + (`nodes.node.ports.db == "127.0.0.1:5432"`, `nodes.node.kube.k3s == {"path": + ..., "context": ..., "endpoint": ...}`, + `nodes.node.fetch_files.hosts.sha256 == ...`), and keep the + credential-absence assertions (`client_certificate_data`/`client_key_data`/ + `content_b64` must still not appear anywhere in the decoded payload) — that + property is unchanged, only the container shape is. +- `test_single_node_keeps_scalars_alongside_output_var` — **delete**; no + scalars survive to keep "alongside" anything except the three survivors, + already covered by `test_child_env_without_output_var_is_unchanged`. +- `test_multi_node_injects_output_var_and_no_scalars` — retarget: update the + body to decode `render_unified_output`'s shape (`nodes` keyed by `"a"`/`"b"`) + instead of the old `OutputSchema.connections` shape; keep the `leaked` + scalar-absence assertion (still a real guard against a regression that + reintroduces target-scoped scalars). +- `test_multi_node_suppresses_scalars_but_exports_kube_channel` — retarget: + its `leaked = [...TUNSTRAP_...]; assert leaked == []` assertion no longer + describes a real guard (the kube channel is unconditional by construction, + so there is nothing left to falsify). Rename to + `test_optional_node_failure_does_not_affect_kube_channel_or_unified_output` + and rewrite the body to assert: the kube channel still fires for the one + surviving connection (`KUBECONFIG`/`KUBE_CONFIG_PATH` present), and the + unified structure (if `--output-var` given) reflects only the surviving node + (`"b"` absent from `nodes`, its failure visible in `session.warnings`). Drop + the `leaked` assertion entirely — asserting the absence of something nothing + produces is a tautology, not a guard. +- `test_child_env_without_output_var_is_unchanged` — retarget: the expected + `injected` dict shrinks to exactly `{"TUNSTRAP_SESSION_DIR": "/s", + "TUNSTRAP_PID": "99", "TUNSTRAP_OUTPUT_FILE": "/s/tunnel-data/output.json"}` + (drop `TUNSTRAP_DB_HOST`/`_PORT`/`_ENDPOINT`; this fixture's node has no + kube_targets, so no kube keys either). Docstring: "the three survivors, + session metadata only." The existing `injected` filter + (`k.startswith(("TUNSTRAP_", "KUBECONFIG"))`) is already broad enough to + catch `TUNSTRAP_OUTPUT_FILE` — only the expected dict changes. + +**`tests/unit/test_envrender.py`** — delete every `render_env`-specific test +**by name**: `test_render_ports_and_session`, +`test_render_kube_sets_kubeconfig`, `test_render_kube_not_materialized_raises`, +`test_render_requires_single_node_zero`, +`test_render_requires_single_node_two`. Also delete the now-unused +module-level `from tunstrap.exceptions import MultiNodeEnvUnsupported` +(`test_envrender.py:4` — `ruff` F401), plus +`test_predicted_env_keys_no_kube_omits_kubeconfig` and +`test_predicted_env_keys_multi_node_is_empty`. + +**Do NOT delete `test_predicted_env_keys_matches_render_env`** — retarget it in +place to `test_predicted_env_keys_covers_actual_injected_keys_under_ +cardinality_shrink` (the safety-envelope half, code given in full above). Two +independent implementations of the injected-key set still exist; the guard +still has a job. + +Replace the two deleted "shape" tests with: + +```python +def test_predicted_env_keys_is_session_scalars_plus_kube_channel() -> None: + """predicted_env_keys collapses to the three survivors + the CONSERVATIVE + kube channel (all three names, not just the branch this input's exact + declared cardinality would hit) -- there is no other injected key left, + and the formula does not vary by exact count.""" + schema = InputSchema.model_validate( + { + "nodes": { + "a": { + "host": "h", "user": "u", "ssh_password": "p", + "kube_targets": {"k3s": {"kubeconfig_path": "/etc/k3s.yaml"}}, + }, + "b": { + "host": "h2", "user": "u", "ssh_password": "p", + "kube_targets": {"k4s": {"kubeconfig_path": "/etc/k4s.yaml"}}, + }, + } + } + ) + assert predicted_env_keys(schema) == { + "TUNSTRAP_SESSION_DIR", "TUNSTRAP_PID", "TUNSTRAP_OUTPUT_FILE", + "KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS", + } + + +def test_predicted_env_keys_no_kube_is_just_the_three_survivors() -> None: + schema = InputSchema.model_validate( + {"nodes": {"a": {"host": "h", "user": "u", "ssh_password": "p", + "remote_targets": {"db": "127.0.0.1:1"}}}} + ) + assert predicted_env_keys(schema) == { + "TUNSTRAP_SESSION_DIR", "TUNSTRAP_PID", "TUNSTRAP_OUTPUT_FILE", + } +``` + +`format_exports`'s own test (`test_format_exports_quotes_safely`) is +unaffected — it takes a plain `dict[str, str]`, not an `OutputSchema`. + +**`tests/unit/test_exceptions.py`**: delete **all three** +`MultiNodeEnvUnsupported` cases by name: the subclass check (`:87-90`), the +exit-code + envelope test (`:94-99`), and the `_EXIT_CODES` table test +(`:107-114`). + +**`tests/unit/test_tofu_proxy.py`**: the two `suppress_kubeconfig`-related +tests (`test_tunnelled_suppresses_kubeconfig_in_child_env`, +`test_tunnelled_drops_an_inherited_kubeconfig_in_the_multi_node_case`) keep +their assertions unchanged (`KUBECONFIG` still must not leak) but their +docstrings currently frame the pop in single-node-vs-multi-node / +`inject_scalars` terms — update both to drop that framing entirely; one +unconditional pop covers every case. + +**`tests/unit/test_manager_fetch.py`, `tests/unit/test_output_schema.py`**: +per the fetched-file table above. + +- [ ] **Step 3: Run to verify failure** + +`.venv/bin/pytest tests/unit/test_cli_run_output_var.py tests/unit/test_cli_run_output_var_projection.py tests/unit/test_cli_run_materialize.py tests/unit/test_cli_run.py tests/unit/test_cli_run_input_env_scrub.py tests/unit/test_cli_runner.py tests/unit/test_cli_run_postspawn.py tests/unit/test_envrender.py tests/unit/test_exceptions.py -v` + +Expected: FAIL across the board — `render_env`/`MultiNodeEnvUnsupported`/ +`inject_scalars` still exist and behave the old way; `_build_child_env` still +requires `inject_scalars` and injects only two survivors, not three; no +materialization call site exists yet; `start --output env` still emits +per-target scalars. + +- [ ] **Step 4: Implement** + +`tunstrap/exceptions.py`: delete the `MultiNodeEnvUnsupported` class and its +`_EXIT_CODES` entry. + +`tunstrap/envrender.py`: delete `render_env` in its entirety, and the +now-unused `from tunstrap.exceptions import MultiNodeEnvUnsupported` import +(`envrender.py:13`). Rewrite `predicted_env_keys`: + +```python +def predicted_env_keys(schema: InputSchema) -> set[str]: + """Env keys ``run`` will inject for this *input* schema, unconditional on + node count: the three session scalars, plus -- conservatively, not per the + exact _kube_channel_keys(count) branch -- all three kube names whenever + any node declares kube_targets at all. Input cardinality can shrink by + output time (an optional node/target can fail without failing the run), so + predicting the exact branch would under-reserve; see the "Anti-drift + guard" section for the cardinality-shrink case this guards against. Used + pre-spawn to reject a colliding --output-var NAME before a daemon exists. + """ + keys = {"TUNSTRAP_SESSION_DIR", "TUNSTRAP_PID", "TUNSTRAP_OUTPUT_FILE"} + if any(node.kube_targets for node in schema.nodes.values()): + keys |= {"KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"} + return keys +``` + +This keeps Task 3's conservative kube rule verbatim and only drops the scalar +half's `if len(schema.nodes) == 1:` per-target block. **Do not reintroduce +`_kube_channel_keys(total_kube)` (the exact per-count branch) here** — that +would silently make the predictor exact again and reopen the under-reservation +hole. + +`tunstrap/schemas.py`: add `path: str | None = None` to `FetchedFile`. + +`tunstrap/session.py`: confirm `SessionDir._write_file`'s exact signature +(`session.py:132`) before item 4 below. **It is not a drop-in reuse** — +`_write_file` is mode-fixed-at-creation but not atomic (`O_TRUNC`, no rename +step), while materialization needs true atomicity too. + +**Daemon/worker side:** add the fetched-file materialization step described in +"Fetched-file materialization" above, at the same site kube materialization +already runs (confirm the function by reading the code). + +`tunstrap/cli.py`: + +1. **Remove the `inject_scalars` parameter from all four places that thread + it:** + - `_build_child_env` (`cli.py:365-372`, parameter declaration) — remove the + parameter and its `if inject_scalars:` branch (`cli.py:399`). + - `_run_child` (`cli.py:466-474`, parameter; `cli.py:486`, passed through + to `_build_child_env`). + - `_supervise_child` (`cli.py:513-521`, parameter; `cli.py:543`, passed + through to `_run_child`). + - `run_command` (`cli.py:648`, `inject_scalars = len(schema.nodes) == 1` — + delete the line entirely; `cli.py:702`, the keyword argument passed to + `_supervise_child` — delete it from the call). + + Confirm all four sites against the checked-out file rather than trusting + these line numbers verbatim — they may have shifted once Tasks 1-4 land. +2. Remove the `cli.py:640` pre-spawn block: + ```python + if output_var is None and len(schema.nodes) != 1: + raise MultiNodeEnvUnsupported(...) + ``` + entirely — multi-node without `--output-var` is no longer rejected. +3. Rewrite `_build_child_env`: + +```python +def _build_child_env( + output: OutputSchema, + *, + output_var: str | None, + input_env: str | None, + suppress_kubeconfig: bool = False, +) -> dict[str, str]: + child_env = dict(os.environ) + if input_env is not None: + child_env.pop(input_env, None) + child_env["TUNSTRAP_SESSION_DIR"] = output.session_dir + child_env["TUNSTRAP_PID"] = str(output.pid) + child_env["TUNSTRAP_OUTPUT_FILE"] = _materialized_output_path(output.session_dir) + child_env.update(render_kube_env(output)) + if suppress_kubeconfig: + for key in ("KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"): + child_env.pop(key, None) + if output_var is not None: + child_env[output_var] = render_output_var(output) + return child_env + + +def _materialized_output_path(session_dir: str) -> str: + """The deterministic path the materialization writer writes to; shared so + _build_child_env's TUNSTRAP_OUTPUT_FILE and the actual writer never + independently compute a different path for the same file.""" + return str(Path(session_dir) / "tunnel-data" / "output.json") +``` + + No branch, no `inject_scalars` parameter anywhere in the call chain. +4. **Materialization writer — a true atomic replace, not write-then-chmod and + not `O_TRUNC` alone.** `SessionDir._write_file`'s real property is + **mode-fixed-at-creation** (`session.py:132`, `os.open(path, O_CREAT | + O_WRONLY | O_TRUNC, 0o600)`, no separate `chmod`) — **not** atomic: + `O_TRUNC` overwrites in place, visible mid-write. `Path.write_text()` + + `.chmod(0o600)` is worse still (a real, umask-dependent `0644` window). + This write needs *both* mode-fixed-at-creation *and* true atomicity, for + three reasons: (1) **torn-read prevention** if the process is killed + mid-write — a truncated file at the final path is indistinguishable from a + valid short one to a naive reader, while `os.replace` guarantees only a + complete old or complete new file is ever observable; (2) + **defense-in-depth** against any future change that reintroduces a + stable/reusable path; (3) the fetched-file writer shares this exact + primitive, so one atomic-replace primitive is reasoned about once, not + twice. Use a temp file + rename: + +```python + materialized_path = _materialized_output_path(output.session_dir) + tunnel_data_dir = Path(materialized_path).parent + tunnel_data_dir.mkdir(parents=True, exist_ok=True) + tmp_path = tunnel_data_dir / f".output.json.{os.getpid()}.tmp" + fd = os.open(tmp_path, os.O_CREAT | os.O_WRONLY | os.O_EXCL, 0o600) + try: + os.write(fd, render_output_var(output).encode()) + finally: + os.close(fd) + os.replace(tmp_path, materialized_path) +``` + + `O_EXCL` on the temp file guards against a colliding temp name (the mode is + already fixed at creation); `os.replace` is the atomic step. If + `SessionDir._write_file` can be refactored into something callable without + a live `SessionDir` instance (this writer runs in the CLI **parent** + process, `run_command`, which holds no `SessionDir` — kube materialization + happens daemon/worker-side, inside the process that does own one), factor + the primitive above into a small shared helper in `session.py` that both + call sites use; otherwise replicate it in `cli.py` as shown. **Do not + describe this as "reusing `_write_file`" if the code is not actually + shared** — the temp-file + `os.replace` step is new work `_write_file` does + not do. + + Place the call in `run_command`'s success path — the same place + `_build_child_env` is already called, **inside the `try` that owns + teardown** (the "cleanup must own the whole post-spawn window" invariant + from `docs/specs/2026-07-31-run-env-io-and-tofu-proxy-design.md` applies: + this write is new work in that protected window) — unconditionally, + regardless of `--output-var` and regardless of node count. **Confirm the + exact `run_command` call site by reading the checked-out `cli.py`**; line + citations for this function have drifted before, so re-resolve rather than + trusting one. +5. **`start_command`'s `--output env` mode** (`cli.py:204-206`, + `sys.stdout.write(format_exports(render_env(out)))`) is the **other** + caller of `render_env` — deleting the function without touching this call + site breaks `start` outright (`NameError`), not just a stale test. `start` + also now materializes under `--output env` (only): it already forces + `daemon.materialize` there via `cli.py:191`'s + `force_materialize=(output_fmt == "env")`, so the kube files already land + on disk; extend that to write `output.json` through the same + `_materialized_output_path`/atomic-write helper from item 4. Update the + branch to build the same three-survivors-plus-kube-channel mapping + `_build_child_env` uses: + ```python + if kind == "success" and output_fmt == "env": + out = OutputSchema.model_validate(message["payload"]) + _write_materialized_output(out) # same helper as item 4 + env = { + "TUNSTRAP_SESSION_DIR": out.session_dir, + "TUNSTRAP_PID": str(out.pid), + "TUNSTRAP_OUTPUT_FILE": _materialized_output_path(out.session_dir), + } + env.update(render_kube_env(out)) + sys.stdout.write(format_exports(env)) + ``` + Fix the existing test pinning this mode (`test_cli_runner.py:392`) in place + — do not add a new test alongside a stale one. + + **Stdin-mode guard — a real reachable failure, not a theoretical one.** + `--output env` forces `daemon.materialize = True` only for **flag mode** + (`build_flag_schema`'s `force_materialize=(output_fmt == "env")`, + `cli.py:191`); a **stdin**-supplied payload's own `daemon.materialize` is + the caller's explicit statement and `_pick_start_input_schema` leaves it + alone (`cli.py:160-174`). A stdin payload declaring `kube_targets` with + `materialize: false` under `--output env` therefore reaches the now + unconditional `render_kube_env(out)` call with `target.path is None`, which + raises a bare `ValueError` — an ugly traceback, not a typed error. **Fix + before wiring the unconditional call:** **choose (a)** — force + `daemon.materialize = True` for the stdin path too when `output_fmt == + "env"`, matching flag mode's own precedent (smallest change, consistent: + `--output env` needs materialized kube paths regardless of input channel). + The alternative, (b) catching `ValueError` around the `render_kube_env` + call and re-raising as a typed `TunstrapError` subclass, is acceptable only + if a reviewer specifically wants materialization to stay an operator + opt-out even under `--output env`. Add a unit test: stdin payload, + `daemon.materialize: false`, `kube_targets` declared, `--output env` → + exit 0 with the kube path materialized (option (a)), or a typed error + (option (b)) — never a bare `ValueError` traceback. + +- [ ] **Step 5: Run to verify pass, then the full unit suite** + +`.venv/bin/pytest tests/unit/test_cli_run_output_var.py tests/unit/test_cli_run_output_var_projection.py tests/unit/test_cli_run_materialize.py tests/unit/test_cli_run.py tests/unit/test_cli_run_input_env_scrub.py tests/unit/test_cli_runner.py tests/unit/test_cli_run_postspawn.py tests/unit/test_envrender.py tests/unit/test_exceptions.py tests/unit/test_tofu_proxy.py tests/unit/test_manager_fetch.py tests/unit/test_output_schema.py -v` + +Expected: **all pass, and only after every row of the blast-radius tables has +actually been applied.** A partial pass with a handful of still-red tests +means a table row was skipped, not that the row was optional. + +`.venv/bin/pytest tests/unit -q` + +Expected: full pass. Read the actual count; do not compare it against any +number recorded in this plan or the spike findings — both predate this task's +deletions and retargets. + +- [ ] **Step 6: Commit** + +```bash +git add tunstrap/cli.py tunstrap/envrender.py tunstrap/exceptions.py tunstrap/schemas.py \ + tunstrap/session.py \ + tests/unit/test_cli_run_output_var.py tests/unit/test_cli_run_output_var_projection.py \ + tests/unit/test_cli_run_materialize.py tests/unit/test_cli_run.py \ + tests/unit/test_cli_run_input_env_scrub.py tests/unit/test_cli_runner.py \ + tests/unit/test_cli_run_postspawn.py tests/unit/test_envrender.py \ + tests/unit/test_exceptions.py tests/unit/test_tofu_proxy.py \ + tests/unit/test_manager_fetch.py tests/unit/test_output_schema.py +git commit -m "feat: unified output materialization; remove TUNSTRAP_* scalars, MultiNodeEnvUnsupported, inject_scalars (#15)" +``` + +**Note:** include `tunstrap/session.py` only if Step 4 item 4 added a shared +atomic-write helper there; also `git add` the daemon/worker module that gained +the fetched-file materialization step. + +- [ ] **Step 7: Integration retargets** + +The blast-radius table's integration rows are their own step, not folded into +Task 7's gate pass — they are behavioural retargets (TDD-shaped: they can fail +against the old code and must pass against the new), not verification only. + +**Files:** `tests/integration/test_run_env_io.py`, +`tests/integration/test_cli_modes.py`, plus the `test_fetch_files.py` / +`test_fetch_security.py` dispositions from the fetched-file table. + +Apply every integration row: `_PROBE_SINGLE`/`_PROBE_MULTI` read +`TUNSTRAP_OUTPUT_FILE` instead of `TUNSTRAP_WEB_PORT`; the "no scalar leak" +check excludes the three sanctioned survivors; +`test_multi_node_without_output_var_is_exit_1` is renamed and inverted to +`test_multi_node_without_output_var_now_succeeds`; `test_cli_modes.py`'s +`start --output env` test and `run`'s child probe both retarget to +`TUNSTRAP_OUTPUT_FILE`. Run (requires Docker, per `tests/README.md`): + +```bash +PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/integration -m integration -q -k "run_env_io or cli_modes or fetch" +``` + +Expected: FAIL before the retargets (old assertions against new behaviour), +PASS after. Commit: + +```bash +git add tests/integration/test_run_env_io.py tests/integration/test_cli_modes.py \ + tests/integration/test_fetch_files.py tests/integration/test_fetch_security.py +git commit -m "test(integration): retarget env-shape assertions for the unified output contract (#15)" +``` + +--- + +### Task 6: Recipe documentation + e2e artifact shape migration + +**This task carries the e2e-tier and shipped-recipe rows of Task 5's +blast-radius tables** — they land here, not in Task 5, because they are the +same textual shape migration as the new recipe content, and +`test_recipe_terragrunt.py`'s drift guard requires the recipe and +`tests/e2e/module/main.tf` to move together or it fails by design. + +**Files:** +- Modify: `docs/recipe_terragrunt.md`, `tests/e2e/module/main.tf`, + `tests/e2e/rig.py`, `tests/e2e/test_tofu_providers.py`, + `tests/e2e/test_terragrunt_apply.py` + +- [ ] **Step 0: Fix the recipe's pre-existing `connections.*` shape (before adding new content)** + +The recipe already contains working HCL/prose in the old shape. Fix these **in +place** before Steps 1-2 add anything new, so the document is never left in a +self-contradictory state (old shape in one section, new shape in another): + +- `docs/recipe_terragrunt.md:287-288` — the `tunnel`/`kubepath` locals: + `try(jsondecode(var.tunstrap), { connections = {} })` → + `try(jsondecode(var.tunstrap), { nodes = {} })`; + `local.tunnel.connections.node.kube_targets.k3s.path` → + `local.tunnel.nodes.node.kube.k3s.path`. +- `docs/recipe_terragrunt.md:~329` — prose point 3, "`path` comes from the + materialized file... `connections.*.kube_targets.*.path`" → `nodes.*.kube.*.path`. +- `docs/recipe_terragrunt.md:~407` — "The input variable is scrubbed" section: + "the module picks the node out of `connections[]`" → `nodes[]`; + also correct the surrounding paragraph's claim that multi-node input + suppresses the scalar/`KUBECONFIG` channel entirely — the kube channel is + unconditional now; only the `TUNSTRAP__*` scalars, which no longer + exist as a concept, were ever suppressed for multi-node. +- `docs/recipe_terragrunt.md:~509` — "What is proven" section: **verify only**, + no shape-specific text to change (the `--output-var` → `TF_VAR_tunstrap` → + `jsondecode` → `config_path` chain description stays accurate once the two + locals above change). + +**`tests/e2e/module/main.tf:27-28`** — the exact chain the e2e tier proves, +mirroring the recipe: `try(jsondecode(var.tunstrap), { connections = {} })` → +`{ nodes = {} }`; `local.tunnel.connections.node.kube_targets.k3s.path` → +`local.tunnel.nodes.node.kube.k3s.path`. Update the module's own header +comment (`main.tf:1-9`, "The exact chain this tier exists to prove") to match. + +**`tests/e2e/rig.py:171`** — docstring: "`module/main.tf` decodes +`connections.node.kube_targets.k3s.path`" → `nodes.node.kube.k3s.path`. + +**`tests/e2e/test_tofu_providers.py:154`** — +`envelope["connections"]["node"]["kube_targets"]["k3s"]["path"]` → +`envelope["nodes"]["node"]["kube"]["k3s"]["path"]`. + +**`tests/e2e/test_tofu_providers.py:251-254`** — the fake envelope dict literal +(`"connections": {"node": {"ports": {}, "kube_targets": {"k3s": {...}}}}`) → +`{"nodes": {"node": {"ports": {}, "kube": {"k3s": {"path": ..., "context": +..., "endpoint": ...}}}}}`, aligning field names with `UnifiedKubeRef` (drop +any field beyond `path`/`context`/`endpoint` the old literal carried — this +fixture only needs enough to drive its dead-cluster negative-control +scenario). + +**`tests/e2e/test_terragrunt_apply.py:339,425`** — +`envelope["connections"]["node"]["kube_targets"]["k3s"]["path"]` (apply and +tunnelled-output cases) → `envelope["nodes"]["node"]["kube"]["k3s"]["path"]` +at both sites. + +**Not touched, disposition recorded** (restated here where a reader would +otherwise expect to find them fixed): `tests/e2e/test_rig.py:278` reads +`start`'s raw stdout JSON, out of scope; `tests/e2e/test_recipe_terragrunt.py`'s +drift guard needs no code change — its compared content updates automatically +once the steps above land. + +- [ ] **Step 0b: Rewrite `docs/recipe_terragrunt.md:366-388` — the "Fetched files are exported verbatim, not projected" subsection** + +This shipped subsection currently argues the **opposite** of the shipped +behaviour (fetch content stays whole in the envelope, `FetchedFile` has no +`path`, dropping `content_b64` would be "a silent, unrecoverable breakage"). +Fix in place, same "before Steps 1-2 add anything new" discipline as Step 0. +Replace the entire subsection (heading through the final paragraph ending +"...is recorded in the spec's 'Out of scope'.") with: + +> ### Fetched files are materialized, not carried in the envelope +> +> The projection above (kube) and this one (`fetch_files`) follow the same +> rule: `run` materializes content to disk under the session dir's +> `tunnel-data/`, mode `0600`, and the consumer-facing envelope carries only a +> reference to it. Each `fetch_files` entry becomes `{path, size, sha256}` on +> success, `{error}` on failure — never `content_b64`. +> +> `FetchedFile` **has a `path`** (`schemas.py`, extended for this ticket), so +> the lossless on-disk alternative exists, the same way it already existed for +> kube. +> +> **The plan-file-persistence risk is resolved as a class, not documented +> around**: since fetched content never enters `TF_VAR_tunstrap` or the +> materialized file at all, `--fetch`ing a secret cannot land it in a saved +> Terraform plan file through this channel. Read the file directly at +> `fetch_files..path` if you need its contents. + +Also check the one adjacent sentence this rewrite does not itself replace: the +"One other free-form string rides this channel unprojected: `warnings[*].error`" +paragraph immediately after (line ~390) stays accurate as written — +`warnings[*].error` is unrelated to `fetch_files` — but confirm after the edit +that "One other" still reads correctly given the preceding subsection no +longer describes `fetch_files` as unprojected. Reword the transition if it no +longer parses; do not leave a dangling "other." + +Verify lines 344 and 361-364 (the kube-drop list and the `start` carve-out, +immediately above this subsection) need **no** edit — both state kube-specific +`content_b64` facts that remain true. + +Run (requires `kind`/`tofu`/`kubectl`/Docker, per `tests/README.md` — optional +locally, mandatory before merge per Task 7 Step 4): + +```bash +PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/e2e -m e2e -q +``` + +Commit this shape-migration half separately from the new recipe content below, +so a reviewer sees "shape rename, no behaviour change" and "new recipe +content" as two independently reviewable diffs: + +```bash +git add tests/e2e/module/main.tf tests/e2e/rig.py tests/e2e/test_tofu_providers.py \ + tests/e2e/test_terragrunt_apply.py +git commit -m "test(e2e): retarget to the unified output nodes.*.kube.*.path shape (#15)" +``` + +- [ ] **Step 1: Add Mode A — env-native kube** + +Add a new section (placement: after the existing provider-config example, so +it reads as "and here is the identity-delivery contract that example depends +on") titled around **Mode A: env-native kube (satisfies the ticket's strict +"nothing live enters Terraform" contract)**: + +1. `KUBE_CONFIG_PATH`/`KUBE_CONFIG_PATHS` from tunstrap's own process + environment (no `var.`-bound value, no file read in HCL at all for kube) + **plus a literal `config_context = "tunstrap--"` per provider + alias** — a **two-alias worked HCL example**, citing findings #3 and #5 by + number: + ```hcl + provider "kubernetes" { + alias = "node1_k3s" + config_context = "tunstrap-node1-k3s" # literal -- never derived from var.tunstrap + } + provider "kubernetes" { + alias = "node2_k3s" + config_context = "tunstrap-node2-k3s" + } + ``` +2. Explicit warning: never derive `config_context`'s value from `var.tunstrap` + or any decoded data — literal only, matching the deterministic naming + scheme exactly. +3. A short "measured facts a consumer needs" list, restated (not re-derived) + from **all six** of the ticket's findings plus this design's provider + findings — all six, do not miscount: + - **#1** — provider configuration **is** re-evaluated at apply. + - **#2** — outputs **freeze silently** — the worst failure mode, name it as + such. + - **#3** — per-alias `config_context` works with an env-supplied kubeconfig + path (Mode A's own basis, shown in item 1's example). + - **#4** — plan-safe end to end, measured live: plan with one set of ports, + mutate only the kubeconfig, apply the *saved* plan → the alias uses the + mutated value, zero plan-variable mismatch. + - **#5** — `KUBE_CONFIG_PATHS` is colon-separated (comma silently falls + back to `localhost:80`). + - **#6** — a live value bound to a `var.` **does** trip "Mismatch between + input and plan variable value" on a saved plan (Mode B's one-shot rule + rests on this). + - A live value bound to a **resource attribute** (not a provider config + block) produces `Error: Provider produced inconsistent final plan` — cite + the committed provider-precedence spec's Q3 result, + and show provider-block placement as the only supported shape in both + Mode A and Mode B. +4. A one-line pointer to the deterministic naming scheme + (`tunstrap--`) and why it matters for anyone piping the + materialized kubeconfig into `kubectl --context` directly instead of + through a provider. + +- [ ] **Step 2: Add Mode B — unified-file convenience** + +Immediately after Step 1's section (same document — a real consumer may use +Mode A for kube and Mode B for ports in the same module), add a section titled +around **Mode B: unified-file convenience (ports + kube references; does NOT +satisfy the ticket's strict contract — state this plainly)**. **No literal, +operator-pinned path and no `var.`-derived locator anywhere in this section:** + +5. **The shape**, with a worked HCL example using the env-carried + `TUNSTRAP_OUTPUT_FILE` locator via Terragrunt's `get_env(...)` — no + `--session-dir` precondition and no operator-agreed path, because the + session dir is ephemeral unconditionally: + ```hcl + locals { + tunnel = try( + jsondecode(file(get_env("TUNSTRAP_OUTPUT_FILE"))), + { nodes = {} }, + ) + } + + provider "kubernetes" { + config_path = local.tunnel.nodes.node1.kube.k3s.path + } + ``` + read directly inside the `locals` block that feeds the provider config — + never through an `output`, per finding #2. +6. **Ports lose their integer form** (`"host:port"` string) — show the + extraction idiom explicitly: + ```hcl + locals { + service1_port = split(":", local.tunnel.nodes.node1.ports.service1)[1] + } + ``` +7. **The stability contract**, restated plainly and matching the design doc's + "Stability contract" word-for-word on the load-bearing claims: **both** Mode + B forms — item 5's `TUNSTRAP_OUTPUT_FILE` form and the `--output-var` + (`var.tunstrap`) form — are **one-shot `plan && apply` only**, with no + saved-plan reuse across a tunstrap restart for either and no locator + exemption of any kind (the check compares the variable's whole value; the + file itself is deleted at teardown alongside the rest of `tunnel-data/`). + State it as plainly as the design doc does: *"Neither Mode B form survives + a tunstrap restart. If you need a saved plan to apply cleanly against fresh + ports or fetched-file content, re-run plan in the same tunstrap + invocation."* Cite findings #1, #2 and #6 by number. +8. **The `jsondecode`-not-JavaScript note**, one sentence: consumption is via + HCL's `jsondecode`; there is no JS runtime anywhere in this stack (ADR + entry 12). +9. **Fetched files:** state *"Fetched file content never enters a Terraform + variable or plan file — only its path, size, and checksum do. Read the file + itself at `fetch_files..path` if you need its contents."* Do not + carry any warning framed around fetched content riding this channel — it + does not. + +Match the existing file's structure (numbered/lettered subsections, HCL code +fences, "Measured Terragrunt facts"-style attribution footers) — read the +file's current shape before writing; do not introduce a new prose style. + +- [ ] **Step 3: Cross-check against the artifacts, the design doc, and the drift guard** + +Confirm every measured fact restated in both new sections matches +the committed provider-precedence spec, the ticket's own +six findings, and the design doc's "Stability contract" subsection verbatim — +no rewording that could drift from the source transcripts or introduce a +second, subtly different phrasing of the same rule. This is a manual +read-through, not a test. + +Then run the recipe↔module drift guard, which must stay green through both +Step 0's shape migration and this step's new content (it fails loudly, by +design, if the two documents disagree on a shared HCL block): + +```bash +PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/e2e/test_recipe_terragrunt.py -m e2e -q +``` + +- [ ] **Step 4: Commit** + +```bash +git add docs/recipe_terragrunt.md +git commit -m "docs(recipe): kubeconfig-as-identity delivery + unified output + stability contract (#15)" +``` + +--- + +### Task 7: Full gate pass + +**Files:** none (verification only). + +- [ ] **Step 1: Style/type/lint gates** + +```bash +.venv/bin/black --check . +.venv/bin/ruff format --check . +.venv/bin/ruff check . +.venv/bin/pylint tunstrap/ +.venv/bin/vulture tunstrap/ +.venv/bin/mypy --strict tunstrap +``` + +Expected: all clean. `vulture` has no whitelist file to update +(`vulture_whitelist.py` was removed; `min_confidence = 80` in +`pyproject.toml`). If `rename_identities`/`render_kube_env`/ +`render_unified_output` get flagged as unused, a call site is missing — that +is not a case for a suppression. Conversely, if `RunKubeTarget`, `render_env`, +or `MultiNodeEnvUnsupported` are still importable from anywhere, +`vulture`/`ruff` catching them is the signal Task 5's deletions were +incomplete. `pylint`'s `fail-under = 9.0` gate applies to the whole +`tunstrap/` package score, not per-file. + +- [ ] **Step 2: Unit suite** + +```bash +.venv/bin/pytest tests/unit -q +``` + +Expected: full pass. **Do not compare the count against any number recorded in +this plan or the spike findings** — this work deletes a meaningful number of +pre-existing tests while adding others. Run it and read the real number. + +- [ ] **Step 3: Integration suite** + +```bash +PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/integration -m integration -q +``` + +Expected: full pass. This tier **does** change: Task 5 Step 7 retargets +`test_run_env_io.py`, `test_cli_modes.py`, and the fetch tests for the same +shape/scalar removal as the unit tier, and this is where those retargets are +proven against the *real* console script and a real docker rig rather than +`CliRunner`. If this step is reached with those retargets not yet landed it +will fail, correctly — that failure is Task 5 Step 7 being incomplete, not a +flake to route around. + +- [ ] **Step 4: e2e suite** + +This tier **does** change too: Task 6 moves `tests/e2e/module/main.tf`, +`rig.py`, `test_tofu_providers.py`, and `test_terragrunt_apply.py` to the +`nodes.*.kube.*.path` shape — real code changes this tier must pass against, +not a no-op regression check. + +```bash +PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/e2e -m e2e -q +``` + +Expected: full pass, including `test_recipe_terragrunt.py`'s drift guard +(already run once in Task 6 Step 3; the full tier here is the final +confirmation nothing else regressed). + +**Separately, and optional:** the design doc's "`e2e` coverage — optional, +with rationale" describes a *different* piece of work — an e2e-level collision +test proving the kube identity rename (rewriting two kind kubeconfigs to a +shared identity before feeding them to `tunstrap start`/`run`). No task here +adds it by default, because Task 2's unit-level regression test already +exercises that defect precisely. If a reviewer wants it, it is an extension of +Task 2, not part of this step. Do not conflate the two: Task 6's e2e shape +migration is mandatory and verified by this step; the collision-specific e2e +coverage is optional. + +- [ ] **Step 5: Final commit / PR** + +No further commit needed if Tasks 1-6 committed cleanly and the gates pass on +the resulting tree. Open or update PR #13 against `feature/run-env-io` per the +ticket's stated target; **do not merge** (org rule — curate and validate, +leave the merge decision to the human reviewer). + +--- + +## Coverage checklist + +**Kube part (Tasks 1-3):** + +| Requirement | Where | +|---|---| +| Ticket work item 1 — patch identity names (cluster + user + context) | Task 1 | +| Rename scope: the active current-context triple only (ADR entry 5) | Task 1 (`rename_identities`, "ignored entries" test) | +| Every reference to the renamed cluster/user swept, incl. non-current contexts | Task 1 (shared-reference test + implementation sweep) | +| Naming scheme `tunstrap--`, no configurable prefix (ADR entry 4) | Task 1 | +| Naming-join collision rejected at validation time (ADR entry 15) | Task 1 Steps 6-9 (`a-b`/`c` vs `a`/`b-c`) | +| `patch_view` owns server-address patching; `dump_kubeconfig` signature unchanged | Task 1 Step 3 | +| Mandatory unit-level k3s collision regression test | Task 2 | +| Ticket work item 2 — multi-node kube channel | Task 3 (`render_kube_env`, node-count-agnostic) | +| Ticket work item 3 — export provider-facing vars per the conditional cardinality contract, never the naive superset (ADR entry 3) | Task 3 (`_kube_channel_keys`) | +| Conservative `predicted_env_keys` + two-part anti-drift guard (ADR entry 16) | Task 3 (formula half) + Task 5 (safety-envelope half) | +| `suppress_kubeconfig` drops all three kube env names (ADR entry 7) | Task 5 (`_build_child_env`) | + +**Unified output contract (Tasks 4-6):** + +| Requirement | Where | +|---|---| +| Unified node-qualified contract replaces the flat scalar channel (ADR entry 10) | Task 4 (shape) + Task 5 (scalar deletion) | +| Materialization is the primary delivery; `--output-var` is the bare-`tofu` bridge (ADR entry 11) | Task 5 Step 4 (unconditional write) | +| Scalar channel removed, not narrowed: `render_env`, `inject_scalars`, `MultiNodeEnvUnsupported` all deleted (ADR entry 13) | Task 5 | +| Kube side unchanged; the unified structure carries only kube *references* | Task 4 (`UnifiedKubeRef`, `extra="forbid"`, `{path, context, endpoint}` only) + Task 5's projection-file retarget | +| Content on disk, paths in env — incl. fetched files (ADR entry 19) | Task 5 (fetched-file materialization, `UnifiedFetchRef`) | +| Atomic-replace writer, not `O_TRUNC`, not write-then-chmod (ADR entry 17) | Task 5 Step 4 item 4, shared with the fetched-file writer | +| Consumer-side transformation via `jsondecode` (ADR entry 12) | Task 6 Step 2 item 8 | +| Breaking deliberately, no compatibility shim (ADR entry 8) | Tasks 5-6 across every tier | +| Recipe carries both consumer modes + the measured facts + the stability contract | Task 6 Steps 0, 0b, 1, 2 | + +**Types:** `rename_identities(dict[str, object], str, str) -> str`; +`render_kube_env(OutputSchema) -> dict[str, str]`; +`_kube_channel_keys(int) -> set[str]`; +`render_unified_output(OutputSchema) -> dict[str, Any]`; +`render_output_var(OutputSchema) -> str` (signature unchanged, body rewritten); +`predicted_env_keys(InputSchema) -> set[str]` (return type unchanged, body +simplified); `_build_child_env(output, *, output_var, input_env, +suppress_kubeconfig=False) -> dict[str, str]` (**no `inject_scalars` +parameter** anywhere in the chain); `_materialized_output_path(str) -> str` +(shared between the env-var value and the writer so the two cannot drift). + +**Deliberately left to the implementer to resolve against the live tree, not +gaps:** the exact `run_command` call site for the materialization write (Task +5 Step 4 item 4 — re-resolve by reading the function; line citations for it +have drifted before); whether `SessionDir._write_file` can be refactored into +a shared atomic-replace helper or the primitive is replicated in `cli.py` +(both paths specified); and the exact daemon/worker function that owns kube +materialization, where the fetched-file step attaches (confirm by reading, do +not assume). diff --git a/pyproject.toml b/pyproject.toml index ce23a7a..9cbed18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,10 @@ readme = "README.md" requires-python = ">=3.10" dynamic = ["version"] dependencies = [ - "asyncssh @ git+https://github.com/AlexMKX/asyncssh.git@v2.23.0+forward-tracker.3", + # Fork divergence: SSHForwardTracker patch; upstream issue tracker: + # https://github.com/ronf/asyncssh/issues. The immutable commit is the + # annotated v2.23.0+forward-tracker.3 tag target. + "asyncssh @ git+https://github.com/AlexMKX/asyncssh.git@938b88cbda588ff2a258d3b43082904259ee0279", "pydantic>=2.13,<3", "click>=8.3,<9", "ruamel.yaml>=0.18,<0.19", @@ -18,9 +21,9 @@ dependencies = [ dev = [ "pytest>=9.0,<10", "pytest-asyncio>=1.3,<2", - "pytest-cov>=5.0", - "mypy>=1.13", - "ruff>=0.8", + "pytest-cov>=5.0,<8", + "mypy>=1.13,<3", + "ruff>=0.16,<0.17", "black>=24.10,<26", "pylint>=3.3,<4", "vulture>=2.13,<3", @@ -28,6 +31,13 @@ dev = [ [project.scripts] tunstrap = "tunstrap.cli:main" +# Second console entry: the OpenTofu proxy. uv tool install yields both +# tunstrap and tunstrap_tofu, so terraform_binary points at a stable installed +# path with nothing copied into the consumer's repo. The pass-through branches +# execvp tofu without importing tunstrap.cli, keeping the fast path import-lean +# by design. See tunstrap/tofu_proxy.py's module docstring ("Cost discipline") +# for the measured numbers and docs/recipe_terragrunt.md for the trade. +tunstrap_tofu = "tunstrap.tofu_proxy:main" [project.urls] Homepage = "https://github.com/AlexMKX/tunstrap" @@ -53,6 +63,37 @@ allow-direct-references = true line-length = 100 target-version = "py310" +[tool.ruff.format] +# Design documents intentionally use Python fences as illustrative, often +# output-sensitive examples; only exclude those Markdown files from formatting. +exclude = ["docs/**/*.md"] + +[tool.ruff.lint] +# S101 (assert) is enforced so production modules can never reintroduce an +# `assert`. This codebase documents twice (tunstrap/cli.py, +# tunstrap/daemon.py) that an assert raises AssertionError -- outside the +# TunstrapError handler, so it escapes as a traceback -- and that `python -O` +# erases the check altogether, leaving a bare TypeError/AttributeError in its +# place. Tests are built on `assert` and retain it via the per-file-ignores +# entry below. Only S101 is pulled in (not the whole `S` bandit group). +extend-select = ["S101"] + +[tool.ruff.lint.per-file-ignores] +# Test fixtures deliberately model process failures, legacy annotations, and +# naive certificates; keep test code unchanged while retaining these rules in +# production modules. S101 stays enabled here: the test suite is built on +# `assert`, so the production-only assert ban must not apply under tests/**. +"tests/**/*.py" = ["S101", "DTZ001", "PLW1510", "PYI034", "PYI036", "RUF012", "RUF059", "UP024", "UP035", "UP037"] +# Coverage instrumentation is deliberately fail-open: startup must continue +# even when the optional coverage bootstrap fails. +"sitecustomize.py" = ["S110"] +# Pydantic validators must raise ValueError so invalid input is reported as a +# validation error rather than escaping model validation as TypeError. +"tunstrap/schemas.py" = ["SIM102", "TRY004"] +# The nested contexts name the TCP socket before wrapping it for TLS, which is +# clearer for this security-sensitive probe than a compact combined statement. +"tunstrap/kube.py" = ["SIM117"] + [tool.mypy] strict = true python_version = "3.10" @@ -69,12 +110,13 @@ ignore_missing_imports = true markers = [ "integration: requires docker (sshd containers)", "unit: pure-Python unit tests", + "e2e: requires kind + tofu + kubectl + terragrunt (real Kubernetes cluster)", ] # Coverage is collected explicitly per job: unit job uses `pytest --cov`, # integration job uses `coverage run -m pytest`. The combine + gate step # enforces --fail-under=80 on the merged data. `addopts` therefore only # controls test selection and asyncio mode. -addopts = "-m 'not integration'" +addopts = "-m 'not integration and not e2e'" asyncio_mode = "auto" [tool.coverage.run] @@ -143,7 +185,7 @@ disable = [ max-line-length = 100 [tool.vulture] -paths = ["tunstrap", "vulture_whitelist.py"] +paths = ["tunstrap"] min_confidence = 80 ignore_decorators = ["@pytest.fixture", "@field_validator*", "@model_validator*", "@classmethod"] ignore_names = ["cls"] diff --git a/tests/README.md b/tests/README.md index 06cd1ce..ade5640 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,12 +1,17 @@ # Tests -This directory contains two suites: +This directory contains three suites: - `tests/unit/` — pure-Python unit tests. Marked with `pytestmark = pytest.mark.unit`. Run with `pytest tests/unit -q`. - `tests/integration/` — Linux + Docker integration tests. Marked with `pytestmark = pytest.mark.integration`. Run with `PATH="$PWD/.venv/bin:$PATH" pytest tests/integration -m integration -q`. +- `tests/e2e/` — Linux + Docker + kind + OpenTofu end-to-end tests. Marked with + `pytestmark = [pytest.mark.e2e]`. Run with + `PATH="$PWD/.venv/bin:$PATH" pytest tests/e2e -m e2e -q`. Excluded from the + default selection by `addopts` and from the coverage combine by design, so a + cluster flake cannot take down the `--fail-under=80` gate. ## Conventions @@ -34,8 +39,93 @@ Defined in `tests/integration/conftest.py`: - `started_daemons` — collects `session_dir` strings from successful start invocations so the suite teardown can stop them by `--session-dir`. +Defined in `tests/e2e/conftest.py` (constants and helpers live in +`tests/e2e/rig.py`, the intended import surface; `conftest.py` holds fixtures. +A few white-box checks in `test_rig.py` import `conftest` directly to exercise +fixture internals, but the rest read from `rig`): + +- `e2e_preflight` — session-scoped; requires Linux and `tunstrap` on PATH. A + missing `tunstrap` is a hard **failure**, not a skip: it means the venv is not + on `PATH` and a skip would report a green tier that tested nothing. +- `e2e_ssh_keypair` — generates this suite's **own** ed25519 keypair into + `tests/e2e/_keys/`. It does not share `tests/integration/_keys/`, which is + gitignored and created only by a fixture pytest never loads here. +- `kind_cluster` — creates `tunstrap-e2e` from `kindest/node:v1.34.0`, deleting + any stale cluster of that name first, and always deletes it on teardown. +- `node_kubeconfig` — copies the control plane's `/etc/kubernetes/admin.conf` + into `tests/e2e/_kube/admin.conf` for the compose mount. +- `kube_rig` — brings `sshd-kube` up on kind's external `kind` network, waits + for a real authenticated SSH exec (not a TCP connect), discovers the random + published port, and returns the connection facts. +- `tofu_plugin_cache` — one shared provider download per session. +- `tofu_module` — a private copy of `tests/e2e/module/` per test, so no test can + pass because of a neighbour's `.terraform/` or `terraform.tfstate`. + ## Local prerequisites for integration - Linux host (macOS works but is slower; CI uses ubuntu-latest). - Docker Compose v2. - Python 3.10+ with the project venv installed: `pip install -e ".[dev]"`. + +## Local prerequisites for e2e + +- Everything the integration suite needs, plus: +- `kind` (0.30.x) and `kubectl` (matched to `kindest/node:v1.34.0`). The tier + uses two kubectls for two different jobs, which is the source of confusion + here: the **in-node oracle** (`kubectl_in_node` in `tests/e2e/rig.py`) runs the + node image's own binary via `docker exec` as a tunnel-*independent* read-back + of cluster state, and needs no host copy; the **host** `kubectl` backs the + read-*through*-the-tunnel assertion in `test_rig.py` + (`kubectl --kubeconfig get nodes`), the flagship gate that + proves real kube API traffic crosses the tunnel — which the in-node oracle + cannot substitute for, since it never traverses the tunnel. +- `tofu` (OpenTofu 1.12.x). Network access on first run: `tofu init` downloads + the `kubernetes` and `helm` providers from `registry.opentofu.org` (~9s). That + is the *runner's* network and has nothing to do with the tunnel. +- `terragrunt` (1.1.x). Required only by `test_recipe_terragrunt.py`, which + drives real `terragrunt hcl validate` / `terragrunt render` against the HCL + fenced blocks extracted straight out of `docs/recipe_terragrunt.md` — so the + recipe's published configuration can no longer drift into something that does + not parse (it once shipped with `terraform_binary` misplaced inside the + `terraform {}` block). Required where it is used, not tier-wide, matching the + host-`kubectl` precedent in `tests/e2e/test_rig.py`. +- No `helm` binary. The Terraform `helm` provider links the Helm v3 Go SDK. +- Budget ~2.5-3 minutes for a full `pytest tests/e2e -m e2e` run once the + `kindest/node` image is local, of which ~90s is cluster and container setup. + A first run additionally pulls `kindest/node:v1.34.0` (~1.45 GB). +- The documented local command does **not** set `TUNSTRAP_E2E_REQUIRE_ALL`. The + e2e CI job sets it to `1`, which turns every "tool missing" skip in + `tests/e2e/rig.py::skip_or_fail` into a **failure** — CI installs every tool + itself, so a skip there means the job reports green while most of the tier + never ran. Locally a missing `kind`/`tofu`/`kubectl` is therefore a *skip*, + not a failure: a green local run with skips is not full coverage. To mirror + CI, `export TUNSTRAP_E2E_REQUIRE_ALL=1` before running. + +## e2e is not parallel-safe (documented deviation) + +The e2e tier is a single, session-scoped fixture chain built on fixed, +non-randomised names, and cannot have two independent runs on the same host +at the same time: + +- one session-scoped kind cluster under a fixed name (`CLUSTER_NAME = + "tunstrap-e2e"` in `rig.py`) and a fixed control-plane container name + derived from it (`CONTROL_PLANE`, `conftest.py`); +- a fixed, repo-relative `tests/e2e/_keys/` (SSH keypair) and + `tests/e2e/_kube/` (kubeconfig) directory, not per-run temp paths; +- an **unconditional** `kind delete cluster --name tunstrap-e2e` at + `kind_cluster` fixture setup (`conftest.py`), which absorbs a leaked + cluster from a killed prior run but would just as happily delete a + concurrent run's live cluster out from under it. + +This is a deliberate trade-off, not an oversight: the cluster name is fixed +because it doubles as the SSH forward target and the expected TLS +`tls_server_name` (see the comment beside `CLUSTER_NAME` in `rig.py`), and +per-run randomisation would ripple through every fixture that derives from +it. Re-architecting the rig for concurrent runs is out of scope for the +current tier. + +**Practical consequence:** run at most one `pytest tests/e2e` invocation per +host at a time (this applies locally and in CI - the e2e job is not +configured for matrix/parallel execution). Do not add `-n auto`/`pytest-xdist` +or a parallel CI matrix leg for this suite without first giving `kind_cluster`, +`_keys/`, and `_kube/` per-run identity. diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/_sshd_conf/allow_tcpfwd.conf b/tests/e2e/_sshd_conf/allow_tcpfwd.conf new file mode 100644 index 0000000..559d734 --- /dev/null +++ b/tests/e2e/_sshd_conf/allow_tcpfwd.conf @@ -0,0 +1 @@ +AllowTcpForwarding yes diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..6d02de5 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,384 @@ +"""E2E fixtures: a kind cluster plus one sshd node joined to kind's network. + +Constants and helpers live in ``tests/e2e/rig.py``; this file holds fixtures +only. +""" + +from __future__ import annotations + +import asyncio +import os +import shutil +import subprocess +import sys +import time +import warnings +from pathlib import Path +from typing import Any, Iterator + +import asyncssh +import pytest + +from tests.e2e.rig import ( + CLUSTER_NAME, + COMPOSE_FILE, + CONTROL_PLANE, + HERE, + IN_NODE_KUBECONFIG, + NODE_IMAGE, + kubectl_in_node, + require_tools, + skip_or_fail, +) + + +@pytest.fixture(scope="session") +def e2e_preflight() -> None: + """Linux, and the product itself (both entry points) on PATH. + + A missing ``tunstrap`` or ``tunstrap_tofu`` is a hard failure rather than a + skip: it means the suite was launched without the venv on PATH, and silently + skipping would report a green tier that tested nothing. ``tunstrap_tofu`` is + the proxy the tier drives now (it replaced the consumer shell shim); both + entry points come from the same ``pip install -e`` so if one is missing the + install itself is broken. + """ + if sys.platform != "linux": + skip_or_fail("e2e tier requires Linux + Docker") + missing = [name for name in ("tunstrap", "tunstrap_tofu") if shutil.which(name) is None] + if missing: + pytest.fail( + f"{missing} not on PATH. Run the e2e tier as:\n" + ' PATH="$PWD/.venv/bin:$PATH" .venv/bin/pytest tests/e2e -m e2e -q' + ) + + +@pytest.fixture(scope="session") +def e2e_ssh_keypair() -> tuple[str, str]: + """Generate (once) and return this suite's own Ed25519 keypair.""" + keys_dir = HERE / "_keys" + keys_dir.mkdir(exist_ok=True) + priv_path = keys_dir / "id_test" + pub_path = keys_dir / "id_test.pub" + if not priv_path.exists() or not pub_path.exists(): + # cryptography, not paramiko: paramiko 4 dropped Ed25519Key.generate, + # and cryptography is already a hard dependency (pyproject.toml). + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + priv_obj = Ed25519PrivateKey.generate() + priv_path.write_text( + priv_obj.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.OpenSSH, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + ) + os.chmod(priv_path, 0o600) + public_line = ( + priv_obj.public_key() + .public_bytes( + encoding=serialization.Encoding.OpenSSH, + format=serialization.PublicFormat.OpenSSH, + ) + .decode() + ) + pub_path.write_text(public_line + " tunstrap-e2e\n") + os.chmod(pub_path, 0o644) + return priv_path.read_text(), pub_path.read_text() + + +@pytest.fixture(scope="session") +def kind_cluster(e2e_preflight: None) -> Iterator[str]: + """Create `tunstrap-e2e` from a pinned node image; always delete it after.""" + del e2e_preflight # ordering only + # Deliberately not "kubectl": this *fixture* needs none. The read-back oracle + # (rig.kubectl_in_node) runs the node image's own binary via docker exec, and + # `kind` itself shells out to no kubectl. The tier's read-through-the-tunnel + # gate in test_rig.py does use a host kubectl, and requires it where it uses it. + require_tools("docker", "kind") + + # `try` opens *before* the pre-delete, so the teardown covers cluster + # creation too. `kind` self-cleans when it exits non-zero itself, but a + # KeyboardInterrupt propagates straight out of subprocess.run - and Ctrl-C + # during a ~35s create is a routine developer action, not an edge case. + # Opened here rather than after the create, which would leave exactly that + # expensive window uncovered. + try: + # A crashed prior run can leave a half-configured cluster of this name, + # which would silently change every result. Delete first, + # unconditionally. This also absorbs a cluster leaked by a SIGKILLed run, + # where no teardown can possibly have run. + subprocess.run( + ["kind", "delete", "cluster", "--name", CLUSTER_NAME], + check=False, + capture_output=True, + ) + subprocess.run( + [ + "kind", + "create", + "cluster", + "--name", + CLUSTER_NAME, + "--image", + NODE_IMAGE, + "--wait", + "90s", + ], + check=True, + ) + ready = kubectl_in_node("get", "nodes", "-o", "name") + if ready.returncode != 0 or ready.stdout.strip() != f"node/{CONTROL_PLANE}": + pytest.fail( + "kind cluster came up but the in-node kubectl oracle does not work: " + f"rc={ready.returncode} stdout={ready.stdout!r} stderr={ready.stderr!r}" + ) + yield CLUSTER_NAME + finally: + # Captured, not inherited: an uncaptured delete prints kind's progress + # over `-q` output. check=False because a teardown must not mask the + # failure that got us here - but a silent failed delete leaks ~1 GB, so + # it is reported rather than swallowed. + removed = subprocess.run( + ["kind", "delete", "cluster", "--name", CLUSTER_NAME], + check=False, + capture_output=True, + text=True, + ) + if removed.returncode != 0: + warnings.warn( + f"failed to delete kind cluster {CLUSTER_NAME!r} " + f"(rc={removed.returncode}); it is still running and will consume " + f"~1 GB until removed: {removed.stderr.strip()}", + stacklevel=1, + ) + + +@pytest.fixture(scope="session") +def node_kubeconfig(kind_cluster: str) -> Iterator[Path]: + """Copy the control plane's in-node kubeconfig to tests/e2e/_kube/admin.conf. + + `/etc/kubernetes/admin.conf` is the file the compose rig mounts at + /etc/kube/admin.conf and that `kube_targets` reads over SSH - exactly as a + consumer reads /etc/rancher/k3s/k3s.yaml. Its `server:` is a DNS name that + resolves on the `kind` network, and that name is in the apiserver cert's + DNS SANs, so `choose_tls_server_name` returns it as an exact match and the + tier exercises the clean, warning-free path. + """ + del kind_cluster # ordering only + kube_dir = HERE / "_kube" + kube_dir.mkdir(exist_ok=True) + + # The Docker daemon creates a missing bind-mount source directory as root, + # and the compose rig declares `./_kube:/etc/kube:ro`. So on any machine + # where compose has ever come up before this fixture ran, `_kube` already + # exists owned by root: mkdir(exist_ok=True) succeeds silently and the write + # below dies with a bare EACCES that names no cause. Ordering this fixture + # ahead of compose-up avoids *creating* that state but cannot heal a machine + # that already has it, so the condition is detected here and reported with + # its remedy. + if not os.access(kube_dir, os.W_OK): + pytest.fail( + f"{kube_dir} exists but is not writable by this user - it is almost " + "certainly root-owned, created by the Docker daemon for the " + "./_kube bind mount before this fixture ran. Remove it and re-run:\n" + " sudo rm -rf tests/e2e/_kube" + ) + + dest = kube_dir / "admin.conf" + # The `try` covers the write, not just the yield. Previously the write sat + # outside it, so a failure there left `_kube` behind with no teardown armed. + try: + dumped = subprocess.run( + ["docker", "exec", CONTROL_PLANE, "cat", "/etc/kubernetes/admin.conf"], + capture_output=True, + text=True, + check=True, + ) + if f"server: https://{CONTROL_PLANE}:6443" not in dumped.stdout: + pytest.fail( + "in-node kubeconfig does not name the control plane by DNS; the " + "forward target would be wrong. Got:\n" + dumped.stdout + ) + dest.write_text(dumped.stdout) + # 0644, not 0600: the unprivileged `tester` user inside sshd-kube reads it. + os.chmod(dest, 0o644) + yield dest + finally: + # ignore_errors so a teardown failure cannot mask a real one, but not + # silently: a `_kube` that survives is exactly the sticky root-owned + # state the guard above has to fail on next run. + shutil.rmtree(kube_dir, ignore_errors=True) + if kube_dir.exists(): + warnings.warn( + f"could not remove {kube_dir}; it likely holds root-owned " + "contents and will fail the next run's writability guard. " + "Remove it with:\n sudo rm -rf tests/e2e/_kube", + stacklevel=1, + ) + + +def _wait_for_ssh( + port: int, + private_pem: str, + timeout: float = 90.0, + attempt_timeout: float = 10.0, +) -> None: + """Poll a real authenticated SSH exec until it succeeds. + + A TCP connect is not sufficient: the listener accepts before + linuxserver/openssh-server has installed the authorized key, so a + connect-only probe returns ready while every later connection is rejected + with `Permission denied (publickey)`. + + Every attempt is bounded. AsyncSSH's default login timeout is 120s - longer + than this function's own 90s deadline - so a peer that accepts the + connection and then never sends a version banner would hang a single attempt + past the deadline, and the "within 90s" below would be a false claim. The + budget is the smaller of ``attempt_timeout`` and the time actually left, and + the retry sleep is clamped the same way, so the reported elapsed time is + true. + """ + key = asyncssh.import_private_key(private_pem) + + async def _probe(budget: float) -> bool: + try: + async with asyncio.timeout(budget): + async with asyncssh.connect( + "127.0.0.1", + port=port, + username="tester", + client_keys=[key], + known_hosts=None, + connect_timeout=budget, + login_timeout=budget, + ) as conn: + result = await conn.run("echo ssh-ready", check=True) + return "ssh-ready" in str(result.stdout) + # TimeoutError covers both asyncio.timeout and asyncssh's own timeouts. + except (OSError, asyncssh.Error, TimeoutError): + return False + + deadline = time.monotonic() + timeout + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + if asyncio.run(_probe(min(attempt_timeout, remaining))): + return + remaining = deadline - time.monotonic() + if remaining <= 0: + break + time.sleep(min(1.0, remaining)) + pytest.fail( + f"sshd-kube never accepted an authenticated SSH exec on 127.0.0.1:{port} " + f"within {timeout:.0f}s" + ) + + +@pytest.fixture(scope="session") +def kube_rig( + e2e_ssh_keypair: tuple[str, str], + node_kubeconfig: Path, +) -> Iterator[dict[str, Any]]: + """Bring up sshd-kube on kind's network and return its connection facts.""" + # Deliberately not `del node_kubeconfig`. Requesting the fixture is what + # orders this one *after* the kubeconfig is on disk, and consuming its value + # keeps that ordering load-bearing rather than decorative: if a later edit + # dropped the parameter, compose would come up first, Docker would create + # the missing ./_kube bind-mount source as root:root, and node_kubeconfig + # would then fail with EACCES on this and every subsequent run. An + # "ordering only" argument is exactly the kind a refactor deletes without + # noticing, so it is asserted on instead. + if not node_kubeconfig.is_file(): + pytest.fail( + f"{node_kubeconfig} must exist before compose comes up: Docker creates a " + "missing ./_kube bind-mount source as root-owned, which poisons that " + "directory for every later run" + ) + + private_pem, _public_line = e2e_ssh_keypair + # `try` opens *before* `compose up`, for the same reason it opens before + # `kind create` in kind_cluster: `up` can create containers and then exit + # non-zero (an unhealthy --wait, a Ctrl-C mid-pull), and a finally armed + # only afterwards would never run, leaking the whole stack. + try: + subprocess.run( + ["docker", "compose", "-f", str(COMPOSE_FILE), "up", "-d", "--wait"], + check=True, + ) + published = subprocess.run( + ["docker", "compose", "-f", str(COMPOSE_FILE), "port", "sshd-kube", "2222"], + capture_output=True, + text=True, + check=True, + ) + # The published port is random by design ("127.0.0.1::2222"), so it must + # be discovered, never assumed. + _host, port_str = published.stdout.strip().rsplit(":", 1) + port = int(port_str) + _wait_for_ssh(port, private_pem) + yield { + "host": "127.0.0.1", + "port": port, + "user": "tester", + "private_pem": private_pem, + "cluster_name": CLUSTER_NAME, + "control_plane": CONTROL_PLANE, + "kubeconfig_in_node_path": IN_NODE_KUBECONFIG, + } + finally: + # Captured and inspected, matching kind_cluster's teardown: check=False + # so a teardown failure cannot mask the failure that got us here, but a + # silent one leaves containers and a volume behind for the next run to + # trip over, so a non-zero exit is reported rather than swallowed. + removed = subprocess.run( + ["docker", "compose", "-f", str(COMPOSE_FILE), "down", "-v"], + check=False, + capture_output=True, + text=True, + ) + if removed.returncode != 0: + warnings.warn( + f"`docker compose down -v` failed (rc={removed.returncode}); the " + f"sshd-kube stack may still be running: {removed.stderr.strip()}", + stacklevel=1, + ) + + +@pytest.fixture(scope="session") +def tofu_plugin_cache(e2e_preflight: None, tmp_path_factory: pytest.TempPathFactory) -> Path: + """One shared ``TF_PLUGIN_CACHE_DIR`` for the whole session. + + Measured twice (Task 3.3 and 6.3): cold init 7.65 s; warm cache *without* a + lock file 8.17 / 8.21 / 8.27 s; warm cache *with* a ``.terraform.lock.hcl`` + 0.226 s. Warm inits are marginally *slower* than cold, not faster. The + dominant cost is registry version resolution + (``Finding hashicorp/helm versions matching "~> 2.17"``), which reruns on + every init because the module ships no ``.terraform.lock.hcl``; the cache + removes only the download, which is not the bottleneck here. + + The cache does NOT buy the init-time win the old docstring claimed. It is + kept as standard hygiene: it is the documented mechanism for avoiding + redundant provider downloads, it costs nothing (a session temp dir), and the + per-test download it removes is real even if locally it is lost in noise. + The actual init-time lever is a committed ``.terraform.lock.hcl``, which is + deliberately absent so provider resolution keeps floating. + """ + del e2e_preflight # ordering only + require_tools("tofu") + return tmp_path_factory.mktemp("tofu-plugin-cache") + + +@pytest.fixture +def tofu_module(tmp_path: Path) -> Path: + """A private copy of tests/e2e/module/ for one test. + + TF_DATA_DIR and the state file live inside it (see rig.tofu_env), so no test + can share .terraform/ or terraform.tfstate with another, and none can pass + because of a neighbour's leftovers. + """ + dest = tmp_path / "module" + shutil.copytree(HERE / "module", dest) + return dest diff --git a/tests/e2e/docker-compose.yml b/tests/e2e/docker-compose.yml new file mode 100644 index 0000000..2de93f7 --- /dev/null +++ b/tests/e2e/docker-compose.yml @@ -0,0 +1,44 @@ +# One SSH node that can reach the kind API server, and nothing else. +# +# The service joins kind's own Docker bridge (`kind`, created by +# `kind create cluster`) because the control plane is only reachable from that +# network: a container on the default bridge cannot connect to it, and the +# host-side kubeconfig kind writes points at a random published host port that +# is useless from inside a container. Joining `kind` mirrors production - the +# SSH host is a machine that can reach the API server, and tunstrap forwards +# to it. +# +# `name:` pins the compose project so this rig can never collide with the +# integration rig, whose project name defaults to its directory (`integration`). +name: tunstrap-e2e + +services: + sshd-kube: + image: lscr.io/linuxserver/openssh-server:latest + environment: + - PUID=1000 + - PGID=1000 + - PUBLIC_KEY_FILE=/keys/id_test.pub + - USER_NAME=tester + - SUDO_ACCESS=false + - PASSWORD_ACCESS=false + volumes: + # Generated by tests/e2e/conftest.py. NOT shared with tests/integration. + - ./_keys:/keys:ro + # MANDATORY. Without this mount the image never emits its + # `Include /config/sshd/sshd_config.d/*.conf` line, the shipped + # `AllowTcpForwarding no` stands, and every forwarded connection is + # refused with `administratively prohibited` - while `tunstrap start` + # still reports success. + - ./_sshd_conf:/config/sshd/sshd_config.d:ro + # The control plane's in-node kubeconfig, written by the fixture. + - ./_kube:/etc/kube:ro + ports: + - "127.0.0.1::2222" + networks: + - kind + +networks: + kind: + external: true + name: kind diff --git a/tests/e2e/module/charts/probe/Chart.yaml b/tests/e2e/module/charts/probe/Chart.yaml new file mode 100644 index 0000000..87e78e2 --- /dev/null +++ b/tests/e2e/module/charts/probe/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: probe +description: One ConfigMap, proving the helm provider reached a real cluster. +type: application +version: 0.1.0 +appVersion: "0.1.0" diff --git a/tests/e2e/module/charts/probe/templates/configmap.yaml b/tests/e2e/module/charts/probe/templates/configmap.yaml new file mode 100644 index 0000000..f4824b1 --- /dev/null +++ b/tests/e2e/module/charts/probe/templates/configmap.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: probe-cm + namespace: {{ .Release.Namespace }} +data: + proof: through-the-tunnel diff --git a/tests/e2e/module/main.tf b/tests/e2e/module/main.tf new file mode 100644 index 0000000..ab1b022 --- /dev/null +++ b/tests/e2e/module/main.tf @@ -0,0 +1,85 @@ +# The exact chain this tier exists to prove: +# tunstrap run --output-var TF_VAR_tunstrap +# -> var.tunstrap (JSON string) +# -> try(jsondecode(...)) +# -> nodes.node.kube.k3s.path +# -> provider config_path +# +# The inert branch is deliberately identical in shape to the one the consumer +# recipe tells operators to write, so this module doubles as a regression test +# for that recipe. + +# `run` projects the envelope before exporting it: the kube target's +# client_key_data, client_certificate_data and content_b64 are dropped, so no +# credential reaches this variable. `sensitive = true` is defence in depth for +# what remains -- it suppresses rendering in plan/apply output and diagnostics. +# It does NOT keep the value out of the plan file, which is why the projection, +# not this flag, is the actual fix. +variable "tunstrap" { + type = string + default = "" + sensitive = true +} + +locals { + # try() is load-bearing: jsondecode("") is an error, so a bare jsondecode + # would make `tofu plan` fail whenever the infrastructure is not applied yet. + tunnel = try(jsondecode(var.tunstrap), { nodes = {} }) + kubepath = try(local.tunnel.nodes.node.kube.k3s.path, "") + + # Both providers must be configured *equivalently* - the whole point of the + # helm block is that it reaches the same cluster the kubernetes provider + # does. Defined once here and referenced twice below, so a one-sided edit is + # not expressible: there is no second copy of the expression to change. + inert = local.kubepath == "" + kube_config_path = local.inert ? null : local.kubepath + kube_host = local.inert ? "https://127.0.0.1:0" : null + kube_ca_certificate = local.inert ? "" : null + kube_client_cert = local.inert ? "" : null + kube_client_key = local.inert ? "" : null +} + +provider "kubernetes" { + config_path = local.kube_config_path + host = local.kube_host + cluster_ca_certificate = local.kube_ca_certificate + client_certificate = local.kube_client_cert + client_key = local.kube_client_key +} + +provider "helm" { + kubernetes { + config_path = local.kube_config_path + host = local.kube_host + cluster_ca_certificate = local.kube_ca_certificate + client_certificate = local.kube_client_cert + client_key = local.kube_client_key + } +} + +resource "kubernetes_namespace" "probe" { + metadata { + name = "tunstrap-e2e" + } +} + +resource "helm_release" "probe" { + name = "probe" + chart = "${path.module}/charts/probe" + namespace = kubernetes_namespace.probe.metadata[0].name +} + +# Read straight out of terraform.tfstate by the chain-integrity assertion and +# compared against nodes.node.kube.k3s.path in the envelope, so a hard-coded +# or fallback path cannot pass. +# +# nonsensitive() is required, not cosmetic: `sensitive = true` on var.tunstrap +# taints everything derived from it, and OpenTofu refuses an output that +# "refers to sensitive values". The kubeconfig *path* is a filename, not a +# credential -- and the envelope no longer carries credentials at all -- so +# unmarking it here is accurate. Any consumer that adds `sensitive = true` and +# also outputs something derived from the variable will hit the same error; +# this is the documented remedy (see docs/recipe_terragrunt.md). +output "kubepath_used" { + value = nonsensitive(local.kubepath) +} diff --git a/tests/e2e/module/versions.tf b/tests/e2e/module/versions.tf new file mode 100644 index 0000000..02c823c --- /dev/null +++ b/tests/e2e/module/versions.tf @@ -0,0 +1,20 @@ +terraform { + required_version = ">= 1.6.0" + + required_providers { + # Pinned, not floated. `>= 2.30.0` resolves hashicorp/kubernetes v3.2.1, + # which emits `Deprecated; use kubernetes_namespace_v1` for the resource + # below - a provider major bump must not be able to turn this tier red for + # a reason unrelated to tunnelling. + kubernetes = { + source = "hashicorp/kubernetes" + version = "~> 2.30" + } + # helm 3.x replaces the nested `kubernetes { }` block with a `kubernetes = {}` + # attribute, which would make main.tf a syntax error. + helm = { + source = "hashicorp/helm" + version = "~> 2.17" + } + } +} diff --git a/tests/e2e/rig.py b/tests/e2e/rig.py new file mode 100644 index 0000000..7aceeb9 --- /dev/null +++ b/tests/e2e/rig.py @@ -0,0 +1,339 @@ +"""Constants and helpers shared by the e2e tests. + +A plain module rather than a conftest, so test modules can import these by name +without depending on how pytest loaded the conftest. Fixtures live in +``conftest.py``; everything importable lives here. + +Self-contained by construction. This suite generates its own Ed25519 keypair +into ``tests/e2e/_keys/`` and commits its own sshd drop-in under +``tests/e2e/_sshd_conf/``. It reaches into no other suite: the integration rig's +``_keys/`` directory is gitignored and is created only as a side effect of a +fixture pytest never loads here, so borrowing it would work on a developer +machine and fail in a clean CI checkout. +""" + +from __future__ import annotations + +import json +import os +import shlex +import shutil +import subprocess +import time +from pathlib import Path +from typing import Any, NoReturn + +import pytest + +HERE = Path(__file__).resolve().parent + +# tests/e2e -> tests -> . Used to reach docs/recipe_terragrunt.md so +# the e2e tier can guard the published recipe directly (see test_recipe_terragrunt +# and test_terragrunt_apply). +REPO_ROOT = HERE.parent.parent +RECIPE_MD = REPO_ROOT / "docs" / "recipe_terragrunt.md" + +CLUSTER_NAME = "tunstrap-e2e" +# kind derives the container name from the cluster name. That name is both the +# SSH forward target and the expected tls_server_name, which is why the cluster +# name is fixed rather than randomised. +CONTROL_PLANE = f"{CLUSTER_NAME}-control-plane" +NODE_IMAGE = "kindest/node:v1.34.0" +COMPOSE_FILE = HERE / "docker-compose.yml" +IN_NODE_KUBECONFIG = "/etc/kube/admin.conf" + +# The proxy under test: the shipped ``tunstrap_tofu`` console entry, invoked by +# name (it is on PATH — e2e_preflight fails the tier if either tunstrap or +# tunstrap_tofu is absent). Replaces the consumer shell shim the tier used to +# drive; the proxy is the documented path now (see docs/recipe_terragrunt.md). +TOFU_PROXY = "tunstrap_tofu" + +# The --output-var negative control: a test-only shell script that runs +# ``tunstrap run`` WITHOUT --output-var, so var.tunstrap keeps its "" default and +# the providers take their inert branch. Earns its keep independently of the +# main proxy — it is the proof that an apply without --output-var fails, i.e. +# that the decoded config_path is the only route to the cluster. Never shipped +# to a consumer; test-only. +CONTROL_SHIM = HERE / "shim" / "tofu-tunstrap-novar" + + +def extract_labeled_blocks(markdown: str) -> dict[str, str]: + """Every labelled fenced block in ``markdown``, as {tag: body}. + + A block is ```` ``` ```` ... ```` ``` ```` - the label is the + second whitespace token of the fence's info string (the language is the + first, and is ignored: `hcl`, `sh`, `json` are all accepted). GitHub renders + the block by its language and ignores the rest of the info string, so the + document stays readable; the label exists only so a test can pull the exact + snippet a reader sees. A snippet that loses its label - or an editor who + strips it - simply disappears from this map, and the caller fails naming the + missing tag rather than silently passing. + + An opening fence with no closing fence is *not* captured: a stray unterminated + fence at end-of-document would otherwise swallow every line after it as a + "block", masking a real truncation. Tags must be unique. + + Shared by test_recipe_terragrunt and test_terragrunt_apply, so the document + is the single source of truth across both. + """ + blocks: dict[str, str] = {} + lines = markdown.splitlines() + n = len(lines) + i = 0 + while i < n: + stripped = lines[i].lstrip() + if stripped.startswith("```"): + info = stripped[3:].strip() + tokens = info.split() + i += 1 + body_start = i + closed = False + while i < n: + if lines[i].lstrip().startswith("```"): + closed = True + break + i += 1 + if closed and len(tokens) >= 2: + tag = tokens[1] + if tag in blocks: + pytest.fail(f"recipe has duplicate ```{tokens[0]} {tag}``` fenced block") + blocks[tag] = "\n".join(lines[body_start:i]) + i += 1 + return blocks + + +def strip_comments(text: str) -> str: + """The executable lines of ``text`` - comments and blanks removed. + + Comments and blank lines carry prose, not logic; stripping them lets a drift + guard compare what a snippet *does* across two sources whose prose differs. + Shared by the module drift guard in test_recipe_terragrunt. + """ + return "\n".join( + line for line in text.splitlines() if line.strip() and not line.lstrip().startswith("#") + ) + + +def skip_or_fail(reason: str) -> NoReturn: + """Skip locally, fail when TUNSTRAP_E2E_REQUIRE_ALL=1. + + A missing external tool is an environment fact, not a product failure, so on + a workstation it is a skip naming the tool. In CI the job installs every + tool itself, so a skip there means the job reports green while most of the + tier never ran - which is exactly how a cluster tier rots into decoration. + The e2e job sets TUNSTRAP_E2E_REQUIRE_ALL=1, which turns every such skip + into a failure. + """ + if os.environ.get("TUNSTRAP_E2E_REQUIRE_ALL") == "1": + pytest.fail(reason + " [TUNSTRAP_E2E_REQUIRE_ALL=1: skipping is not allowed here]") + pytest.skip(reason) + + +def require_tools(*names: str) -> None: + """Skip (or, in CI, fail) unless every named binary is on PATH. + + ``tunstrap`` is deliberately not handled here - see ``e2e_preflight`` in + conftest.py, where it is always a failure. + """ + missing = [name for name in names if shutil.which(name) is None] + if missing: + skip_or_fail("e2e tier requires " + ", ".join(missing) + " on PATH") + + +def kubectl_in_node(*args: str) -> subprocess.CompletedProcess[str]: + """Run kubectl *inside* the kind control-plane container. + + Deliberately an independent oracle. It does not traverse the tunnel, so a + broken tunnel can never make a read-back appear to succeed, and it uses the + version-matched kubectl shipped in the node image rather than whatever the + host happens to have installed. + """ + return subprocess.run( + [ + "docker", + "exec", + CONTROL_PLANE, + "kubectl", + "--kubeconfig", + "/etc/kubernetes/admin.conf", + *args, + ], + capture_output=True, + text=True, + check=False, + ) + + +def tunstrap_input_json(rig: dict[str, Any], *, materialize: bool | None = None) -> str: + """The InputSchema the shim reads from TUNSTRAP_INPUT. + + The node key is ``node`` and the kube-target key is ``k3s`` because + ``module/main.tf`` decodes ``nodes.node.kube.k3s.path``. + + ``materialize`` is omitted by default, on purpose. ``run`` forces + ``daemon.materialize = True`` on an --input-env payload ("the one place run + mutates the supplied schema"), so leaving it out keeps that invariant + load-bearing for the whole tier: if the forcing were ever removed, ``path`` + would come back null, ``config_path`` would be empty, and every provider + test would fail. ``start`` does *not* force it, so the one test that drives + ``start`` directly passes ``materialize=True`` explicitly. + """ + daemon: dict[str, Any] = {"auto_stop_idle_seconds": 300} + if materialize is not None: + daemon["materialize"] = materialize + return json.dumps( + { + "nodes": { + "node": { + "host": rig["host"], + "port": rig["port"], + "user": rig["user"], + "ssh_pkey": rig["private_pem"], + "kube_targets": {"k3s": {"kubeconfig_path": rig["kubeconfig_in_node_path"]}}, + } + }, + "daemon": daemon, + } + ) + + +def tofu_env( + module_dir: Path, + cache: Path, + *, + extra: dict[str, str] | None = None, +) -> dict[str, str]: + """A hermetic environment for one tofu invocation. + + KUBECONFIG is removed and HOME is redirected to a scratch directory on + purpose: an ambient kubeconfig, or an operator's ~/.kube/config, would let a + broken TF_VAR_tunstrap -> config_path chain still reach a cluster. That is + the silent pass this whole tier exists to prevent. This scrubs the *parent* + environment; the proxy's own `suppress_kubeconfig` scrubs the one `run` + injects. + """ + env = dict(os.environ) + env.pop("KUBECONFIG", None) + env.pop("TUNSTRAP_INPUT", None) + scratch_home = module_dir.parent / "home" + scratch_home.mkdir(exist_ok=True) + env["HOME"] = str(scratch_home) + env["TF_DATA_DIR"] = str(module_dir / ".terraform") + env["TF_PLUGIN_CACHE_DIR"] = str(cache) + env["TF_IN_AUTOMATION"] = "1" + env["TF_INPUT"] = "0" + if extra: + env.update(extra) + return env + + +def write_tofu_recorder(bin_dir: Path, dump_dir: Path) -> Path: + """Install a `tofu` on PATH that records argv + env, then execs the real one. + + Recording *and* exec'ing - rather than faking - means the environment the + test asserts on is the environment of the invocation that actually talked to + the cluster, not a stand-in for it. + + `env -0` is used rather than `env` because NUL separation is unambiguous for + values containing newlines; a line-oriented dump could be misread. + + For `init` invocations the dumped environment carries `TUNSTRAP_INPUT`, + i.e. the generated (test-only) SSH private key, so `dump_dir` and the + dumps written into it are locked to owner-only - matching the 0700/0600 + the production session paths already use + (``tunstrap/session.py::atomic_write``) rather than + the default umask. + """ + real = shutil.which("tofu") + if real is None: # pragma: no cover - require_tools ran first + skip_or_fail("e2e tier requires tofu on PATH") + bin_dir.mkdir(parents=True, exist_ok=True) + dump_dir.mkdir(parents=True, exist_ok=True) + dump_dir.chmod(0o700) + script = bin_dir / "tofu" + script.write_text( + "#!/bin/sh\n" + f'dump="{dump_dir}/$$"\n' + 'printf "%s\\n" "$@" > "$dump.argv"\n' + 'chmod 600 "$dump.argv"\n' + 'env -0 > "$dump.env0"\n' + 'chmod 600 "$dump.env0"\n' + f'exec "{real}" "$@"\n' + ) + script.chmod(0o755) + return script + + +def write_fake_tofu( + bin_dir: Path, + marker_dir: Path, + *, + exit_code: int, + stdout_line: str, +) -> Path: + """Install a fake `tofu`: record argv, print one fixed line, exit `exit_code`. + + Deterministic by construction - one fixed line, no timestamps, no + environment echo - because the stdout-purity assertion compares its bytes + across two runs. + + The line is emitted with ``printf '%s\\n'`` and shell-quoted via + ``shlex.quote``: the previous ``printf "{line}\\n"`` baked the line into a + printf *format string*, so a ``%`` was read as a directive, a ``"`` broke the + quoting, and a ``\\`` was an escape. Every prior call happened to pass a line + free of those bytes, so the latent defect never surfaced. The stdout-purity + task is the one that has to push adversarial bytes through the stream, so the + emitter was hardened rather than the test data tamed. + """ + bin_dir.mkdir(parents=True, exist_ok=True) + marker_dir.mkdir(parents=True, exist_ok=True) + script = bin_dir / "tofu" + script.write_text( + "#!/bin/sh\n" + f'printf "%s\\n" "$@" > "{marker_dir}/$$.argv"\n' + f"printf '%s\\n' {shlex.quote(stdout_line)}\n" + f"exit {exit_code}\n" + ) + script.chmod(0o755) + return script + + +def read_env_dump(path: Path) -> dict[str, str]: + """Parse an `env -0` dump. NUL-separated, so any value is safe.""" + result: dict[str, str] = {} + for chunk in path.read_bytes().split(b"\0"): + if not chunk: + continue + key, _sep, value = chunk.partition(b"=") + result[key.decode()] = value.decode() + return result + + +def collect_tofu_invocations(dump_dir: Path) -> list[tuple[list[str], dict[str, str]]]: + """Every recorded tofu invocation as (argv, env), oldest first.""" + invocations: list[tuple[list[str], dict[str, str]]] = [] + for env_path in sorted(dump_dir.glob("*.env0"), key=lambda p: p.stat().st_mtime): + argv = env_path.with_suffix(".argv").read_text().splitlines() + invocations.append((argv, read_env_dump(env_path))) + return invocations + + +def recorded_argvs(marker_dir: Path) -> list[list[str]]: + """Every fake-tofu invocation's argv, oldest first.""" + return [ + path.read_text().splitlines() + for path in sorted(marker_dir.glob("*.argv"), key=lambda p: p.stat().st_mtime) + ] + + +def wait_for_namespace_gone(name: str, timeout: float = 120.0) -> None: + """Block until the named Namespace is really gone, via the in-node oracle.""" + deadline = time.monotonic() + timeout + last = "" + while time.monotonic() < deadline: + probe = kubectl_in_node("get", "namespace", name, "-o", "name") + if probe.returncode != 0 and "not found" in probe.stderr.lower(): + return + last = f"rc={probe.returncode} stdout={probe.stdout!r} stderr={probe.stderr!r}" + time.sleep(2.0) + pytest.fail(f"namespace {name!r} still present after {timeout:.0f}s: {last}") diff --git a/tests/e2e/shim/tofu-tunstrap-novar b/tests/e2e/shim/tofu-tunstrap-novar new file mode 100755 index 0000000..2e6eece --- /dev/null +++ b/tests/e2e/shim/tofu-tunstrap-novar @@ -0,0 +1,24 @@ +#!/bin/sh +# NEGATIVE CONTROL - test-only, never shipped to a consumer. +# +# Runs `tunstrap run` WITHOUT --output-var, so var.tunstrap keeps its "" default, +# local.kubepath is "", and the providers take their inert branch - so an apply +# through this control MUST fail. If it succeeds, something other than the +# decoded config_path is reaching the cluster and every positive assertion in +# this tier is worthless. That is why `env -u KUBECONFIG` is still here: the +# main proxy removes KUBECONFIG in-process (suppress_kubeconfig); this control +# drives `tunstrap run` directly (no --output-var to suppress), so it scrubs +# KUBECONFIG on the child command line for the same reason - a broken chain must +# fail, not silently reach the cluster through the injected KUBECONFIG. +# +# This is the --output-var negative control. It is independent of the main +# tunstrap_tofu proxy (which always sets --output-var) and stays regardless of +# the proxy migration. There is no longer a "main" shell shim to be +# byte-identical to; the proxy is the shipped tunstrap_tofu entry point. + +[ -n "$TUNSTRAP_INPUT" ] || exec tofu "$@" + +case "$1" in init|-version) exec tofu "$@" ;; esac + +exec tunstrap run --input-env TUNSTRAP_INPUT \ + -- env -u KUBECONFIG tofu "$@" diff --git a/tests/e2e/test_recipe_terragrunt.py b/tests/e2e/test_recipe_terragrunt.py new file mode 100644 index 0000000..556ba04 --- /dev/null +++ b/tests/e2e/test_recipe_terragrunt.py @@ -0,0 +1,339 @@ +"""The Terragrunt recipe: its published HCL must decode, and its published +snippets must not drift from what the e2e tier drives. + +Code: docs/recipe_terragrunt.md. +Method: extract the labelled fenced blocks straight out of the document and +- drive the Terragrunt blocks through real `terragrunt hcl validate` / `render` + (schema decode + terraform_binary resolution), exercising BOTH sides of the + unit's env_vars ternary; +- check the module-side snippet against the driven module file. +The document is the source of truth; the tests hold no retyped copy. Mirrors the +AST guard in test_rig.py. +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +from tests.e2e.rig import ( + HERE, + RECIPE_MD, + extract_labeled_blocks, + require_tools, + strip_comments, +) + +pytestmark = [pytest.mark.e2e] + +RECIPE = RECIPE_MD +MODULE_MAIN_TF = HERE / "module" / "main.tf" + + +def _consumer_repo(tmp_path: Path) -> Path: + """A bare consumer working tree for one recipe-block check. + + `terragrunt hcl validate` and `render` evaluate static HCL and need no git + repo (verified). Earlier this helper was git-init'd to make + ``${get_repo_root()}`` resolve, but the recipe's root block no longer uses + that expression (it is a literal path or a run_cmd), so neither git nor a + commit is required. Kept as a helper for shape parity with a real consumer + directory. + """ + repo = tmp_path / "consumer" + repo.mkdir() + return repo + + +def _uncomment(block: str) -> str: + """Strip a leading ``#`` and one space from each line of a commented block. + + The recipe's ``terragrunt-locals`` block is a commented-out ``locals {...}`` + example (it reads as guidance, not live config). Uncommenting yields the HCL a + consumer would write. Applied per-line so indentation survives and only the + comment marker goes. + """ + return "\n".join( + line[2:] if line.startswith("# ") else line.removeprefix("#") for line in block.splitlines() + ) + + +def test_root_block_points_terraform_binary_at_the_proxy(tmp_path: Path) -> None: + """Both root-block forms in the recipe are valid and reach tunstrap_tofu. + + The recipe carries TWO labelled root fences: ``terragrunt-root`` (the literal- + path default) and ``terragrunt-root-runcmd`` (the optional run_cmd form). + The literal fence is validated statically (it carries a consumer-specific + placeholder, so it cannot render to a real path); the run_cmd fence is + RENDERED and must resolve to the actual installed ``tunstrap_tofu`` on PATH, + executable - a strictly stronger pin than the literal allows, and the proof + that the recipe's run_cmd form works. hcl validate also catches a misplaced + terraform_binary (back inside terraform{}, which TG rejects with "An argument + named terraform_binary is not expected here"). (The verbatim red for the + misplaced case is in the SDD report for the original pin.) + """ + require_tools("terragrunt") + installed = shutil.which("tunstrap_tofu") + path_msg = "tunstrap_tofu not on PATH; e2e_preflight should have failed the tier" + assert installed is not None, path_msg + blocks = extract_labeled_blocks(RECIPE.read_text()) + for label in ("terragrunt-root", "terragrunt-root-runcmd"): + assert label in blocks, f"recipe is missing its ```hcl {label}``` block" + + # Literal fence: static validation only (its path is a placeholder). + repo = tmp_path / "literal" + repo.mkdir() + (repo / "terragrunt.hcl").write_text(blocks["terragrunt-root"]) + root_validate = subprocess.run( + ["terragrunt", "hcl", "validate", "--working-dir", str(repo)], + capture_output=True, + text=True, + check=False, + ) + root_detail = "literal root terragrunt.hcl failed schema validation:\n" + root_detail += f"{root_validate.stderr}{root_validate.stdout}" + assert root_validate.returncode == 0, root_detail + + # run_cmd fence: render must resolve to the real installed tunstrap_tofu. + repo2 = tmp_path / "runcmd" + repo2.mkdir() + (repo2 / "terragrunt.hcl").write_text(blocks["terragrunt-root-runcmd"]) + rendered = subprocess.run( + ["terragrunt", "render", "--config", "terragrunt.hcl", "--format", "json"], + cwd=repo2, + capture_output=True, + text=True, + check=False, + ) + render_detail = ( + f"run_cmd root terragrunt.hcl failed to render:\n{rendered.stderr}{rendered.stdout}" + ) + assert rendered.returncode == 0, render_detail + resolved = json.loads(rendered.stdout)["terraform_binary"] + resolved_msg = ( + f"terraform_binary resolved to {resolved!r}, expected the installed {installed!r}" + ) + assert resolved == installed, resolved_msg + exec_msg = f"terraform_binary resolved to {resolved!r}, which is not an executable file" + assert Path(resolved).is_file() and os.access(resolved, os.X_OK), exec_msg + + +def test_the_run_cmd_marker_is_load_bearing_for_terragrunt_output_json( + tmp_path: Path, +) -> None: + """The recipe's ``--terragrunt-quiet`` first arg to run_cmd keeps output clean. + + The recipe's run_cmd option documents ``--terragrunt-quiet`` as load-bearing: + it is the FIRST ARGUMENT TO run_cmd (which consumes it to suppress logging the + command's output), NOT a terragrunt CLI flag (which does not exist in v1.1.1 - + a round-trip was spent confusing the two). Without it, run_cmd prepends the + resolved path to every ``terragrunt output -json`` and the JSON no longer + parses - the same shape as ``env -u KUBECONFIG`` in the old shim (drop the + incantation, fail somewhere unrelated). + + Uses ``command -v tofu`` (not the proxy) to isolate run_cmd's marker + behaviour from tunnelling; the marker is a run_cmd property independent of + which command it wraps. Needs no cluster - one trivial output + apply. + """ + require_tools("terragrunt", "tofu") + repo = tmp_path / "repo" + repo.mkdir() + mod = repo / "mod" + mod.mkdir() + (mod / "main.tf").write_text('output "x" { value = "hello" }\n') + env = dict(os.environ) + env.pop("TUNSTRAP_INPUT", None) + + def write_root(*, with_marker: bool) -> None: + marker = '"--terragrunt-quiet", ' if with_marker else "" + (repo / "terragrunt.hcl").write_text( + 'terraform { source = "./mod" }\n' + f'terraform_binary = run_cmd({marker}"sh", "-c", "command -v tofu")\n' + ) + + # Build state once (with the marker, so apply is clean). + write_root(with_marker=True) + applied = subprocess.run( + ["terragrunt", "apply", "-auto-approve"], cwd=repo, env=env, capture_output=True, text=True + ) + assert applied.returncode == 0, f"apply failed:\n{applied.stdout}{applied.stderr}" + + # WITH marker: output -json parses to the expected value. + write_root(with_marker=True) + with_marker = subprocess.run( + ["terragrunt", "output", "-json"], cwd=repo, env=env, capture_output=True, text=True + ) + assert with_marker.returncode == 0, with_marker.stderr + assert json.loads(with_marker.stdout)["x"]["value"] == "hello" + + # WITHOUT marker: the resolved path is prepended -> the JSON does not parse. + write_root(with_marker=False) + no_marker = subprocess.run( + ["terragrunt", "output", "-json"], cwd=repo, env=env, capture_output=True, text=True + ) + no_marker_lines = no_marker.stdout.splitlines() + leak_msg = ( + f"expected the resolved tofu path prepended to stdout without the marker; " + f"got {len(no_marker_lines)} line(s): {no_marker.stdout[:120]!r}" + ) + assert no_marker_lines and no_marker_lines[0].endswith("/tofu"), leak_msg + with pytest.raises(json.JSONDecodeError): + json.loads(no_marker.stdout) + + +def test_recipe_install_fence_tells_the_consumer_to_install(tmp_path: Path) -> None: + """The recipe's install fence points at the package, not a copied file. + + Re-aims the drift guard that used to pin the recipe's ```sh tofu-shim``` + snippet byte-identical to tests/e2e/shim/tofu-tunstrap. That shim is retired + (tunstrap_tofu replaces it), so the pin now guards what the recipe tells a + consumer to DO to get the proxy: a ```sh install``` fence whose command + installs the package (yielding both tunstrap and tunstrap_tofu). Fails-when- + broken: the fence is removed, renamed, or stops installing the package (e.g. + reverts to a `cp bin/tofu-tunstrap` instruction), and the guard catches it. + """ + del tmp_path + blocks = extract_labeled_blocks(RECIPE.read_text()) + missing_install = ( + "recipe is missing its ```sh install``` block - the label that pins the " + "install instruction is gone (it replaced the retired ```sh tofu-shim```)" + ) + assert "install" in blocks, missing_install + install_cmd = blocks["install"] + uv_msg = f"install fence no longer uses `uv tool install`: {install_cmd!r}" + assert "uv tool install" in install_cmd, uv_msg + pkg_msg = f"install fence no longer installs the tunstrap package: {install_cmd!r}" + assert "tunstrap" in install_cmd, pkg_msg + + +def test_shell_shim_alt_fence_is_valid_and_bypasses(tmp_path: Path) -> None: + """The labelled alternative shell shim pastes, parses, and bypasses. + + The recipe's ```sh tofu-shim-alt``` fence is the one artifact a consumer can + still paste verbatim (the lower-overhead alternative to ``tunstrap_tofu``), + so it is pinned rather than left unlabelled: ``sh -n`` proves it parses, and a + bypass smoke test (the shim, a fake ``tofu`` first on PATH, ``TUNSTRAP_INPUT`` + unset) proves the fast-path pass-through actually reaches ``tofu``. Needs no + cluster - just ``sh`` (always present). + """ + blocks = extract_labeled_blocks(RECIPE.read_text()) + missing = ( + "recipe is missing its ```sh tofu-shim-alt``` block - the labelled alternative shim is gone" + ) + assert "tofu-shim-alt" in blocks, missing + snippet = blocks["tofu-shim-alt"] + + shim = tmp_path / "tofu-tunstrap" + shim.write_text(snippet) + shim.chmod(0o755) + syntax = subprocess.run(["sh", "-n", str(shim)], capture_output=True, text=True) + assert syntax.returncode == 0, f"tofu-shim-alt fails sh -n: {syntax.stderr}" + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake = bin_dir / "tofu" + fake.write_text('#!/bin/sh\nprintf "FAKE:%s\\n" "$1"\nexit 0\n') + fake.chmod(0o755) + env = dict(os.environ) + env["PATH"] = f"{bin_dir}:{env['PATH']}" + env.pop("TUNSTRAP_INPUT", None) + + result = subprocess.run([str(shim), "plan"], env=env, capture_output=True, text=True) + detail = f"shim-alt bypass smoke failed: rc={result.returncode} stdout={result.stdout!r} stderr={result.stderr!r}" + assert result.returncode == 0, detail + assert result.stdout == "FAKE:plan\n", detail + + +@pytest.mark.parametrize("empty_host", [False, True], ids=["host-set", "host-empty"]) +def test_unit_block_decodes_both_ternary_sides(empty_host: bool, tmp_path: Path) -> None: + """The recipe's unit block decodes on BOTH sides of the env_vars ternary. + + The env_vars map is `local.cluster_host != "" ? { TUNSTRAP_INPUT = jsonencode(...) } : {}`. + hcl validate evaluates only the taken branch, so to force the + jsonencode branch (every consumer-visible key: nodes, kube_targets, daemon) + the host must be non-empty. The earlier pin set cluster_host="" and so only + ever decoded the inert `{}` side. Both params now decode. + + The locals come from the recipe's own commented ``terragrunt-locals`` example + (uncommented) - not a non-recipe stub: the recipe documents the shape and + this enforces it. For host-empty the example's host is zeroed via a + value-agnostic regex, so the placeholder can change without breaking the test. + + Fails-when-broken (host-set): a malformed key in the jsonencode branch - e.g. + a projection field rename - fails hcl validate. (host-empty): a broken + ternary or undefined local fails the same way. + """ + require_tools("terragrunt") + blocks = extract_labeled_blocks(RECIPE.read_text()) + missing_unit = ( + "recipe is missing its ```hcl terragrunt-unit``` block - the label that " + "pins the extra_arguments snippet is gone" + ) + missing_locals = ( + "recipe is missing its ```hcl terragrunt-locals``` block - the commented " + "locals example that lets this test drop its non-recipe stub is gone" + ) + assert "terragrunt-unit" in blocks, missing_unit + assert "terragrunt-locals" in blocks, missing_locals + + locals_block = _uncomment(blocks["terragrunt-locals"]) + if empty_host: + locals_block = re.sub(r'(cluster_host\s*=\s*)"[^"]*"', r'\1""', locals_block) + + # The unit block carries `include "root" { path = find_in_parent_folders(...) }`, + # so validate it as a real unit under a root.hcl parent (the recipe's shape), + # not a standalone file - otherwise the include has nothing to find. + repo = _consumer_repo(tmp_path) + (repo / "root.hcl").write_text( + "# parent config; contents irrelevant to unit-block validation\n" + ) + unit = repo / "unit" + unit.mkdir() + (unit / "terragrunt.hcl").write_text(f"{locals_block}\n\n{blocks['terragrunt-unit']}") + validated = subprocess.run( + ["terragrunt", "hcl", "validate", "--working-dir", str(unit)], + capture_output=True, + text=True, + check=False, + ) + side = "host-empty (inert `{}` branch)" if empty_host else "host-set (jsonencode branch)" + detail = f"unit terragrunt.hcl ({side}) failed schema validation:\n" + detail += f"{validated.stderr}{validated.stdout}" + assert validated.returncode == 0, detail + + +def test_module_snippet_matches_the_driven_module() -> None: + """The recipe's module snippet does not drift from tests/e2e/module/main.tf. + + The recipe's ```hcl tf-module``` block is the provider-wiring excerpt a + consumer copies; main.tf is the file the e2e tier drives. They are not + byte-identical (main.tf adds resources + output, and the comments were + written separately), so compare executable lines: every logic line in the + snippet must appear in the driven module. A projection field rename in either + source breaks this. + + Fails-when-broken: edit a logic line in the snippet (e.g. rename + kube_targets.k3s.path) without matching main.tf and that line is reported + missing. Reuses strip_comments (the shim drift-guard helper), extending that + idiom rather than adding a new one. Needs no tools - pure text comparison. + """ + blocks = extract_labeled_blocks(RECIPE.read_text()) + missing_module = ( + "recipe is missing its ```hcl tf-module``` block - the label that pins " + "the module-side snippet is gone" + ) + assert "tf-module" in blocks, missing_module + + recipe_logic = strip_comments(blocks["tf-module"]).splitlines() + driven_set = set(strip_comments(MODULE_MAIN_TF.read_text()).splitlines()) + missing = [line for line in recipe_logic if line not in driven_set] + drift = f"recipe ```hcl tf-module``` logic lines missing from {MODULE_MAIN_TF}:\n" + "\n".join( + missing + ) + assert not missing, drift diff --git a/tests/e2e/test_rig.py b/tests/e2e/test_rig.py new file mode 100644 index 0000000..621a084 --- /dev/null +++ b/tests/e2e/test_rig.py @@ -0,0 +1,516 @@ +"""The e2e rig itself: self-containment, and real kube API traffic through it. + +Code: tests/e2e/conftest.py, tests/e2e/docker-compose.yml. +Method: inspect the generated key material, then drive `tunstrap start` against +the kind cluster and talk to the API server through the resulting tunnel. +""" + +from __future__ import annotations + +import ast +import json +import socket +import subprocess +import threading +import time +from pathlib import Path +from typing import Any + +import pytest + +from tests.e2e import conftest +from tests.e2e.rig import ( + CLUSTER_NAME, + COMPOSE_FILE, + CONTROL_PLANE, + HERE, + kubectl_in_node, + recorded_argvs, + require_tools, + tunstrap_input_json, + write_fake_tofu, + write_tofu_recorder, +) + +pytestmark = [pytest.mark.e2e] + + +def test_rig_generates_its_own_keypair(e2e_ssh_keypair: tuple[str, str]) -> None: + """The e2e suite owns its key material and never reads the integration rig's.""" + private_pem, public_line = e2e_ssh_keypair + assert private_pem.startswith("-----BEGIN OPENSSH PRIVATE KEY-----") + assert public_line.startswith("ssh-ed25519 ") + + priv_path = HERE / "_keys" / "id_test" + assert priv_path.is_file() + assert priv_path.stat().st_mode & 0o777 == 0o600 + assert (HERE / "_keys" / "id_test.pub").is_file() + + +def test_rig_borrows_nothing_from_another_suite(e2e_ssh_keypair: tuple[str, str]) -> None: + """No cross-suite import, no cross-suite mount, and our own key material.""" + del e2e_ssh_keypair # requested so the keypair exists before we assert on it + + # Parsed as an AST, not scanned as text. A substring search for the + # forbidden path would match this test's own source - its docstring, its + # own condition, and rig.py's module docstring all name that path in prose - + # and could therefore never pass. Import statements are the thing that + # actually creates a code dependency, and they are unambiguous in an AST. + foreign: set[str] = set() + for module in sorted(HERE.glob("*.py")): + for node in ast.walk(ast.parse(module.read_text())): + if isinstance(node, ast.Import): + foreign.update( + alias.name + for alias in node.names + if alias.name.startswith("tests") and not alias.name.startswith("tests.e2e") + ) + elif isinstance(node, ast.ImportFrom): + if node.level >= 2: + foreign.add("." * node.level + (node.module or "")) + elif ( + node.module + and node.module.startswith("tests") + and not node.module.startswith("tests.e2e") + ): + foreign.add(node.module) + assert not foreign, f"e2e modules importing another suite: {sorted(foreign)}" + + # Every bind mount is relative to this directory. Matching on ":/" catches + # any host:container pair, including one written as ../integration/_keys, + # which a "starts with ./" filter would silently skip. + compose = (HERE / "docker-compose.yml").read_text() + lines = [line.strip() for line in compose.splitlines()] + mounts = sorted(line[2:] for line in lines if line.startswith("- ") and ":/" in line) + assert mounts == [ + "./_keys:/keys:ro", + "./_kube:/etc/kube:ro", + "./_sshd_conf:/config/sshd/sshd_config.d:ro", + ], mounts + + # ...and the three things those mounts point at are ours. + assert (HERE / "_keys" / "id_test").is_file() + assert (HERE / "_keys" / "id_test.pub").is_file() + assert (HERE / "_sshd_conf" / "allow_tcpfwd.conf").is_file() + + +def test_sshd_forwarding_dropin_is_tracked_and_correct() -> None: + """The drop-in that turns AllowTcpForwarding on is committed, not generated.""" + dropin = HERE / "_sshd_conf" / "allow_tcpfwd.conf" + assert dropin.read_text().strip() == "AllowTcpForwarding yes" + + compose = (HERE / "docker-compose.yml").read_text() + assert "./_sshd_conf:/config/sshd/sshd_config.d:ro" in compose + assert "./_keys:/keys:ro" in compose + assert "./_kube:/etc/kube:ro" in compose + + +def test_generated_rig_paths_are_gitignored() -> None: + """_keys/ and _kube/ never enter the index; _sshd_conf/ always does.""" + repo_root = Path(__file__).resolve().parents[2] + tracked = subprocess.run( + ["git", "ls-files", "tests/e2e"], + cwd=repo_root, + capture_output=True, + text=True, + check=True, + ).stdout.split() + assert "tests/e2e/_sshd_conf/allow_tcpfwd.conf" in tracked + assert not [p for p in tracked if p.startswith("tests/e2e/_keys/")] + assert not [p for p in tracked if p.startswith("tests/e2e/_kube/")] + + +def test_cluster_node_is_ready_through_the_independent_oracle(kind_cluster: str) -> None: + """The in-node kubectl oracle reaches the API server without any tunnel. + + Assertion audit - what each line can actually catch: + - `returncode`/`stdout`: BEHAVIOURAL, but weakly so. Re-runs the oracle live, + so it catches a control plane that died between fixture setup and now, and + it catches `kindest/node` dropping the bundled kubectl - which would + silently disable this tier's only independent read-back path. It is weakly + self-referential about the *name*: CONTROL_PLANE is both the `docker exec` + target and the expected value, so a rename moves both sides together. What + survives is a real pin on kind's convention that the node name equals the + container name, which `kube_targets` depends on. + - `kind_cluster == CLUSTER_NAME`: PIN, not behavioural. The fixture yields + that constant, so this cannot fail against a broken cluster. Kept because + that name is also the SSH forward target and the expected tls_server_name, + so it documents the coupling at the point of use. + - the `wait` probe: BEHAVIOURAL, and the only line here that makes this + test's name true. + """ + probe = kubectl_in_node("get", "nodes", "-o", "name") + assert probe.returncode == 0, probe.stderr + assert probe.stdout.strip() == f"node/{CONTROL_PLANE}" + assert kind_cluster == CLUSTER_NAME + + # `get nodes -o name` prints the node whatever its condition, so nothing + # above distinguishes Ready from NotReady. Readiness is really enforced by + # `kind create --wait 90s` + check=True in the fixture; this asserts it + # directly so the test's name is earned rather than assumed. --timeout=0 + # means "check once and do not wait", so a NotReady node fails immediately + # instead of hanging. + ready = kubectl_in_node("wait", "--for=condition=Ready", "node", "--all", "--timeout=0") + assert ready.returncode == 0, ready.stderr + + +def test_in_node_kubeconfig_has_the_shape_the_tunnel_flow_depends_on( + node_kubeconfig: Path, +) -> None: + """admin.conf names the control plane by DNS and embeds CA + client creds. + + Assertion audit - two of these five are deliberate belt-and-braces, not + behavioural checks, and saying so is the point: + - `server:` line: PIN. The fixture already `pytest.fail`s on this exact + substring and then writes that same string to the file, so it is strictly + implied and cannot fire while the fixture is as it is. Kept as a + regression guard on the *fixture*: if someone later rewrites the file, or + drops the fixture's check, this catches it here where the dependency is + documented. The fixture's own failure message is the better diagnostic. + - mode 0644: PIN, for the same reason - the fixture chmods 0644 + unconditionally with an absolute mode, so no fixture-produced file can + violate this. Kept because 0600 would break the unprivileged `tester` user + reading it over SSH, which is otherwise invisible until Task 2.4 fails + obscurely. + - the three `-data:` assertions: BEHAVIOURAL. Nothing in the fixture looks at + them, so they pin upstream kubeconfig content: if kind ever emitted + external credentials (an exec plugin, or a `client-key` path instead of + embedded data), `parse_kubeconfig` would raise and these fail first, + naming the reason. + """ + text = node_kubeconfig.read_text() + assert f"server: https://{CONTROL_PLANE}:6443" in text # pin (see docstring) + assert "certificate-authority-data:" in text + assert "client-certificate-data:" in text + assert "client-key-data:" in text + assert node_kubeconfig.stat().st_mode & 0o777 == 0o644 # pin (see docstring) + + +def test_rig_publishes_a_dynamic_port_and_accepts_the_generated_key( + kube_rig: dict[str, Any], +) -> None: + """The rig hands back a live, authenticated SSH endpoint on a random port. + + Assertion audit - what actually carries weight here: + - The strongest check is *implicit*: reaching the body at all means + `kube_rig` completed, and `kube_rig` does not yield until `_wait_for_ssh` + has run a real authenticated `echo ssh-ready` over SSH. A dead or + unauthenticated sshd errors this test in setup, before any assert runs. + Measured against a container holding a decoy key: the probe failed with + "never accepted an authenticated SSH exec ... within 6s" while a plain TCP + connect to the same port succeeded. + - `port != 2222`: BEHAVIOURAL, and the load-bearing assertion of the body. + Compose publishes "127.0.0.1::2222", i.e. a random host port, so a fixture + that assumed 2222 would connect to nothing - or to an unrelated local + service - and every downstream tunnelling test would fail with an opaque + SSH error. Observed ports across runs: 33161, 33162 (ephemeral range). + - `port > 1024`: BEHAVIOURAL (weak). Catches a parse that yielded 0 or a + negative from `docker compose port` output. + - `kubeconfig_in_node_path == "/etc/kube/admin.conf"`: BEHAVIOURAL as a + cross-file pin. The fixture yields the IN_NODE_KUBECONFIG constant while + this compares against the literal, so changing the constant without + changing docker-compose.yml's `./_kube:/etc/kube:ro` mount fails here. + - `host`, `user`, `control_plane`: PINS. The fixture yields those literals + and constants, so they cannot fail against a broken rig. Kept because they + are the exact tuple Task 2.5 and every Phase 4/5 tunnelling test consume. + - `docker compose ps` contains "sshd-kube": BEHAVIOURAL. Separates "port + discovery is wrong" from "the container is dead". + """ + assert kube_rig["host"] == "127.0.0.1" + assert kube_rig["port"] > 1024 + assert kube_rig["port"] != 2222 + assert kube_rig["user"] == "tester" + assert kube_rig["control_plane"] == CONTROL_PLANE + assert kube_rig["kubeconfig_in_node_path"] == "/etc/kube/admin.conf" + + listed = subprocess.run( + ["docker", "compose", "-f", str(COMPOSE_FILE), "ps", "--format", "{{.Name}}"], + capture_output=True, + text=True, + check=True, + ) + assert "sshd-kube" in listed.stdout + + +def test_tunnel_carries_real_kube_api_traffic(kube_rig: dict[str, Any], tmp_path: Path) -> None: + """A tunstrap tunnel to sshd-kube reaches the real API server, warning-free. + + The tier's first-failure gate. Everything before it tests the harness; this + is the first test that pushes real Kubernetes API traffic through a real + tunstrap tunnel, so once it is green a later failure can be attributed to + the feature under test rather than to the rig. + + Assertion audit - every assertion here is behavioural, and each has a + distinct symptom (demonstrated by the assertions below): + - `started.returncode == 0`: fires if the forwarding drop-in is missing or + the service is off kind's network, because the SAN probe itself traverses + the forward during `start` and is refused `administratively prohibited`. + - `warnings == []` and the materialized kubeconfig's `tls-server-name`: + pin the clean, exact-SAN-match branch. A silent downgrade to + insecure-skip-tls-verify would still let kubectl succeed, so without + these the test would pass while the security property it exists to prove + had regressed. + - `endpoint` is a local https URL and `path` is not None: materialization + and patching. `run` forces materialize, `start` does not, hence the + explicit materialize=True here. + - the `kubectl` probe: the actual gate. `node/` distinguishes + "reached the real API server" from "reached something that answered" - a + TLS handshake that terminated anywhere else cannot produce that exact node + name. Proven to fail while `start` still exits 0 by breaking the forward + after a successful start, with the independent in-node oracle staying + green throughout to show the cluster was healthy and the tunnel was not. + """ + # The in-node oracle deliberately uses the node image's own kubectl, so the + # tier needs no host kubectl until here - this is the first consumer of one. + require_tools("kubectl") + + session_dir = str(tmp_path / "session") + started = subprocess.run( + ["tunstrap", "start", "--session-dir", session_dir], + input=tunstrap_input_json(kube_rig, materialize=True), + text=True, + capture_output=True, + check=False, + ) + assert started.returncode == 0, f"stdout={started.stdout!r} stderr={started.stderr!r}" + envelope = json.loads(started.stdout) + try: + assert envelope["warnings"] == [] + target = envelope["connections"]["node"]["kube_targets"]["k3s"] + assert target["endpoint"].startswith("https://127.0.0.1:") + kubeconfig = target["path"] + assert kubeconfig is not None, "materialize=True must yield a path" + assert f"tls-server-name: {CONTROL_PLANE}" in Path(kubeconfig).read_text() + assert set(target) == {"path", "context", "endpoint"} + + probe = subprocess.run( + ["kubectl", "--kubeconfig", kubeconfig, "get", "nodes", "-o", "name"], + capture_output=True, + text=True, + check=False, + ) + assert probe.returncode == 0, probe.stderr + assert probe.stdout.strip() == f"node/{CONTROL_PLANE}" + finally: + subprocess.run( + [ + "tunstrap", + "stop", + "--session-dir", + session_dir, + "--grace-seconds", + "1", + ], + capture_output=True, + text=True, + check=False, + ) + + +def test_ssh_readiness_bounds_each_attempt_and_honours_its_deadline( + e2e_ssh_keypair: tuple[str, str], +) -> None: + """A stalled listener cannot make the readiness poll overrun its deadline. + + Needs no cluster and no Docker. AsyncSSH's default login timeout is 120s - + longer than the probe's own 90s deadline - so an unbounded attempt against a + peer that accepts and then never speaks SSH blocks past the deadline + entirely, and the "within Ns" in the failure message becomes a false claim. + + Fails-when-broken: without a per-attempt bound this takes ~120s for a 4s + deadline and the elapsed assertion fires. It cannot pass vacuously either - + if the probe wrongly reported success against a listener that never sent a + version banner, `pytest.raises` would fail instead. + """ + private_pem, _public_line = e2e_ssh_keypair + + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", 0)) + listener.listen(16) + # Closing a socket does not wake a thread already blocked in accept() on + # Linux, so the loop polls with a timeout and watches a stop flag instead - + # otherwise cleanup would sit out a full join timeout on every run. + listener.settimeout(0.25) + port = int(listener.getsockname()[1]) + held: list[socket.socket] = [] + stop = threading.Event() + + def _accept_and_stay_silent() -> None: + while not stop.is_set(): + try: + conn, _addr = listener.accept() + except TimeoutError: + continue + except OSError: + return + # Deliberately never write an SSH version banner. This is the + # "listening but not speaking" peer that hangs a login. + held.append(conn) + + accepter = threading.Thread(target=_accept_and_stay_silent, daemon=True) + accepter.start() + + deadline_s = 4.0 + try: + started = time.monotonic() + with pytest.raises(pytest.fail.Exception) as excinfo: + conftest._wait_for_ssh(port, private_pem, timeout=deadline_s) + elapsed = time.monotonic() - started + finally: + stop.set() + accepter.join(timeout=5.0) + listener.close() + for conn in held: + conn.close() + + assert f"127.0.0.1:{port}" in str(excinfo.value) + # The claim in the message must be true: it says "within 4s", so it must not + # have taken 120. Slack covers one in-flight attempt plus scheduling. + overran = f"readiness poll overran: claimed {deadline_s:.0f}s, took {elapsed:.1f}s" + assert elapsed < deadline_s + 5.0, overran + + +def _kubeconfig_stub(tmp_path: Path) -> Path: + """A file that satisfies kube_rig's ordering guard without a real cluster.""" + stub = tmp_path / "admin.conf" + stub.write_text("stub\n") + return stub + + +def test_kube_rig_arms_its_teardown_before_compose_up( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A compose up that half-starts and then fails must still be torn down. + + Needs no cluster and no Docker: `subprocess.run` is replaced, so this + exercises the fixture's control flow directly. + + Fails-when-broken: if `try` opens *after* `compose up`, the finally is never + armed, no `down` is issued, and the leaked stack survives the run. That is + the same defect the 2.3 review found in `kind_cluster`. + """ + calls: list[list[str]] = [] + + def fake_run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + calls.append(list(cmd)) + if "up" in cmd: + # Containers may already exist at this point - this is precisely the + # case where teardown matters. + raise subprocess.CalledProcessError(1, cmd) + return subprocess.CompletedProcess(cmd, 0, "", "") + + monkeypatch.setattr(subprocess, "run", fake_run) + + generator = conftest.kube_rig.__wrapped__(("pem", "pub"), _kubeconfig_stub(tmp_path)) + with pytest.raises(subprocess.CalledProcessError): + next(generator) + + assert any("down" in call for call in calls), ( + "compose up failed and no `compose down` followed - the teardown was " + f"never armed. Calls seen: {calls}" + ) + + +def test_kube_rig_reports_a_failed_compose_down( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A teardown that fails must be visible, not silent. + + Fails-when-broken: with `check=False` and no inspection, a failed + `compose down` leaves the stack running and says nothing, so the next run + inherits a dirty rig with no clue why. `pytest.warns` reports DID NOT WARN. + """ + + def fake_run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + if "port" in cmd: + return subprocess.CompletedProcess(cmd, 0, "127.0.0.1:12345\n", "") + if "down" in cmd: + return subprocess.CompletedProcess(cmd, 1, "", "error: network kind is in use") + return subprocess.CompletedProcess(cmd, 0, "", "") + + monkeypatch.setattr(subprocess, "run", fake_run) + monkeypatch.setattr(conftest, "_wait_for_ssh", lambda *a, **k: None) + + generator = conftest.kube_rig.__wrapped__(("pem", "pub"), _kubeconfig_stub(tmp_path)) + rig = next(generator) + assert rig["port"] == 12345 # discovered, not assumed + + with pytest.warns(UserWarning, match="network kind is in use"), pytest.raises(StopIteration): + next(generator) + + +def test_write_fake_tofu_forwards_an_adversarial_stdout_line_byte_identical( + tmp_path: Path, +) -> None: + """write_fake_tofu emits percent, quote and backslash bytes unmangled. + + The emitter once baked the line into a printf *format string*, so a percent + was read as a directive, a double-quote broke the shell quoting, and a + backslash was an escape. Three tasks passed without tripping it because every + line was tame (FAKE_TOFU_RAN, FAKE_TOFU_EXIT_42). The stdout-purity task is + exactly the one that has to push adversarial bytes, so the emitter was + switched to ``printf '%s\\n' ``. + + Fails-when-broken: against the old emitter this line is a shell syntax error + (unbalanced quote), so the script exits non-zero with empty stdout and both + assertions fire. The exact-byte check (not "no percent left over") is what + closes the door on a future emitter that mangles some *other* byte: a novel + contaminant still changes the length or content and fails the equality. + + Needs no cluster and no Docker - the fake script is run directly. + """ + bin_dir = tmp_path / "bin" + marker_dir = tmp_path / "marks" + # One line carrying all three adversarial bytes at once. + line = 'pre%smid"post\\tail' + write_fake_tofu(bin_dir, marker_dir, exit_code=0, stdout_line=line) + + result = subprocess.run( + [str(bin_dir / "tofu"), "ignored-argv"], + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert result.stdout == (line + "\n").encode() + # The fix touched the whole generated script; confirm argv recording survived. + assert recorded_argvs(marker_dir) == [["ignored-argv"]] + + +def test_write_tofu_recorder_locks_down_its_diagnostic_dumps(tmp_path: Path) -> None: + """The 0700/0600 modes the recorder sets are asserted, not merely written. + + For `init` invocations the dumped environment carries `TUNSTRAP_INPUT`, i.e. + the generated SSH private key, so the recorder deliberately overrides the + default umask. Nothing checked it, so dropping either `chmod` — or the + `dump_dir.chmod(0o700)` — was a silent regression on a directory holding key + material. + + Exact compares (`& 0o777 == …`), not `not ... & 0o077`: a mode that merely + happens to be private under this runner's umask must not pass for a mode the + rig actually set. The directory check is the load-bearing one, since it makes + the files unreachable by other users whatever their own mode; the per-file + checks are defence in depth for a directory mode that later regresses. + + Needs `tofu` on PATH (the recorder execs it) but no cluster: `-version` is + served locally. + """ + require_tools("tofu") + bin_dir = tmp_path / "bin" + dump_dir = tmp_path / "dumps" + script = write_tofu_recorder(bin_dir, dump_dir) + + result = subprocess.run([str(script), "-version"], capture_output=True, check=False) + assert result.returncode == 0, result.stderr + + assert dump_dir.stat().st_mode & 0o777 == 0o700, "the dump directory is not owner-only" + argv_dumps = sorted(dump_dir.glob("*.argv")) + env_dumps = sorted(dump_dir.glob("*.env0")) + assert len(argv_dumps) == 1, f"expected exactly one argv dump, got {argv_dumps}" + assert len(env_dumps) == 1, f"expected exactly one env dump, got {env_dumps}" + for dump in (*argv_dumps, *env_dumps): + assert dump.stat().st_mode & 0o777 == 0o600, f"{dump.name} is not owner-only" + # Anti-vacuity: the dumps must be the real recording, not empty files that + # would satisfy a mode check while proving the recorder does nothing. + assert argv_dumps[0].read_text() == "-version\n" + assert b"PATH=" in env_dumps[0].read_bytes() diff --git a/tests/e2e/test_shim.py b/tests/e2e/test_shim.py new file mode 100644 index 0000000..430b099 --- /dev/null +++ b/tests/e2e/test_shim.py @@ -0,0 +1,178 @@ +"""The ``tunstrap_tofu`` proxy: dispatch, exit codes, and stdout purity. + +Code: tunstrap/tofu_proxy.py (the shipped console entry point). +Method: drive the proxy with a fake `tofu` on PATH (in front of the real one), +so the assertions are about the proxy and tunstrap rather than about OpenTofu. + +This replaces the consumer shell shim the tier used to drive. The proxy is the +documented path (docs/recipe_terragrunt.md); the shell-shim-specific drift and +textual guards (byte-identity to a recipe snippet, ``env -u KUBECONFIG`` on the +command line) are gone with it. The KUBECONFIG property those guards served is +re-expressed behaviourally in test_terragrunt_apply.py, which records the proxy's +child environment and asserts KUBECONFIG absent — observing the child +environment rather than the command line. +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path +from typing import Any + +import pytest + +from tests.e2e.rig import ( + CONTROL_SHIM, + TOFU_PROXY, + recorded_argvs, + tunstrap_input_json, + write_fake_tofu, +) + +pytestmark = [pytest.mark.e2e] + + +def test_control_shim_is_executable_posix_sh() -> None: + """The negative control is mode 0755 and declares /bin/sh (a file terraform_binary probes).""" + assert CONTROL_SHIM.is_file(), CONTROL_SHIM + assert CONTROL_SHIM.stat().st_mode & 0o777 == 0o755, CONTROL_SHIM + assert CONTROL_SHIM.read_text().startswith("#!/bin/sh\n"), CONTROL_SHIM + + +def _proxy_env(bin_dir: Path) -> dict[str, str]: + """Parent environment with the fake tofu first on PATH and no stale payload. + + ``tunstrap_tofu`` itself resolves further down PATH (the editable-install + venv); the fake tofu shadows it only for the proxy's own ``tofu`` exec. + """ + env = dict(os.environ) + env["PATH"] = f"{bin_dir}:{env['PATH']}" + env.pop("TUNSTRAP_INPUT", None) + env.pop("KUBECONFIG", None) + return env + + +def _run_proxy(argv: list[str], env: dict[str, str]) -> subprocess.CompletedProcess[str]: + """Run the installed ``tunstrap_tofu`` (PATH-resolved) with capture.""" + return subprocess.run([TOFU_PROXY, *argv], env=env, capture_output=True, text=True, check=False) + + +def test_init_passes_through_even_with_a_poisoned_payload( + e2e_preflight: None, tmp_path: Path +) -> None: + """`init` never reads TUNSTRAP_INPUT, so an invalid one cannot stop it. + + Load-bearing: Terragrunt's env_vars reaches the auto-init for a tunnelled + command, so the proxy MUST bypass init to avoid a redundant tunnel per plan. + """ + del e2e_preflight + bin_dir = tmp_path / "bin" + marker_dir = tmp_path / "marks" + write_fake_tofu(bin_dir, marker_dir, exit_code=0, stdout_line="FAKE_TOFU_RAN") + env = _proxy_env(bin_dir) + # Deliberately invalid *and* non-empty: any accidental `tunstrap run` exits 1 + # with SchemaValidationError, pre-spawn, and tofu never launches. + env["TUNSTRAP_INPUT"] = "{invalid" + + result = _run_proxy(["init", "-input=false"], env) + assert result.returncode == 0, result.stdout + result.stderr + assert recorded_argvs(marker_dir) == [["init", "-input=false"]] + + +def test_version_passes_through_even_with_a_poisoned_payload( + e2e_preflight: None, tmp_path: Path +) -> None: + """`-version` takes the same bypass; Terragrunt probes it once per run.""" + del e2e_preflight + bin_dir = tmp_path / "bin" + marker_dir = tmp_path / "marks" + write_fake_tofu(bin_dir, marker_dir, exit_code=0, stdout_line="FAKE_TOFU_RAN") + env = _proxy_env(bin_dir) + env["TUNSTRAP_INPUT"] = "{invalid" + + result = _run_proxy(["-version"], env) + assert result.returncode == 0, result.stdout + result.stderr + assert recorded_argvs(marker_dir) == [["-version"]] + + +def test_chdir_init_passes_through_the_fixed_gap(e2e_preflight: None, tmp_path: Path) -> None: + """`tofu -chdir=DIR init` bypasses — the gap the shell shim could not close. + + The shell shim's ``case "$1"`` saw ``-chdir=DIR`` as the first token and so + built a needless tunnel for ``-chdir`` inits. The proxy parses argv past + global flags, so ``init`` is correctly identified as the subcommand. This is + the e2e expression of the fix; the bypass set is pinned exhaustively in + tests/unit/test_tofu_proxy.py::test_should_bypass_returns_true_for_the_pinned_bypass_set. + """ + del e2e_preflight + bin_dir = tmp_path / "bin" + marker_dir = tmp_path / "marks" + write_fake_tofu(bin_dir, marker_dir, exit_code=0, stdout_line="FAKE_TOFU_RAN") + env = _proxy_env(bin_dir) + env["TUNSTRAP_INPUT"] = "{invalid" + + # Both = and space forms of -chdir must reach the init bypass. + for argv in (["-chdir=somewhere", "init"], ["-chdir", "somewhere", "init"]): + result = _run_proxy(argv, env) + assert result.returncode == 0, f"{argv}: {result.stdout}{result.stderr}" + assert recorded_argvs(marker_dir) == [argv], f"{argv} did not reach tofu verbatim" + # Clear markers between forms so the next iteration's recorded_argvs is unambiguous. + for m in marker_dir.glob("*.argv"): + m.unlink() + + +def test_unset_payload_passes_through(e2e_preflight: None, tmp_path: Path) -> None: + """With TUNSTRAP_INPUT unset the proxy is a transparent exec to tofu.""" + del e2e_preflight + bin_dir = tmp_path / "bin" + marker_dir = tmp_path / "marks" + write_fake_tofu(bin_dir, marker_dir, exit_code=0, stdout_line="FAKE_TOFU_RAN") + env = _proxy_env(bin_dir) + assert "TUNSTRAP_INPUT" not in env + + result = _run_proxy(["plan"], env) + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout == "FAKE_TOFU_RAN\n" + assert recorded_argvs(marker_dir) == [["plan"]] + + +def test_child_exit_code_propagates_verbatim(kube_rig: dict[str, Any], tmp_path: Path) -> None: + """A child exiting 42 makes the proxy exit 42, and the child provably ran.""" + bin_dir = tmp_path / "bin" + marker_dir = tmp_path / "marks" + # 42 is outside tunstrap's reserved set (1, 2, 3, 4, 64), distinct from the + # launch-failure code 127, and below the 128+N band _run_child maps a + # signalled child into - so no tunstrap path can produce it by accident. + write_fake_tofu(bin_dir, marker_dir, exit_code=42, stdout_line="FAKE_TOFU_EXIT_42") + env = _proxy_env(bin_dir) + env["TUNSTRAP_INPUT"] = tunstrap_input_json(kube_rig) + + result = _run_proxy(["apply"], env) + detail = f"rc={result.returncode} stdout={result.stdout!r} stderr={result.stderr!r}" + assert result.returncode == 42, detail + assert result.stdout == "FAKE_TOFU_EXIT_42\n" + assert recorded_argvs(marker_dir) == [["apply"]] + + +def test_tunnelled_stdout_is_byte_identical_to_the_untunnelled_child( + kube_rig: dict[str, Any], tmp_path: Path +) -> None: + """Under the proxy, fd 1 belongs to tofu and to nothing else.""" + bin_dir = tmp_path / "bin" + marker_dir = tmp_path / "marks" + write_fake_tofu(bin_dir, marker_dir, exit_code=0, stdout_line="FAKE_TOFU_STDOUT_SENTINEL") + base = _proxy_env(bin_dir) + + # Oracle: the same proxy, the same fake tofu, the same argv - but with + # TUNSTRAP_INPUT unset, so the proxy's first branch execs straight into the + # child and tunstrap is not in the picture at all. + direct = _run_proxy(["apply"], base) + tunnelled = _run_proxy(["apply"], env={**base, "TUNSTRAP_INPUT": tunstrap_input_json(kube_rig)}) + + assert direct.returncode == 0, direct.stderr + assert tunnelled.returncode == 0, tunnelled.stderr + # Pin the oracle itself, so "both produced nothing" cannot pass. + assert direct.stdout == "FAKE_TOFU_STDOUT_SENTINEL\n" + assert tunnelled.stdout == direct.stdout + assert recorded_argvs(marker_dir) == [["apply"], ["apply"]] diff --git a/tests/e2e/test_terragrunt_apply.py b/tests/e2e/test_terragrunt_apply.py new file mode 100644 index 0000000..22b6eba --- /dev/null +++ b/tests/e2e/test_terragrunt_apply.py @@ -0,0 +1,574 @@ +"""Real `terragrunt apply`/`destroy`/`output` through the proxy, against the cluster. + +Code: docs/recipe_terragrunt.md, tunstrap/tofu_proxy.py (driven as TOFU_PROXY), +tests/e2e/shim/tofu-tunstrap-novar (the --output-var negative control). +Method: stand up a consumer repo whose terragrunt.hcl uses the recipe's pinned +root block (verbatim) plus a copy of its extra_arguments mechanism, then drive +real Terragrunt through it. Read every result through the in-node oracle +(`kubectl_in_node`) and a recording `tofu` - never Terragrunt's own exit code. + +Two configurations: +- ``test_terragrunt_apply_destroy_through_the_proxy``: the recipe's recommended + `commands` list (output absent). Drives apply/destroy and asserts the full + four-row env asymmetry (-version / init / apply+destroy / output) from the + recording tofu, AFTER destroy so every command's invocation is captured. +- ``test_tunnelled_output_through_tunstrap_run_parses_cleanly``: `output` ADDED + to `commands` (the worst case) so `terragrunt output -json` runs through + `tunstrap run`, proving tunstrap run's own stdout survives a real consumer's + parse - the gap the branch review named. The recipe deliberately OMITS output + (it reads state, not the cluster); this test proves the purity property under + the worst case, it does not recommend tunnelled output. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path +from typing import Any + +import pytest + +from tests.e2e.rig import ( + CONTROL_SHIM, + collect_tofu_invocations, + kubectl_in_node, + require_tools, + tofu_env, + tunstrap_input_json, + wait_for_namespace_gone, + write_tofu_recorder, +) + +pytestmark = [pytest.mark.e2e] + +# A COPY of the recipe's commands list (docs/recipe_terragrunt.md, terragrunt-unit +# block). Only the root block is extracted; the unit block is hand-copied because +# it also carries the payload, whose values are illustrative k3s defaults +# (root@22, /etc/rancher/k3s/k3s.yaml) that do not match the kind rig. So a recipe +# edit adding/removing a command will NOT drift this test - a known, documented +# limitation, not a silently-assumed equivalence. +RECIPE_COMMANDS = ["plan", "apply", "destroy", "refresh", "import"] + + +def _installed_proxy() -> str: + """The absolute path of the ``tunstrap_tofu`` entry point on PATH. + + What a real consumer pastes into ``terraform_binary`` after running + ``command -v tunstrap_tofu``. e2e_preflight guarantees it is installed. + """ + path = shutil.which("tunstrap_tofu") + assert path is not None, "tunstrap_tofu not on PATH; e2e_preflight should have failed the tier" + return path + + +def _consumer_repo( + tmp_path: Path, + rig: dict[str, Any], + module_src: Path, + *, + commands: list[str], + terraform_binary: str, + control_shim: Path | None = None, + include_root: bool = True, +) -> Path: + """A real consumer hierarchy: ``root.hcl`` + ``unit/terragrunt.hcl``. + + Exercises the recipe's two blocks the way a consumer assembles them, NOT + concatenated into one file (which hid the silent non-inheritance failure: + a unit without ``include "root"`` falls back to plain ``tofu`` and dies at + 127.0.0.1:0 - see test_a_non_inherited_root_is_caught_...). ``root.hcl`` + carries ``terraform_binary``; the unit inherits it via + ``include "root" { path = find_in_parent_folders("root.hcl") }`` (the form + the recipe documents - ``find_in_parent_folders`` defaults to ``terragrunt.hcl`` + and must name ``root.hcl`` explicitly). The module is copied into ``unit/`` + so ``source = "."`` resolves there. Returns the unit dir (terragrunt's cwd). + + ``include_root=False`` omits the include - the failure mode the recipe's + troubleshooting distinguishes from a forgotten ``commands`` entry. + + ``control_shim`` (the ``--output-var`` negative control) overrides + ``terraform_binary`` to a copied shim in ``bin/``; it is a test tool, not a + consumer artifact, and still inherits via the include. + + The unit block carries a copy of the recipe's extra_arguments *mechanism* + with a rig-built payload (the recipe's carries illustrative k3s values that + do not match the kind rig). No ``git init``: nothing in the config uses + ``get_repo_root()`` and ``find_in_parent_folders``/``source = "."`` need no + git repo (verified). + """ + repo = tmp_path / "consumer" + repo.mkdir() + unit = repo / "unit" + unit.mkdir() + for entry in module_src.iterdir(): + dst = unit / entry.name + if dst.exists(): + continue + if entry.is_dir(): + shutil.copytree(entry, dst) + else: + shutil.copy2(entry, dst) + + if control_shim is not None: + bin_dir = repo / "bin" + bin_dir.mkdir() + copied = bin_dir / "tofu-tunstrap-novar" + shutil.copy2(control_shim, copied) + os.chmod(copied, 0o755) + binary_value = str(copied) + else: + binary_value = terraform_binary + (repo / "root.hcl").write_text(f'terraform_binary = "{binary_value}"\n') + + payload = tunstrap_input_json(rig) + commands_hcl = ", ".join(f'"{c}"' for c in commands) + include_block = ( + 'include "root" {\n path = find_in_parent_folders("root.hcl")\n}\n\n' + if include_root + else "" + ) + unit_block = ( + f"{include_block}" + "locals {\n" + f' cluster_host = "{rig["host"]}"\n' + " tunstrap_input_json = < dict[str, str]: + """A hermetic env for a terragrunt process, with an optional recording tofu. + + `tofu_env` scrubs KUBECONFIG and redirects HOME (the silent-pass guards the + whole tier depends on); TF_DATA_DIR is dropped because under Terragrunt tofu + runs in .terragrunt-cache//, not the module copy. TUNSTRAP_INPUT and + TF_VAR_tunstrap are both popped, so within this env extra_arguments.env_vars + is the *only* source of TUNSTRAP_INPUT and the proxy's `--output-var` the + only source of TF_VAR_tunstrap. The TF_VAR_tunstrap pop makes the + tunnelled-branch inference hermetic by construction: the value the recorder + sees in an apply/destroy env could only have been set by tunstrap run, not + inherited from an ambient export. The ambient KUBECONFIG scrub here is the + OUTER guard; the proxy's ``suppress_kubeconfig`` is the inner one - both are + needed for the routing exclusion to hold. + """ + env = tofu_env(module, cache) + env.pop("TF_DATA_DIR", None) + if recorder_bin is not None: + env["PATH"] = f"{recorder_bin}:{env['PATH']}" + env.pop("TUNSTRAP_INPUT", None) + env.pop("TF_VAR_tunstrap", None) + return env + + +def _tg(repo: Path, env: dict[str, str], *args: str) -> subprocess.CompletedProcess[str]: + """Run `terragrunt --no-color ` in `repo`, captured, never raising.""" + return subprocess.run( + ["terragrunt", "--no-color", *args], + cwd=repo, + env=env, + capture_output=True, + text=True, + check=False, + ) + + +def _invocations_by_command(dump_dir: Path) -> dict[str, list[dict[str, str]]]: + """Recorded tofu invocations grouped by first argv token, oldest first.""" + grouped: dict[str, list[dict[str, str]]] = {} + for argv, env_seen in collect_tofu_invocations(dump_dir): + grouped.setdefault(argv[0], []).append(env_seen) + return grouped + + +def _assert_row( + label: str, + envs: list[dict[str, str]], + *, + has_nonempty: tuple[str, ...] = (), + lacks: tuple[str, ...] = (), +) -> None: + """Each recorded invocation of ``label`` carries ``has_nonempty`` (set to a + non-empty value) and lacks every key in ``lacks``. + + Fails-when-broken per row: an empty `envs` (the command never ran through the + recording tofu) fails first; a missing key fails naming it; a present + forbidden key fails naming it. The asymmetry this encodes is the recipe's + measured behaviour, so a regression in the proxy's init/-version bypass or in + tunstrap run's TUNSTRAP_INPUT scrub surfaces here. + """ + assert envs, f"no `{label}` invocation was recorded" + for key in has_nonempty: + msg = f"`{label}` invocation did not carry non-empty {key}" + assert all(e.get(key, "") != "" for e in envs), msg + for key in lacks: + msg = f"`{label}` invocation carried {key}, which it must not" + assert all(key not in e for e in envs), msg + + +def test_terragrunt_apply_destroy_through_the_proxy( + kube_rig: dict[str, Any], + tofu_module: Path, + tofu_plugin_cache: Path, + tmp_path: Path, +) -> None: + """Apply+destroy through the proxy with the recommended commands; full asymmetry. + + `output` is deliberately ABSENT from `commands` (the recipe's + recommendation: output reads state, not the cluster). So `terragrunt output + -json` here takes the proxy's pass-through branch (`execvp tofu`); + `tunstrap run` is NOT in that pipeline. The tunnelled-output purity claim has + its own test below - this one is about apply/destroy and the four-row + delivery/bypass asymmetry. + + Every env assertion runs AFTER destroy, so destroy's invocation (and every + prior command's) is in the recorder. Fails-when-broken, per assertion: + - apply rc 0 + oracle namespace/configmap/release: a broken env_vars delivery + or --output-var chain drops tofu to the inert branch (127.0.0.1:0); apply + fails, nothing is created (demonstrated in the no-output-var test below). + - -version row (neither var): env_vars skips -version and the proxy bypasses + it; if either regressed, -version would carry a var and the row fails. + - init row (TUNSTRAP_INPUT set, no TF_VAR_tunstrap): env_vars reaches + auto-init but the proxy bypasses init; if the bypass regressed, init would + carry TF_VAR_tunstrap (a redundant tunnel per plan) and the row fails. The + row quantifies over every recorded init via ``all()``, so a future + Terragrunt that auto-inits for a command outside ``commands`` (an init + without TUNSTRAP_INPUT) would also redden it - a loud signal, not a silent + pass; both failure shapes are the intended behaviour. + - apply/destroy rows (TF_VAR_tunstrap set; TUNSTRAP_INPUT and KUBECONFIG + absent): TUNSTRAP_INPUT absence is the ssh_pkey scrub (recipe:"The input + variable is scrubbed"); KUBECONFIG absence is the proxy's + ``suppress_kubeconfig`` (the property the shell shim used to buy with + ``env -u KUBECONFIG`` on the command line; observed here in the child env). + If either leaked, the row fails. + - output row (neither var): output pass-through; if it carried a var, output + was not pass-through and the row fails. + - output value == envelope path: a STATE-integrity check (the path tofu + recorded during apply == the envelope). It does NOT prove routing - that + exclusion comes from the proxy's `suppress_kubeconfig` plus `tofu_env`, not + this compare. + """ + require_tools("terragrunt") + bin_rec = tmp_path / "bin_rec" + dump_dir = tmp_path / "dumps" + write_tofu_recorder(bin_rec, dump_dir) + repo = _consumer_repo( + tmp_path, + kube_rig, + tofu_module, + commands=RECIPE_COMMANDS, + terraform_binary=_installed_proxy(), + ) + env = _terragrunt_env(tofu_module, tofu_plugin_cache, bin_rec) + + applied = _tg(repo, env, "apply", "-auto-approve") + apply_failed = f"terragrunt apply failed:\n{applied.stdout}{applied.stderr}" + assert applied.returncode == 0, apply_failed + + namespace = kubectl_in_node( + "get", "namespace", "tunstrap-e2e", "-o", "jsonpath={.metadata.name}" + ) + ns_detail = f"rc={namespace.returncode} stdout={namespace.stdout!r} stderr={namespace.stderr!r}" + assert namespace.returncode == 0, ns_detail + assert namespace.stdout == "tunstrap-e2e", ns_detail + + configmap = kubectl_in_node( + "get", "configmap", "probe-cm", "-n", "tunstrap-e2e", "-o", "jsonpath={.data.proof}" + ) + assert configmap.returncode == 0, configmap.stderr + assert configmap.stdout == "through-the-tunnel", configmap.stdout + + release = kubectl_in_node( + "get", + "secret", + "sh.helm.release.v1.probe.v1", + "-n", + "tunstrap-e2e", + "-o", + "jsonpath={.metadata.name}", + ) + assert release.returncode == 0, release.stderr + assert release.stdout == "sh.helm.release.v1.probe.v1", release.stdout + + # output pass-through (output not in commands): run while state exists, assert later. + out = _tg(repo, env, "output", "-json") + out_detail = f"terragrunt output -json (pass-through) failed:\n{out.stdout}{out.stderr}" + assert out.returncode == 0, out_detail + + destroyed = _tg(repo, env, "destroy", "-auto-approve") + destroy_failed = f"terragrunt destroy failed:\n{destroyed.stdout}{destroyed.stderr}" + assert destroyed.returncode == 0, destroy_failed + wait_for_namespace_gone("tunstrap-e2e") + + # --- recorder asymmetry: every command's invocation is now captured --- + by_cmd = _invocations_by_command(dump_dir) + _assert_row("-version", by_cmd.get("-version", []), lacks=("TUNSTRAP_INPUT", "TF_VAR_tunstrap")) + _assert_row( + "init", by_cmd.get("init", []), has_nonempty=("TUNSTRAP_INPUT",), lacks=("TF_VAR_tunstrap",) + ) + for cmd in ("apply", "destroy"): + _assert_row( + cmd, + by_cmd.get(cmd, []), + has_nonempty=("TF_VAR_tunstrap",), + lacks=("TUNSTRAP_INPUT", "KUBECONFIG"), + ) + _assert_row("output", by_cmd.get("output", []), lacks=("TUNSTRAP_INPUT", "TF_VAR_tunstrap")) + + # state integrity: the path tofu recorded during apply == the envelope path. + # Not a routing proof (routing exclusion = suppress_kubeconfig + tofu_env). + parsed = json.loads(out.stdout) + kubepath = parsed["kubepath_used"]["value"] + envelope = json.loads(by_cmd["apply"][-1]["TF_VAR_tunstrap"]) + expected_path = envelope["nodes"]["node"]["kube"]["k3s"]["path"] + path_detail = f"output kubepath_used={kubepath!r} envelope path={expected_path!r}" + assert kubepath == expected_path, path_detail + + +def test_tunnelled_output_through_tunstrap_run_parses_cleanly( + kube_rig: dict[str, Any], + tofu_module: Path, + tofu_plugin_cache: Path, + tmp_path: Path, +) -> None: + """`terragrunt output -json` through `tunstrap run` parses cleanly (the claim). + + With `output` ADDED to `commands`, env_vars delivers TUNSTRAP_INPUT for + output, the proxy routes it through `tunstrap run` (in-process), and + `terragrunt output -json` parses tofu's stdout that has traversed the proxy. + This is the end-to-end purity claim at the Terragrunt layer: a real + consumer's JSON parse of tunnelled output succeeds. + + Framing: tunnelled `output` is the WORST CASE, not a recommendation. The + recipe omits `output` from `commands` on purpose (output reads state, not the + cluster; tunnelling it is wasteful). This test forces the worst case to prove + the parse holds when tunstrap run IS in the pipeline; it does not suggest + adding `output` to `commands`. + + What is and is not proven here: + - `json.loads(out.stdout)` succeeds AND the output row carried TF_VAR_tunstrap + (so the invocation provably traversed tunstrap run, not the pass-through + branch). That is the positive end-to-end claim. + - The ``json.loads`` itself is **unfalsified at this layer**: no + wrapper-based break makes a *contamination of the parsed stream* fail the + parse. Wrapping ``tunstrap`` does nothing (the proxy runs run IN-PROCESS and + never invokes the ``tunstrap`` binary); wrapping ``tofu`` to prepend bytes + makes ``terragrunt output -json`` exit 1 with EMPTY captured stdout + (Terragrunt mediates the output and discards it on its own parse failure), + which reddens this test's own ``assert out.returncode == 0`` and + ``json.loads`` (on empty input) - so a contamination IS caught here, just + via the return code / empty-stdout path rather than via a parse of a + contaminated stream. Neither shape isolates tunstrap-run's stdout purity, + which is proven FALSIFIABLE at the proxy layer by the byte-equality test + tests/e2e/test_shim.py::test_tunnelled_stdout_is_byte_identical_to_the_untunnelled_child. + Do not read this test's green parse as evidence the parse is fragile to + contamination - it is not shown here. + """ + require_tools("terragrunt") + bin_rec = tmp_path / "bin_rec" + dump_dir = tmp_path / "dumps" + write_tofu_recorder(bin_rec, dump_dir) + repo = _consumer_repo( + tmp_path, + kube_rig, + tofu_module, + commands=[*RECIPE_COMMANDS, "output"], + terraform_binary=_installed_proxy(), + ) + env = _terragrunt_env(tofu_module, tofu_plugin_cache, bin_rec) + + applied = _tg(repo, env, "apply", "-auto-approve") + apply_failed = f"terragrunt apply failed:\n{applied.stdout}{applied.stderr}" + assert applied.returncode == 0, apply_failed + + # --- the central claim: tunstrap run's stdout survives a real consumer's parse --- + out = _tg(repo, env, "output", "-json") + out_detail = f"terragrunt output -json (tunnelled) failed:\n{out.stdout}{out.stderr}" + assert out.returncode == 0, out_detail + parsed = json.loads(out.stdout) + kubepath = parsed["kubepath_used"]["value"] + + # Confirm it actually traversed tunstrap run (not the pass-through branch): + # the output invocation must carry TF_VAR_tunstrap. _terragrunt_env pops any + # ambient TF_VAR_tunstrap (and `tofu_env` never injects it), so within this + # run the only thing that can set it is `tunstrap run --output-var` - its + # presence is the tunnelled-branch proof, resting on the env scrub, not on + # an absolute "only tunstrap run sets it" (a caller could export it). + by_cmd = _invocations_by_command(dump_dir) + _assert_row( + "output", + by_cmd.get("output", []), + has_nonempty=("TF_VAR_tunstrap",), + lacks=("TUNSTRAP_INPUT",), + ) + # kubepath_used reads STATE (written during apply), so the expected value is + # the APPLY invocation's envelope path - NOT the output invocation's. Output + # opens a fresh tunnel with its own session dir, so its path is always a + # different temp dir; comparing against it would fail on every green run. + envelope = json.loads(by_cmd["apply"][-1]["TF_VAR_tunstrap"]) + expected_path = envelope["nodes"]["node"]["kube"]["k3s"]["path"] + path_detail = ( + f"tunnelled output kubepath_used={kubepath!r} apply envelope path={expected_path!r}" + ) + assert kubepath == expected_path, path_detail + + # NOTE on the retired pollution sub-check. An earlier version of this test + # appended a discriminating negative control: wrap `tunstrap` on PATH to + # write a marker before exec'ing the real binary, proving the marker reached + # the stream terragrunt parses AND broke the JSON parse. That worked under the + # shell shim (which `exec`d the `tunstrap` binary) but is inapplicable under + # the shipped proxy, which runs `tunstrap run` IN-PROCESS and never invokes + # the `tunstrap` binary. Wrapping `tofu` instead does not work either: + # `terragrunt output -json` mediates tofu's output and discards the stream on + # its own parse failure (rc=1, stdout empty), so the marker never reaches the + # captured stdout. The purity property the sub-check served - the tunnelled + # path adds no bytes to fd 1 beyond tofu's output - is proven MORE directly by + # tests/e2e/test_shim.py::test_tunnelled_stdout_is_byte_identical_to_the_untunnelled_child + # (byte-equality against a direct-run oracle), so the terragrunt-layer + # negative control is retired rather than left as dead, always-red code. + + destroyed = _tg(repo, env, "destroy", "-auto-approve") + destroy_failed = f"terragrunt destroy failed:\n{destroyed.stdout}{destroyed.stderr}" + assert destroyed.returncode == 0, destroy_failed + wait_for_namespace_gone("tunstrap-e2e") + + +def test_terragrunt_apply_without_output_var_fails_through_terragrunt( + kube_rig: dict[str, Any], + tofu_module: Path, + tofu_plugin_cache: Path, + tmp_path: Path, +) -> None: + """Negative control for the config_path route (NOT the stdout claim). + + The CONTROL shim (a test-only ``tunstrap run`` invocation WITHOUT + --output-var) opens the tunnel but never exports TF_VAR_tunstrap, so + var.tunstrap keeps its "" default, the module takes its inert branch, and the + providers dial https://127.0.0.1:0. That the apply fails - read through the + oracle, not Terragrunt's exit code - is what makes the positive apply test's + success meaningful. + + This targets the config_path/`--output-var` route. It is NOT the break for + the stdout-purity claim (that is the byte-equality test + tests/e2e/test_shim.py::test_tunnelled_stdout_is_byte_identical_to_the_untunnelled_child); + it overlaps + test_tofu_providers::test_apply_without_output_var_fails_even_with_the_tunnel_up + because the config_path route is worth guarding at both the direct-proxy and + Terragrunt-env_vars layers. The cluster is confirmed healthy throughout. + """ + require_tools("terragrunt") + repo = _consumer_repo( + tmp_path, + kube_rig, + tofu_module, + commands=RECIPE_COMMANDS, + control_shim=CONTROL_SHIM, + terraform_binary="", # unused: control_shim set means the copied control is the binary + ) + env = _terragrunt_env(tofu_module, tofu_plugin_cache) + + applied = _tg(repo, env, "apply", "-auto-approve") + combined = applied.stdout + applied.stderr + success = ( + "apply SUCCEEDED through Terragrunt without --output-var: something other " + "than the decoded config_path is reaching the cluster, and the positive " + "test's success proves nothing.\n" + combined + ) + assert applied.returncode != 0, success + assert "127.0.0.1:0" in combined, combined + + namespace = kubectl_in_node("get", "namespace", "tunstrap-e2e", "-o", "name") + assert namespace.returncode != 0 + assert "not found" in namespace.stderr.lower() + + health = kubectl_in_node("get", "--raw", "/healthz") + assert health.returncode == 0, health.stderr + assert health.stdout.strip() == "ok" + + +def test_a_non_inherited_root_is_caught_and_distinguishable_from_a_missing_commands_entry( + kube_rig: dict[str, Any], + tofu_module: Path, + tofu_plugin_cache: Path, + tmp_path: Path, +) -> None: + """A unit without ``include "root"`` fails at 127.0.0.1:0 - but tellably. + + The recipe's two HCL blocks are inherited, not concatenated: a unit that + omits ``include "root"`` does not inherit ``terraform_binary``, Terragrunt + silently falls back to plain ``tofu`` on PATH, and the apply dies at the inert + ``127.0.0.1:0`` endpoint. That symptom is IDENTICAL to a forgotten ``commands`` + entry, so the recipe's troubleshooting must tell them apart - and so must this + test, which is the whole reason the rig stopped concatenating the two fences + into one file. + + The distinguisher is what reached ``tofu``: + - non-inherited root: ``extra_arguments.env_vars`` still delivered + ``TUNSTRAP_INPUT`` (it does not depend on ``terraform_binary``), but nothing + set ``TF_VAR_tunstrap`` (the proxy never ran). Recording tofu shows + ``TUNSTRAP_INPUT`` PRESENT, ``TF_VAR_tunstrap`` ABSENT. + - forgotten ``commands`` entry: that command's env lacks ``TUNSTRAP_INPUT`` + (the list controls delivery) AND ``TF_VAR_tunstrap``. Both ABSENT. + + The test pins the non-inherited-root signature. The forgotten-commands + signature (``TUNSTRAP_INPUT`` absent) is exercised by the ``-version``/output + rows of ``test_terragrunt_apply_destroy_through_the_proxy``. + """ + require_tools("terragrunt") + bin_rec = tmp_path / "bin_rec_noinh" + dump_dir = tmp_path / "dumps_noinh" + write_tofu_recorder(bin_rec, dump_dir) + # include_root=False: root.hcl carries terraform_binary but the unit never + # inherits it - the exact mis-assembly a newcomer makes. + unit = _consumer_repo( + tmp_path, + kube_rig, + tofu_module, + commands=RECIPE_COMMANDS, + terraform_binary=_installed_proxy(), + include_root=False, + ) + env = _terragrunt_env(tofu_module, tofu_plugin_cache, bin_rec) + + applied = _tg(unit, env, "apply", "-auto-approve") + combined = applied.stdout + applied.stderr + fail_msg = ( + "apply SUCCEEDED with a non-inherited root (no include) - terraform_binary " + "was somehow inherited despite the missing include, which defeats this " + "test's premise.\n" + combined + ) + assert applied.returncode != 0, fail_msg + assert "127.0.0.1:0" in combined, combined + + # The distinguishing signature: TUNSTRAP_INPUT present (env_vars delivered + # it), TF_VAR_tunstrap absent (the proxy never ran). A forgotten-commands + # entry would leave TUNSTRAP_INPUT absent too - which is how the two are + # told apart (see the recipe's "Failure modes"). + by_cmd = _invocations_by_command(dump_dir) + _assert_row( + "apply", + by_cmd.get("apply", []), + has_nonempty=("TUNSTRAP_INPUT",), + lacks=("TF_VAR_tunstrap",), + ) + # Cluster health: the failure is attributable to the missing include, not a + # dead rig. + health = kubectl_in_node("get", "--raw", "/healthz") + assert health.returncode == 0, health.stderr + assert health.stdout.strip() == "ok" diff --git a/tests/e2e/test_tofu_providers.py b/tests/e2e/test_tofu_providers.py new file mode 100644 index 0000000..6fff22f --- /dev/null +++ b/tests/e2e/test_tofu_providers.py @@ -0,0 +1,290 @@ +"""OpenTofu's kubernetes and helm providers, driven through a tunstrap tunnel. + +Code: tests/e2e/module/, tunstrap/tofu_proxy.py (the shipped ``tunstrap_tofu`` +console entry, driven as ``TOFU_PROXY``). +Method: run the installed proxy against a per-test copy of the module and a real +kind cluster; read results back through an oracle that does not use the tunnel. +""" + +from __future__ import annotations + +import json +import socket +import subprocess +from pathlib import Path +from typing import Any + +import pytest + +from tests.e2e.rig import ( + CONTROL_SHIM, + TOFU_PROXY, + collect_tofu_invocations, + kubectl_in_node, + tofu_env, + tunstrap_input_json, + wait_for_namespace_gone, + write_tofu_recorder, +) + +pytestmark = [pytest.mark.e2e] + + +def test_plan_succeeds_with_the_inert_branch(tofu_module: Path, tofu_plugin_cache: Path) -> None: + """With TF_VAR_tunstrap unset the module still plans: try(jsondecode()) holds.""" + env = tofu_env(tofu_module, tofu_plugin_cache) + assert "TF_VAR_tunstrap" not in env + + init = subprocess.run( + ["tofu", "init", "-input=false"], + cwd=tofu_module, + env=env, + capture_output=True, + text=True, + check=False, + ) + assert init.returncode == 0, init.stdout + init.stderr + + planned = subprocess.run( + ["tofu", "plan", "-input=false", "-refresh=false"], + cwd=tofu_module, + env=env, + capture_output=True, + text=True, + check=False, + ) + assert planned.returncode == 0, planned.stdout + planned.stderr + assert "2 to add" in planned.stdout + + # Exit 0 and "2 to add" both hold for a module that quietly reached a real + # cluster - they say the plan happened, not that it was *inert*. The output + # is what separates the two: local.kubepath is "" only when the try() chain + # found no path, which is the state the Task 4.2 negative control depends + # on. Without this line a module that had silently picked up an ambient + # kubeconfig would sail through. + assert 'kubepath_used = ""' in planned.stdout + + +def test_apply_creates_real_objects_through_the_tunnel_and_destroy_removes_them( + kube_rig: dict[str, Any], + tofu_module: Path, + tofu_plugin_cache: Path, + tmp_path: Path, +) -> None: + """The whole chain: --output-var -> jsondecode -> config_path -> real objects.""" + bin_dir = tmp_path / "bin" + dump_dir = tmp_path / "dumps" + write_tofu_recorder(bin_dir, dump_dir) + env = tofu_env( + tofu_module, + tofu_plugin_cache, + extra={ + "TUNSTRAP_INPUT": tunstrap_input_json(kube_rig), + "PATH": f"{bin_dir}:{tofu_env(tofu_module, tofu_plugin_cache)['PATH']}", + }, + ) + + init = subprocess.run( + [TOFU_PROXY, "init", "-input=false"], + cwd=tofu_module, + env=env, + capture_output=True, + text=True, + check=False, + ) + assert init.returncode == 0, init.stdout + init.stderr + + applied = subprocess.run( + [TOFU_PROXY, "apply", "-auto-approve", "-input=false"], + cwd=tofu_module, + env=env, + capture_output=True, + text=True, + check=False, + ) + assert applied.returncode == 0, applied.stdout + applied.stderr + assert "Apply complete!" in applied.stdout + + # --- assertions 1, 2, 3: read back through the oracle that never tunnels --- + namespace = kubectl_in_node( + "get", "namespace", "tunstrap-e2e", "-o", "jsonpath={.metadata.name}" + ) + assert namespace.returncode == 0, namespace.stderr + assert namespace.stdout == "tunstrap-e2e" + + configmap = kubectl_in_node( + "get", + "configmap", + "probe-cm", + "-n", + "tunstrap-e2e", + "-o", + "jsonpath={.data.proof}", + ) + assert configmap.returncode == 0, configmap.stderr + assert configmap.stdout == "through-the-tunnel" + + release = kubectl_in_node( + "get", + "secret", + "sh.helm.release.v1.probe.v1", + "-n", + "tunstrap-e2e", + "-o", + "jsonpath={.metadata.name}", + ) + assert release.returncode == 0, release.stderr + assert release.stdout == "sh.helm.release.v1.probe.v1" + + # --- assertion 4a + 7: what the real invocations actually saw --- + invocations = collect_tofu_invocations(dump_dir) + by_command = {argv[0]: env_seen for argv, env_seen in invocations} + assert sorted(by_command) == ["apply", "init"] + + init_env = by_command["init"] + assert init_env["TUNSTRAP_INPUT"] != "" + assert "TF_VAR_tunstrap" not in init_env + + apply_env = by_command["apply"] + assert "KUBECONFIG" not in apply_env + assert "TF_VAR_tunstrap" in apply_env + + # --- assertion 4c: the path the module used is the path the envelope gave --- + envelope = json.loads(apply_env["TF_VAR_tunstrap"]) + expected_path = envelope["nodes"]["node"]["kube"]["k3s"]["path"] + assert expected_path + state = json.loads((tofu_module / "terraform.tfstate").read_text()) + assert state["outputs"]["kubepath_used"]["value"] == expected_path + + # --- assertion 5: destroy really removes them --- + destroyed = subprocess.run( + [TOFU_PROXY, "destroy", "-auto-approve", "-input=false"], + cwd=tofu_module, + env=env, + capture_output=True, + text=True, + check=False, + ) + assert destroyed.returncode == 0, destroyed.stdout + destroyed.stderr + assert "Destroy complete!" in destroyed.stdout + wait_for_namespace_gone("tunstrap-e2e") + + +def test_apply_without_output_var_fails_even_with_the_tunnel_up( + kube_rig: dict[str, Any], + tofu_module: Path, + tofu_plugin_cache: Path, +) -> None: + """Negative control: the decoded config_path is the ONLY route to the cluster.""" + env = tofu_env( + tofu_module, + tofu_plugin_cache, + extra={"TUNSTRAP_INPUT": tunstrap_input_json(kube_rig)}, + ) + + init = subprocess.run( + [str(CONTROL_SHIM), "init", "-input=false"], + cwd=tofu_module, + env=env, + capture_output=True, + text=True, + check=False, + ) + assert init.returncode == 0, init.stdout + init.stderr + + applied = subprocess.run( + [str(CONTROL_SHIM), "apply", "-auto-approve", "-input=false"], + cwd=tofu_module, + env=env, + capture_output=True, + text=True, + check=False, + ) + combined = applied.stdout + applied.stderr + success_msg = ( + "apply SUCCEEDED without TF_VAR_tunstrap: something other than the " + "decoded config_path is reaching the cluster, and every positive " + "assertion in this tier proves nothing.\n" + combined + ) + assert applied.returncode != 0, success_msg + assert "127.0.0.1:0" in combined, combined + + # Nothing was created. + namespace = kubectl_in_node("get", "namespace", "tunstrap-e2e", "-o", "name") + assert namespace.returncode != 0 + assert "not found" in namespace.stderr.lower() + + # The cluster was alive the whole time, so the failure above is + # attributable to the missing variable rather than to a dead rig. + health = kubectl_in_node("get", "--raw", "/healthz") + assert health.returncode == 0, health.stderr + assert health.stdout.strip() == "ok" + + +def test_real_provider_failure_surfaces_as_nonzero( + tofu_module: Path, tofu_plugin_cache: Path, tmp_path: Path +) -> None: + """A config_path pointing at a dead endpoint fails, and names that endpoint.""" + with socket.socket() as probe_sock: + probe_sock.bind(("127.0.0.1", 0)) + dead_port = probe_sock.getsockname()[1] + # The socket is closed on exit from the `with`, so nothing listens there. + + dead_kubeconfig = tmp_path / "dead.kubeconfig" + dead_kubeconfig.write_text( + "apiVersion: v1\n" + "kind: Config\n" + "clusters:\n" + "- name: dead\n" + " cluster:\n" + f" server: https://127.0.0.1:{dead_port}\n" + " insecure-skip-tls-verify: true\n" + "contexts:\n" + "- name: dead\n" + " context: {cluster: dead, user: dead}\n" + "current-context: dead\n" + "users:\n" + "- name: dead\n" + " user: {}\n" + ) + envelope = { + "nodes": { + "node": { + "ports": {}, + "kube": { + "k3s": { + "path": str(dead_kubeconfig), + "context": "dead", + "endpoint": f"https://127.0.0.1:{dead_port}", + } + }, + } + } + } + env = tofu_env( + tofu_module, + tofu_plugin_cache, + extra={"TF_VAR_tunstrap": json.dumps(envelope)}, + ) + + init = subprocess.run( + ["tofu", "init", "-input=false"], + cwd=tofu_module, + env=env, + capture_output=True, + text=True, + check=False, + ) + assert init.returncode == 0, init.stdout + init.stderr + + applied = subprocess.run( + ["tofu", "apply", "-auto-approve", "-input=false"], + cwd=tofu_module, + env=env, + capture_output=True, + text=True, + check=False, + ) + combined = applied.stdout + applied.stderr + assert applied.returncode != 0, combined + assert f"127.0.0.1:{dead_port}" in combined, combined diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 9bf5ef4..15aea50 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -11,7 +11,6 @@ import pytest - HERE = Path(__file__).resolve().parent diff --git a/tests/integration/test_auto_stop.py b/tests/integration/test_auto_stop.py index e2ec392..2a38544 100644 --- a/tests/integration/test_auto_stop.py +++ b/tests/integration/test_auto_stop.py @@ -19,7 +19,6 @@ from tests.integration.conftest import tunstrap_start - pytestmark = pytest.mark.integration diff --git a/tests/integration/test_cli_modes.py b/tests/integration/test_cli_modes.py index 5bd3516..038fb11 100644 --- a/tests/integration/test_cli_modes.py +++ b/tests/integration/test_cli_modes.py @@ -2,8 +2,9 @@ Validates against the same Docker SSH fixtures as the other integration tests: - `start USER@HOST:PORT --ssh-key --target NAME=... --output env` emits - shell `export` lines; the advertised TUNSTRAP__PORT accepts a TCP - connection (proves the forward is live); `stop --session-dir` cleans up. + shell `export` lines for the three session survivors; the port materialized + under TUNSTRAP_OUTPUT_FILE accepts a TCP connection (proves the forward is + live); `stop --session-dir` cleans up. - `run USER@HOST ... -- CMD` injects TUNSTRAP_*/KUBECONFIG, runs the child, tears the session down afterwards, and propagates the child's exit code. @@ -20,6 +21,7 @@ from __future__ import annotations +import json import socket import subprocess import sys @@ -28,7 +30,6 @@ import pytest - pytestmark = pytest.mark.integration @@ -109,10 +110,13 @@ def test_start_output_env_live_forward( env = _parse_exports(result.stdout) started_daemons.append(str(session_dir)) - assert "TUNSTRAP_WEB_PORT" in env, env - port = int(env["TUNSTRAP_WEB_PORT"]) - assert env["TUNSTRAP_WEB_ENDPOINT"] == f"127.0.0.1:{port}", env + assert "TUNSTRAP_WEB_PORT" not in env, env assert env["TUNSTRAP_SESSION_DIR"] == str(session_dir), env + assert "TUNSTRAP_OUTPUT_FILE" in env, env + with open(env["TUNSTRAP_OUTPUT_FILE"], encoding="utf-8") as materialized_file: + materialized = json.load(materialized_file) + endpoint = materialized["nodes"]["node"]["ports"]["web"] + port = int(endpoint.rsplit(":", 1)[1]) assert _tcp_connect_ok(port), f"TCP connect to forwarded port {port} failed" stop = subprocess.run( @@ -133,10 +137,13 @@ def test_run_success_and_teardown( session_dir = tmp_path / "session" connection = _connection(ssh_test_cluster) - # Child probes the injected TUNSTRAP_WEB_PORT via a host-side TCP connect. + # Child probes the materialized web port via a host-side TCP connect. probe = ( - "import os, socket; " - "socket.create_connection(('127.0.0.1', int(os.environ['TUNSTRAP_WEB_PORT'])), 5).close()" + "import json, os, socket; " + "m = json.load(open(os.environ['TUNSTRAP_OUTPUT_FILE'])); " + "endpoint = m['nodes']['node']['ports']['web']; " + "port = int(endpoint.rsplit(':', 1)[1]); " + "socket.create_connection(('127.0.0.1', port), 5).close()" ) result = subprocess.run( [ diff --git a/tests/integration/test_daemon_input_env.py b/tests/integration/test_daemon_input_env.py new file mode 100644 index 0000000..2c97a91 --- /dev/null +++ b/tests/integration/test_daemon_input_env.py @@ -0,0 +1,91 @@ +"""The detached worker must not retain run's secret input environment. + +Validates: ``spawn_daemon`` removes *the variable ``--input-env`` actually +names* from the worker's environment — not a hardcoded ``TUNSTRAP_INPUT``. +``--input-env VAR`` takes an arbitrary name; that is the whole point of the +option, and the recipe's own consumers are free to call it anything. A scrub +keyed on one literal leaves the SSH private key PEM in the environment of a +long-lived detached process for every other name. + +Code: tunstrap/daemon.py (spawn_daemon), tunstrap/cli.py (run_command) +Assertion: neither the variable name nor a distinctive marker carried *inside +its value* appears in ``/proc//environ``, paired with a positive +``PATH=`` check so a worker handed an empty environment could not pass +vacuously. +Method: the real CLI, the real detached worker, read back through ``/proc`` — +no mocked ``Popen``. Parametrized over the canonical name and a deliberately +non-canonical one; the non-canonical case is the one that discriminates. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import time +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.integration + +# Lives only inside the input variable's *value*. The worker learns it over +# stdin (as daemon.log_file), never through the environment, so finding these +# bytes in the worker's environ means the variable's value survived — catching +# a scrub that dropped the name but left the value under some other key. +MARKER = "TUNSTRAP-WORKER-ENV-MARKER" + + +@pytest.mark.parametrize( + "var_name", + ["TUNSTRAP_INPUT", "TG_TUNSTRAP_PAYLOAD"], + ids=["canonical", "non-canonical"], +) +def test_real_worker_does_not_inherit_the_input_variable(tmp_path: Path, var_name: str) -> None: + """Inspect the real detached worker rather than a mocked Popen call. + + The ``non-canonical`` case is the regression guard: it is red against a + scrub hardcoded to ``TUNSTRAP_INPUT`` and green only once the name + ``--input-env`` was given is threaded through to ``spawn_daemon``. + """ + session_dir = tmp_path / "session" + log_file = tmp_path / f"{MARKER}.log" + env = dict(os.environ) + env.pop("TUNSTRAP_INPUT", None) # no ambient value may satisfy this test + env[var_name] = json.dumps({"nodes": {}, "daemon": {"log_file": str(log_file)}}) + proc = subprocess.Popen( + [ + "tunstrap", + "run", + "--input-env", + var_name, + "--output-var", + "TF_VAR_tunstrap", + "--session-dir", + str(session_dir), + "--", + "sleep", + "10", + ], + env=env, + ) + try: + identity = session_dir / "tunnel-data" / "daemon.pid" + deadline = time.monotonic() + 5 + while not identity.exists() and time.monotonic() < deadline: + time.sleep(0.05) + assert identity.exists(), "worker did not publish its identity" + worker_env = Path(f"/proc/{identity.read_text().strip()}/environ").read_bytes() + + # Messages hoisted to locals: black and ruff format disagree on the + # parenthesised assert-message construct (recorded repo-wide). + leaked_name = f"the worker inherited {var_name}, which holds the SSH private key" + leaked_value = "the input variable's value survived in the worker environment" + assert f"{var_name}=".encode() not in worker_env, leaked_name + assert MARKER.encode() not in worker_env, leaked_value + # Anti-vacuity: the worker env must still be a real inherited one, so a + # scrub that handed the worker an empty environment cannot pass here. + assert b"PATH=" in worker_env, "worker env must still inherit the parent environment" + finally: + proc.terminate() + proc.wait(timeout=10) diff --git a/tests/integration/test_fetch_files.py b/tests/integration/test_fetch_files.py index f0f6699..8d495c1 100644 --- a/tests/integration/test_fetch_files.py +++ b/tests/integration/test_fetch_files.py @@ -17,7 +17,6 @@ from tests.integration.conftest import tunstrap_start - pytestmark = pytest.mark.integration diff --git a/tests/integration/test_fetch_security.py b/tests/integration/test_fetch_security.py index 352b962..321cb5c 100644 --- a/tests/integration/test_fetch_security.py +++ b/tests/integration/test_fetch_security.py @@ -1,13 +1,20 @@ -"""Fetched-file content stays out of logs. +"""Fetched-file content stays out of logs, --output-var, and the materialized manifest. -Validates: fetched file bytes are emitted only on stdout; never copied -to the daemon log file or to stderr. -Code: tunstrap/manager.py, tunstrap/daemon.py +Validates: fetched file bytes are emitted only on `start`'s raw stdout +envelope (that channel is unaffected and out of scope for R16/#15); never +copied to the daemon log file or to stderr; and -- the stronger property this +task adds -- never present in `--output-var`/the materialized `output.json`, +only reachable through the reported `path` on disk. +Code: tunstrap/manager.py, tunstrap/daemon.py, tunstrap/cli.py, tunstrap/envrender.py """ from __future__ import annotations +import base64 +import json import os +import subprocess +import sys import tempfile from pathlib import Path from typing import Any @@ -16,7 +23,6 @@ from tests.integration.conftest import tunstrap_start - pytestmark = pytest.mark.integration @@ -67,7 +73,14 @@ def test_stdout_only_carrier_of_content( prepared_files: dict[str, Path], started_daemons: list[str], ) -> None: - """Fetched content_b64 appears on stdout only, never on stderr.""" + """Fetched content_b64 appears on start's raw stdout envelope, never on stderr. + + This test sends the default unmaterialized payload, for which start's JSON + stdout retains the complete envelope. The stronger claim -- that fetched + content never rides the consumer-facing + --output-var/materialized channels -- is + test_output_var_and_materialized_output_never_carry_fetched_content, below. + """ payload = { "nodes": { "a": { @@ -88,3 +101,93 @@ def test_stdout_only_carrier_of_content( content_b64 = body["connections"]["a"]["fetch_files"]["kubeconfig"]["content_b64"] assert content_b64 in outcome["stdout"] assert content_b64 not in outcome["stderr"] + + +_FETCH_SECURITY_PROBE = """ +import base64, hashlib, json, os, stat, sys + +decoded = json.loads(os.environ["TF_VAR_tunstrap"]) +ff = decoded["nodes"]["a"]["fetch_files"]["kubeconfig"] +assert "content_b64" not in ff, "content_b64 leaked into --output-var" +assert set(ff) == {"path", "size", "sha256"}, ff + +materialized = json.load(open(os.environ["TUNSTRAP_OUTPUT_FILE"])) +mff = materialized["nodes"]["a"]["fetch_files"]["kubeconfig"] +assert "content_b64" not in mff, "content_b64 leaked into the materialized output.json" +assert mff["path"] == ff["path"] + +# The checks that must run inside this process, before run's teardown +# removes tunnel-data/: mode and byte-identity of the materialized file. +raw = open(mff["path"], "rb").read() +assert stat.S_IMODE(os.stat(mff["path"]).st_mode) == 0o600 +assert hashlib.sha256(raw).hexdigest() == mff["sha256"] +assert base64.b64encode(raw).decode() not in os.environ["TF_VAR_tunstrap"] +sys.stdout.write("PROBE_OK") +""" + + +def test_output_var_and_materialized_output_never_carry_fetched_content( + ssh_test_cluster: dict[str, Any], + prepared_files: dict[str, Path], + tmp_path: Path, + started_daemons: list[str], +) -> None: + """Fetched content never rides --output-var or the materialized output.json + -- only the 0600 on-disk file at the reported path carries it. + + A STRONGER security property than the sibling test above: that test + accepts content_b64 riding start's raw stdout envelope (unaffected, + unchanged, out of scope). This one proves fetched bytes are absent from + every consumer-facing channel `run` exposes -- TF_VAR_tunstrap, the + materialized output.json, stdout, and stderr -- and are reachable only + through the reported `path`, whose bytes match the fetched source exactly. + """ + session_dir = tmp_path / "session" + payload = json.dumps( + { + "nodes": { + "a": { + "host": "127.0.0.1", + "user": "tester", + "port": ssh_test_cluster["ports"]["sshd-a"], + "ssh_pkey": ssh_test_cluster["private_pem"], + "remote_targets": {"p": "127.0.0.1:6443"}, + "fetch_files": {"kubeconfig": {"path": "/srv/files/kubeconfig"}}, + } + } + } + ) + env = dict(os.environ) + env["TUNSTRAP_INPUT"] = payload + started_daemons.append(str(session_dir)) + result = subprocess.run( + [ + "tunstrap", + "run", + "--input-env", + "TUNSTRAP_INPUT", + "--output-var", + "TF_VAR_tunstrap", + "--session-dir", + str(session_dir), + "--", + sys.executable, + "-c", + _FETCH_SECURITY_PROBE, + ], + text=True, + capture_output=True, + check=False, + env=env, + ) + assert result.returncode == 0, f"stdout={result.stdout!r} stderr={result.stderr!r}" + assert result.stdout == "PROBE_OK", result.stdout + + # The mode/byte-identity checks against the materialized file itself run + # inside the probe, above -- run's teardown removes tunnel-data/ once this + # subprocess returns, so the file is gone by the time this assertion runs. + host_bytes = prepared_files["kubeconfig"].read_bytes() + content_b64 = base64.b64encode(host_bytes).decode() + assert content_b64 not in result.stdout + assert content_b64 not in result.stderr + assert not (session_dir / "tunnel-data").exists(), "teardown left tunnel-data behind" diff --git a/tests/integration/test_kube_targets.py b/tests/integration/test_kube_targets.py index 94a40dd..2c6380c 100644 --- a/tests/integration/test_kube_targets.py +++ b/tests/integration/test_kube_targets.py @@ -4,8 +4,8 @@ server points at the local forwarded port and whose tls-server-name is the probed SAN; materialize writes the file; stop cleans up the session dir. Code: tunstrap kube mode (kube.py, manager.py, _worker.py, cli.py) -Assertion: output.connections[node].kube_targets.k3s.endpoint is local; -tls_server_name == 'dev-kube-1'; materialized path exists then is removed. +Assertion: output.connections[node].kube_targets.k3s.endpoint is local; the +materialized kubeconfig has tls-server-name == 'dev-kube-1'; path exists then is removed. Method: drive `tunstrap start`/`stop` subprocesses against compose. """ @@ -87,11 +87,12 @@ def test_kube_target_end_to_end( out = json.loads(result.stdout) kt = out["connections"]["node"]["kube_targets"]["k3s"] assert kt["endpoint"].startswith("https://127.0.0.1:"), kt - assert kt["tls_server_name"] == "dev-kube-1", kt - patched = base64.b64decode(kt["content_b64"]).decode() + assert kt["context"] == "tunstrap-node-k3s", kt + assert kt["path"] is not None and Path(kt["path"]).is_file(), kt + patched = Path(kt["path"]).read_text() assert "127.0.0.1" in patched assert "tls-server-name: dev-kube-1" in patched - assert kt["path"] is not None and Path(kt["path"]).is_file(), kt + assert set(kt) == {"path", "context", "endpoint"}, kt # Stop cleans up the session dir's tunnel-data. stop = subprocess.run( diff --git a/tests/integration/test_multiport.py b/tests/integration/test_multiport.py index c33d00b..cb7e156 100644 --- a/tests/integration/test_multiport.py +++ b/tests/integration/test_multiport.py @@ -13,7 +13,6 @@ from tests.integration.conftest import tunstrap_start - pytestmark = pytest.mark.integration diff --git a/tests/integration/test_remote_targets.py b/tests/integration/test_remote_targets.py index db5e19f..5653450 100644 --- a/tests/integration/test_remote_targets.py +++ b/tests/integration/test_remote_targets.py @@ -17,7 +17,6 @@ from tests.integration.conftest import tunstrap_start - pytestmark = pytest.mark.integration diff --git a/tests/integration/test_run_env_io.py b/tests/integration/test_run_env_io.py new file mode 100644 index 0000000..ae707f2 --- /dev/null +++ b/tests/integration/test_run_env_io.py @@ -0,0 +1,231 @@ +"""`run --input-env` / `--output-var` against the real docker SSH rig. + +Validates: a complete InputSchema handed to run through the environment opens +real tunnels; the child receives the unified output structure as JSON +(projected to drop the kube credentials — see +tests/unit/test_cli_run_output_var_projection.py) and the advertised endpoints +actually accept connections; multi-node results carry every node and no +target-scoped TUNSTRAP_* scalars; and the teardown removes the session. + +Both nodes point at `sshd-bastion`: it is the only service in the rig with +AllowTcpForwarding enabled and a route to the internal `target-1`, so it is +the only host through which bytes can move. Using two node keys for the same +container still exercises the multi-node output path end to end. + +Code: tunstrap/cli.py, tunstrap/cli_input.py, tunstrap/envrender.py +Method: run the installed `tunstrap` console script as a subprocess with the +payload in its environment; the child is a Python probe that asserts on the +injected variables and opens real TCP connections to the forwarded ports. + +No assertion on `result.stderr` here: these tests own the env-injection +contract, and the separate claim that a successful run leaves stderr empty is +asserted in test_run_teardown_latency.py. An earlier version of this note said +the opposite -- that every successful run emitted `run: daemon not stopped +cleanly: identity changed during grace` and spent the whole 10s grace window +doing it. That was the unreaped-zombie defect in the grace poll, since fixed +in session.py (`_has_exited`). +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest + +pytestmark = pytest.mark.integration + +_TIMEOUT = 120 + +_PROBE_SINGLE = """ +import json, os, socket, sys +envelope = json.loads(os.environ["TF_VAR_tunstrap"]) +assert sorted(envelope["nodes"]) == ["hub"], envelope["nodes"] +endpoint = envelope["nodes"]["hub"]["ports"]["web"] +materialized = json.load(open(os.environ["TUNSTRAP_OUTPUT_FILE"])) +assert materialized["nodes"]["hub"]["ports"]["web"] == endpoint, ( + materialized["nodes"]["hub"]["ports"]["web"], endpoint +) +port = int(endpoint.rsplit(":", 1)[1]) +assert envelope["session"]["pid"] > 0, envelope["session"]["pid"] +assert envelope["session"]["session_dir"], envelope["session"]["session_dir"] +socket.create_connection(("127.0.0.1", port), 5).close() +sys.stdout.write("PROBE_OK") +""" + +_PROBE_MULTI = """ +import json, os, socket, sys +envelope = json.loads(os.environ["TF_VAR_tunstrap"]) +assert sorted(envelope["nodes"]) == ["edge", "hub"], sorted(envelope["nodes"]) +# TUNSTRAP_INPUT is the payload variable the parent inherited, not an injected +# scalar; TUNSTRAP_SESSION_DIR/_PID/_OUTPUT_FILE are the three sanctioned +# survivors, unconditional on node count -- not the ambiguous per-target scalars. +survivors = {"TUNSTRAP_SESSION_DIR", "TUNSTRAP_PID", "TUNSTRAP_OUTPUT_FILE", "TUNSTRAP_INPUT"} +leaked = sorted(k for k in os.environ if k.startswith("TUNSTRAP_") and k not in survivors) +assert leaked == [], leaked +for name in ("hub", "edge"): + port = int(envelope["nodes"][name]["ports"]["web"].rsplit(":", 1)[1]) + socket.create_connection(("127.0.0.1", port), 5).close() +sys.stdout.write("PROBE_OK") +""" + + +def _node(cluster: dict[str, Any]) -> dict[str, Any]: + return { + "host": cluster["host"], + "port": cluster["bastion_port"], + "user": cluster["user"], + "ssh_pkey": cluster["private_pem"], + "remote_targets": {"web": "target-1:80"}, + "required": True, + } + + +def _payload(cluster: dict[str, Any], *, names: list[str]) -> str: + return json.dumps({"nodes": {name: _node(cluster) for name in names}}) + + +def _run( + args: list[str], payload: str | None, cwd: Path | None = None +) -> subprocess.CompletedProcess[str]: + env = dict(os.environ) + # Environment hygiene, mirroring the shim's `env -u KUBECONFIG`: an + # inherited KUBECONFIG is a silent fallback route to a cluster and has no + # business in a test about tunnels. It is hygiene here, not an oracle -- + # these nodes declare no kube_targets, so run would never inject one and an + # assertion on its absence could not fail. The falsifiable version of that + # check is the unit test `test_multi_node_suppression_uses_input_count`. + env.pop("KUBECONFIG", None) + if payload is None: + env.pop("TUNSTRAP_INPUT", None) + else: + env["TUNSTRAP_INPUT"] = payload + return subprocess.run( + ["tunstrap", *args], + text=True, + capture_output=True, + check=False, + env=env, + cwd=cwd, + timeout=_TIMEOUT, + ) + + +def test_single_node_env_input_and_structured_output( + ssh_test_cluster: dict[str, Any], + tmp_path: Path, + started_daemons: list[str], +) -> None: + """One node: the child gets the envelope, the scalars, and a live endpoint.""" + session_dir = tmp_path / "session" + started_daemons.append(str(session_dir)) + result = _run( + [ + "run", + "--input-env", + "TUNSTRAP_INPUT", + "--output-var", + "TF_VAR_tunstrap", + "--session-dir", + str(session_dir), + "--", + sys.executable, + "-c", + _PROBE_SINGLE, + ], + _payload(ssh_test_cluster, names=["hub"]), + ) + assert result.returncode == 0, f"stdout={result.stdout!r} stderr={result.stderr!r}" + assert result.stdout == "PROBE_OK" + assert not (session_dir / "tunnel-data").exists(), "teardown left tunnel-data behind" + + +def test_multi_node_env_input_carries_every_node_and_no_scalars( + ssh_test_cluster: dict[str, Any], + tmp_path: Path, + started_daemons: list[str], +) -> None: + """Two nodes: both endpoints live, envelope keyed by node, zero TUNSTRAP_* leak.""" + session_dir = tmp_path / "session" + started_daemons.append(str(session_dir)) + result = _run( + [ + "run", + "--input-env", + "TUNSTRAP_INPUT", + "--output-var", + "TF_VAR_tunstrap", + "--session-dir", + str(session_dir), + "--", + sys.executable, + "-c", + _PROBE_MULTI, + ], + _payload(ssh_test_cluster, names=["hub", "edge"]), + ) + assert result.returncode == 0, f"stdout={result.stdout!r} stderr={result.stderr!r}" + assert result.stdout == "PROBE_OK" + assert not (session_dir / "tunnel-data").exists(), "teardown left tunnel-data behind" + + +def test_multi_node_without_output_var_now_succeeds( + ssh_test_cluster: dict[str, Any], tmp_path: Path, started_daemons: list[str] +) -> None: + """Two nodes and no --output-var succeeds: materialization covers multi-node + unconditionally, so the opt-in --output-var gate has nothing left to force. + + Materialization *content* is not re-verified here -- that is the unit + tier's job (test_cli_run_materialize.py); this test's remaining job is + confirming the real console script allows the case. + """ + session_dir = tmp_path / "session" + started_daemons.append(str(session_dir)) + result = _run( + [ + "run", + "--input-env", + "TUNSTRAP_INPUT", + "--session-dir", + str(session_dir), + "--", + "true", + ], + _payload(ssh_test_cluster, names=["hub", "edge"]), + ) + assert result.returncode == 0, f"stdout={result.stdout!r} stderr={result.stderr!r}" + assert "MultiNodeEnvUnsupported" not in result.stderr, result.stderr + assert not (session_dir / "tunnel-data").exists(), "teardown left tunnel-data behind" + + +def test_unset_payload_variable_is_exit_1(tmp_path: Path) -> None: + """An unset payload variable is a typed exit-1 error on stderr.""" + del tmp_path # the run is rejected before any session dir is touched + result = _run(["run", "--input-env", "TUNSTRAP_INPUT", "--", "true"], None) + assert result.returncode == 1, f"stdout={result.stdout!r} stderr={result.stderr!r}" + assert result.stdout == "", f"run leaked to stdout: {result.stdout!r}" + assert json.loads(result.stderr)["error"] == "SchemaValidationError" + + +def test_connection_flags_under_input_env_are_exit_64( + ssh_test_cluster: dict[str, Any], +) -> None: + """The conflict matrix holds through the real console script, not just CliRunner.""" + result = _run( + [ + "run", + "--input-env", + "TUNSTRAP_INPUT", + "--target", + "web=target-1:80", + "--", + "true", + ], + _payload(ssh_test_cluster, names=["hub"]), + ) + assert result.returncode == 64, f"stdout={result.stdout!r} stderr={result.stderr!r}" + assert "connection flags are redundant" in result.stderr diff --git a/tests/integration/test_run_stdout_purity.py b/tests/integration/test_run_stdout_purity.py new file mode 100644 index 0000000..61be0b6 --- /dev/null +++ b/tests/integration/test_run_stdout_purity.py @@ -0,0 +1,211 @@ +"""`run`'s stdout must be byte-for-byte the child's stdout. + +Validates: nothing tunstrap writes reaches fd 1 after the child starts, across +a clean run, a non-zero child, a zero-grace stop (which takes the SIGKILL or +identity-changed branch), and a teardown whose identity check fails. Under the +tofu proxy this stream is parsed by Terragrunt, so a single injected byte is a +correctness bug, not a cosmetic one. + +This is the only place the invariant is checked at the file-descriptor level. +The unit tests cannot do it: CliRunner swaps `sys.stdout` for an in-memory +object, so `result.stdout == ""` there proves only that no Python-level write +happened -- it would not notice an `os.write(1, ...)`, a C-level write, or a +grandchild that inherited fd 1. + +The oracle is differential, not a literal. Each test compares the wrapped +process's stdout with the bytes the *same* child script produces when run +without the wrapper. Comparing against a hand-written constant would only +prove tunstrap did not inject one specific thing; comparing against the +unwrapped child also catches a byte that was dropped, reordered or translated. + +Two deliberate choices make that comparison meaningful: + +* stdout is captured as **bytes** (no ``text=True``). Universal-newline + translation would silently rewrite ``\\r\\n`` and hide exactly the class of + corruption this test exists to catch. +* the child emits a CR, an LF and a final byte with **no trailing newline**, so + an appended diagnostic, a stripped terminator or a translated line ending all + change the result. + +Code: tunstrap/cli.py (_teardown_run), tunstrap/session.py (stop_session) +Assertion: result.stdout equals the unwrapped child's stdout exactly; +diagnostics, when any, appear only on stderr. +Method: the installed console script as a subprocess, forwarding through +`sshd-bastion` -- the only rig service with AllowTcpForwarding enabled and a +route to the internal `target-1`. + +Note: these tests assert on stdout only, except where a diagnostic is the point +(the tampered-identity case at the bottom). That a successful run also leaves +stderr *empty* is a separate invariant and is asserted in +test_run_teardown_latency.py. An earlier version of this note recorded the +opposite -- that every successful run burned the full grace window and then +wrote `run: daemon not stopped cleanly: identity changed during grace`, which +is why these tests used to take ~10s each. That was the unreaped-zombie defect +in the grace poll, since fixed in session.py (`_has_exited`). +""" + +from __future__ import annotations + +import os +import signal +import subprocess +import time +from pathlib import Path +from typing import Any + +import pytest + +pytestmark = pytest.mark.integration + +SENTINEL = "CHILD_STDOUT_SENTINEL" +_TAIL = "TAIL_NO_TRAILING_NEWLINE" +_TIMEOUT = 120 + +# printf, not echo: no trailing newline, and \r\n is emitted literally. +_CHILD_SCRIPT = f"printf '%s\\r\\n%s' {SENTINEL} {_TAIL}" +_EXPECTED = f"{SENTINEL}\r\n{_TAIL}".encode() + + +def _write_key(tmp_path: Path, pem: str) -> Path: + key_path = tmp_path / "id_test" + key_path.write_text(pem) + key_path.chmod(0o600) + return key_path + + +def _base_args(cluster: dict[str, Any], key: Path, session_dir: Path) -> list[str]: + return [ + "tunstrap", + "run", + f"{cluster['user']}@localhost:{cluster['bastion_port']}", + "--ssh-key", + str(key), + "--target", + "web=target-1:80", + "--session-dir", + str(session_dir), + ] + + +def _run(argv: list[str]) -> subprocess.CompletedProcess[bytes]: + """Run argv capturing raw bytes: no newline translation, no decoding.""" + return subprocess.run(argv, capture_output=True, check=False, timeout=_TIMEOUT) + + +def _baseline(script: str, tmp_path: Path) -> bytes: + """Stdout of the identical child script with no wrapper: the oracle. + + ``TUNSTRAP_SESSION_DIR`` is pointed at a scratch directory so the tamper + variant's redirect succeeds here too, and the two runs really do execute + the same script. + """ + root = tmp_path / "baseline" + (root / "tunnel-data").mkdir(parents=True, exist_ok=True) + env = dict(os.environ) + env["TUNSTRAP_SESSION_DIR"] = str(root) + produced = subprocess.run( + ["sh", "-c", script], + capture_output=True, + check=False, + env=env, + timeout=_TIMEOUT, + ).stdout + # Guard the oracle itself: an empty or malformed baseline would make every + # comparison below vacuous, which is the failure mode this suite keeps + # finding elsewhere. + assert produced == _EXPECTED, f"baseline child produced {produced!r}" + return produced + + +def test_stdout_is_only_the_child_on_success( + ssh_test_cluster: dict[str, Any], tmp_path: Path, started_daemons: list[str] +) -> None: + """A clean run emits the child's bytes and nothing else.""" + session_dir = tmp_path / "session" + started_daemons.append(str(session_dir)) + key = _write_key(tmp_path, ssh_test_cluster["private_pem"]) + result = _run( + [*_base_args(ssh_test_cluster, key, session_dir), "--", "sh", "-c", _CHILD_SCRIPT] + ) + expected = _baseline(_CHILD_SCRIPT, tmp_path) + assert result.returncode == 0, f"stderr={result.stderr!r}" + assert result.stdout == expected, f"run injected bytes: {result.stdout!r}" + + +def test_stdout_is_only_the_child_on_nonzero_exit( + ssh_test_cluster: dict[str, Any], tmp_path: Path, started_daemons: list[str] +) -> None: + """A failing child keeps its exit code and its exclusive claim on stdout.""" + session_dir = tmp_path / "session" + started_daemons.append(str(session_dir)) + key = _write_key(tmp_path, ssh_test_cluster["private_pem"]) + script = f"{_CHILD_SCRIPT}; exit 7" + result = _run([*_base_args(ssh_test_cluster, key, session_dir), "--", "sh", "-c", script]) + expected = _baseline(script, tmp_path) + assert result.returncode == 7, f"stderr={result.stderr!r}" + assert result.stdout == expected, f"run injected bytes: {result.stdout!r}" + + +def test_stdout_is_only_the_child_with_zero_grace( + ssh_test_cluster: dict[str, Any], tmp_path: Path, started_daemons: list[str] +) -> None: + """--grace-seconds 0 takes the escalation path; stdout is still pure.""" + session_dir = tmp_path / "session" + started_daemons.append(str(session_dir)) + key = _write_key(tmp_path, ssh_test_cluster["private_pem"]) + result = _run( + [ + *_base_args(ssh_test_cluster, key, session_dir), + "--grace-seconds", + "0", + "--", + "sh", + "-c", + _CHILD_SCRIPT, + ] + ) + expected = _baseline(_CHILD_SCRIPT, tmp_path) + assert result.returncode == 0, f"stderr={result.stderr!r}" + assert result.stdout == expected, f"run injected bytes: {result.stdout!r}" + + +def test_stdout_is_pure_when_teardown_identity_fails( + ssh_test_cluster: dict[str, Any], tmp_path: Path, started_daemons: list[str] +) -> None: + """A tampered identity makes teardown fail loudly — on stderr only. + + The child overwrites tunnel-data/daemon.pid with 1 before exiting. pid 1 + exists but does not hold this session's lock, so verify_session returns + `mismatch`, stop_session refuses to signal it, and the real daemon + survives. The test then stops that daemon itself, using the pid recorded + in session.lock, so nothing leaks. + """ + session_dir = tmp_path / "session" + started_daemons.append(str(session_dir)) + key = _write_key(tmp_path, ssh_test_cluster["private_pem"]) + script = f'{_CHILD_SCRIPT}; printf "1\\n" > "$TUNSTRAP_SESSION_DIR/tunnel-data/daemon.pid"' + result = _run( + [ + *_base_args(ssh_test_cluster, key, session_dir), + "--auto-stop-idle-seconds", + "30", + "--", + "sh", + "-c", + script, + ] + ) + try: + expected = _baseline(script, tmp_path) + assert result.returncode == 0, f"stderr={result.stderr!r}" + assert result.stdout == expected, f"run injected bytes: {result.stdout!r}" + assert b"identity mismatch" in result.stderr, result.stderr + finally: + lock = session_dir / "session.lock" + if lock.is_file(): + daemon_pid = int(lock.read_text().strip()) + os.kill(daemon_pid, signal.SIGTERM) + deadline = time.monotonic() + 30 + while time.monotonic() < deadline and lock.exists(): + time.sleep(0.2) + assert not lock.exists(), "the orphaned daemon did not exit within 30s" diff --git a/tests/integration/test_run_teardown_latency.py b/tests/integration/test_run_teardown_latency.py new file mode 100644 index 0000000..a90ef14 --- /dev/null +++ b/tests/integration/test_run_teardown_latency.py @@ -0,0 +1,105 @@ +"""A successful `run` must not pay for the shutdown grace window. + +Validates: `run` against a live daemon tears down as soon as the daemon is +gone, and says nothing about it. This is the product-level claim behind the +tofu-proxy use case — `run` wraps every OpenTofu invocation, so a teardown +that always waits out `--grace-seconds` is a fixed tax on every command, and +the diagnostic it then emitted was a false failure line on a run that worked. + +Code: tunstrap/session.py (_has_exited, stop_session), tunstrap/cli.py +(_teardown_run_inner) +Assertion: wall-clock duration of a successful run stays far below the grace +window it was given, and stderr carries no teardown diagnostic. +Method: the installed console script as a subprocess against a real daemon, +forwarding through `sshd-bastion` — the only rig service with +AllowTcpForwarding enabled and a route to the internal `target-1`. The grace +window is set to 30s, well above anything the connection setup can cost, so +the two outcomes are unambiguous. + +Why an integration test: the unit suite for `stop_session` monkeypatches +`os.kill`, which makes an unreaped-child zombie unrepresentable. Only a real +daemon spawned by the real CLI reproduces the topology, and only a wall-clock +assertion notices that the answer arrived 30 seconds late. + +How these fail if the defect returns: with the reap removed from +`_has_exited`, `os.kill(pid, 0)` keeps succeeding against the daemon's zombie +for the entire window. `test_successful_run_does_not_wait_out_the_grace_window` +then measures >= GRACE seconds against a BUDGET of half that, and +`test_successful_run_reports_no_teardown_diagnostic` finds +`run: daemon not stopped cleanly: identity changed during grace` on stderr. +""" + +from __future__ import annotations + +import subprocess +import time +from pathlib import Path +from typing import Any + +import pytest + +pytestmark = pytest.mark.integration + +_TIMEOUT = 120 +# Deliberately larger than the 10s default: a run that burns this is impossible +# to mistake for a slow runner, and a healthy run is unaffected by its size. +GRACE = 30 +# Teardown of a healthy daemon costs one 0.5s poll interval. Everything else in +# the budget is SSH connection setup, measured at well under a second. +BUDGET = GRACE / 2 + + +def _run_once(cluster: dict[str, Any], tmp_path: Path, session_dir: Path) -> tuple[float, bytes]: + """Run a trivial child through `run`; return (elapsed seconds, stderr).""" + key = tmp_path / "id_test" + key.write_text(cluster["private_pem"]) + key.chmod(0o600) + argv = [ + "tunstrap", + "run", + f"{cluster['user']}@localhost:{cluster['bastion_port']}", + "--ssh-key", + str(key), + "--target", + "web=target-1:80", + "--session-dir", + str(session_dir), + "--grace-seconds", + str(GRACE), + "--", + "true", + ] + started = time.monotonic() + result = subprocess.run(argv, capture_output=True, check=False, timeout=_TIMEOUT) + elapsed = time.monotonic() - started + assert result.returncode == 0, f"run failed: {result.stderr!r}" + return elapsed, result.stderr + + +def test_successful_run_does_not_wait_out_the_grace_window( + ssh_test_cluster: dict[str, Any], tmp_path: Path, started_daemons: list[str] +) -> None: + """A run whose daemon shuts down cleanly returns promptly.""" + session_dir = tmp_path / "session" + started_daemons.append(str(session_dir)) + elapsed, _stderr = _run_once(ssh_test_cluster, tmp_path, session_dir) + assert elapsed < BUDGET, ( + f"run took {elapsed:.1f}s with --grace-seconds {GRACE}; " + "teardown is waiting out the grace window instead of noticing the daemon exited" + ) + + +def test_successful_run_reports_no_teardown_diagnostic( + ssh_test_cluster: dict[str, Any], tmp_path: Path, started_daemons: list[str] +) -> None: + """A clean stop takes stop_session's success branch, so run stays quiet. + + Asserts the whole stream is empty rather than just the absence of one + string: every `_teardown_run` diagnostic — a failed stop, a surviving + tunnel-data, an unremovable session root — is a real problem on a run that + otherwise succeeded, and none of them should ever appear here. + """ + session_dir = tmp_path / "session" + started_daemons.append(str(session_dir)) + _elapsed, stderr = _run_once(ssh_test_cluster, tmp_path, session_dir) + assert stderr == b"", f"a successful run wrote to stderr: {stderr!r}" diff --git a/tests/integration/test_start.py b/tests/integration/test_start.py index 38a810a..e34c0fe 100644 --- a/tests/integration/test_start.py +++ b/tests/integration/test_start.py @@ -13,7 +13,6 @@ from tests.integration.conftest import tunstrap_start - pytestmark = pytest.mark.integration diff --git a/tests/integration/test_status.py b/tests/integration/test_status.py index 5922098..1da9e1b 100644 --- a/tests/integration/test_status.py +++ b/tests/integration/test_status.py @@ -2,7 +2,7 @@ Validates: alive-then-dead status transitions keyed off --session-dir through the real CLI binary. -Code: tunstrap/cli.py::status +Code: tunstrap/cli.py::status_command """ from __future__ import annotations @@ -15,7 +15,6 @@ from tests.integration.conftest import tunstrap_start - pytestmark = pytest.mark.integration diff --git a/tests/integration/test_stop.py b/tests/integration/test_stop.py index 9a3a8ed..9c9c123 100644 --- a/tests/integration/test_stop.py +++ b/tests/integration/test_stop.py @@ -2,7 +2,7 @@ Validates: stop terminates an alive daemon, refuses on wrong identity, and reports not-found on a non-existent PID. -Code: tunstrap/cli.py::stop +Code: tunstrap/cli.py::stop_command """ from __future__ import annotations @@ -18,7 +18,6 @@ from tests.integration.conftest import tunstrap_start - pytestmark = pytest.mark.integration diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 7f6749d..28fe396 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -2,6 +2,7 @@ from __future__ import annotations +import shutil from typing import Any @@ -15,3 +16,20 @@ def make_node(**overrides: Any) -> dict[str, Any]: } base.update(overrides) return base + + +def cleaning_teardown( + session_dir: str, grace_seconds: int, *, minted_root: str | None = None +) -> None: + """Stand-in for ``cli._teardown_run`` that still removes a run-minted root. + + ``run`` mints its own session directory before spawning when the caller + supplies no ``--session-dir``, and owns removing it. A stub that ignored + ``minted_root`` would leak one temp directory per test. The ``None`` + default keeps this usable before that parameter exists (Task 4.1), and + means a caller-supplied ``--session-dir`` is never removed — matching the + real ``_teardown_run``, which also never touches a caller's directory. + """ + del session_dir, grace_seconds # signature parity with cli._teardown_run + if minted_root is not None: + shutil.rmtree(minted_root, ignore_errors=True) diff --git a/tests/unit/test_ci_version_coupling.py b/tests/unit/test_ci_version_coupling.py new file mode 100644 index 0000000..925eb34 --- /dev/null +++ b/tests/unit/test_ci_version_coupling.py @@ -0,0 +1,85 @@ +"""Guard the kubectl/node-image version coupling the CI comment asserts. + +`.github/workflows/test.yml` installs a kubectl whose version must match the +kindest/node image pinned as ``NODE_IMAGE`` in tests/e2e/rig.py - the workflow + comment at the kubectl step says so explicitly. Nothing enforced the coupling: + the pins originate from separate release streams, and Renovate annotations + cannot make distinct datasources move together. This repository is outside the + organization's Renovate autodiscovery scope, so the inert annotations were + removed rather than implying automated upkeep. + +A test is therefore the only enforcement that works regardless of enrolment. +Lives in the unit tier (not e2e) on purpose: the e2e job ``needs: unit``, so a +divergence fails the unit job and skips cluster setup rather than surfacing as +a confusing kubectl/cluster skew mid-run. Needs no cluster - two file reads. +It reads rig.py as TEXT rather than importing it, so the unit tier stays +decoupled from the e2e package's code. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.unit] + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_RIG = REPO_ROOT / "tests" / "e2e" / "rig.py" +_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "test.yml" + +# kindest/node:vX.Y.Z -> the Kubernetes version baked into the node image. +_NODE_RE = re.compile(r'NODE_IMAGE\s*=\s*"kindest/node:v(\d+\.\d+)\.\d+"') +# dl.k8s.io/release/vX.Y.Z/ -> the kubectl the CI installs. +_KUBECTL_RE = re.compile(r"dl\.k8s\.io/release/v(\d+\.\d+)\.\d+") + + +def test_kubectl_pin_matches_node_image_pin() -> None: + """The CI kubectl minor version matches the kind node image's. + + Asserted at the minor (X.Y) level: kubectl/cluster skew policy tolerates + +/-1 minor, but the recipe's intent is an exact match, and the divergence + that must not pass silently is a minor skew - e.g. a Renovate bump of + kubectl 1.34 -> 1.35 while the node stays 1.34. Patch differences + (1.34.0 vs 1.34.1) are benign - kubectl and kindest/node release patches + independently - so an exact-version assertion would false-positive on those. + + Fails-when-broken verbatim red recorded in the task report: node held at + 1.34, kubectl pin temporarily raised to 1.35. + + ``re.search`` binds to the *first* ``dl.k8s.io`` URL in the workflow. With + one kubectl-install step that is fine, but a second step (an arm64 + runner, a macOS job, ...) would leave the guard checking only the first + match while the second drifts unwatched - silently green. ``findall`` plus + an exactly-one assertion turns that into a loud, named failure instead. A + full YAML parse is deliberately not used here: it would let the test + reason over `jobs`/`steps` structurally, but this guard only needs to + reject a *second matching URL string*, which a plain-text scan already + catches at a fraction of the cost. + """ + rig = _RIG.read_text() + node = _NODE_RE.search(rig) + assert node is not None, ( + f"could not parse a kindest/node vX.Y.Z from {_RIG}; the NODE_IMAGE " + f"assignment format has changed or moved" + ) + + workflow = _WORKFLOW.read_text() + kubectl_matches = _KUBECTL_RE.findall(workflow) + assert len(kubectl_matches) == 1, ( + f"expected exactly one dl.k8s.io/release/vX.Y.Z/ URL in {_WORKFLOW}, " + f"found {len(kubectl_matches)}: {kubectl_matches}. A second kubectl " + f"install step means this guard is only watching one of them - name " + f"the new step explicitly or extend this test to cover it, rather " + f"than letting the second URL drift unchecked." + ) + kubectl_version = kubectl_matches[0] + + assert kubectl_version == node.group(1), ( + f"kubectl pin v{kubectl_version} does not match NODE_IMAGE " + f"(kindest/node v{node.group(1)} in {_RIG}). These must move together: " + f"bump both the dl.k8s.io/release/vX.Y.Z/ URL in {_WORKFLOW} and " + f"NODE_IMAGE in tests/e2e/rig.py, or the e2e cluster and its kubectl " + f"will skew." + ) diff --git a/tests/unit/test_cli_input.py b/tests/unit/test_cli_input.py index 1555a39..5f64955 100644 --- a/tests/unit/test_cli_input.py +++ b/tests/unit/test_cli_input.py @@ -1,9 +1,12 @@ # tests/unit/test_cli_input.py import pytest -from tunstrap.cli_input import parse_endpoint, parse_named, build_single_node_schema + +from tunstrap.cli_input import build_single_node_schema, parse_endpoint, parse_named from tunstrap.exceptions import SchemaValidationError from tunstrap.schemas import DaemonOptions +pytestmark = pytest.mark.unit + def test_parse_endpoint_defaults_port(): assert parse_endpoint("root@host") == ("root", "host", 22) diff --git a/tests/unit/test_cli_input_env.py b/tests/unit/test_cli_input_env.py new file mode 100644 index 0000000..52781e6 --- /dev/null +++ b/tests/unit/test_cli_input_env.py @@ -0,0 +1,142 @@ +"""InputSchema from an environment variable (`run --input-env VAR`). + +Validates: build_schema_from_env parses and validates exactly like start's +stdin path, and turns every failure into a SchemaValidationError (exit 1) +whose details never carry the offending input. +Code: tunstrap/cli_input.py +Assertion: the happy path equals the stdin-parsed equivalent; each failure +mode raises SchemaValidationError with the documented details keys and no +ssh_pkey anywhere in the serialised error. +Method: monkeypatch.setenv plus direct calls; no CLI, no daemon. +""" + +from __future__ import annotations + +import json + +import pytest + +from tunstrap.cli_input import build_schema_from_env +from tunstrap.exceptions import SchemaValidationError +from tunstrap.schemas import InputSchema + +pytestmark = pytest.mark.unit + +VAR = "TUNSTRAP_INPUT_TEST" + +_NODE = { + "host": "h.example.net", + "user": "u", + "ssh_password": "p", + "remote_targets": {"db": "127.0.0.1:5432"}, +} + + +def test_valid_single_node_equals_stdin_parse(monkeypatch: pytest.MonkeyPatch) -> None: + """The env path yields the same InputSchema the stdin path would.""" + raw = json.dumps({"nodes": {"node": _NODE}}) + monkeypatch.setenv(VAR, raw) + assert build_schema_from_env(VAR) == InputSchema.model_validate(json.loads(raw)) + + +def test_valid_multi_node(monkeypatch: pytest.MonkeyPatch) -> None: + """Multi-node payloads parse; run's own gate decides what to do with them.""" + monkeypatch.setenv(VAR, json.dumps({"nodes": {"a": _NODE, "b": dict(_NODE)}})) + schema = build_schema_from_env(VAR) + assert sorted(schema.nodes) == ["a", "b"] + + +@pytest.mark.parametrize("value", [None, "", " \n\t "]) +def test_unset_empty_or_whitespace(monkeypatch: pytest.MonkeyPatch, value: str | None) -> None: + """Unset, empty and whitespace-only are the same failure, and name the var.""" + if value is None: + monkeypatch.delenv(VAR, raising=False) + else: + monkeypatch.setenv(VAR, value) + with pytest.raises(SchemaValidationError) as excinfo: + build_schema_from_env(VAR) + assert excinfo.value.details == {"var": VAR} + + +def test_malformed_json_reports_position(monkeypatch: pytest.MonkeyPatch) -> None: + """Non-JSON content reports the decoder's byte position, like stdin does.""" + monkeypatch.setenv(VAR, "{invalid") + with pytest.raises(SchemaValidationError) as excinfo: + build_schema_from_env(VAR) + assert excinfo.value.details["var"] == VAR + assert isinstance(excinfo.value.details["position"], int) + + +def test_schema_invalid_reports_errors(monkeypatch: pytest.MonkeyPatch) -> None: + """Valid JSON that is not an InputSchema reports pydantic's errors list.""" + monkeypatch.setenv(VAR, json.dumps({"nodes": {"node": {"host": "h"}}})) + with pytest.raises(SchemaValidationError) as excinfo: + build_schema_from_env(VAR) + assert excinfo.value.details["var"] == VAR + assert excinfo.value.details["errors"], "pydantic errors must be surfaced" + + +def test_schema_error_never_echoes_the_private_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A malformed node must not echo ssh_pkey back through the error envelope.""" + secret = "-----BEGIN OPENSSH PRIVATE KEY-----\nDEADBEEF\n" + monkeypatch.setenv( + VAR, + json.dumps({"nodes": {"node": {"user": "u", "ssh_pkey": secret, "port": "not-an-int"}}}), + ) + with pytest.raises(SchemaValidationError) as excinfo: + build_schema_from_env(VAR) + assert "DEADBEEF" not in json.dumps(excinfo.value.to_error_output()) + + +def test_include_input_false_is_load_bearing_for_a_non_dict_secret( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Isolates ``include_input=False`` from the ``_scrub`` layer behind it. + + Two independent layers keep the PEM out of the error envelope, and the + test above cannot tell them apart: its failure lands on ``port``, so + pydantic's ``input`` is the whole node *dict*, which still has a literal + ``ssh_pkey`` key -- and ``TunstrapError._scrub`` strips that by key name + whether or not ``include_input=False`` was passed. Reverting the guard + leaves that test green (verified). + + Here the failure lands *on* ``ssh_pkey`` and its input is a bare list, so + there is no ``ssh_pkey`` key for ``_scrub`` to match on and the secret is + just a string inside a list it copies verbatim. ``include_input=False`` is + then the only thing standing between the PEM and stderr. + + Fails with the PEM in the rendered envelope if + ``tunstrap/cli_input.py::build_single_node_schema`` drops its guard. The + same shape would isolate the other error-rendering call sites; this pins + the one whose docstring makes the claim. + """ + secret = "-----BEGIN OPENSSH PRIVATE KEY-----\nDEADBEEF\n" + monkeypatch.setenv( + VAR, + json.dumps( + { + "nodes": { + "node": { + "user": "u", + "host": "h.example.net", + # A list where a string belongs: the error is reported + # against ssh_pkey itself, with the list as its input. + "ssh_pkey": [secret], + "remote_targets": {"p": "127.0.0.1:6443"}, + } + } + } + ), + ) + with pytest.raises(SchemaValidationError) as excinfo: + build_schema_from_env(VAR) + + # Guard the premise: if no error lands on ssh_pkey there is nothing for + # include_input to leak, and the assertion below would be vacuous. + locs = [error["loc"] for error in excinfo.value.details["errors"]] + assert any("ssh_pkey" in loc for loc in locs), f"no error landed on ssh_pkey: {locs}" + + rendered = json.dumps(excinfo.value.to_error_output()) + assert "DEADBEEF" not in rendered, f"the private key reached the error envelope: {rendered}" diff --git a/tests/unit/test_cli_parsing.py b/tests/unit/test_cli_parsing.py index 96d6f36..ab80e56 100644 --- a/tests/unit/test_cli_parsing.py +++ b/tests/unit/test_cli_parsing.py @@ -7,6 +7,8 @@ from __future__ import annotations +from importlib.metadata import version + import pytest from click.testing import CliRunner @@ -23,10 +25,33 @@ def test_help_exits_zero() -> None: def test_version_flag() -> None: - """Print package version and exit 0.""" + """Print the exact version the package metadata resolves, not the fallback. + + Compares the flag's output against ``importlib.metadata.version("tunstrap")`` + — the same source the lazy ``--version`` callback reads through the package + ``__init__``'s PEP 562 ``__getattr__``. This accepts ANY PEP 440 version + setuptools-scm/hatch-vcs derives: a 3-component release from a tagged clone + (``0.0.5.dev72+…``), a 2-component release from a tagless CI shallow clone + (``0.1.dev1+g6e691637c``), ``rcN``, ``+local`` — without a regex that guesses + at the release-segment width and breaks on the CI shape. + + Rejects the not-installed fallback by construction: when the package is not + installed, ``version()`` raises ``PackageNotFoundError`` (which is exactly + when the flag would emit ``0.0.0+unknown``), failing this test rather than + passing on the placeholder. The ``"unknown"`` check makes that intent + legible at a glance. + """ result = CliRunner().invoke(main, ["--version"]) assert result.exit_code == 0 - assert "tunstrap" in result.output + expected = version("tunstrap") + # Local message vars: black and ruff format disagree on the multi-line + # ``assert cond, (msg)`` form, so hoist (established pattern in this file). + mismatch_msg = ( + f"--version output does not match the resolved metadata version: {result.output!r}" + ) + assert result.output.strip() == f"tunstrap, version {expected}", mismatch_msg + fallback_msg = f"--version fell back to the not-installed placeholder: {result.output!r}" + assert "unknown" not in result.output, fallback_msg def test_unknown_subcommand_exits_64() -> None: diff --git a/tests/unit/test_cli_run.py b/tests/unit/test_cli_run.py index 952896c..94fcd9e 100644 --- a/tests/unit/test_cli_run.py +++ b/tests/unit/test_cli_run.py @@ -8,22 +8,28 @@ from __future__ import annotations +import signal as signal_mod +from pathlib import Path from typing import Any import pytest from click.testing import CliRunner +from tests.unit.conftest import cleaning_teardown from tunstrap import cli as cli_mod from tunstrap.cli import main +pytestmark = pytest.mark.unit -def _success_payload() -> dict[str, Any]: + +def _success_payload(session_dir: str | None) -> dict[str, Any]: + assert session_dir is not None return { "kind": "success", "payload": { "connections": {"h": {"ports": {"db": 5432}, "fetch_files": {}, "kube_targets": {}}}, "pid": 99, - "session_dir": "/s", + "session_dir": session_dir, "started_at": "now", }, } @@ -49,14 +55,22 @@ def send_signal(self, signum: int) -> None: FakePopen.signals.append(signum) -def test_run_injects_env_and_propagates_exit(monkeypatch): +def test_run_injects_env_and_propagates_exit(monkeypatch, tmp_path: Path): monkeypatch.setattr( - cli_mod, "spawn_daemon", lambda schema, session_dir=None: _success_payload() + cli_mod, + "spawn_daemon", + lambda schema, session_dir=None, *, input_env=None: _success_payload(session_dir), ) stops: list[tuple[str, int]] = [] - monkeypatch.setattr(cli_mod, "_teardown_run", lambda sd, gs: stops.append((sd, gs))) + + def _record(sd: str, gs: int, *, minted_root: str | None = None) -> None: + stops.append((sd, gs)) + cleaning_teardown(sd, gs, minted_root=minted_root) + + monkeypatch.setattr(cli_mod, "_teardown_run", _record) monkeypatch.setattr(cli_mod.subprocess, "Popen", FakePopen) + session_dir = str(tmp_path / "x") res = CliRunner().invoke( main, [ @@ -65,6 +79,8 @@ def test_run_injects_env_and_propagates_exit(monkeypatch): "--target", "db=127.0.0.1:5432", "--ssh-password-stdin", + "--session-dir", + session_dir, "--", "echo", "hi", @@ -75,10 +91,74 @@ def test_run_injects_env_and_propagates_exit(monkeypatch): assert res.exit_code == 7 assert FakePopen.last_cmd == ["echo", "hi"] assert FakePopen.last_env is not None - assert FakePopen.last_env["TUNSTRAP_DB_PORT"] == "5432" - # Child env is os.environ + render_env(output), not a bare dict. + assert FakePopen.last_env["TUNSTRAP_SESSION_DIR"] == session_dir + assert FakePopen.last_env["TUNSTRAP_PID"] == "99" + assert FakePopen.last_env["TUNSTRAP_OUTPUT_FILE"] == str( + Path(session_dir) / "tunnel-data" / "output.json" + ) + assert "TUNSTRAP_DB_PORT" not in FakePopen.last_env + # Child env is os.environ + the session/kube channel, not a bare dict. assert "PATH" in FakePopen.last_env - assert stops == [("/s", 10)], "teardown must run with resolved session dir" + assert stops == [(session_dir, 10)], "teardown must run with the session dir run owns" + + +class SignalledPopen(FakePopen): + """Child killed by a signal: ``Popen.wait()`` reports that as ``-N``.""" + + def __init__(self, cmd: list[str], env: dict[str, str] | None = None) -> None: + super().__init__(cmd, env) + self.returncode = -signal_mod.SIGTERM + + +def test_signalled_child_exits_with_the_shell_convention(monkeypatch): + """A child killed by SIGTERM surfaces as 143, not the truncated 241. + + ``Popen.wait()`` returns ``-N`` for "killed by signal N", and ``sys.exit`` + hands that straight to the OS, which truncates it modulo 256 -- so SIGTERM + arrived as 241 (verified: ``sys.exit(-15)`` yields returncode 241), a value + no caller can interpret. 128+N is what every shell puts in ``$?`` and what + a wrapper around tofu will compare against. + + Fails with ``-15`` if the normalisation in ``_run_child`` is removed. The + OS truncation to 241 is stdlib behaviour and is not re-tested here; what + this pins is the value tunstrap chooses to exit with. + """ + monkeypatch.setattr( + cli_mod, + "spawn_daemon", + lambda schema, session_dir=None, *, input_env=None: _success_payload(session_dir), + ) + monkeypatch.setattr(cli_mod, "_teardown_run", cleaning_teardown) + monkeypatch.setattr(cli_mod.subprocess, "Popen", SignalledPopen) + + res = CliRunner().invoke( + main, + ["run", "u@h", "--target", "db=127.0.0.1:5432", "--ssh-password-stdin", "--", "sleep", "1"], + input="secret\n", + ) + assert res.exit_code == 128 + signal_mod.SIGTERM + + +def test_normal_child_exit_code_is_untouched(monkeypatch): + """The negative control: a plain non-zero code is passed through as-is. + + Without this, normalising unconditionally (say ``abs(code)``) would pass + the test above while corrupting every ordinary exit code. + """ + monkeypatch.setattr( + cli_mod, + "spawn_daemon", + lambda schema, session_dir=None, *, input_env=None: _success_payload(session_dir), + ) + monkeypatch.setattr(cli_mod, "_teardown_run", cleaning_teardown) + monkeypatch.setattr(cli_mod.subprocess, "Popen", FakePopen) + + res = CliRunner().invoke( + main, + ["run", "u@h", "--target", "db=127.0.0.1:5432", "--ssh-password-stdin", "--", "true"], + input="secret\n", + ) + assert res.exit_code == 7 def test_run_requires_command(): @@ -88,10 +168,17 @@ def test_run_requires_command(): def test_run_teardown_on_child_exception(monkeypatch): monkeypatch.setattr( - cli_mod, "spawn_daemon", lambda schema, session_dir=None: _success_payload() + cli_mod, + "spawn_daemon", + lambda schema, session_dir=None, *, input_env=None: _success_payload(session_dir), ) stops: list[str] = [] - monkeypatch.setattr(cli_mod, "_teardown_run", lambda sd, gs: stops.append(sd)) + + def _record(sd: str, gs: int, *, minted_root: str | None = None) -> None: + stops.append(sd) + cleaning_teardown(sd, gs, minted_root=minted_root) + + monkeypatch.setattr(cli_mod, "_teardown_run", _record) def boom(cmd, env=None): raise OSError("no such binary") @@ -112,7 +199,7 @@ def test_run_session_active_exit3(monkeypatch): monkeypatch.setattr( cli_mod, "spawn_daemon", - lambda schema, session_dir=None: { + lambda schema, session_dir=None, *, input_env=None: { "kind": "session_active", "payload": {"error": "SessionActive"}, }, @@ -140,9 +227,11 @@ def test_run_forwards_signals(monkeypatch): import signal as signal_mod monkeypatch.setattr( - cli_mod, "spawn_daemon", lambda schema, session_dir=None: _success_payload() + cli_mod, + "spawn_daemon", + lambda schema, session_dir=None, *, input_env=None: _success_payload(session_dir), ) - monkeypatch.setattr(cli_mod, "_teardown_run", lambda sd, gs: None) + monkeypatch.setattr(cli_mod, "_teardown_run", cleaning_teardown) captured: dict[str, Any] = {} @@ -189,9 +278,11 @@ def test_run_rejects_output_option() -> None: def test_run_preserves_child_flags(monkeypatch: pytest.MonkeyPatch) -> None: """Tokens after `--` (including child's own flags) reach the child verbatim.""" monkeypatch.setattr( - cli_mod, "spawn_daemon", lambda schema, session_dir=None: _success_payload() + cli_mod, + "spawn_daemon", + lambda schema, session_dir=None, *, input_env=None: _success_payload(session_dir), ) - monkeypatch.setattr(cli_mod, "_teardown_run", lambda sd, gs: None) + monkeypatch.setattr(cli_mod, "_teardown_run", cleaning_teardown) monkeypatch.setattr(cli_mod.subprocess, "Popen", FakePopen) res = CliRunner().invoke( main, diff --git a/tests/unit/test_cli_run_args.py b/tests/unit/test_cli_run_args.py new file mode 100644 index 0000000..ef8e13d --- /dev/null +++ b/tests/unit/test_cli_run_args.py @@ -0,0 +1,261 @@ +"""`run`'s argument surface: one variadic, split after parsing. + +Validates: the exact documented shim invocation binds no connection and the +whole child command; flag mode is unchanged; option-looking child arguments +after `--` are never absorbed by tunstrap; every unusable arity is exit 64. +Code: tunstrap/cli.py (_split_run_args, run_command) +Assertion: the command handed to Popen, the schema handed to spawn_daemon, +and the exit codes/messages for the bad arities. +Method: CliRunner with spawn_daemon, subprocess.Popen and _teardown_run +monkeypatched; --input-env payloads supplied with monkeypatch.setenv. No +daemon, no docker: runs unchanged on macOS. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from click.testing import CliRunner + +from tests.unit.conftest import cleaning_teardown +from tunstrap import cli as cli_mod +from tunstrap.cli import main + +pytestmark = pytest.mark.unit + +VAR = "TUNSTRAP_INPUT" + +_NODE = { + "host": "h.example.net", + "user": "u", + "ssh_password": "p", + "remote_targets": {"db": "127.0.0.1:5432"}, +} + + +def _payload(**daemon: Any) -> str: + body: dict[str, Any] = {"nodes": {"node": _NODE}} + if daemon: + body["daemon"] = daemon + return json.dumps(body) + + +def _success_payload(session_dir: str | None) -> dict[str, Any]: + assert session_dir is not None + return { + "kind": "success", + "payload": { + "connections": {"node": {"ports": {"db": 5432}, "fetch_files": {}, "kube_targets": {}}}, + "pid": 99, + "session_dir": session_dir, + "started_at": "now", + }, + } + + +class FakePopen: + """Popen stand-in recording the command and env it was handed.""" + + last_cmd: list[str] | None = None + last_env: dict[str, str] | None = None + + def __init__(self, cmd: list[str], env: dict[str, str] | None = None) -> None: + FakePopen.last_cmd = cmd + FakePopen.last_env = env + self.returncode = 0 + + def wait(self) -> int: + return self.returncode + + def send_signal(self, signum: int) -> None: + """Accept forwarded signals; the fake child ignores them.""" + + +@pytest.fixture(name="captured") +def _captured(monkeypatch: pytest.MonkeyPatch) -> list[Any]: + """Capture the schema handed to spawn_daemon; stub out child + teardown.""" + seen: list[Any] = [] + + def _spawn( + schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + seen.append(schema) + return _success_payload(session_dir) + + monkeypatch.setattr(cli_mod, "spawn_daemon", _spawn) + monkeypatch.setattr(cli_mod.subprocess, "Popen", FakePopen) + monkeypatch.setattr(cli_mod, "_teardown_run", cleaning_teardown) + FakePopen.last_cmd = None + FakePopen.last_env = None + return seen + + +def test_exact_shim_invocation(monkeypatch: pytest.MonkeyPatch, captured: list[Any]) -> None: + """The documented shim invocation, verbatim, binds no connection.""" + monkeypatch.setenv(VAR, _payload()) + result = CliRunner().invoke( + main, + [ + "run", + "--input-env", + "TUNSTRAP_INPUT", + "--output-var", + "TF_VAR_tunstrap", + "--", + "tofu", + "plan", + ], + ) + assert result.exit_code == 0, result.stderr + assert FakePopen.last_cmd == ["tofu", "plan"] + assert sorted(captured[0].nodes) == ["node"] + assert captured[0].nodes["node"].host == "h.example.net" + + +def test_flag_mode_unchanged(tmp_path: Path, captured: list[Any]) -> None: + """Flag mode still binds CONNECTION, the flags, and the child command.""" + key = tmp_path / "id" + key.write_text("KEYMATERIAL\n") + result = CliRunner().invoke( + main, + [ + "run", + "user@host", + "--ssh-key", + str(key), + "--target", + "web=127.0.0.1:80", + "--", + "helm", + "list", + ], + ) + assert result.exit_code == 0, result.stderr + assert FakePopen.last_cmd == ["helm", "list"] + node = captured[0].nodes["node"] + assert (node.user, node.host, node.port) == ("user", "host", 22) + assert sorted(node.remote_targets) == ["web"] + assert node.ssh_pkey == "KEYMATERIAL\n" + + +def test_child_dash_arguments_survive(monkeypatch: pytest.MonkeyPatch, captured: list[Any]) -> None: + """Every `-`-prefixed child token after `--` reaches the child verbatim.""" + monkeypatch.setenv(VAR, _payload()) + result = CliRunner().invoke( + main, + ["run", "--input-env", VAR, "--", "tofu", "plan", "-out=x", "-var", "a=b"], + ) + assert result.exit_code == 0, result.stderr + assert FakePopen.last_cmd == ["tofu", "plan", "-out=x", "-var", "a=b"] + + +def test_child_may_use_tunstraps_own_flag_names( + monkeypatch: pytest.MonkeyPatch, captured: list[Any] +) -> None: + """A child flag spelled like a tunstrap flag is the child's, not tunstrap's.""" + monkeypatch.setenv(VAR, _payload()) + result = CliRunner().invoke( + main, ["run", "--input-env", VAR, "--", "env", "--ssh-key", "sneaky"] + ) + assert result.exit_code == 0, result.stderr + assert FakePopen.last_cmd == ["env", "--ssh-key", "sneaky"] + assert captured[0].nodes["node"].ssh_pkey is None, "tunstrap absorbed --ssh-key" + + +def test_doubled_separator_is_stripped(tmp_path: Path, captured: list[Any]) -> None: + """Click consumes only the first `--`; the second is stripped by run.""" + key = tmp_path / "id" + key.write_text("K\n") + result = CliRunner().invoke( + main, + [ + "run", + "user@host", + "--ssh-key", + str(key), + "--target", + "w=127.0.0.1:80", + "--", + "--", + "helm", + ], + ) + assert result.exit_code == 0, result.stderr + assert FakePopen.last_cmd == ["helm"] + + +def test_missing_separator_before_dash_argument_is_usage_error( + monkeypatch: pytest.MonkeyPatch, captured: list[Any] +) -> None: + """Without `--`, a `-`-prefixed child argument is parsed as a tunstrap option.""" + monkeypatch.setenv(VAR, _payload()) + result = CliRunner().invoke(main, ["run", "--input-env", VAR, "tofu", "-version"]) + assert result.exit_code == 64 + assert "no such option" in result.output.lower() + assert captured == [], "usage error must not spawn a daemon" + + +def test_input_env_without_command_is_usage_error( + monkeypatch: pytest.MonkeyPatch, captured: list[Any] +) -> None: + """--input-env with no command is exit 64 and spawns nothing.""" + monkeypatch.setenv(VAR, _payload()) + result = CliRunner().invoke(main, ["run", "--input-env", VAR]) + assert result.exit_code == 64 + assert "run requires a command" in result.output + assert captured == [], "usage error must not spawn a daemon" + + +def test_connection_without_command_is_usage_error(captured: list[Any]) -> None: + """A connection with no command is exit 64 and spawns nothing.""" + result = CliRunner().invoke(main, ["run", "user@host"]) + assert result.exit_code == 64 + assert "run requires a command" in result.output + assert captured == [], "usage error must not spawn a daemon" + + +def test_no_arguments_at_all_is_usage_error(captured: list[Any]) -> None: + """Bare `run` names both input channels instead of Click's 'Missing argument'.""" + result = CliRunner().invoke(main, ["run"]) + assert result.exit_code == 64 + assert "run requires USER@HOST[:PORT] or --input-env VAR" in result.output + assert captured == [], "usage error must not spawn a daemon" + + +def test_run_forces_materialize_on_env_payload( + monkeypatch: pytest.MonkeyPatch, captured: list[Any] +) -> None: + """A payload saying materialize=false still reaches spawn_daemon as True.""" + monkeypatch.setenv(VAR, _payload(materialize=False)) + result = CliRunner().invoke(main, ["run", "--input-env", VAR, "--", "true"]) + assert result.exit_code == 0, result.stderr + assert captured[0].daemon.materialize is True + + +@pytest.mark.parametrize("name", ["1BAD", "has-dash", "has space", "", "a.b"]) +def test_invalid_output_var_name_is_usage_error( + monkeypatch: pytest.MonkeyPatch, captured: list[Any], name: str +) -> None: + """--output-var must be a valid environment-variable name.""" + monkeypatch.setenv(VAR, _payload()) + result = CliRunner().invoke( + main, ["run", "--input-env", VAR, "--output-var", name, "--", "true"] + ) + assert result.exit_code == 64 + assert "--output-var" in result.output + assert captured == [], "usage error must not spawn a daemon" + + +def test_input_env_unset_is_exit_1_before_spawn( + monkeypatch: pytest.MonkeyPatch, captured: list[Any] +) -> None: + """An unset payload variable is a typed exit-1 error on stderr, pre-spawn.""" + monkeypatch.delenv(VAR, raising=False) + result = CliRunner().invoke(main, ["run", "--input-env", VAR, "--", "true"]) + assert result.exit_code == 1 + assert json.loads(result.stderr)["error"] == "SchemaValidationError" + assert result.stdout == "", f"run leaked to stdout: {result.stdout!r}" + assert captured == [], "a bad payload must not spawn a daemon" diff --git a/tests/unit/test_cli_run_conflicts.py b/tests/unit/test_cli_run_conflicts.py new file mode 100644 index 0000000..5f2be28 --- /dev/null +++ b/tests/unit/test_cli_run_conflicts.py @@ -0,0 +1,207 @@ +"""`run`'s conflict matrix under --input-env. + +Validates: every flag --input-env makes redundant is a usage error (64), and +none of them can leak a daemon — each rejection is proven to happen before +spawn_daemon is reached. +Code: tunstrap/cli.py (_reject_flags_under_input_env) +Assertion: exit code 64, a message naming the offending flag, and an empty +spawn-call log. +Method: CliRunner with spawn_daemon monkeypatched to record and fail loudly. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from click.testing import CliRunner + +from tests.unit.conftest import cleaning_teardown +from tunstrap import cli as cli_mod +from tunstrap.cli import main +from tunstrap.exceptions import DaemonError + +pytestmark = pytest.mark.unit + +VAR = "TUNSTRAP_INPUT" + +_PAYLOAD = json.dumps( + { + "nodes": { + "node": { + "host": "h.example.net", + "user": "u", + "ssh_password": "p", + "remote_targets": {"db": "127.0.0.1:5432"}, + } + } + } +) + + +@pytest.fixture(name="spawns") +def _spawns(monkeypatch: pytest.MonkeyPatch) -> list[Any]: + """Record every spawn_daemon call. The list must stay empty in this module.""" + calls: list[Any] = [] + + def _spawn( + schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + calls.append(schema) + raise AssertionError("spawn_daemon must not be reached by a usage error") + + monkeypatch.setattr(cli_mod, "spawn_daemon", _spawn) + monkeypatch.setattr(cli_mod, "_teardown_run", cleaning_teardown) + monkeypatch.setenv(VAR, _PAYLOAD) + return calls + + +@pytest.mark.parametrize( + "extra", + [ + ["--ssh-key-passphrase", "x"], + ["--ssh-password-stdin"], + ["--target", "web=127.0.0.1:80"], + ["--kube", "k3s=/etc/k3s.yaml"], + ["--fetch", "f=/etc/hosts"], + ], +) +def test_connection_flags_rejected(spawns: list[Any], extra: list[str]) -> None: + """Connection flags are redundant under --input-env and are exit 64.""" + result = CliRunner().invoke( + main, ["run", "--input-env", VAR, *extra, "--", "true"], input="pw\n" + ) + assert result.exit_code == 64 + assert "connection flags are redundant" in result.output + assert spawns == [] + + +def test_ssh_key_flag_rejected(tmp_path: Path, spawns: list[Any]) -> None: + """--ssh-key is rejected before its file is even read.""" + key = tmp_path / "id" + key.write_text("K\n") + result = CliRunner().invoke( + main, ["run", "--input-env", VAR, "--ssh-key", str(key), "--", "true"] + ) + assert result.exit_code == 64 + assert "connection flags are redundant" in result.output + assert spawns == [] + + +@pytest.mark.parametrize( + "extra, needle", + [ + (["--auto-stop-idle-seconds", "30"], "daemon.auto_stop_idle_seconds"), + (["--grace-seconds", "30"], "daemon.shutdown_grace_seconds"), + (["--log-file", "/tmp/t.log"], "daemon.log_file"), + (["--materialize"], "always materializes"), + ], +) +def test_daemon_flags_rejected(spawns: list[Any], extra: list[str], needle: str) -> None: + """Daemon flags are usage errors under --input-env, not overrides or no-ops.""" + result = CliRunner().invoke(main, ["run", "--input-env", VAR, *extra, "--", "true"]) + assert result.exit_code == 64 + assert needle in result.output + assert spawns == [] + + +def test_daemon_flags_still_work_in_flag_mode( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The rejection is scoped to --input-env; flag mode still honours them.""" + seen: list[Any] = [] + + def _spawn( + schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + seen.append(schema) + # A TunstrapError, not SystemExit: it takes run's own pre-spawn error + # path, which from Task 4.1 on also discards the minted session root. + raise DaemonError("captured; stop here", {}) + + monkeypatch.setattr(cli_mod, "spawn_daemon", _spawn) + key = tmp_path / "id" + key.write_text("K\n") + CliRunner().invoke( + main, + [ + "run", + "user@host", + "--ssh-key", + str(key), + "--target", + "web=127.0.0.1:80", + "--auto-stop-idle-seconds", + "30", + "--log-file", + "/tmp/t.log", + "--materialize", + "--", + "true", + ], + ) + assert len(seen) == 1 + assert seen[0].daemon.auto_stop_idle_seconds == 30 + assert seen[0].daemon.log_file == "/tmp/t.log" + assert seen[0].daemon.materialize is True + + +@pytest.mark.parametrize( + "extra, stdin, expected_passphrase, expected_fetch_path", + [ + (["--ssh-key-passphrase", "x"], None, "x", None), + (["--ssh-password-stdin"], "pw\n", None, None), + (["--target", "web=127.0.0.1:80"], None, None, None), + (["--kube", "k3s=/etc/k3s.yaml"], None, None, None), + (["--fetch", "f=/etc/hosts"], None, None, "/etc/hosts"), + ], +) +def test_connection_flags_still_work_in_flag_mode( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + extra: list[str], + stdin: str | None, + expected_passphrase: str | None, + expected_fetch_path: str | None, +) -> None: + """Each connection flag rejected for env input still reaches flag-mode spawn.""" + seen: list[Any] = [] + + def _spawn( + schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + seen.append(schema) + raise DaemonError("captured; stop here", {}) + + monkeypatch.setattr(cli_mod, "spawn_daemon", _spawn) + # The base command supplies auth via --ssh-key rather than relying on an + # ambient SSH_AUTH_SOCK (an ssh-agent runs on a dev workstation but not on + # a CI runner). Without explicit auth, InputSchema._validate_auth rejects a + # keyless/passwordless node and `run` exits before spawn — which is the + # product working as intended, not something this flag-mode test should + # depend on. Mirrors test_daemon_flags_still_work_in_flag_mode below. + key = tmp_path / "id" + key.write_text("K\n") + CliRunner().invoke( + main, + [ + "run", + "user@host", + "--ssh-key", + str(key), + "--target", + "base=127.0.0.1:80", + *extra, + "--", + "true", + ], + input=stdin, + ) + assert len(seen) == 1 + node = seen[0].nodes["node"] + if expected_passphrase is not None: + assert node.ssh_pkey_passphrase == expected_passphrase + if expected_fetch_path is not None: + assert node.fetch_files["f"].path == expected_fetch_path diff --git a/tests/unit/test_cli_run_input_env_scrub.py b/tests/unit/test_cli_run_input_env_scrub.py new file mode 100644 index 0000000..e69d0dd --- /dev/null +++ b/tests/unit/test_cli_run_input_env_scrub.py @@ -0,0 +1,206 @@ +"""The `--input-env` variable must not be inherited by the child process. + +Under the documented recipe that variable holds the InputSchema, whose +``ssh_pkey`` is an SSH private key in PEM form. The child is ``tofu``, which +hands its environment to every provider plugin, ``external`` data source and +``local-exec`` provisioner. ``run`` is the one component that knows this +variable is secret-bearing, so it is the one component that can remove it. + +Code: tunstrap/cli.py (_build_child_env, _run_child, _supervise_child) +Assertion: the variable is absent from the child environment, and the *literal +PEM bytes* appear under no key at all — paired with a positive check that the +environment is still a real inherited one, so a fix that handed the child an +empty environment could not pass. +Method: CliRunner with spawn_daemon, subprocess.Popen and _teardown_run +monkeypatched; the child env is captured off the fake Popen. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from click.testing import CliRunner + +from tests.unit.conftest import cleaning_teardown +from tunstrap import cli as cli_mod +from tunstrap.cli import main + +pytestmark = pytest.mark.unit + +VAR = "TUNSTRAP_INPUT" + +# Distinctive on purpose: every absence assertion is made against these exact +# bytes, so it cannot be satisfied by a payload that was never generated. +SSH_PKEY_PEM = ( + "-----BEGIN OPENSSH PRIVATE KEY-----\n" + "TUNSTRAP-UNIT-SSH-PRIVATE-KEY-MUST-NEVER-REACH-THE-CHILD\n" + "-----END OPENSSH PRIVATE KEY-----\n" +) + +INPUT_PAYLOAD = json.dumps( + { + "nodes": { + "node": { + "host": "h.example.net", + "user": "u", + "ssh_pkey": SSH_PKEY_PEM, + "remote_targets": {"db": "127.0.0.1:5432"}, + } + } + } +) + + +def _success_payload(session_dir: str) -> dict[str, Any]: + return { + "connections": { + "node": {"ports": {"db": 5432}, "fetch_files": {}, "kube_targets": {}}, + }, + "pid": 99, + "session_dir": session_dir, + "started_at": "2026-07-31T00:00:00Z", + } + + +class FakePopen: + last_env: dict[str, str] | None = None + + def __init__(self, cmd: list[str], env: dict[str, str] | None = None) -> None: + FakePopen.last_env = env + self.returncode = 0 + + def wait(self) -> int: + return self.returncode + + def send_signal(self, signum: int) -> None: + """Accept forwarded signals; the fake child ignores them.""" + + +@pytest.fixture(name="spawn") +def _spawn(monkeypatch: pytest.MonkeyPatch) -> list[Any]: + seen: list[Any] = [] + + def _install(message: dict[str, Any]) -> None: + def _spawn_daemon( + schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + seen.append({"schema": schema, "session_dir": session_dir, "input_env": input_env}) + return message + + monkeypatch.setattr(cli_mod, "spawn_daemon", _spawn_daemon) + + monkeypatch.setattr(cli_mod.subprocess, "Popen", FakePopen) + monkeypatch.setattr(cli_mod, "_teardown_run", cleaning_teardown) + FakePopen.last_env = None + seen.append(_install) + return seen + + +def _run( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any], tmp_path: Path, *args: str +) -> dict[str, str]: + """Drive one full `run --input-env VAR [args] -- true`.""" + spawn[0]({"kind": "success", "payload": _success_payload(str(tmp_path))}) + monkeypatch.setenv(VAR, INPUT_PAYLOAD) + result = CliRunner().invoke(main, ["run", "--input-env", VAR, *args, "--", "true"]) + assert result.exit_code == 0, result.stderr + assert FakePopen.last_env is not None + return FakePopen.last_env + + +def test_input_env_variable_is_scrubbed_from_the_child_environment( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any], tmp_path: Path +) -> None: + """The variable holding the InputSchema is not inherited by the child.""" + env = _run(monkeypatch, spawn, tmp_path) + + assert VAR not in env, f"{VAR} carries the SSH private key and was inherited by the child" + + +def test_run_forwards_the_input_variable_name_to_the_spawn( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any], tmp_path: Path +) -> None: + """``spawn_daemon`` is told, by name, which variable is secret-bearing. + + The worker's scrub cannot be keyed on a literal — ``--input-env`` takes an + arbitrary name — so the name has to be forwarded. This pins the forwarding + at unit level; that the detached worker's environment really loses it is + proven against a real process in + ``tests/integration/test_daemon_input_env.py``. Red if ``run`` stops + passing the argument: the parameter then keeps its ``None`` default. + """ + _run(monkeypatch, spawn, tmp_path) + + assert spawn[-1]["input_env"] == VAR, "run must tell spawn_daemon which variable to scrub" + + +def test_ssh_private_key_reaches_no_child_variable( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any], tmp_path: Path +) -> None: + """Asserted on the key bytes, so a copy under any other name is caught too.""" + env = _run(monkeypatch, spawn, tmp_path) + blob = "\n".join(f"{k}={v}" for k, v in env.items()) + + assert SSH_PKEY_PEM not in blob, "the SSH private key reached the child environment" + assert "TUNSTRAP-UNIT-SSH-PRIVATE-KEY" not in blob, "SSH key material reached the child" + # Anti-vacuity: the child env must still be a real inherited environment. + assert "PATH" in env, "child env must still inherit os.environ" + + +def test_scrub_is_narrow_and_leaves_the_rest_of_the_environment_alone( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any], tmp_path: Path +) -> None: + """Only the named variable is removed, not the environment at large.""" + monkeypatch.setenv("TUNSTRAP_UNRELATED_KEEP_ME", "keep") + env = _run(monkeypatch, spawn, tmp_path) + + assert env["TUNSTRAP_UNRELATED_KEEP_ME"] == "keep" + session_dir_survived = env["TUNSTRAP_SESSION_DIR"] == str(tmp_path) + assert session_dir_survived, "the session scalars must survive the scrub" + + +def test_scrub_runs_before_injection_so_a_reused_name_is_not_restored( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any], tmp_path: Path +) -> None: + """`--input-env X --output-var X` yields the output, never the input secret. + + Nothing rejects reusing one name for both flags (``_validate_output_var`` + only guards the keys ``run`` injects), so the ordering inside + ``_build_child_env`` is what decides this: the scrub happens first, then + the projected output is written. The reverse order would delete the output + and leave the child with neither — or, worse, leave the secret in place. + """ + env = _run(monkeypatch, spawn, tmp_path, "--output-var", VAR) + + assert VAR in env, "the output variable should have been written under the reused name" + assert SSH_PKEY_PEM not in env[VAR], "the input secret survived under the reused name" + assert json.loads(env[VAR])["session"]["pid"] == 99, "the value must be the output envelope" + + +def test_input_payload_shutdown_grace_controls_teardown( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any], tmp_path: Path +) -> None: + """The payload daemon block, not a hidden CLI default, sets run's grace. + + The recorder delegates to ``cleaning_teardown`` rather than swallowing the + call: ``run`` mints a real temp directory before spawning, so a stub that + only recorded would leave one ``/tmp/tunstrap-run-*`` root behind on every + run of this test. + """ + observed: list[int] = [] + + def _teardown(path: str, grace: int, *, minted_root: str | None = None) -> None: + observed.append(grace) + cleaning_teardown(path, grace, minted_root=minted_root) + + monkeypatch.setattr(cli_mod, "_teardown_run", _teardown) + payload = json.loads(INPUT_PAYLOAD) + payload["daemon"] = {"shutdown_grace_seconds": 23} + spawn[0]({"kind": "success", "payload": _success_payload(str(tmp_path))}) + monkeypatch.setenv(VAR, json.dumps(payload)) + result = CliRunner().invoke(main, ["run", "--input-env", VAR, "--", "true"]) + assert result.exit_code == 0, result.stderr + assert observed == [23] diff --git a/tests/unit/test_cli_run_materialize.py b/tests/unit/test_cli_run_materialize.py new file mode 100644 index 0000000..31e6a58 --- /dev/null +++ b/tests/unit/test_cli_run_materialize.py @@ -0,0 +1,139 @@ +"""run's unified-output materialization: /tunnel-data/output.json. + +Validates: run always writes the unified structure to a deterministic path, +mode 0600, regardless of --output-var or node count; the file's content +equals render_unified_output's output for the same OutputSchema. +Code: tunstrap/cli.py (materialization call site) +Method: CliRunner + spawn_daemon/Popen/_teardown_run monkeypatched, as in +test_cli_run_output_var.py; read the file back after invoke(). +""" + +from __future__ import annotations + +import json +import stat +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from tests.unit.conftest import cleaning_teardown +from tunstrap import cli as cli_mod +from tunstrap.cli import main +from tunstrap.envrender import render_unified_output +from tunstrap.schemas import OutputSchema + +pytestmark = pytest.mark.unit + + +def test_run_materializes_output_json(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A single-node run writes tunnel-data/output.json, mode 0600, matching content.""" + session_dir = tmp_path / "session" + session_dir.mkdir() + payload = { + "connections": {"h": {"ports": {"db": 5432}, "fetch_files": {}, "kube_targets": {}}}, + "pid": 99, + "session_dir": str(session_dir), + "started_at": "2026-08-07T00:00:00Z", + } + monkeypatch.setattr( + cli_mod, + "spawn_daemon", + lambda schema, session_dir=None, *, input_env=None: {"kind": "success", "payload": payload}, + ) + monkeypatch.setattr(cli_mod.subprocess, "Popen", _FakePopen) + monkeypatch.setattr(cli_mod, "_teardown_run", cleaning_teardown) + monkeypatch.setenv( + "TUNSTRAP_INPUT", + json.dumps( + { + "nodes": { + "node": { + "host": "h", + "user": "u", + "ssh_password": "p", + "remote_targets": {"db": "127.0.0.1:5432"}, + } + } + } + ), + ) + result = CliRunner().invoke(main, ["run", "--input-env", "TUNSTRAP_INPUT", "--", "true"]) + assert result.exit_code == 0, result.stderr + materialized = session_dir / "tunnel-data" / "output.json" + assert materialized.exists() + assert stat.S_IMODE(materialized.stat().st_mode) == 0o600 + out = OutputSchema.model_validate(payload) + assert json.loads(materialized.read_text()) == render_unified_output(out) + assert _FakePopen.last_env is not None + assert _FakePopen.last_env["TUNSTRAP_OUTPUT_FILE"] == str(materialized) + + +def test_run_materializes_output_json_for_multi_node_without_output_var( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A two-node run with no --output-var still writes tunnel-data/output.json, + carrying both nodes -- materialization is unconditional on both --output-var + and node count, not just on node count alone.""" + session_dir = tmp_path / "session" + session_dir.mkdir() + payload = { + "connections": { + "a": {"ports": {"db": 5432}, "fetch_files": {}, "kube_targets": {}}, + "b": {"ports": {"db": 5433}, "fetch_files": {}, "kube_targets": {}}, + }, + "pid": 99, + "session_dir": str(session_dir), + "started_at": "2026-08-07T00:00:00Z", + } + monkeypatch.setattr( + cli_mod, + "spawn_daemon", + lambda schema, session_dir=None, *, input_env=None: {"kind": "success", "payload": payload}, + ) + monkeypatch.setattr(cli_mod.subprocess, "Popen", _FakePopen) + monkeypatch.setattr(cli_mod, "_teardown_run", cleaning_teardown) + monkeypatch.setenv( + "TUNSTRAP_INPUT", + json.dumps( + { + "nodes": { + "a": { + "host": "h1", + "user": "u", + "ssh_password": "p", + "remote_targets": {"db": "127.0.0.1:5432"}, + }, + "b": { + "host": "h2", + "user": "u", + "ssh_password": "p", + "remote_targets": {"db": "127.0.0.1:5433"}, + }, + } + } + ), + ) + result = CliRunner().invoke(main, ["run", "--input-env", "TUNSTRAP_INPUT", "--", "true"]) + assert result.exit_code == 0, result.stderr + materialized = session_dir / "tunnel-data" / "output.json" + assert materialized.exists() + assert stat.S_IMODE(materialized.stat().st_mode) == 0o600 + out = OutputSchema.model_validate(payload) + decoded = json.loads(materialized.read_text()) + assert decoded == render_unified_output(out) + assert sorted(decoded["nodes"]) == ["a", "b"] + + +class _FakePopen: + last_env: dict[str, str] | None = None + returncode = 0 + + def __init__(self, cmd: list[str], env: dict[str, str] | None = None) -> None: + _FakePopen.last_env = env + + def wait(self) -> int: + return 0 + + def send_signal(self, _signum: int) -> None: + pass diff --git a/tests/unit/test_cli_run_output_var.py b/tests/unit/test_cli_run_output_var.py new file mode 100644 index 0000000..642e0fc --- /dev/null +++ b/tests/unit/test_cli_run_output_var.py @@ -0,0 +1,501 @@ +"""`--output-var`: name validation, collision rejection, and injection. + +Validates: NAME must be a valid env-var name and must not collide with a key +run already injects; the child receives the unified output structure as JSON +under NAME, projected to drop the kube credentials (see +test_cli_run_output_var_projection.py); multi-node input succeeds unconditionally +now that materialization covers it, with or without --output-var. +Code: tunstrap/cli.py (_validate_output_var, _build_child_env) +Assertion: exit codes and messages for the rejections; the child env contents +for the injections. +Method: CliRunner with spawn_daemon, subprocess.Popen and _teardown_run +monkeypatched; payloads supplied with monkeypatch.setenv. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from click.testing import CliRunner + +from tests.unit.conftest import cleaning_teardown +from tunstrap import cli as cli_mod +from tunstrap.cli import main +from tunstrap.exceptions import DaemonError +from tunstrap.schemas import OutputSchema + +pytestmark = pytest.mark.unit + +VAR = "TUNSTRAP_INPUT" + + +def _node(**overrides: Any) -> dict[str, Any]: + base: dict[str, Any] = { + "host": "h.example.net", + "user": "u", + "ssh_password": "p", + "remote_targets": {"db": "127.0.0.1:5432"}, + } + base.update(overrides) + return base + + +def _payload(nodes: dict[str, Any] | None = None) -> str: + return json.dumps({"nodes": nodes if nodes is not None else {"node": _node()}}) + + +def _conn(**ports: int) -> dict[str, Any]: + return {"ports": dict(ports), "fetch_files": {}, "kube_targets": {}} + + +def _success(connections: dict[str, Any], *, session_dir: str) -> dict[str, Any]: + return { + "kind": "success", + "payload": { + "connections": connections, + "pid": 99, + "session_dir": session_dir, + "started_at": "2026-07-31T00:00:00Z", + }, + } + + +_RICH_KUBE: dict[str, Any] = { + "cluster_name": "probe-cluster", + "context_name": "probe-context", + "local_port": 41111, + "endpoint": "https://127.0.0.1:41111", + "tls_server_name": "probe-control-plane", + "certificate_authority_data": "Y2E=", + "client_certificate_data": "Y2VydA==", + "client_key_data": "a2V5", + "content_b64": "a3ViZWNvbmZpZw==", + "path": "/s/tunnel-data/node-k3s", +} + + +def _rich_payload(session_dir: str) -> dict[str, Any]: + """Non-default in every field the projection drops: a real warning, a + fetched file, and the seven kube_target fields beyond path/endpoint. + + ``fetch_files.hosts`` carries both ``content_b64`` (required by + FetchedFile's success/error xor -- it stays internal plumbing, never + deleted) and ``path`` (already materialized daemon-side by the time a + success envelope reaches ``run``); the *decoded* --output-var value must + carry only ``path``, never ``content_b64``. + """ + return { + "connections": { + "node": { + "ports": {"db": 5432}, + "fetch_files": { + "hosts": { + "content_b64": "aG9zdHM=", + "path": f"{session_dir}/tunnel-data/node-hosts", + "size": 6, + "sha256": "ab" * 32, + } + }, + "kube_targets": {"k3s": _RICH_KUBE}, + } + }, + "pid": 99, + "session_dir": session_dir, + "started_at": "2026-07-31T00:00:00Z", + "warnings": [ + {"node": "edge", "error": "optional node refused the forward", "skipped": True} + ], + } + + +class FakePopen: + last_env: dict[str, str] | None = None + + def __init__(self, cmd: list[str], env: dict[str, str] | None = None) -> None: + FakePopen.last_env = env + self.returncode = 0 + + def wait(self) -> int: + return self.returncode + + def send_signal(self, signum: int) -> None: + """Accept forwarded signals; the fake child ignores them.""" + + +@pytest.fixture(name="spawn") +def _spawn(monkeypatch: pytest.MonkeyPatch) -> list[Any]: + seen: list[Any] = [] + + def _install(message: dict[str, Any]) -> None: + def _spawn_daemon( + schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + seen.append(schema) + return message + + monkeypatch.setattr(cli_mod, "spawn_daemon", _spawn_daemon) + + monkeypatch.setattr(cli_mod.subprocess, "Popen", FakePopen) + monkeypatch.setattr(cli_mod, "_teardown_run", cleaning_teardown) + FakePopen.last_env = None + seen.append(_install) # seen[0] is the installer; schemas follow + return seen + + +def test_collision_with_kubeconfig_is_usage_error( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any], tmp_path: Path +) -> None: + """KUBECONFIG is injected whenever the node has a kube target, so it collides.""" + spawn[0](_success({"node": _conn()}, session_dir=str(tmp_path))) + monkeypatch.setenv( + VAR, + _payload({"node": _node(kube_targets={"k3s": {"kubeconfig_path": "/etc/k3s.yaml"}})}), + ) + result = CliRunner().invoke( + main, ["run", "--input-env", VAR, "--output-var", "KUBECONFIG", "--", "true"] + ) + assert result.exit_code == 64 + assert "KUBECONFIG" in result.output + assert len(spawn) == 1, "a usage error must not spawn a daemon" + + +@pytest.mark.parametrize("name", ["KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"]) +def test_collision_with_kube_names_is_usage_error_even_without_kube_targets( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any], tmp_path: Path, name: str +) -> None: + """``run`` scrubs the three kube names from the inherited environment + *unconditionally* -- regardless of schema -- so they collide with + ``--output-var`` even for a payload that declares zero kube targets + (issue #23). Without this guard the scrubber would delete the operator's + inherited value and the output-var assignment would write the unified JSON + under it, silently clobbering it.""" + spawn[0](_success({"node": _conn(db=5432)}, session_dir=str(tmp_path))) + monkeypatch.setenv(VAR, _payload()) # _node() declares no kube_targets + result = CliRunner().invoke( + main, ["run", "--input-env", VAR, "--output-var", name, "--", "true"] + ) + assert result.exit_code == 64 + assert name in result.output + assert len(spawn) == 1, "a usage error must not spawn a daemon" + + +def test_tunstrap_prefixed_output_var_name_is_accepted( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any], tmp_path: Path +) -> None: + """--output-var TUNSTRAP_ANYTHING is not rejected just for the prefix.""" + spawn[0](_success({"node": _conn(db=5432)}, session_dir=str(tmp_path))) + monkeypatch.setenv(VAR, _payload()) + result = CliRunner().invoke( + main, + ["run", "--input-env", VAR, "--output-var", "TUNSTRAP_WEB_PORT", "--", "true"], + ) + assert result.exit_code == 0, result.stderr + assert len(spawn) == 2, "a legal name must reach spawn_daemon" + + +def test_multi_node_run_succeeds_without_output_var( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any], tmp_path: Path +) -> None: + """Multi-node input with NO --output-var succeeds: materialization covers + multi-node unconditionally, so the opt-in gate has nothing left to force.""" + session_dir = str(tmp_path) + survivor_a = {"ports": {}, "fetch_files": {}, "kube_targets": {"k3s": _RICH_KUBE}} + other_kube = dict(_RICH_KUBE, path=f"{session_dir}/tunnel-data/node-b-k3s") + survivor_b = {"ports": {}, "fetch_files": {}, "kube_targets": {"k3s": other_kube}} + spawn[0]( + { + "kind": "success", + "payload": { + "connections": {"a": survivor_a, "b": survivor_b}, + "pid": 99, + "session_dir": session_dir, + "started_at": "2026-08-07T00:00:00Z", + }, + } + ) + monkeypatch.setenv(VAR, _payload({"a": _node(), "b": _node()})) + result = CliRunner().invoke(main, ["run", "--input-env", VAR, "--", "true"]) + assert result.exit_code == 0, result.stderr + assert FakePopen.last_env is not None + joined = f"{_RICH_KUBE['path']}:{session_dir}/tunnel-data/node-b-k3s" + assert FakePopen.last_env["KUBECONFIG"] == joined + assert FakePopen.last_env["KUBE_CONFIG_PATHS"] == joined + assert "KUBE_CONFIG_PATH" not in FakePopen.last_env + assert FakePopen.last_env["TUNSTRAP_SESSION_DIR"] == session_dir + assert FakePopen.last_env["TUNSTRAP_PID"] == "99" + assert FakePopen.last_env["TUNSTRAP_OUTPUT_FILE"] == f"{session_dir}/tunnel-data/output.json" + + +def test_suppress_kubeconfig_drops_only_injected_kubeconfig() -> None: + """suppress_kubeconfig (the tunstrap_tofu proxy's guard) drops only the + injected KUBECONFIG. KUBE_CONFIG_PATH must survive -- it is the + provider-facing name Mode A relies on through the proxy (issue #14); a + guard that also dropped it would make Mode A unusable through + tunstrap_tofu, the documented entry point (ADR entry 20).""" + from tunstrap.cli import _build_child_env + + out = OutputSchema.model_validate( + { + "connections": {"h": {"ports": {}, "kube_targets": {"k3s": _RICH_KUBE}}}, + "pid": 1, + "session_dir": "/s", + "started_at": "now", + } + ) + env = _build_child_env(out, output_var=None, input_env=None, suppress_kubeconfig=True) + assert "KUBECONFIG" not in env + assert env["KUBE_CONFIG_PATH"] == _RICH_KUBE["path"] + + +def test_suppress_kubeconfig_drops_only_injected_kubeconfig_multi_file() -> None: + """Same guarantee on the >=2-file branch: KUBE_CONFIG_PATHS survives.""" + from tunstrap.cli import _build_child_env + + other = dict(_RICH_KUBE, path="/s/tunnel-data/node-b-k3s") + out = OutputSchema.model_validate( + { + "connections": { + "a": {"ports": {}, "kube_targets": {"k3s": _RICH_KUBE}}, + "b": {"ports": {}, "kube_targets": {"k3s": other}}, + }, + "pid": 1, + "session_dir": "/s", + "started_at": "now", + } + ) + env = _build_child_env(out, output_var=None, input_env=None, suppress_kubeconfig=True) + assert "KUBECONFIG" not in env + assert "KUBE_CONFIG_PATH" not in env + assert env["KUBE_CONFIG_PATHS"] == f"{_RICH_KUBE['path']}:{other['path']}" + + +def test_plain_path_keeps_full_kube_channel_untouched() -> None: + """Without suppress_kubeconfig (plain `tunstrap run`), _build_child_env + passes render_kube_env's channel through unfiltered -- all names the + conditional cardinality contract sets reach the child.""" + from tunstrap.cli import _build_child_env + + out = OutputSchema.model_validate( + { + "connections": {"h": {"ports": {}, "kube_targets": {"k3s": _RICH_KUBE}}}, + "pid": 1, + "session_dir": "/s", + "started_at": "now", + } + ) + env = _build_child_env(out, output_var=None, input_env=None, suppress_kubeconfig=False) + assert env["KUBECONFIG"] == _RICH_KUBE["path"] + assert env["KUBE_CONFIG_PATH"] == _RICH_KUBE["path"] + assert "KUBE_CONFIG_PATHS" not in env + + +@pytest.mark.parametrize("suppress_kubeconfig", [False, True]) +def test_inherited_kube_env_never_survives_even_without_kube_targets( + monkeypatch: pytest.MonkeyPatch, suppress_kubeconfig: bool +) -> None: + """A stray operator KUBECONFIG/KUBE_CONFIG_PATH(S) must never reach the + child, on either path, even when there are no kube targets to inject a + replacement that would otherwise overwrite it.""" + from tunstrap.cli import _build_child_env + + monkeypatch.setenv("KUBECONFIG", "/tmp/operator/.kube/config") + monkeypatch.setenv("KUBE_CONFIG_PATH", "/tmp/operator/.kube/config") + monkeypatch.setenv("KUBE_CONFIG_PATHS", "/tmp/operator/.kube/config:/other") + out = OutputSchema.model_validate( + { + "connections": {"h": {"ports": {}, "kube_targets": {}}}, + "pid": 1, + "session_dir": "/s", + "started_at": "now", + } + ) + env = _build_child_env( + out, output_var=None, input_env=None, suppress_kubeconfig=suppress_kubeconfig + ) + assert "KUBECONFIG" not in env + assert "KUBE_CONFIG_PATH" not in env + assert "KUBE_CONFIG_PATHS" not in env + + +def test_multi_node_with_output_var_reaches_spawn( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any] +) -> None: + """--output-var is the node-keyed channel; multi-node input reaches spawn. + + spawn_daemon is made to fail immediately so this asserts only that the + pre-spawn validation let the run through -- the child-env half of + multi-node behaviour is exercised by test_multi_node_run_succeeds_without_output_var + and the other injection tests in this file. + """ + + def _spawn_daemon( + schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + spawn.append(schema) + raise DaemonError("captured; stop before the child runs", {}) + + monkeypatch.setattr(cli_mod, "spawn_daemon", _spawn_daemon) + monkeypatch.setenv(VAR, _payload({"a": _node(), "b": _node()})) + result = CliRunner().invoke( + main, ["run", "--input-env", VAR, "--output-var", "TF_VAR_t", "--", "true"] + ) + assert result.exit_code == 4, result.stderr + assert len(spawn) == 2, "multi-node input with --output-var must reach spawn_daemon" + + +def test_single_node_without_output_var_still_works( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any], tmp_path: Path +) -> None: + """--output-var is optional; single-node flagless runs are untouched.""" + spawn[0](_success({"node": _conn(db=5432)}, session_dir=str(tmp_path))) + monkeypatch.setenv(VAR, _payload()) + result = CliRunner().invoke(main, ["run", "--input-env", VAR, "--", "true"]) + assert result.exit_code == 0, result.stderr + assert len(spawn) == 2 + + +def test_output_var_carries_the_unified_structure_minus_kube_credentials( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any], tmp_path: Path +) -> None: + """The child receives every field except the kube target's credentials, in + the unified node-qualified shape. + + The credential-absence property is unchanged from the old scalar-era + version of this test, but the container shape narrows: ``connections`` -> + ``nodes``, ports become "host:port" strings, and the kube channel is + ``{path, context, endpoint}`` only. + + The payload is non-default in every field, so a projection that collapsed a + dict to ``{}`` or dropped ``warnings`` still fails here. + """ + session_dir = str(tmp_path) + payload = _rich_payload(session_dir) + spawn[0]({"kind": "success", "payload": payload}) + monkeypatch.setenv(VAR, _payload()) + result = CliRunner().invoke( + main, ["run", "--input-env", VAR, "--output-var", "TF_VAR_t", "--", "true"] + ) + assert result.exit_code == 0, result.stderr + assert FakePopen.last_env is not None + decoded = json.loads(FakePopen.last_env["TF_VAR_t"]) + + assert decoded["session"]["warnings"][0]["node"] == "edge" + assert decoded["session"]["warnings"][0]["error"] == "optional node refused the forward" + assert decoded["session"]["started_at"] == "2026-07-31T00:00:00Z" + assert decoded["session"]["pid"] == 99 + assert decoded["session"]["session_dir"] == session_dir + + node = decoded["nodes"]["node"] + assert node["ports"] == {"db": "127.0.0.1:5432"} + assert node["fetch_files"]["hosts"]["sha256"] == "ab" * 32 + assert node["fetch_files"]["hosts"]["path"] == f"{session_dir}/tunnel-data/node-hosts" + + kube = node["kube"]["k3s"] + assert kube == { + "path": "/s/tunnel-data/node-k3s", + "context": "probe-context", + "endpoint": "https://127.0.0.1:41111", + } + + # The credential fields the projection exists to remove -- and everything + # beyond path/context/endpoint, since UnifiedKubeRef narrows to references. + assert "client_certificate_data" not in kube + assert "client_key_data" not in kube + assert "content_b64" not in kube + assert "cluster_name" not in kube + assert "local_port" not in kube + assert "tls_server_name" not in kube + assert "certificate_authority_data" not in kube + + +def test_multi_node_injects_output_var_and_no_target_scoped_scalars( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any], tmp_path: Path +) -> None: + """More than one node: the structure carries both, plus the three session + survivors -- but never a target-scoped scalar (no node dimension to + disambiguate one).""" + session_dir = str(tmp_path) + spawn[0](_success({"a": _conn(db=5432), "b": _conn(db=5433)}, session_dir=session_dir)) + monkeypatch.setenv(VAR, _payload({"a": _node(), "b": _node()})) + result = CliRunner().invoke( + main, ["run", "--input-env", VAR, "--output-var", "TF_VAR_t", "--", "true"] + ) + assert result.exit_code == 0, result.stderr + assert FakePopen.last_env is not None + survivors = {"TUNSTRAP_SESSION_DIR", "TUNSTRAP_PID", "TUNSTRAP_OUTPUT_FILE", VAR} + leaked = [k for k in FakePopen.last_env if k.startswith("TUNSTRAP_") and k not in survivors] + assert leaked == [], f"multi-node run injected a target-scoped scalar: {leaked}" + decoded = json.loads(FakePopen.last_env["TF_VAR_t"]) + assert sorted(decoded["nodes"]) == ["a", "b"] + assert decoded["nodes"]["a"]["ports"] == {"db": "127.0.0.1:5432"} + assert decoded["nodes"]["b"]["ports"] == {"db": "127.0.0.1:5433"} + + +def test_optional_node_failure_does_not_affect_kube_channel_or_unified_output( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any], tmp_path: Path +) -> None: + """One surviving node out of two declared: the kube channel still fires for + the survivor, and the unified structure reflects only that node -- the + failure is visible in session.warnings, not as an absence anywhere else. + """ + session_dir = str(tmp_path) + survivor = { + "ports": {"db": 5432}, + "fetch_files": {}, + "kube_targets": {"k3s": _RICH_KUBE}, + } + spawn[0]( + { + "kind": "success", + "payload": { + "connections": {"a": survivor}, + "pid": 99, + "session_dir": session_dir, + "started_at": "2026-07-31T00:00:00Z", + "warnings": [{"node": "b", "error": "optional node failed"}], + }, + } + ) + monkeypatch.setenv(VAR, _payload({"a": _node(), "b": _node(required=False)})) + result = CliRunner().invoke( + main, ["run", "--input-env", VAR, "--output-var", "TF_VAR_t", "--", "true"] + ) + assert result.exit_code == 0, result.stderr + assert FakePopen.last_env is not None + assert "KUBECONFIG" in FakePopen.last_env, "the surviving node's kube channel must still fire" + assert "KUBE_CONFIG_PATH" in FakePopen.last_env + assert "TF_VAR_t" in FakePopen.last_env + decoded = json.loads(FakePopen.last_env["TF_VAR_t"]) + assert list(decoded["nodes"]) == ["a"], "the failed node is absent, not present-with-error" + assert decoded["session"]["warnings"] == [ + {"node": "b", "error": "optional node failed", "skipped": True} + ] + + +def test_child_env_without_output_var_is_unchanged( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any], tmp_path: Path +) -> None: + """Without --output-var the injected set is exactly the three session + survivors, plus the kube channel when kube_targets exist -- nothing else.""" + # The developer's own environment must not decide this assertion. + monkeypatch.delenv("KUBECONFIG", raising=False) + session_dir = str(tmp_path) + spawn[0](_success({"node": _conn(db=5432)}, session_dir=session_dir)) + monkeypatch.setenv(VAR, _payload()) + result = CliRunner().invoke(main, ["run", "--input-env", VAR, "--", "true"]) + assert result.exit_code == 0, result.stderr + assert FakePopen.last_env is not None + injected = { + k: v + for k, v in FakePopen.last_env.items() + if k != VAR and k.startswith(("TUNSTRAP_", "KUBECONFIG")) + } + assert injected == { + "TUNSTRAP_SESSION_DIR": session_dir, + "TUNSTRAP_PID": "99", + "TUNSTRAP_OUTPUT_FILE": f"{session_dir}/tunnel-data/output.json", + } + assert "PATH" in FakePopen.last_env, "child env must still inherit os.environ" diff --git a/tests/unit/test_cli_run_output_var_projection.py b/tests/unit/test_cli_run_output_var_projection.py new file mode 100644 index 0000000..4872f64 --- /dev/null +++ b/tests/unit/test_cli_run_output_var_projection.py @@ -0,0 +1,235 @@ +"""`--output-var` must not carry kube credentials into a Terraform variable. + +The value of the variable named by ``--output-var`` becomes ``TF_VAR_tunstrap`` +under the documented recipe. OpenTofu persists root-module variable values in +the plan file, which pipelines routinely archive, and renders unmarked +variables in diagnostics — so anything in this channel must be assumed to reach +durable storage that is not treated as a secret. + +``KubeTargetOutput`` carries ``client_key_data`` (a private key), +``content_b64`` (the whole patched kubeconfig, which embeds that key) and +``client_certificate_data`` (no key, but it discloses the Kubernetes RBAC +identity). None are needed here: ``run`` forces ``materialize=True``, so the +consumer chain reads ``path`` off disk. The unified projection (``UnifiedKubeRef``) +narrows further than credential removal alone -- it carries exactly +``{path, context, endpoint}``, dropping every other field on +``KubeTargetOutput`` (``cluster_name``, ``local_port``, ``tls_server_name``, +``certificate_authority_data``) even though none of those four are credentials. + +Code: tunstrap/envrender.py (render_unified_output, render_output_var), +tunstrap/schemas.py (UnifiedKubeRef), tunstrap/cli.py (_build_child_env) +Assertion: the *literal bytes* of the fixture's key material must not appear +anywhere in the child environment, paired with an exact-equality check on the +surviving fields so a payload that stopped being produced cannot satisfy the +absence assertions vacuously. +Method: CliRunner with spawn_daemon, subprocess.Popen and _teardown_run +monkeypatched; the child env is captured off the fake Popen. +""" + +from __future__ import annotations + +import base64 +import json +from pathlib import Path +from typing import Any + +import pytest +from click.testing import CliRunner + +from tests.unit.conftest import cleaning_teardown +from tunstrap import cli as cli_mod +from tunstrap.cli import main + +pytestmark = pytest.mark.unit + +VAR = "TUNSTRAP_INPUT" + + +def _b64(text: str) -> str: + return base64.b64encode(text.encode()).decode() + + +# Distinctive, realistic material. Every absence assertion below is made +# against these exact strings, so it cannot be satisfied by an empty or +# never-generated payload. +CLIENT_KEY_PEM = ( + "-----BEGIN EC PRIVATE KEY-----\n" + "TUNSTRAP-UNIT-KUBE-CLIENT-PRIVATE-KEY-MUST-NEVER-BE-PERSISTED\n" + "-----END EC PRIVATE KEY-----\n" +) +CLIENT_CERT_PEM = ( + "-----BEGIN CERTIFICATE-----\n" + "TUNSTRAP-UNIT-KUBE-CLIENT-CERT-CN-admin-O-system-masters\n" + "-----END CERTIFICATE-----\n" +) +CA_PEM = ( + "-----BEGIN CERTIFICATE-----\n" + "TUNSTRAP-UNIT-CLUSTER-CA-PUBLIC-TRUST-ANCHOR\n" + "-----END CERTIFICATE-----\n" +) + +CLIENT_KEY_B64 = _b64(CLIENT_KEY_PEM) +CLIENT_CERT_B64 = _b64(CLIENT_CERT_PEM) +CA_B64 = _b64(CA_PEM) + +# The patched kubeconfig really does embed the client key, which is why +# content_b64 is exactly as dangerous as client_key_data itself. +KUBECONFIG_TEXT = ( + "apiVersion: v1\n" + "clusters:\n- cluster:\n certificate-authority-data: " + CA_B64 + "\n" + "users:\n- user:\n client-key-data: " + CLIENT_KEY_B64 + "\n" +) +CONTENT_B64 = _b64(KUBECONFIG_TEXT) + +KUBE_PATH = "/s/tunnel-data/node-k3s" + +SECRET_KUBE: dict[str, Any] = { + "cluster_name": "probe-cluster", + "context_name": "probe-context", + "local_port": 41111, + "endpoint": "https://127.0.0.1:41111", + "tls_server_name": "probe-control-plane", + "certificate_authority_data": CA_B64, + "client_certificate_data": CLIENT_CERT_B64, + "client_key_data": CLIENT_KEY_B64, + "content_b64": CONTENT_B64, + "path": KUBE_PATH, +} + + +def _secret_payload(session_dir: str) -> dict[str, Any]: + return { + "connections": { + "node": { + "ports": {"db": 5432}, + "fetch_files": {}, + "kube_targets": {"k3s": SECRET_KUBE}, + } + }, + "pid": 99, + "session_dir": session_dir, + "started_at": "2026-07-31T00:00:00Z", + } + + +INPUT_PAYLOAD = json.dumps( + { + "nodes": { + "node": { + "host": "h.example.net", + "user": "u", + "ssh_password": "p", + "remote_targets": {"db": "127.0.0.1:5432"}, + } + } + } +) + + +class FakePopen: + last_env: dict[str, str] | None = None + + def __init__(self, cmd: list[str], env: dict[str, str] | None = None) -> None: + FakePopen.last_env = env + self.returncode = 0 + + def wait(self) -> int: + return self.returncode + + def send_signal(self, signum: int) -> None: + """Accept forwarded signals; the fake child ignores them.""" + + +@pytest.fixture(name="spawn") +def _spawn(monkeypatch: pytest.MonkeyPatch) -> list[Any]: + seen: list[Any] = [] + + def _install(message: dict[str, Any]) -> None: + def _spawn_daemon( + schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + seen.append(schema) + return message + + monkeypatch.setattr(cli_mod, "spawn_daemon", _spawn_daemon) + + monkeypatch.setattr(cli_mod.subprocess, "Popen", FakePopen) + monkeypatch.setattr(cli_mod, "_teardown_run", cleaning_teardown) + FakePopen.last_env = None + seen.append(_install) + return seen + + +def _run(monkeypatch: pytest.MonkeyPatch, spawn: list[Any], tmp_path: Path) -> dict[str, str]: + """Drive one full `run --input-env VAR --output-var TF_VAR_tunstrap`.""" + spawn[0]({"kind": "success", "payload": _secret_payload(str(tmp_path))}) + monkeypatch.setenv(VAR, INPUT_PAYLOAD) + result = CliRunner().invoke( + main, + ["run", "--input-env", VAR, "--output-var", "TF_VAR_tunstrap", "--", "true"], + ) + assert result.exit_code == 0, result.stderr + assert FakePopen.last_env is not None + return FakePopen.last_env + + +def test_output_var_never_carries_kube_private_key_material( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any], tmp_path: Path +) -> None: + """No kube credential reaches TF_VAR_tunstrap, nor any other child variable. + + Asserted over the whole child environment rather than the one variable, so + a change that moved the payload to a different name could not quietly + reopen the hole. + """ + env = _run(monkeypatch, spawn, tmp_path) + blob = "\n".join(f"{k}={v}" for k, v in env.items()) + + assert CLIENT_KEY_B64 not in blob, "kube client PRIVATE KEY reached the child environment" + assert CLIENT_KEY_PEM not in blob, "kube client private key reached the child in PEM form" + assert CONTENT_B64 not in blob, "the full patched kubeconfig reached the child environment" + assert CLIENT_CERT_B64 not in blob, "kube client certificate (RBAC identity) reached the child" + + # Gone from the structure, not merely renamed. + target = json.loads(env["TF_VAR_tunstrap"])["nodes"]["node"]["kube"]["k3s"] + assert "client_key_data" not in target + assert "client_certificate_data" not in target + assert "content_b64" not in target + + +def test_output_var_keeps_every_field_the_consumer_chain_reads( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any], tmp_path: Path +) -> None: + """The projection is exact: these fields survive, and nothing else does. + + This is the anti-vacuity half of the pair above. If the payload stopped + being produced, or the kube target collapsed to ``{}``, every absence + assertion would still pass while this one fails. The expected dict is + narrower than credential removal alone: ``UnifiedKubeRef`` carries exactly + ``{path, context, endpoint}`` -- ``cluster_name``, ``local_port``, + ``tls_server_name`` and ``certificate_authority_data`` are also gone, + because the design narrows to references only, not just to + non-credentials. + """ + env = _run(monkeypatch, spawn, tmp_path) + target = json.loads(env["TF_VAR_tunstrap"])["nodes"]["node"]["kube"]["k3s"] + + assert target == { + "path": KUBE_PATH, + "context": "probe-context", + "endpoint": "https://127.0.0.1:41111", + } + + +def test_output_var_projection_leaves_the_rest_of_the_envelope_intact( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any], tmp_path: Path +) -> None: + """Only kube credentials are dropped; the envelope is otherwise unchanged + in shape, up one level under the unified structure's session/nodes split.""" + env = _run(monkeypatch, spawn, tmp_path) + decoded = json.loads(env["TF_VAR_tunstrap"]) + + assert decoded["session"]["pid"] == 99 + assert decoded["session"]["session_dir"] == str(tmp_path) + assert decoded["session"]["started_at"] == "2026-07-31T00:00:00Z" + assert decoded["nodes"]["node"]["ports"] == {"db": "127.0.0.1:5432"} diff --git a/tests/unit/test_cli_run_postspawn.py b/tests/unit/test_cli_run_postspawn.py new file mode 100644 index 0000000..74e0983 --- /dev/null +++ b/tests/unit/test_cli_run_postspawn.py @@ -0,0 +1,1056 @@ +"""`run`'s teardown: silent on stdout, diagnostic on stderr, never raising. + +Validates: after the child exits, tunstrap writes nothing to fd 1 — that is the +invariant the tofu-proxy pattern rests on — while a genuine teardown failure is +still reported, on stderr, without changing the exit code. +Code: tunstrap/cli.py (_teardown_run) +Assertion: result.stdout carries only the child's bytes; failure text appears in +result.stderr; a raising stop primitive does not change the child's exit code. +Method: CliRunner with spawn_daemon, subprocess.Popen, stop_session, +SessionDir.read_identity and SessionDir.cleanup_path all monkeypatched, so no +daemon, no signals and no filesystem work — this passes unchanged on macOS. +""" + +from __future__ import annotations + +import json +import shlex +import shutil +import signal as signal_mod +import tempfile +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import pytest +from click.testing import CliRunner + +from tests.unit.conftest import cleaning_teardown +from tunstrap import cli as cli_mod +from tunstrap import session as session_mod +from tunstrap.cli import main +from tunstrap.exceptions import DaemonError, DaemonHandshakeError +from tunstrap.identity import IdentityCheckResult +from tunstrap.session import StopOutcome + +pytestmark = pytest.mark.unit + + +def _success_payload(session_dir: str | None) -> dict[str, Any]: + assert session_dir is not None + return { + "kind": "success", + "payload": { + "connections": {"node": {"ports": {"db": 5432}, "fetch_files": {}, "kube_targets": {}}}, + "pid": 99, + "session_dir": session_dir, + "started_at": "now", + }, + } + + +class QuietPopen: + """Popen stand-in that writes nothing and exits 7.""" + + def __init__(self, cmd: list[str], env: dict[str, str] | None = None) -> None: + self.cmd = cmd + self.env = env + self.returncode = 7 + + def wait(self) -> int: + return self.returncode + + def send_signal(self, signum: int) -> None: + """Accept forwarded signals; the fake child ignores them.""" + + +@pytest.fixture(name="spawned") +def _spawned(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + cli_mod, + "spawn_daemon", + lambda _schema, session_dir=None, *, input_env=None: _success_payload(session_dir), + ) + monkeypatch.setattr(cli_mod.subprocess, "Popen", QuietPopen) + monkeypatch.setattr(cli_mod.SessionDir, "read_identity", staticmethod(lambda _sd: 4242)) + + +_ARGS = [ + "run", + "u@h", + "--target", + "db=127.0.0.1:5432", + "--ssh-password-stdin", + "--", + "true", +] + + +def test_teardown_silent_on_success(monkeypatch: pytest.MonkeyPatch, spawned: None) -> None: + """A clean teardown writes nothing at all to stdout.""" + monkeypatch.setattr(cli_mod, "stop_session", lambda _sd, _pid, _g, force: StopOutcome(True)) + monkeypatch.setattr(cli_mod.SessionDir, "cleanup_path", classmethod(lambda _cls, _sd: [])) + result = CliRunner().invoke(main, _ARGS, input="secret\n") + assert result.exit_code == 7 + assert result.stdout == "", f"run leaked to stdout: {result.stdout!r}" + + +def test_teardown_stop_failure_goes_to_stderr( + monkeypatch: pytest.MonkeyPatch, spawned: None, tmp_path: Path +) -> None: + """A non-stopped outcome is reported on stderr and stdout stays clean.""" + monkeypatch.setattr( + cli_mod, + "stop_session", + lambda _sd, _pid, _g, force: StopOutcome(False, "identity mismatch"), + ) + monkeypatch.setattr(cli_mod.SessionDir, "cleanup_path", classmethod(lambda _cls, _sd: [])) + result = CliRunner().invoke( + main, [*_ARGS[:-2], "--session-dir", str(tmp_path), "--", "true"], input="secret\n" + ) + assert result.exit_code == 7 + assert result.stdout == "", f"run leaked to stdout: {result.stdout!r}" + assert "identity mismatch" in result.stderr + + +def test_teardown_unremovable_paths_go_to_stderr( + monkeypatch: pytest.MonkeyPatch, spawned: None +) -> None: + """Paths cleanup could not remove are named on stderr, not swallowed.""" + monkeypatch.setattr(cli_mod, "stop_session", lambda _sd, _pid, _g, force: StopOutcome(True)) + monkeypatch.setattr( + cli_mod.SessionDir, + "cleanup_path", + classmethod(lambda _cls, _sd: ["/s/tunnel-data"]), + ) + result = CliRunner().invoke(main, _ARGS, input="secret\n") + assert result.exit_code == 7 + assert result.stdout == "", f"run leaked to stdout: {result.stdout!r}" + assert "/s/tunnel-data" in result.stderr + + +def test_teardown_exception_does_not_change_exit_code( + monkeypatch: pytest.MonkeyPatch, spawned: None, tmp_path: Path +) -> None: + """A raising stop primitive is reported on stderr; the child's 7 still wins. + + Supplies ``--session-dir`` so nothing is minted: what this test owns is the + exit code and stdout purity, and the fate of a minted root under a raising + stop belongs to + ``test_raising_stop_preserves_the_minted_session_root``. + """ + + def _boom(_sd: str, _pid: int, _g: int, force: bool) -> StopOutcome: + raise RuntimeError("stop exploded") + + monkeypatch.setattr(cli_mod, "stop_session", _boom) + monkeypatch.setattr(cli_mod.SessionDir, "cleanup_path", classmethod(lambda _cls, _sd: [])) + result = CliRunner().invoke( + main, [*_ARGS[:-2], "--session-dir", str(tmp_path), "--", "true"], input="secret\n" + ) + assert result.exit_code == 7, "teardown failure must never override the child code" + assert result.stdout == "", f"run leaked to stdout: {result.stdout!r}" + assert "stop exploded" in result.stderr + + +def test_raising_stop_preserves_the_minted_session_root( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A stop that *raises* preserves the session root, as a reported failure does. + + ``StopOutcome(False, …)`` means we know the daemon survived; an exception + means we do not know its state at all, which is the stronger reason to keep + the identity file rather than destroy it. The root ``run`` minted is the + only place that file can be, so a raising teardown must not take it along — + otherwise a surviving daemon becomes exactly the orphan this window exists + to prevent. + + A *minted* root is the only falsifiable shape for this claim: a + caller-supplied ``--session-dir`` is never removed on any path, so + asserting its survival could not fail. + + Scoped to the path this test's own spawn stub observed, never a glob of the + shared temp directory — such a glob also sees roots minted by other tests + and is order-dependent by construction. + """ + minted: list[str | None] = [] + + def _spawn( + _schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + minted.append(session_dir) + return _success_payload(session_dir) + + def _boom(_sd: str, _pid: int, _g: int, force: bool) -> StopOutcome: + raise RuntimeError("stop exploded") + + monkeypatch.setattr(cli_mod, "spawn_daemon", _spawn) + monkeypatch.setattr(cli_mod.subprocess, "Popen", QuietPopen) + monkeypatch.setattr(cli_mod.SessionDir, "read_identity", staticmethod(lambda _sd: 4242)) + monkeypatch.setattr(cli_mod, "stop_session", _boom) + + result = CliRunner().invoke(main, _ARGS, input="secret\n") + + root = minted[0] + assert root is not None + try: + assert result.exit_code == 7 + assert Path(root).is_dir(), "a raising stop destroyed the only handle on the daemon" + assert f"tunstrap stop --session-dir {root}" in result.stderr + finally: + shutil.rmtree(root, ignore_errors=True) + + +def test_teardown_permission_error_does_not_change_exit_code( + monkeypatch: pytest.MonkeyPatch, spawned: None, tmp_path: Path +) -> None: + """A recycled pid permission failure is reported; the child's 7 still wins. + + ``--session-dir`` for the same reason as the sibling above: a raising stop + now preserves a minted root by contract, so minting one here would leak it. + """ + monkeypatch.setattr(session_mod, "verify_session", lambda _sd, _pid: IdentityCheckResult.match) + + def _permission_denied(_pid: int, _sig: int) -> None: + raise PermissionError("recycled pid") + + monkeypatch.setattr(session_mod.os, "kill", _permission_denied) + monkeypatch.setattr(cli_mod.SessionDir, "cleanup_path", classmethod(lambda _cls, _sd: [])) + result = CliRunner().invoke( + main, [*_ARGS[:-2], "--session-dir", str(tmp_path), "--", "true"], input="secret\n" + ) + assert result.exit_code == 7, "teardown failure must never override the child code" + assert result.stdout == "", f"run leaked to stdout: {result.stdout!r}" + assert "PermissionError: recycled pid" in result.stderr + + +def test_teardown_keyboard_interrupt_does_not_change_exit_code( + monkeypatch: pytest.MonkeyPatch, spawned: None, tmp_path: Path +) -> None: + """A KeyboardInterrupt from teardown does not override the child's exit code. + + ``--session-dir`` for the same reason as the two siblings above. + """ + + def _interrupted(_sd: str, _pid: int, _g: int, *, force: bool) -> StopOutcome: + raise KeyboardInterrupt("second Ctrl-C") + + monkeypatch.setattr(cli_mod, "stop_session", _interrupted) + monkeypatch.setattr(cli_mod.SessionDir, "cleanup_path", classmethod(lambda _cls, _sd: [])) + result = CliRunner().invoke( + main, [*_ARGS[:-2], "--session-dir", str(tmp_path), "--", "true"], input="secret\n" + ) + assert result.exit_code == 7, "teardown interruption must never override the child code" + assert result.stdout == "", f"run leaked to stdout: {result.stdout!r}" + assert "KeyboardInterrupt: second Ctrl-C" in result.stderr + + +def test_teardown_already_exited_daemon_is_silent( + monkeypatch: pytest.MonkeyPatch, spawned: None +) -> None: + """A daemon that already exited is normal and produces no teardown warning.""" + monkeypatch.setattr( + cli_mod, "stop_session", lambda _sd, _pid, _g, *, force: StopOutcome(False, "not found") + ) + monkeypatch.setattr(cli_mod.SessionDir, "cleanup_path", classmethod(lambda _cls, _sd: [])) + result = CliRunner().invoke(main, _ARGS, input="secret\n") + assert result.exit_code == 7 + assert result.stdout == "", f"run leaked to stdout: {result.stdout!r}" + assert result.stderr == "", f"run warned about an already-exited daemon: {result.stderr!r}" + + +def test_teardown_stderr_write_failure_is_swallowed(monkeypatch: pytest.MonkeyPatch) -> None: + """A diagnostic write failure cannot escape the never-raise teardown wrapper.""" + + class BrokenStderr: + def write(self, _message: str) -> int: + raise BrokenPipeError("stderr closed") + + def _boom(_sd: str, _pid: int, _g: int, *, force: bool) -> StopOutcome: + raise RuntimeError("stop exploded") + + monkeypatch.setattr(cli_mod.SessionDir, "read_identity", staticmethod(lambda _sd: 4242)) + monkeypatch.setattr(cli_mod, "stop_session", _boom) + monkeypatch.setattr(cli_mod.sys, "stderr", BrokenStderr()) + cli_mod._teardown_run("/s", 0, minted_root=None) + + +@pytest.fixture(name="teardowns") +def _teardowns(monkeypatch: pytest.MonkeyPatch) -> list[tuple[Any, ...]]: + """Record every _teardown_run call as (session_dir, grace, minted_root). + + Records *and* cleans: `run` mints a real temp directory before spawning, + so a fixture that only recorded would leak one per test in this module. + """ + calls: list[tuple[Any, ...]] = [] + + def _record(session_dir: str, grace_seconds: int, *, minted_root: str | None) -> None: + calls.append((session_dir, grace_seconds, minted_root)) + cleaning_teardown(session_dir, grace_seconds, minted_root=minted_root) + + monkeypatch.setattr(cli_mod, "_teardown_run", _record) + return calls + + +def test_run_mints_the_session_path_before_spawning( + monkeypatch: pytest.MonkeyPatch, teardowns: list[tuple[Any, ...]] +) -> None: + """With no --session-dir, run creates the directory itself and passes it on.""" + spawned: list[str | None] = [] + + def _spawn( + _schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + spawned.append(session_dir) + assert session_dir is not None, "run must not let the worker generate the path" + assert Path(session_dir).is_dir(), "the minted path must exist before spawning" + return _success_payload(session_dir) + + monkeypatch.setattr(cli_mod, "spawn_daemon", _spawn) + monkeypatch.setattr(cli_mod.subprocess, "Popen", QuietPopen) + result = CliRunner().invoke(main, _ARGS, input="secret\n") + assert result.exit_code == 7 + minted = spawned[0] + assert minted is not None + assert minted.startswith(tempfile.gettempdir()) + assert teardowns == [(minted, 10, minted)] + + +def test_teardown_uses_the_minted_path_not_the_payload( + monkeypatch: pytest.MonkeyPatch, teardowns: list[tuple[Any, ...]], tmp_path: Path +) -> None: + """The success payload's session_dir is ignored; the minted path wins. + + This is the orphan fix: cleanup must not depend on the object whose + validation can fail. The payload's bogus session_dir is a real (if + unrelated) writable directory, not literally ``/completely/bogus``: that + literal would make the unconditional output.json materialization raise + PermissionError before this test's own property is even exercised. + """ + spawned: list[str | None] = [] + bogus = str(tmp_path / "completely-bogus") + Path(bogus).mkdir() + + def _spawn( + _schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + spawned.append(session_dir) + payload = _success_payload(bogus) + return payload + + monkeypatch.setattr(cli_mod, "spawn_daemon", _spawn) + monkeypatch.setattr(cli_mod.subprocess, "Popen", QuietPopen) + result = CliRunner().invoke(main, _ARGS, input="secret\n") + assert result.exit_code == 7 + assert teardowns[0][0] == spawned[0] + assert teardowns[0][0] != bogus + + +def test_supplied_session_dir_is_never_minted( + monkeypatch: pytest.MonkeyPatch, teardowns: list[tuple[Any, ...]], tmp_path: Path +) -> None: + """A caller-supplied --session-dir is passed through with minted_root=None. + + This covers the *wiring* only: which path run hands to teardown, and that + it claims no ownership of it. It deliberately makes no claim about the + directory surviving -- the `teardowns` fixture substitutes + `cleaning_teardown`, which removes only `minted_root`, so a supplied + directory survives it by construction and a survival assertion here could + never fail. That claim belongs to + `test_production_teardown_keeps_a_supplied_session_dir`, which lets the + real `_teardown_run` run. + """ + supplied = tmp_path / "work" + supplied.mkdir() + monkeypatch.setattr( + cli_mod, + "spawn_daemon", + lambda _s, session_dir=None, *, input_env=None: _success_payload(session_dir), + ) + monkeypatch.setattr(cli_mod.subprocess, "Popen", QuietPopen) + result = CliRunner().invoke( + main, + [ + "run", + "u@h", + "--target", + "db=127.0.0.1:5432", + "--ssh-password-stdin", + "--session-dir", + str(supplied), + "--", + "true", + ], + input="secret\n", + ) + assert result.exit_code == 7 + assert teardowns == [(str(supplied), 10, None)] + + +def test_production_teardown_keeps_a_supplied_session_dir( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Production teardown removes tunnel-data and nothing else the caller owns. + + The ownership asymmetry exercised against the real `_teardown_run`: no + `teardowns` fixture, no `cleanup_path` or `read_identity` stub, so + `_teardown_run_inner` does its own filesystem work. A supplied path makes + the worker's SessionDir non-generated, so the root is the caller's; an + implementation that removed `session_dir` unconditionally would take the + sentinel and the root with it. + """ + supplied = tmp_path / "work" + supplied.mkdir() + sentinel = supplied / "caller-owned.txt" + sentinel.write_text("do not delete me") + data = supplied / "tunnel-data" + data.mkdir() + (data / "daemon.pid").write_text("4242\n") + + monkeypatch.setattr( + cli_mod, + "spawn_daemon", + lambda _s, session_dir=None, *, input_env=None: _success_payload(session_dir), + ) + monkeypatch.setattr(cli_mod.subprocess, "Popen", QuietPopen) + monkeypatch.setattr(cli_mod, "stop_session", lambda _sd, _pid, _g, force: StopOutcome(True)) + + result = CliRunner().invoke( + main, + [ + "run", + "u@h", + "--target", + "db=127.0.0.1:5432", + "--ssh-password-stdin", + "--session-dir", + str(supplied), + "--", + "true", + ], + input="secret\n", + ) + assert result.exit_code == 7 + assert result.stderr == "", f"a clean teardown warned: {result.stderr!r}" + assert not data.exists(), "teardown must remove tunnel-data from a supplied session dir" + assert supplied.is_dir(), "run must never remove a caller-supplied session dir" + assert sentinel.read_text() == "do not delete me", "teardown destroyed caller-owned content" + + +def test_failed_teardown_keeps_identity_data_for_manual_recovery( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """An unverified live daemon retains the data needed to stop it safely.""" + session_dir = tmp_path / "session" + tunnel_data = session_dir / "tunnel-data" + tunnel_data.mkdir(parents=True) + identity = tunnel_data / "daemon.pid" + identity.write_text("4242\n") + monkeypatch.setattr( + cli_mod, + "spawn_daemon", + lambda _s, session_dir=None, *, input_env=None: _success_payload(session_dir), + ) + monkeypatch.setattr(cli_mod.subprocess, "Popen", QuietPopen) + monkeypatch.setattr(cli_mod.SessionDir, "read_identity", staticmethod(lambda _sd: 4242)) + monkeypatch.setattr( + cli_mod, + "stop_session", + lambda _sd, _pid, _grace, force: StopOutcome(False, "identity mismatch"), + ) + + result = CliRunner().invoke( + main, [*_ARGS[:-2], "--session-dir", str(session_dir), "--", "true"], input="secret\n" + ) + + assert result.exit_code == 7 + assert identity.read_text() == "4242\n" + assert f"tunstrap stop --session-dir {session_dir}" in result.stderr + + +def _recovery_command(stderr: str) -> list[str]: + """Pull the printed recovery command out of run's teardown diagnostic.""" + for line in stderr.splitlines(): + _, sep, command = line.partition("Recover with: ") + if sep: + return shlex.split(command) + raise AssertionError(f"no recovery command in stderr: {stderr!r}") + + +def test_the_printed_recovery_command_is_one_tunstrap_accepts( + monkeypatch: pytest.MonkeyPatch, spawned: None, tmp_path: Path +) -> None: + """The emitted command is parsed back out and actually run, not eyeballed. + + A diagnostic naming a flag that does not exist is worse than none: the + operator holding preserved session data — the entire point of preserving + it — follows the instruction and gets a usage error. ``stop`` accepts only + ``--session-dir`` and ``--grace-seconds``; it already forces + unconditionally (``tunstrap/cli.py::stop_command``), so there is no + ``--force`` to pass. + + The command is extracted from what ``run`` actually printed and fed to the + real CLI, so this cannot drift from the message: any flag ``stop`` does not + define makes Click exit 2 here. + """ + monkeypatch.setattr( + cli_mod, + "stop_session", + lambda _sd, _pid, _g, force: StopOutcome(False, "identity mismatch"), + ) + result = CliRunner().invoke( + main, [*_ARGS[:-2], "--session-dir", str(tmp_path), "--", "true"], input="secret\n" + ) + assert result.exit_code == 7 + + argv = _recovery_command(result.stderr) + assert argv[0] == "tunstrap", f"the recovery command must invoke tunstrap: {argv}" + recovery = CliRunner().invoke(main, argv[1:]) + + assert recovery.exit_code == 1, ( + f"tunstrap cannot parse the command it told the operator to run: " + f"{argv} -> exit {recovery.exit_code}\n{recovery.output}" + ) + # Anti-vacuity: prove the invocation really reached `stop` and produced its + # documented JSON, rather than exiting 0 from somewhere harmless. + assert json.loads(recovery.stdout)["stopped"] is False + + +def test_the_printed_recovery_command_does_not_eat_what_it_recovers( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Running the command we print, against a stop that stays unresolved, keeps the identity. + + The whole point of preserving session data is that the operator can find + and kill a daemon that outlived teardown. ``run`` points them at ``tunstrap + stop``; if ``stop`` deletes ``tunnel-data`` whether or not it managed to + stop anything, following our own instruction destroys the handle the + preservation existed to keep — and the second, manual attempt has nothing + left to work with. + + End to end on purpose: the identity is a real file, ``read_identity`` and + ``cleanup_path`` are the real ones, and the command is the one ``run`` + actually printed, extracted by the same helper as + ``test_the_printed_recovery_command_is_one_tunstrap_accepts`` so the two + cannot drift apart. Only ``stop_session`` is stubbed, to hold the outcome + unresolved across both invocations — which is the situation under test. + """ + data = tmp_path / "tunnel-data" + data.mkdir() + identity = data / "daemon.pid" + identity.write_text("4242\n") + + monkeypatch.setattr( + cli_mod, + "spawn_daemon", + lambda _s, session_dir=None, *, input_env=None: _success_payload(session_dir), + ) + monkeypatch.setattr(cli_mod.subprocess, "Popen", QuietPopen) + monkeypatch.setattr( + cli_mod, + "stop_session", + lambda _sd, _pid, _g, force: StopOutcome(False, "identity mismatch"), + ) + + result = CliRunner().invoke( + main, [*_ARGS[:-2], "--session-dir", str(tmp_path), "--", "true"], input="secret\n" + ) + assert result.exit_code == 7 + assert identity.read_text() == "4242\n", "run's own teardown destroyed the identity" + + argv = _recovery_command(result.stderr) + recovery = CliRunner().invoke(main, argv[1:]) + + assert recovery.exit_code == 1 + assert json.loads(recovery.stdout)["stopped"] is False, "the daemon was not stopped" + ate_the_handle = "the recovery command deleted the identity it was supposed to recover" + assert identity.read_text() == "4242\n", ate_the_handle + + +def test_a_minted_root_is_named_as_the_operators_to_delete( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Recovery is incomplete without it: ``stop`` never removes its own argument. + + ``stop --session-dir`` is normally pointed at the operator's own directory, + so it removes ``tunnel-data`` and nothing else — deleting its argument + would be a destructive change to a public verb. ``run`` is the only + component that knows the root is disposable, because it minted it, so + ``run`` is where that gets said. Silent for a caller-supplied directory, + which tunstrap must never suggest deleting. + """ + minted: list[str | None] = [] + + def _spawn( + _schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + minted.append(session_dir) + return _success_payload(session_dir) + + monkeypatch.setattr(cli_mod, "spawn_daemon", _spawn) + monkeypatch.setattr(cli_mod.subprocess, "Popen", QuietPopen) + monkeypatch.setattr(cli_mod.SessionDir, "read_identity", staticmethod(lambda _sd: 4242)) + monkeypatch.setattr( + cli_mod, + "stop_session", + lambda _sd, _pid, _g, force: StopOutcome(False, "identity mismatch"), + ) + + result = CliRunner().invoke(main, _ARGS, input="secret\n") + + root = minted[0] + assert root is not None + try: + assert root in result.stderr + assert "created by run" in result.stderr, ( + f"a minted root was preserved without telling the operator to delete it: " + f"{result.stderr!r}" + ) + finally: + shutil.rmtree(root, ignore_errors=True) + + +def test_a_supplied_session_dir_is_never_suggested_for_deletion( + monkeypatch: pytest.MonkeyPatch, spawned: None, tmp_path: Path +) -> None: + """The delete-it note is scoped to roots run minted, never a caller's directory.""" + monkeypatch.setattr( + cli_mod, + "stop_session", + lambda _sd, _pid, _g, force: StopOutcome(False, "identity mismatch"), + ) + result = CliRunner().invoke( + main, [*_ARGS[:-2], "--session-dir", str(tmp_path), "--", "true"], input="secret\n" + ) + assert result.exit_code == 7 + assert "identity mismatch" in result.stderr + suggested_deletion = ( + f"tunstrap told the operator to delete their own directory: {result.stderr!r}" + ) + assert "created by run" not in result.stderr, suggested_deletion + + +def _run_with_real_teardown(monkeypatch: pytest.MonkeyPatch, session_dir: Path) -> Any: + """Drive one `run` against a supplied session dir with production teardown. + + No ``teardowns`` fixture and no ``read_identity`` stub, so + ``_teardown_run_inner`` does its own filesystem work and its own identity + read — which is the thing under test here. + """ + monkeypatch.setattr( + cli_mod, + "spawn_daemon", + lambda _s, session_dir=None, *, input_env=None: _success_payload(session_dir), + ) + monkeypatch.setattr(cli_mod.subprocess, "Popen", QuietPopen) + return CliRunner().invoke( + main, [*_ARGS[:-2], "--session-dir", str(session_dir), "--", "true"], input="secret\n" + ) + + +def test_an_unparseable_identity_preserves_instead_of_deleting( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A daemon.pid we cannot parse means unknown state, not "nothing ran". + + ``read_identity`` raises the same ``SessionError`` for a *missing*, an + *unreadable* and a *malformed* identity, and teardown used to read all + three as "the daemon never recorded one" and delete the session data. Only + the first says that. A file holding something that is not a pid — the shape + a truncated write takes — says a daemon got far enough to create it and we + cannot address it, which by the preservation contract is a reason to keep + the data, not to destroy it. + """ + data = tmp_path / "tunnel-data" + data.mkdir() + identity = data / "daemon.pid" + identity.write_text("not-a-pid\n") + + result = _run_with_real_teardown(monkeypatch, tmp_path) + + assert result.exit_code == 7 + assert identity.read_text() == "not-a-pid\n", "teardown destroyed an unparseable identity" + assert "cannot read the daemon identity" in result.stderr + assert f"tunstrap stop --session-dir {tmp_path}" in result.stderr + + +def test_a_missing_identity_still_cleans_up( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The negative control: absence really does mean nothing was recorded. + + Without this, widening the preservation rule to every ``SessionError`` + would look correct — and would leave every ordinary run that never + published an identity undeleted forever. + """ + data = tmp_path / "tunnel-data" + data.mkdir() + (data / "materialized.kubeconfig").write_text("credential-bearing") + + result = _run_with_real_teardown(monkeypatch, tmp_path) + + assert result.exit_code == 7 + assert not data.exists(), "a missing identity must still clean up tunnel-data" + assert result.stderr == "", f"a missing identity is normal and must not warn: {result.stderr!r}" + + +def test_minted_root_is_discarded_when_the_worker_reports_the_failure( + monkeypatch: pytest.MonkeyPatch, teardowns: list[tuple[Any, ...]] +) -> None: + """A worker-authored failure needs no teardown, and leaves no minted dir. + + This is the narrower of the two spawn-failure classes. A plain + ``DaemonError`` is what the worker itself authored: it reached its own + guard, released the session lock and removed its session dir before + reporting (``tunstrap/_worker.py::main``), then exited. Nothing is running, so + ``teardowns == []`` is right *here* — but it is right because of who + failed, not because "spawn raised". The sibling test below covers the case + where that inference does not hold. + """ + seen: list[str | None] = [] + + def _spawn( + _schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + seen.append(session_dir) + raise DaemonError("worker died", {}) + + monkeypatch.setattr(cli_mod, "spawn_daemon", _spawn) + result = CliRunner().invoke(main, _ARGS, input="secret\n") + assert result.exit_code == 4 + assert seen[0] is not None + assert not Path(seen[0]).exists(), "a failed spawn leaked a minted session root" + assert teardowns == [], "the worker cleaned up after itself; nothing to tear down" + + +def test_handshake_failure_stops_the_worker_instead_of_orphaning_it( + monkeypatch: pytest.MonkeyPatch, teardowns: list[tuple[Any, ...]] +) -> None: + """A parent-side handshake failure tears down: the worker may be alive. + + ``spawn_daemon`` detaches the worker at ``Popen`` and only then attempts + the handshake. If *that* fails, the worker is running, holding the session + lock, with a tunnel open — and the old code took the worker-authored path: + delete the session root and exit, leaving a daemon with no directory and + nobody to stop it. + + Fails if the handler is collapsed back into the generic ``TunstrapError`` + arm: ``teardowns`` is then empty and the daemon is orphaned. + """ + seen: list[str | None] = [] + + def _spawn( + _schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + seen.append(session_dir) + raise DaemonHandshakeError("worker IPC produced invalid JSON", {"position": 0}) + + monkeypatch.setattr(cli_mod, "spawn_daemon", _spawn) + result = CliRunner().invoke(main, _ARGS, input="secret\n") + minted = seen[0] + assert minted is not None + assert result.exit_code == 4, "a handshake failure keeps DaemonError's exit code" + assert teardowns == [(minted, 10, minted)], "a possibly-live worker must be stopped" + assert not Path(minted).exists(), "teardown must still remove the minted session root" + assert json.loads(result.stderr)["error"] == "DaemonHandshakeError" + assert result.stdout == "", f"run leaked to stdout: {result.stdout!r}" + + +def test_minted_root_is_removed_after_a_successful_run(monkeypatch: pytest.MonkeyPatch) -> None: + """After a real teardown, the minted root itself is gone from disk.""" + seen: list[str | None] = [] + + def _spawn( + _schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + seen.append(session_dir) + return _success_payload(session_dir) + + monkeypatch.setattr(cli_mod, "spawn_daemon", _spawn) + monkeypatch.setattr(cli_mod.subprocess, "Popen", QuietPopen) + monkeypatch.setattr(cli_mod, "stop_session", lambda _sd, _pid, _g, force: StopOutcome(True)) + monkeypatch.setattr(cli_mod.SessionDir, "read_identity", staticmethod(lambda _sd: 4242)) + result = CliRunner().invoke(main, _ARGS, input="secret\n") + assert result.exit_code == 7 + assert seen[0] is not None + assert not Path(seen[0]).exists(), "teardown left the minted session root behind" + + +@pytest.fixture(name="signal_guard", autouse=True) +def _signal_guard() -> Iterator[None]: + """Guarantee this process's SIGINT/SIGTERM handlers survive the test. + + Autouse because every test here that reaches ``_run_child`` installs + ``_forward`` as this process's SIGINT/SIGTERM handler. If such a test fails + before restoration, ``_forward`` stays bound to a dead ``QuietPopen`` for + the remainder of the session, and a later test — or a real Ctrl-C — would + behave unpredictably. + + ``signal_mod.signal`` is captured at setup: a test that makes restoration + raise does so by patching that very function, and this fixture may be torn + down *before* monkeypatch undoes the patch, so calling it by attribute + would re-enter the flaky stub and error out in teardown. + """ + real_signal = signal_mod.signal + saved = [(s, signal_mod.getsignal(s)) for s in (signal_mod.SIGINT, signal_mod.SIGTERM)] + try: + yield + finally: + for signum, handler in saved: + real_signal(signum, handler) + + +def test_malformed_success_payload_still_tears_down( + monkeypatch: pytest.MonkeyPatch, teardowns: list[tuple[Any, ...]] +) -> None: + """A success payload missing session_dir must not orphan the daemon.""" + spawned: list[str | None] = [] + + def _spawn( + _schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + spawned.append(session_dir) + payload = _success_payload("/s") + del payload["payload"]["session_dir"] + return payload + + monkeypatch.setattr(cli_mod, "spawn_daemon", _spawn) + monkeypatch.setattr(cli_mod.subprocess, "Popen", QuietPopen) + result = CliRunner().invoke(main, _ARGS, input="secret\n") + assert result.exit_code == 4 + assert result.stdout == "", f"run leaked to stdout: {result.stdout!r}" + assert json.loads(result.stderr)["error"] == "DaemonError" + assert len(teardowns) == 1, "teardown must run exactly once" + assert teardowns[0][0] == spawned[0], "teardown must use the minted path" + + +def test_non_string_session_dir_still_tears_down( + monkeypatch: pytest.MonkeyPatch, teardowns: list[tuple[Any, ...]] +) -> None: + """A non-string session_dir fails validation post-spawn but still tears down.""" + + def _spawn( + _schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + payload = _success_payload("/s") + payload["payload"]["session_dir"] = 17 + return payload + + monkeypatch.setattr(cli_mod, "spawn_daemon", _spawn) + monkeypatch.setattr(cli_mod.subprocess, "Popen", QuietPopen) + result = CliRunner().invoke(main, _ARGS, input="secret\n") + assert result.exit_code == 4 + assert len(teardowns) == 1 + + +@pytest.mark.parametrize("missing", ["kind", "payload"]) +def test_unreadable_envelope_still_tears_down( + monkeypatch: pytest.MonkeyPatch, teardowns: list[tuple[Any, ...]], missing: str +) -> None: + """An envelope run cannot index must not orphan a daemon that may be live. + + ``message["kind"]`` and ``message["payload"]`` are read after the spawn. + Indexing them outside cleanup ownership -- including as the argument + expression of the supervise call, which is evaluated in the caller -- lets + a KeyError escape while a worker may already be running. + """ + envelope = _success_payload("/s") + del envelope[missing] + monkeypatch.setattr( + cli_mod, "spawn_daemon", lambda _s, session_dir=None, *, input_env=None: envelope + ) + monkeypatch.setattr(cli_mod.subprocess, "Popen", QuietPopen) + result = CliRunner().invoke(main, _ARGS, input="secret\n") + assert result.exit_code == 4 + assert result.stdout == "", f"run leaked to stdout: {result.stdout!r}" + assert json.loads(result.stderr)["error"] == "DaemonError" + assert len(teardowns) == 1, "an unreadable envelope must still tear down, exactly once" + + +@pytest.mark.parametrize("target", ["write_materialized_output", "_build_child_env"]) +def test_post_spawn_exception_tears_down_once( + monkeypatch: pytest.MonkeyPatch, teardowns: list[tuple[Any, ...]], target: str +) -> None: + """Anything raised between the spawn and Popen still stops the daemon, exit 4.""" + + def _boom(*_a: Any, **_kw: Any) -> Any: + raise RuntimeError(f"{target} exploded") + + monkeypatch.setattr( + cli_mod, + "spawn_daemon", + lambda _s, session_dir=None, *, input_env=None: _success_payload(session_dir), + ) + monkeypatch.setattr(cli_mod.subprocess, "Popen", QuietPopen) + monkeypatch.setattr(cli_mod, target, _boom) + result = CliRunner().invoke(main, _ARGS, input="secret\n") + assert result.exit_code == 4 + assert result.stdout == "", f"run leaked to stdout: {result.stdout!r}" + assert json.loads(result.stderr)["error"] == "DaemonError" + assert len(teardowns) == 1, "teardown must run exactly once" + + +def test_launch_failure_is_127_and_tears_down( + monkeypatch: pytest.MonkeyPatch, teardowns: list[tuple[Any, ...]] +) -> None: + """An unlaunchable child is 127, distinct from the post-spawn guard's 4.""" + + def _boom(_cmd: list[str], env: dict[str, str] | None = None) -> Any: + raise OSError("no such binary") + + monkeypatch.setattr( + cli_mod, + "spawn_daemon", + lambda _s, session_dir=None, *, input_env=None: _success_payload(session_dir), + ) + monkeypatch.setattr(cli_mod.subprocess, "Popen", _boom) + result = CliRunner().invoke(main, _ARGS, input="secret\n") + assert result.exit_code == 127 + assert result.stdout == "", f"run leaked to stdout: {result.stdout!r}" + assert "failed to launch command" in result.stderr + assert len(teardowns) == 1 + + +def test_materialization_failure_is_daemon_error_not_launch_failure( + monkeypatch: pytest.MonkeyPatch, teardowns: list[tuple[Any, ...]] +) -> None: + """An unwritable session dir must not be misreported as "failed to launch + command" (exit 127): that message and code mean the child itself could + not be found or exec'd, not that the session directory is unwritable. + Reported as DaemonError (exit 4), the same as any other unexpected + post-spawn failure; teardown still runs exactly once.""" + + def _boom(_out: Any) -> None: + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr( + cli_mod, + "spawn_daemon", + lambda _s, session_dir=None, *, input_env=None: _success_payload(session_dir), + ) + monkeypatch.setattr(cli_mod, "write_materialized_output", _boom) + result = CliRunner().invoke(main, _ARGS, input="secret\n") + assert result.exit_code == 4 + assert result.stdout == "", f"run leaked to stdout: {result.stdout!r}" + assert "failed to launch command" not in result.stderr + error = json.loads(result.stderr) + assert error["error"] == "DaemonError" + assert error["details"]["type"] == "PermissionError" + assert len(teardowns) == 1 + + +def test_failing_signal_restoration_cannot_skip_teardown( + monkeypatch: pytest.MonkeyPatch, + teardowns: list[tuple[Any, ...]], + signal_guard: None, +) -> None: + """If restoring the handlers raises, the daemon is still stopped.""" + real_signal = signal_mod.signal + calls = {"n": 0} + + def _flaky(signum: int, handler: Any) -> Any: + calls["n"] += 1 + if calls["n"] > 2: # the first two are installs, the rest are restores + raise RuntimeError("cannot restore handler") + return real_signal(signum, handler) + + monkeypatch.setattr( + cli_mod, + "spawn_daemon", + lambda _s, session_dir=None, *, input_env=None: _success_payload(session_dir), + ) + monkeypatch.setattr(cli_mod.subprocess, "Popen", QuietPopen) + monkeypatch.setattr(cli_mod.signal, "signal", _flaky) + result = CliRunner().invoke(main, _ARGS, input="secret\n") + assert len(teardowns) == 1, "teardown must run even when restoration raises" + assert result.exit_code == 4 + assert result.stdout == "", f"run leaked to stdout: {result.stdout!r}" + assert json.loads(result.stderr)["error"] == "DaemonError" + + +def test_signal_handlers_are_restored_on_the_happy_path( + monkeypatch: pytest.MonkeyPatch, teardowns: list[tuple[Any, ...]] +) -> None: + """After a normal run the process's original handlers are back in place.""" + before = ( + signal_mod.getsignal(signal_mod.SIGINT), + signal_mod.getsignal(signal_mod.SIGTERM), + ) + monkeypatch.setattr( + cli_mod, + "spawn_daemon", + lambda _s, session_dir=None, *, input_env=None: _success_payload(session_dir), + ) + monkeypatch.setattr(cli_mod.subprocess, "Popen", QuietPopen) + result = CliRunner().invoke(main, _ARGS, input="secret\n") + assert result.exit_code == 7 + after = ( + signal_mod.getsignal(signal_mod.SIGINT), + signal_mod.getsignal(signal_mod.SIGTERM), + ) + assert after == before, "run left its own SIGINT/SIGTERM handlers installed" + + +def test_lone_optional_node_failure_still_succeeds_with_only_a_warning( + monkeypatch: pytest.MonkeyPatch, teardowns: list[tuple[Any, ...]] +) -> None: + """A required:false node that failed is an expected outcome, not an internal error. + + manager.py builds ``connections`` from successful nodes only + (``tunstrap/manager.py::TunnelManager``), so a lone optional node that never came up yields + a *success* envelope with ``connections == {}`` and a warning. + ``_build_child_env`` no longer branches on connection count -- the + unconditional session scalars and materialization apply regardless of + whether any node actually survived -- so the child still runs and its own + exit code wins; the failure is visible only in ``session.warnings``. + """ + + def _spawn( + _s: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + payload = { + "connections": {}, + "pid": 99, + "session_dir": session_dir, + "started_at": "now", + "warnings": [{"node": "edge", "error": "optional node refused the forward"}], + } + return {"kind": "success", "payload": payload} + + captured_env: dict[str, str] | None = None + + class _CapturingPopen(QuietPopen): + def __init__(self, cmd: list[str], env: dict[str, str] | None = None) -> None: + nonlocal captured_env + captured_env = env + super().__init__(cmd, env) + + monkeypatch.setattr(cli_mod, "spawn_daemon", _spawn) + monkeypatch.setattr(cli_mod.subprocess, "Popen", _CapturingPopen) + monkeypatch.setenv( + "TUNSTRAP_INPUT", + json.dumps( + { + "nodes": { + "edge": { + "host": "h.example.net", + "user": "u", + "ssh_password": "p", + "required": False, + "remote_targets": {"db": "127.0.0.1:5432"}, + } + } + } + ), + ) + result = CliRunner().invoke( + main, ["run", "--input-env", "TUNSTRAP_INPUT", "--output-var", "TF_VAR_t", "--", "true"] + ) + assert result.exit_code == 7, result.stderr # QuietPopen's own exit code, not exit 1 + assert captured_env is not None + decoded = json.loads(captured_env["TF_VAR_t"]) + assert decoded["session"]["warnings"] == [ + {"node": "edge", "error": "optional node refused the forward", "skipped": True} + ] + assert decoded["nodes"] == {} + assert len(teardowns) == 1, "the daemon must still be stopped" diff --git a/tests/unit/test_cli_runner.py b/tests/unit/test_cli_runner.py index ad4db5e..dde3235 100644 --- a/tests/unit/test_cli_runner.py +++ b/tests/unit/test_cli_runner.py @@ -9,6 +9,7 @@ import json import os +import stat import tempfile from pathlib import Path from typing import Any @@ -18,17 +19,21 @@ from tunstrap import cli as cli_mod from tunstrap.cli import main +from tunstrap.session import StopOutcome pytestmark = pytest.mark.unit def _patch_spawn_success(monkeypatch: pytest.MonkeyPatch) -> None: - def fake_spawn_daemon(schema: Any, session_dir: str | None = None) -> dict[str, Any]: + def fake_spawn_daemon( + schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: return { "kind": "success", "payload": { "connections": {}, "pid": 4242, + "session_dir": "/tmp/session", "started_at": "2026-05-20T00:00:00Z", "warnings": [], }, @@ -61,7 +66,9 @@ def test_start_success_returns_zero(monkeypatch: pytest.MonkeyPatch) -> None: def test_start_required_failure_returns_two(monkeypatch: pytest.MonkeyPatch) -> None: """RequiredTunnelFailure is surfaced via exit code 2.""" - def fake_spawn_daemon(schema: Any, session_dir: str | None = None) -> dict[str, Any]: + def fake_spawn_daemon( + schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: return { "kind": "required_failure", "payload": { @@ -93,7 +100,9 @@ def fake_spawn_daemon(schema: Any, session_dir: str | None = None) -> dict[str, def test_start_daemon_error_returns_four(monkeypatch: pytest.MonkeyPatch) -> None: """daemon_error IPC kind surfaces via exit code 4.""" - def fake_spawn_daemon(schema: Any, session_dir: str | None = None) -> dict[str, Any]: + def fake_spawn_daemon( + schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: return { "kind": "daemon_error", "payload": { @@ -155,15 +164,21 @@ def test_status_unknown_session_dir_reports_not_alive(tmp_path: Path) -> None: assert out == {"alive": False} -def test_stop_session_error_reports_and_exits_zero(tmp_path: Path) -> None: - """stop --session-dir returns structured JSON + exit 0.""" +def test_stop_session_error_reports_and_exits_one(tmp_path: Path) -> None: + """stop --session-dir returns structured JSON + exit 1. + + Reads ``result.stdout``, not ``result.output``: click 8.4's CliRunner + interleaves stderr into ``.output``, so once this outcome grew its + stderr preservation notice, decoding ``.output`` as JSON broke. The + envelope has always been a stdout-only contract. + """ from tunstrap.cli import main as cli_main runner = CliRunner() missing = tmp_path / "no-such-session" result = runner.invoke(cli_main, ["stop", "--session-dir", str(missing)]) - assert result.exit_code == 0 - payload = json.loads(result.output) + assert result.exit_code == 1 + payload = json.loads(result.stdout) assert payload["stopped"] is False assert ( "cannot read identity" in payload["reason"].lower() @@ -174,31 +189,32 @@ def test_stop_session_error_reports_and_exits_zero(tmp_path: Path) -> None: def test_stop_removes_tunnel_data_on_success( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """stop removes /tunnel-data after a successful match path.""" + """A successful stop is rendered and removes /tunnel-data.""" import tunstrap.cli as cli_mod - from tunstrap.identity import IdentityCheckResult sd = tmp_path / "session" data = sd / "tunnel-data" data.mkdir(parents=True) (data / "daemon.pid").write_text(f"{os.getpid()}\n") - def fake_verify(_session_dir: str, _pid: int) -> object: - return IdentityCheckResult.match + calls: list[tuple[str, int, int, bool]] = [] - call_count = {"n": 0} + def _stop_session( + session_dir: str, pid: int, grace_seconds: int, *, force: bool + ) -> StopOutcome: + calls.append((session_dir, pid, grace_seconds, force)) + return StopOutcome(True) - def fake_kill(_pid: int, sig: int) -> None: - call_count["n"] += 1 - if call_count["n"] >= 2: - raise ProcessLookupError - - monkeypatch.setattr(cli_mod, "verify_session", fake_verify) - monkeypatch.setattr(os, "kill", fake_kill) + monkeypatch.setattr(cli_mod, "stop_session", _stop_session) runner = CliRunner() - result = runner.invoke(cli_mod.main, ["stop", "--session-dir", str(sd)]) + result = runner.invoke( + cli_mod.main, + ["stop", "--session-dir", str(sd), "--grace-seconds", "17"], + ) assert result.exit_code == 0 + assert result.stdout == '{"stopped": true}\n' + assert calls == [(str(sd), os.getpid(), 17, True)] assert not data.exists(), f"tunnel-data should be removed; result={result.output!r}" @@ -220,38 +236,6 @@ def test_stop_unknown_pid_reports_not_found() -> None: assert out == {"stopped": False, "reason": "not found"} -def test_stop_identity_mismatch_reports_reason(monkeypatch: pytest.MonkeyPatch) -> None: - """stop where the live holder's pid differs reports an identity mismatch.""" - from tunstrap.identity import IdentityCheckResult - - monkeypatch.setattr( - cli_mod, - "verify_session", - lambda session_dir, pid: IdentityCheckResult.mismatch, - ) - sd = _make_session_dir(12345) - result = CliRunner().invoke(main, ["stop", "--session-dir", sd]) - assert result.exit_code == 0 - out = json.loads(result.output) - assert out == {"stopped": False, "reason": "identity mismatch"} - - -def test_stop_identity_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: - """stop reports unavailable identity (e.g., /proc not readable).""" - from tunstrap.identity import IdentityCheckResult - - monkeypatch.setattr( - cli_mod, - "verify_session", - lambda session_dir, pid: IdentityCheckResult.unavailable, - ) - sd = _make_session_dir(12345) - result = CliRunner().invoke(main, ["stop", "--session-dir", sd]) - assert result.exit_code == 0 - out = json.loads(result.output) - assert out == {"stopped": False, "reason": "identity check unavailable"} - - def test_start_invalid_json_returns_one() -> None: """start with non-JSON stdin reports SchemaValidationError (exit 1).""" result = CliRunner().invoke(main, ["start"], input="not-json-at-all") @@ -277,7 +261,9 @@ def test_start_schema_violation_returns_one(monkeypatch: pytest.MonkeyPatch) -> def test_start_unexpected_exception_returns_four(monkeypatch: pytest.MonkeyPatch) -> None: """Unexpected exception in spawn_daemon is wrapped in DaemonError (exit 4).""" - def boom(schema: Any, session_dir: str | None = None) -> dict[str, Any]: + def boom( + schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: raise RuntimeError("boom") monkeypatch.setattr(cli_mod, "spawn_daemon", boom) @@ -308,7 +294,9 @@ def test_start_flag_mode_builds_schema(monkeypatch: pytest.MonkeyPatch) -> None: """Flag mode: USER@HOST + --target builds the correct single-node InputSchema.""" captured: dict[str, Any] = {} - def fake_spawn(schema: Any, session_dir: str | None = None) -> dict[str, Any]: + def fake_spawn( + schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: captured["schema"] = schema return { "kind": "success", @@ -331,6 +319,22 @@ def fake_spawn(schema: Any, session_dir: str | None = None) -> dict[str, Any]: assert captured["schema"].nodes["node"].user == "root" +def test_start_flag_model_validation_does_not_print_ssh_key(tmp_path: Path) -> None: + """Flag-mode node validation must not expose the key read from --ssh-key.""" + secret = "FLAG-MODE-PRIVATE-KEY" + key_file = tmp_path / "id_key" + key_file.write_text(secret) + + result = CliRunner().invoke(main, ["start", "root@h", "--ssh-key", str(key_file)]) + + assert result.exit_code == 1 + assert secret not in result.output + payload = json.loads(result.output) + error = payload["details"]["errors"][0] + assert error["loc"] == ["nodes", "node"] + assert "node must define at least one" in error["msg"] + + def test_start_rejects_trailing_command() -> None: """start + trailing -- CMD is rejected (exit 64); output mentions 'run'.""" res = CliRunner().invoke(main, ["start", "root@h", "--", "helm", "list"]) @@ -354,10 +358,14 @@ def test_start_conn_flag_without_connection_rejected() -> None: assert res.exit_code == 64 -def test_start_output_env(monkeypatch: pytest.MonkeyPatch) -> None: - """--output env prints export lines including TUNSTRAP_DB_PORT.""" +def test_start_output_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """--output env prints the three survivors plus the kube channel, materializing output.json.""" + payload_session_dir = tmp_path / "s" + payload_session_dir.mkdir() - def fake_spawn(schema: Any, session_dir: str | None = None) -> dict[str, Any]: + def fake_spawn( + schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: return { "kind": "success", "payload": { @@ -365,7 +373,7 @@ def fake_spawn(schema: Any, session_dir: str | None = None) -> dict[str, Any]: "h": {"ports": {"db": 5432}, "fetch_files": {}, "kube_targets": {}} }, "pid": 7, - "session_dir": "/s", + "session_dir": str(payload_session_dir), "started_at": "now", }, } @@ -385,4 +393,387 @@ def fake_spawn(schema: Any, session_dir: str | None = None) -> dict[str, Any]: input="secret\n", ) assert res.exit_code == 0, res.output - assert "export TUNSTRAP_DB_PORT='5432'" in res.output + assert f"export TUNSTRAP_SESSION_DIR='{payload_session_dir}'" in res.output + assert "export TUNSTRAP_PID='7'" in res.output + materialized = payload_session_dir / "tunnel-data" / "output.json" + assert f"export TUNSTRAP_OUTPUT_FILE='{materialized}'" in res.output + assert "TUNSTRAP_DB_PORT" not in res.output + assert "KUBECONFIG" not in res.output, "no kube_targets in this payload" + assert materialized.exists() + assert stat.S_IMODE(materialized.stat().st_mode) == 0o600 + + +def test_start_json_materialized_kubeconfig_never_prints_credential_content( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Materialized start JSON exposes only the kube reference, never its content.""" + payload_session_dir = tmp_path / "s" + payload_session_dir.mkdir() + kube_path = payload_session_dir / "tunnel-data" / "kube-node-k3s" + + def fake_spawn( + schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + return { + "kind": "success", + "payload": { + "connections": { + "node": { + "ports": {}, + "fetch_files": {}, + "kube_targets": { + "k3s": { + "cluster_name": "cluster", + "context_name": "context", + "local_port": 7000, + "endpoint": "https://127.0.0.1:7000", + "tls_server_name": "tls-name", + "certificate_authority_data": "CA-MARKER", + "client_certificate_data": "CERTIFICATE-MARKER", + "client_key_data": "PRIVATE-KEY-MARKER", + "content_b64": "FULL-KUBECONFIG-MARKER", + "path": str(kube_path), + } + }, + } + }, + "pid": 7, + "session_dir": str(payload_session_dir), + "started_at": "now", + }, + } + + monkeypatch.setattr(cli_mod, "spawn_daemon", fake_spawn) + + res = CliRunner().invoke(main, ["start", "u@h", "--target", "db=127.0.0.1:5432"]) + + assert res.exit_code == 0, res.output + for secret in ( + "CA-MARKER", + "CERTIFICATE-MARKER", + "PRIVATE-KEY-MARKER", + "FULL-KUBECONFIG-MARKER", + ): + assert secret not in res.output + target = json.loads(res.output)["connections"]["node"]["kube_targets"]["k3s"] + assert target == { + "path": str(kube_path), + "context": "context", + "endpoint": "https://127.0.0.1:7000", + } + + +def test_start_json_unmaterialized_kubeconfig_keeps_stdout_delivery( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """An unmaterialized kubeconfig remains available through start JSON.""" + payload_session_dir = tmp_path / "s" + payload_session_dir.mkdir() + + def fake_spawn( + schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + return { + "kind": "success", + "payload": { + "connections": { + "node": { + "ports": {}, + "fetch_files": {}, + "kube_targets": { + "k3s": { + "cluster_name": "cluster", + "context_name": "context", + "local_port": 7000, + "endpoint": "https://127.0.0.1:7000", + "tls_server_name": "tls-name", + "certificate_authority_data": "CA-MARKER", + "client_certificate_data": "CERTIFICATE-MARKER", + "client_key_data": "PRIVATE-KEY-MARKER", + "content_b64": "FULL-KUBECONFIG-MARKER", + "path": None, + } + }, + } + }, + "pid": 7, + "session_dir": str(payload_session_dir), + "started_at": "now", + }, + } + + monkeypatch.setattr(cli_mod, "spawn_daemon", fake_spawn) + + res = CliRunner().invoke(main, ["start", "u@h", "--target", "db=127.0.0.1:5432"]) + + assert res.exit_code == 0, res.output + target = json.loads(res.output)["connections"]["node"]["kube_targets"]["k3s"] + assert target["content_b64"] == "FULL-KUBECONFIG-MARKER" + assert target["path"] is None + + +def test_start_json_materialized_fetch_file_never_prints_content( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A materialized fetched file exposes only its on-disk reference.""" + payload_session_dir = tmp_path / "s" + payload_session_dir.mkdir() + fetch_path = payload_session_dir / "tunnel-data" / "fetch-node-kubeconfig" + + def fake_spawn( + schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + return { + "kind": "success", + "payload": { + "connections": { + "node": { + "ports": {}, + "fetch_files": { + "kubeconfig": { + "content_b64": "FETCHED-SECRET-MARKER", + "path": str(fetch_path), + "size": 21, + "sha256": "a" * 64, + } + }, + "kube_targets": {}, + } + }, + "pid": 7, + "session_dir": str(payload_session_dir), + "started_at": "now", + }, + } + + monkeypatch.setattr(cli_mod, "spawn_daemon", fake_spawn) + + res = CliRunner().invoke(main, ["start", "u@h", "--target", "db=127.0.0.1:5432"]) + + assert res.exit_code == 0, res.output + assert "FETCHED-SECRET-MARKER" not in res.output + fetched = json.loads(res.output)["connections"]["node"]["fetch_files"]["kubeconfig"] + assert fetched == {"path": str(fetch_path), "size": 21, "sha256": "a" * 64} + + +def test_start_json_unmaterialized_fetch_file_keeps_stdout_delivery( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """An unmaterialized fetched file remains available through start JSON.""" + payload_session_dir = tmp_path / "s" + payload_session_dir.mkdir() + + def fake_spawn( + schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + return { + "kind": "success", + "payload": { + "connections": { + "node": { + "ports": {}, + "fetch_files": { + "kubeconfig": { + "content_b64": "FETCHED-SECRET-MARKER", + "path": None, + "size": 21, + "sha256": "a" * 64, + } + }, + "kube_targets": {}, + } + }, + "pid": 7, + "session_dir": str(payload_session_dir), + "started_at": "now", + }, + } + + monkeypatch.setattr(cli_mod, "spawn_daemon", fake_spawn) + + res = CliRunner().invoke(main, ["start", "u@h", "--target", "db=127.0.0.1:5432"]) + + assert res.exit_code == 0, res.output + fetched = json.loads(res.output)["connections"]["node"]["fetch_files"]["kubeconfig"] + assert fetched["content_b64"] == "FETCHED-SECRET-MARKER" + assert fetched["path"] is None + + +def test_stdin_payload_output_env_forces_materialize_for_kube_targets( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A stdin payload declaring materialize: false and kube_targets under + --output env must not reach render_kube_env with an unmaterialized path + (a bare ValueError, not a typed error) -- option (a): --output env forces + daemon.materialize = True for the stdin channel too, matching flag mode's + own force_materialize precedent.""" + captured: dict[str, Any] = {} + payload_session_dir = tmp_path / "s" + payload_session_dir.mkdir() + + def fake_spawn( + schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + captured["schema"] = schema + return { + "kind": "success", + "payload": { + "connections": { + "h": { + "ports": {}, + "fetch_files": {}, + "kube_targets": { + "k3s": { + "cluster_name": "c", + "context_name": "ctx", + "local_port": 7000, + "endpoint": "https://127.0.0.1:7000", + "tls_server_name": "c", + "certificate_authority_data": "Y2E=", + "client_certificate_data": "Y2VydA==", + "client_key_data": "a2V5", + "content_b64": "a3ViZWNvbmZpZw==", + "path": str(payload_session_dir / "tunnel-data" / "k3s"), + } + }, + } + }, + "pid": 7, + "session_dir": str(payload_session_dir), + "started_at": "now", + }, + } + + monkeypatch.setattr(cli_mod, "spawn_daemon", fake_spawn) + stdin_payload = json.dumps( + { + "nodes": { + "h": { + "host": "h", + "user": "u", + "ssh_password": "p", + "kube_targets": {"k3s": {"kubeconfig_path": "/etc/k3s.yaml"}}, + } + }, + "daemon": {"materialize": False}, + } + ) + res = CliRunner().invoke(main, ["start", "--output", "env"], input=stdin_payload) + assert res.exit_code == 0, res.output + forced_materialize = captured["schema"].daemon.materialize is True + assert forced_materialize, "--output env must force materialize=True for a stdin payload too" + assert f"export KUBECONFIG='{payload_session_dir / 'tunnel-data' / 'k3s'}'" in res.output + + +def test_start_post_spawn_render_failure_preserves_recovery_handle( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A post-spawn output failure identifies the possibly-live daemon to its operator. + + The returned success payload has an unmaterialized kube target, which makes + ``render_kube_env`` raise during ``start --output env``. The worker is + represented by this live test-process PID: the contract under test is that + the error still gives the operator its session directory and a command that + accepts that directory, rather than whether this test process is stopped. + """ + session_path = str(tmp_path / "session") + + def fake_spawn( + _schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + return { + "kind": "success", + "payload": { + "connections": { + "node": { + "ports": {}, + "fetch_files": {}, + "kube_targets": { + "k3s": { + "cluster_name": "c", + "context_name": "ctx", + "local_port": 7000, + "endpoint": "https://127.0.0.1:7000", + "tls_server_name": "c", + "certificate_authority_data": "Y2E=", + "client_certificate_data": "Y2VydA==", + "client_key_data": "a2V5", + "content_b64": "a3ViZWNvbmZpZw==", + "path": None, + } + }, + } + }, + "pid": os.getpid(), + "session_dir": session_path, + "started_at": "now", + }, + } + + monkeypatch.setattr(cli_mod, "spawn_daemon", fake_spawn) + + result = CliRunner().invoke( + main, + ["start", "u@h", "--target", "db=127.0.0.1:5432", "--output", "env"], + ) + + assert result.exit_code == 4 + error = json.loads(result.stdout) + assert error["error"] == "DaemonError" + assert error["details"]["type"] == "ValueError" + assert error["details"]["session_dir"] == session_path + assert error["details"]["pid"] == os.getpid() + assert f"tunstrap stop --session-dir {session_path}" in result.stderr + + +def test_start_post_spawn_unusable_envelope_preserves_supplied_session_dir( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A malformed success reply still leaves a caller-supplied root recoverable. + + Deleting the fallback to ``session_dir`` in + ``_report_start_post_spawn_failure`` makes this fail: the daemon's worker + uses the supplied root verbatim, despite not providing a usable reply. + """ + session_path = str(tmp_path / "session") + + def fake_spawn( + _schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + return {"kind": "success", "payload": None} + + monkeypatch.setattr(cli_mod, "spawn_daemon", fake_spawn) + + result = CliRunner().invoke( + main, + [ + "start", + "u@h", + "--target", + "db=127.0.0.1:5432", + "--session-dir", + session_path, + ], + ) + + assert result.exit_code == 4 + error = json.loads( + next(line for line in result.output.splitlines() if line.startswith('{"error"')) + ) + assert error["details"] == {"type": "ValidationError", "session_dir": session_path} + assert f"tunstrap stop --session-dir {session_path}" in result.output + + +@pytest.mark.parametrize( + "message", + [ + {"kind": "daemon_error", "payload": {"session_dir": "/s", "pid": 7}}, + {"kind": "success", "payload": None}, + {"kind": "success", "payload": {"session_dir": 7, "pid": 7}}, + {"kind": "success", "payload": {"session_dir": "/s", "pid": True}}, + {"kind": "success", "payload": {"session_dir": "/s", "pid": 0}}, + ], +) +def test_start_recovery_handles_reject_unusable_envelopes(message: object) -> None: + """Only a successful envelope with safe scalar handles gets recovery output.""" + assert cli_mod._start_recovery_handles(message) is None diff --git a/tests/unit/test_cli_start_validation.py b/tests/unit/test_cli_start_validation.py index 4bf6378..3f71f4a 100644 --- a/tests/unit/test_cli_start_validation.py +++ b/tests/unit/test_cli_start_validation.py @@ -45,3 +45,138 @@ def test_start_rejects_legacy_require_field() -> None: payload = json.loads(result.output) assert payload["error"] == "SchemaValidationError" assert "require" in json.dumps(payload["details"]) + + +def test_start_field_validation_retains_location_and_message() -> None: + """Stripping field input retains the location and message needed to fix it.""" + body = json.dumps( + { + "nodes": { + "a": { + "host": "h", + "port": "not-a-port", + "user": "u", + "ssh_pkey": "valid-private-key", + "remote_targets": {"p": "127.0.0.1:22"}, + } + } + } + ) + + result = CliRunner().invoke(main, ["start"], input=body) + + assert result.exit_code == 1 + payload = json.loads(result.output) + error = payload["details"]["errors"][0] + assert error["loc"] == ["nodes", "a", "port"] + assert "valid integer" in error["msg"] + + +def test_start_model_validation_does_not_print_ssh_pkey() -> None: + """A node-level validator must not expose its complete input on stdout.""" + secret = "MODEL-LEVEL-PRIVATE-KEY" + body = json.dumps( + { + "nodes": { + "a": {"host": "h", "user": "u", "ssh_pkey": secret}, + } + } + ) + + result = CliRunner().invoke(main, ["start"], input=body) + + assert result.exit_code == 1 + assert secret not in result.output + payload = json.loads(result.output) + error = payload["details"]["errors"][0] + assert error["loc"] == ["nodes", "a"] + assert "node must define at least one" in error["msg"] + + +def test_start_model_validation_does_not_print_ssh_pkey_passphrase() -> None: + """A node-level validator must not expose an SSH key passphrase on stdout.""" + secret = "MODEL-LEVEL-PRIVATE-KEY-PASSPHRASE" + body = json.dumps( + { + "nodes": { + "a": {"host": "h", "user": "u", "ssh_pkey_passphrase": secret}, + } + } + ) + + result = CliRunner().invoke(main, ["start"], input=body) + + assert result.exit_code == 1 + assert secret not in result.output + payload = json.loads(result.output) + error = payload["details"]["errors"][0] + assert error["loc"] == ["nodes", "a"] + assert "node must define at least one" in error["msg"] + + +def test_start_nodes_validator_does_not_print_any_node_secrets( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The nodes-wide validator must not expose secrets from valid sibling nodes.""" + monkeypatch.delenv("SSH_AUTH_SOCK", raising=False) + pkey_secret = "VALID-NODE-PRIVATE-KEY" + password_secret = "ANOTHER-VALID-NODE-PASSWORD" + body = json.dumps( + { + "nodes": { + "key-node": { + "host": "key-host", + "user": "u", + "ssh_pkey": pkey_secret, + "remote_targets": {"p": "127.0.0.1:22"}, + }, + "password-node": { + "host": "password-host", + "user": "u", + "ssh_password": password_secret, + "remote_targets": {"p": "127.0.0.1:22"}, + }, + "unauthenticated-node": { + "host": "missing-auth-host", + "user": "u", + "remote_targets": {"p": "127.0.0.1:22"}, + }, + } + } + ) + + result = CliRunner().invoke(main, ["start"], input=body) + + assert result.exit_code == 1 + assert pkey_secret not in result.output + assert password_secret not in result.output + payload = json.loads(result.output) + error = payload["details"]["errors"][0] + assert error["loc"] == ["nodes"] + assert "unauthenticated-node" in error["msg"] + + +def test_start_nested_remote_target_validation_does_not_print_input() -> None: + """Nested pydantic errors must not interpolate their invalid input into stdout.""" + secret = "NESTED-REMOTE-TARGET-PRIVATE-KEY" + body = json.dumps( + { + "nodes": { + "a": { + "host": "h", + "user": "u", + "ssh_password": "valid-password", + "remote_targets": {"p": {"host": "target", "port": 22, "ssh_pkey": secret}}, + } + } + } + ) + + result = CliRunner().invoke(main, ["start"], input=body) + + assert result.exit_code == 1 + assert secret not in result.output + payload = json.loads(result.output) + error = payload["details"]["errors"][0] + assert error["loc"] == ["nodes", "a", "remote_targets"] + assert "invalid dict form" in error["msg"] diff --git a/tests/unit/test_cli_stop_output.py b/tests/unit/test_cli_stop_output.py new file mode 100644 index 0000000..aa54fdd --- /dev/null +++ b/tests/unit/test_cli_stop_output.py @@ -0,0 +1,292 @@ +"""`stop`'s stdout contract, pinned byte for byte. + +Validates: after the stop mechanism moved into the silent session.stop_session +primitive, `tunstrap stop` still writes exactly the JSON it wrote before — +same keys, same order, same spacing, same trailing newline. +Code: tunstrap/cli.py (stop_command, _stop_outcome_json) +Assertion: result.stdout equals a literal recorded from the pre-refactor +implementation; result.stderr is empty. +Method: monkeypatch cli.stop_session to return each StopOutcome and +SessionDir.read_identity/cleanup_path so no real daemon is involved. Uses +result.stdout (not result.output), because click 8.4's CliRunner interleaves +stderr into .output. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from tunstrap import cli as cli_mod +from tunstrap import session as session_mod +from tunstrap.cli import main +from tunstrap.session import SessionError, SessionIdentityUnreadable, StopOutcome + +pytestmark = pytest.mark.unit + + +@pytest.fixture(name="stubbed_session") +def _stubbed_session(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(cli_mod.SessionDir, "read_identity", staticmethod(lambda _sd: 4242)) + monkeypatch.setattr(cli_mod.SessionDir, "cleanup_path", classmethod(lambda _cls, _sd: [])) + + +@pytest.mark.parametrize( + "outcome, expected", + [ + (StopOutcome(False, "not found"), '{"stopped": false, "reason": "not found"}\n'), + ( + StopOutcome(False, "identity mismatch"), + '{"stopped": false, "reason": "identity mismatch", "preserved": true}\n', + ), + ( + StopOutcome(False, "identity check unavailable"), + '{"stopped": false, "reason": "identity check unavailable", "preserved": true}\n', + ), + (StopOutcome(True), '{"stopped": true}\n'), + ( + StopOutcome(False, "still alive"), + '{"stopped": false, "reason": "still alive", "preserved": true}\n', + ), + ( + StopOutcome(False, "identity changed during grace"), + '{"stopped": false, "reason": "identity changed during grace", "preserved": true}\n', + ), + (StopOutcome(True, forced=True), '{"stopped": true, "forced": true}\n'), + ], +) +def test_stop_stdout_is_byte_identical( + monkeypatch: pytest.MonkeyPatch, + stubbed_session: None, + outcome: StopOutcome, + expected: str, +) -> None: + """Each StopOutcome renders as the exact bytes stop wrote before the refactor. + + ``preserved`` is the one addition, and it is additive: it appears only on + the four outcomes where stop now keeps the session data, so the three + resolved shapes — including the most-parsed ``{"stopped": true}`` — are + unchanged to the byte. It is on stdout because it is machine-readable + state; the *human* notice that goes with it is asserted on stderr below. + """ + monkeypatch.setattr(cli_mod, "stop_session", lambda _sd, _pid, _grace, *, force: outcome) + result = CliRunner().invoke(main, ["stop", "--session-dir", "/s"]) + assert result.exit_code == (0 if cli_mod._stop_resolved(outcome) else 1) + assert result.stdout == expected + + +@pytest.mark.parametrize( + "outcome, warns", + [ + (StopOutcome(True), False), + (StopOutcome(True, forced=True), False), + (StopOutcome(False, "not found"), False), + (StopOutcome(False, "identity mismatch"), True), + (StopOutcome(False, "identity check unavailable"), True), + (StopOutcome(False, "still alive"), True), + (StopOutcome(False, "identity changed during grace"), True), + ], +) +def test_stop_warns_on_stderr_exactly_when_it_preserves( + monkeypatch: pytest.MonkeyPatch, + stubbed_session: None, + outcome: StopOutcome, + warns: bool, +) -> None: + """The human notice goes to stderr, and only when data was actually kept. + + stdout is a machine-readable envelope that callers parse, so a sentence for + a person cannot go there — that is the repo's stdout-purity invariant. But + an operator who runs the recovery command and gets ``stopped: false`` needs + to be told the data is still on disk, or the silence reads as "nothing + left to do". + + Both directions are parametrized: a notice on a *resolved* outcome would be + noise on the normal path, and is caught here too. + """ + monkeypatch.setattr(cli_mod, "stop_session", lambda _sd, _pid, _grace, *, force: outcome) + result = CliRunner().invoke(main, ["stop", "--session-dir", "/s"]) + assert result.exit_code == (0 if cli_mod._stop_resolved(outcome) else 1) + if warns: + assert "session data preserved under /s" in result.stderr + assert str(outcome.reason) in result.stderr + else: + assert result.stderr == "", f"a resolved stop must stay silent: {result.stderr!r}" + + +@pytest.mark.parametrize( + "raised", + [ + SessionError("cannot read identity from /s/tunnel-data: nope"), + SessionIdentityUnreadable("cannot read identity from /s/tunnel-data: nope"), + ], + ids=["missing", "unreadable-or-malformed"], +) +def test_stop_identity_failure_envelope_is_byte_exact( + monkeypatch: pytest.MonkeyPatch, raised: SessionError +) -> None: + """Every identity-read failure reports ``preserved``, byte for byte. + + These paths return before cleanup, so they *do* keep ``tunnel-data`` — and + they used to render their own JSON literal inline, omitting ``preserved`` + entirely. A caller parsing the envelope therefore read "no preserved key" + as "the directory was cleaned", which was false on exactly these outcomes. + + Byte-exact rather than ``json.loads``: the point of the field is a stable + machine-readable envelope, so key order, spacing and the trailing newline + are the contract, not just the decoded mapping. The reason text is pinned + to a controlled message so the whole line can be asserted. + + Both the base ``SessionError`` and the ``SessionIdentityUnreadable`` + subclass are covered, and both render identically: in ``stop`` nothing is + deleted either way, so signalling a difference would describe one that does + not exist. ``run`` splits them because there the distinction decides + whether to delete. + """ + + def _boom(_session_dir: str) -> int: + raise raised + + monkeypatch.setattr(cli_mod.SessionDir, "read_identity", staticmethod(_boom)) + monkeypatch.setattr(cli_mod.SessionDir, "cleanup_path", classmethod(lambda _cls, _sd: [])) + result = CliRunner().invoke(main, ["stop", "--session-dir", "/s"]) + assert result.exit_code == 1 + assert result.stdout == ( + '{"stopped": false, "reason": "cannot read identity from /s/tunnel-data: nope",' + ' "preserved": true}\n' + ) + assert "session data preserved under /s" in result.stderr + + +def _session_with_identity(root: Path, pid: str = "4242\n") -> tuple[Path, Path]: + """Build a real session dir holding a real identity file and a real secret.""" + data = root / "tunnel-data" + data.mkdir(parents=True) + (data / "daemon.pid").write_text(pid) + (data / "materialized.kubeconfig").write_text("credential-bearing") + return data, data / "daemon.pid" + + +@pytest.mark.parametrize( + "outcome", + [StopOutcome(True), StopOutcome(True, forced=True), StopOutcome(False, "not found")], + ids=["stopped", "forced", "not-found"], +) +def test_stop_still_deletes_on_a_resolved_outcome( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, outcome: StopOutcome +) -> None: + """The negative control, and the load-bearing half of the whole change. + + Without it, "preserve on everything" would satisfy the preservation tests + while leaking every session dir tunstrap ever created. ``not found`` is + included deliberately: it is a *resolved* outcome — the normal shape once + auto-stop-idle has fired — and reading it as a failure would mean the + common path stopped cleaning up. + """ + data, _ = _session_with_identity(tmp_path) + monkeypatch.setattr(cli_mod, "stop_session", lambda _sd, _pid, _grace, *, force: outcome) + + result = CliRunner().invoke(main, ["stop", "--session-dir", str(tmp_path)]) + + assert result.exit_code == 0 + assert not data.exists(), "a resolved stop must still remove tunnel-data" + + +@pytest.mark.parametrize( + "reason", + ["identity mismatch", "identity check unavailable", "still alive", "identity changed"], +) +def test_stop_keeps_the_identity_on_an_unresolved_outcome( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, reason: str +) -> None: + """An unresolved stop leaves the operator the handle it could not use itself.""" + data, identity = _session_with_identity(tmp_path) + monkeypatch.setattr( + cli_mod, "stop_session", lambda _sd, _pid, _grace, *, force: StopOutcome(False, reason) + ) + + result = CliRunner().invoke(main, ["stop", "--session-dir", str(tmp_path)]) + + assert result.exit_code == 1 + ate_the_handle = "stop deleted the identity of a daemon it could not stop" + assert data.exists() and identity.read_text() == "4242\n", ate_the_handle + + +@pytest.mark.parametrize( + "shape", + ["missing", "unreadable", "malformed"], + ids=["missing", "unreadable", "malformed"], +) +def test_stop_deletes_nothing_when_it_cannot_read_the_identity(tmp_path: Path, shape: str) -> None: + """All three identity-read failures leave the session dir alone. + + ``SessionIdentityUnreadable`` exists now, but it is not applied here, and + this pins why: the split's purpose is to decide *whether to delete*, and + ``stop`` already deletes nothing on any of the three — it returns before + reaching cleanup. Distinguishing them would add surface with no behavioural + consequence. The operator still sees which one it was, because the reason + string carries the underlying OSError or ValueError text. + + A missing identity is deliberately not treated as "safe to clean" here, as + it is in ``run``: ``stop`` has no way to know the directory is not a daemon + that is starting up right now and has yet to write its pid. + """ + data = tmp_path / "tunnel-data" + data.mkdir(parents=True) + (data / "materialized.kubeconfig").write_text("credential-bearing") + if shape == "unreadable": + (data / "daemon.pid").mkdir() + elif shape == "malformed": + (data / "daemon.pid").write_text("not-a-pid\n") + + result = CliRunner().invoke(main, ["stop", "--session-dir", str(tmp_path)]) + + assert result.exit_code == 1 + assert json.loads(result.stdout)["stopped"] is False + assert (data / "materialized.kubeconfig").exists(), "stop deleted state it could not assess" + # The envelope must carry the signal that matches what just happened on + # disk. Asserted against a real OSError/ValueError reason -- which no test + # can spell in advance -- so the ends of the line are pinned instead: key + # order, spacing, `preserved` last, trailing newline. + assert result.stdout.startswith('{"stopped": false, "reason": "') + unsignalled = f"a preserving outcome reported no preserved key: {result.stdout!r}" + assert result.stdout.endswith('", "preserved": true}\n'), unsignalled + assert f"session data preserved under {tmp_path}" in result.stderr + + +def test_stop_with_a_non_positive_pid_through_the_cli_never_signals( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The composed path: a corrupt ``-1`` pid through the real CLI never signals. + + Every other layer is tested with the layer below it stubbed, so no test + drives a non-positive pid through the real CLI. A regression that reopens + the hole by a different route — a new ``stop_session`` caller that skips + ``read_identity``, or a change to which exception ``cli.stop_command`` + catches — would pass the whole suite today. This writes ``-1`` into a real + ``tunnel-data/daemon.pid`` and invokes ``stop`` through Click's runner with + ``os.kill`` replaced by a recorder, so the only way it stays green is for + the chain to hold end to end: ``read_identity`` rejects ``-1``, and even if + it did not, ``stop_session``'s entry guard does. + + The recorder staying empty is the blast-radius contract; ``preserved: true`` + with ``tunnel-data`` still on disk is the disposal contract for a daemon + that cannot be addressed (not ``not found``, which would delete). Asserted + on ``result.stdout`` because Click's ``CliRunner`` interleaves stderr into + ``result.output``. + """ + data, _ = _session_with_identity(tmp_path, pid="-1\n") + sent: list[tuple[int, int]] = [] + monkeypatch.setattr(session_mod.os, "kill", lambda p, s: sent.append((p, s))) + + result = CliRunner().invoke(main, ["stop", "--session-dir", str(tmp_path)]) + + assert result.exit_code == 1 + assert sent == [], f"os.kill was called with a non-positive pid: {sent}" + body = json.loads(result.stdout) + assert body["stopped"] is False + assert body["preserved"] is True + assert data.exists(), "tunnel-data must survive a pid it could not address" diff --git a/tests/unit/test_code_citations.py b/tests/unit/test_code_citations.py new file mode 100644 index 0000000..8ce3bea --- /dev/null +++ b/tests/unit/test_code_citations.py @@ -0,0 +1,90 @@ +"""Keep live code citations resolvable and independent of source line numbers.""" + +from __future__ import annotations + +import ast +import re +import subprocess +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.unit + +REPO_ROOT = Path(__file__).resolve().parents[2] +_PYTHON_SUFFIX = "." + "py" +_SYMBOL_SEPARATOR = ":" + ":" +_CITATION_RE = re.compile( + r"(? list[Path]: + """Return the tracked live files whose code citations are maintained.""" + tracked = subprocess.run( + ["git", "ls-files", "-z"], + cwd=REPO_ROOT, + capture_output=True, + check=True, + ).stdout.decode() + paths = [Path(path) for path in tracked.split("\0") if path] + # docs/superpowers/plans/** and docs/specs/** are frozen historical records. + # Their line references were accurate when written; rewriting them would + # falsify that history, so this deliberately excludes them from the guard. + return [ + REPO_ROOT / path + for path in paths + if path == Path("README.md") + or path == Path("docs/recipe_terragrunt.md") + or (path.parts[0] in {"tunstrap", "tests"} and path.suffix == _PYTHON_SUFFIX) + ] + + +def _defined_symbols(path: Path) -> set[str]: + """Return module symbols and methods defined by a Python source file.""" + tree = ast.parse(path.read_text(), filename=str(path)) + symbols: set[str] = set() + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + symbols.add(node.name) + if isinstance(node, ast.ClassDef): + symbols.update( + member.name + for member in node.body + if isinstance(member, (ast.FunctionDef, ast.AsyncFunctionDef)) + ) + elif isinstance(node, (ast.Assign, ast.AnnAssign)): + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + symbols.update(target.id for target in targets if isinstance(target, ast.Name)) + return symbols + + +def test_live_symbol_citations_resolve() -> None: + """Every live path-and-symbol citation identifies a definition in its file.""" + violations: list[str] = [] + for source_path in _tracked_in_scope_files(): + for relative_path, symbol in _CITATION_RE.findall(source_path.read_text()): + target_path = REPO_ROOT / relative_path + citation = relative_path + _SYMBOL_SEPARATOR + symbol + if not target_path.is_file() or symbol not in _defined_symbols(target_path): + violations.append(citation) + + assert not violations, f"unresolvable code citations: {violations}" + + +def test_live_citations_do_not_use_line_numbers() -> None: + """Live citations must name a stable symbol rather than a shifting line.""" + violations = [ + str(source_path.relative_to(REPO_ROOT)) + for source_path in _tracked_in_scope_files() + if _LINE_CITATION_RE.search(source_path.read_text()) + ] + + assert not violations, f"line-number code citations: {violations}" diff --git a/tests/unit/test_daemon_handshake.py b/tests/unit/test_daemon_handshake.py new file mode 100644 index 0000000..df24d2a --- /dev/null +++ b/tests/unit/test_daemon_handshake.py @@ -0,0 +1,248 @@ +"""Parent-side handshake failures, on the far side of the detach point. + +Validates: once ``subprocess.Popen`` has returned, a worker exists and is +detached. Every failure ``spawn_daemon`` can hit after that moment is +*parent-side* — the worker may be perfectly healthy, running, and holding the +session lock — so it must reach the caller as ``DaemonHandshakeError``, the +signal that a daemon needs stopping. A worker-authored failure arrives as an +IPC frame instead and is not this class. + +Code: tunstrap/daemon.py (spawn_daemon, _read_ipc_response) +Assertion: each post-detach failure raises DaemonHandshakeError and keeps +DaemonError's exit code 4. +Method: subprocess.Popen replaced by a fake that emulates the worker's side of +the IPC pipe, so the failures can be produced exactly and no real process is +started. + +How these fail if the defect returns: revert the raises to plain +``DaemonError`` and every case here fails, because ``DaemonHandshakeError`` is +the strictly narrower type the CLI keys its teardown decision on — a plain +``DaemonError`` sends ``run`` down the discard-without-teardown path that +orphans a live worker. ``test_missing_stdin_pipe_is_a_handshake_error`` +additionally fails today with ``AssertionError``, which is not a +``TunstrapError`` at all and so escapes the CLI's handler entirely. +""" + +from __future__ import annotations + +import io +import json +import os +import subprocess +import threading +from typing import IO, Any + +import pytest + +from tunstrap import daemon as daemon_mod +from tunstrap.daemon import spawn_daemon +from tunstrap.exceptions import ( + DaemonError, + DaemonHandshakeError, + DaemonHandshakeTimeoutError, + exit_code_for, +) +from tunstrap.schemas import InputSchema + +pytestmark = pytest.mark.unit + + +def _schema() -> InputSchema: + return InputSchema.model_validate({"nodes": {}}) + + +def _fake_popen(frame: bytes | None, *, stdin: bool = True) -> Any: + """Build a Popen stand-in that writes ``frame`` to the inherited IPC fd. + + The parent closes its own copy of the write end right after Popen returns, + so writing here and never holding the fd open reproduces exactly what the + real worker's pipe looks like from the read side: the bytes, then EOF. + """ + + class _FakePopen: # pylint: disable=too-few-public-methods + def __init__( + self, + argv: list[str], + *, + pass_fds: list[int], + **_kwargs: object, + ) -> None: + self.argv = argv + self.pid = 424242 + self.stdin: IO[bytes] | None = io.BytesIO() if stdin else None + if frame is not None: + os.write(pass_fds[0], frame) + + return _FakePopen + + +@pytest.mark.parametrize( + "frame, expected_message", + [ + (b"", "worker IPC pipe closed without a message"), + (b"{not json", "worker IPC produced invalid JSON"), + (json.dumps({"kind": "surprise"}).encode(), "unexpected IPC message kind"), + ], +) +def test_post_detach_ipc_failures_are_handshake_errors( + monkeypatch: pytest.MonkeyPatch, frame: bytes, expected_message: str +) -> None: + """Every _read_ipc_response failure names the parent, not the worker.""" + monkeypatch.setattr(daemon_mod.subprocess, "Popen", _fake_popen(frame)) + with pytest.raises(DaemonHandshakeError) as caught: + spawn_daemon(_schema()) + assert caught.value.message == expected_message + + +def test_missing_stdin_pipe_is_a_handshake_error(monkeypatch: pytest.MonkeyPatch) -> None: + """The replaced ``assert``: a real check, of the right type, kept under -O. + + ``assert proc.stdin is not None`` was an AssertionError — outside the + TunstrapError hierarchy, so it escaped ``run``'s handler as a traceback — + and ``python -O`` erased it entirely, leaving an AttributeError on the next + line. Both failure modes are on the far side of the detach point. + """ + monkeypatch.setattr(daemon_mod.subprocess, "Popen", _fake_popen(None, stdin=False)) + with pytest.raises(DaemonHandshakeError) as caught: + spawn_daemon(_schema()) + assert caught.value.message == "worker stdin pipe unavailable" + + +def test_handshake_error_is_a_daemon_error_and_still_exits_4() -> None: + """Narrowing the type must not move the documented exit code. + + ``exit_code_for`` keys on the exact type, so a subclass without its own + registry entry would silently fall through to the default 1 and change + every parent-side IPC failure's exit code from 4. + """ + exc = DaemonHandshakeError("boom", {}) + assert isinstance(exc, DaemonError), "callers catching DaemonError must still see this" + assert exit_code_for(exc) == 4 + + +def test_handshake_timeout_error_is_a_handshake_error_and_still_exits_4() -> None: + """The exact-type exit registry keeps startup timeouts on exit code 4.""" + exc = DaemonHandshakeTimeoutError("boom", {}) + assert isinstance(exc, DaemonHandshakeError) + assert exit_code_for(exc) == 4 + + +def test_worker_authored_frames_are_returned_not_raised(monkeypatch: pytest.MonkeyPatch) -> None: + """The negative control: a well-formed frame is data, not a handshake failure. + + Without this, an implementation that raised DaemonHandshakeError for + *every* post-detach outcome would pass all the cases above. + """ + frame = json.dumps({"kind": "daemon_error", "payload": {"error": "DaemonError"}}).encode() + monkeypatch.setattr(daemon_mod.subprocess, "Popen", _fake_popen(frame)) + message = spawn_daemon(_schema()) + assert message["kind"] == "daemon_error" + + +def test_retained_ipc_writer_times_out_terminates_and_reaps_worker( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A worker retaining its IPC writer cannot indefinitely block startup. + + This fails if the startup deadline or either reap operation is removed: + the protective outer event expires, releases the writer, and the result is + a plain EOF handshake error rather than the timeout error. + """ + held: list[int] = [] + held_lock = threading.Lock() + completed = threading.Event() + outcome: list[BaseException] = [] + + def close_retained_writer() -> None: + """Close the retained writer exactly once across the two test threads.""" + with held_lock: + if held: + os.close(held.pop()) + + class _RetainedWriterPopen: # pylint: disable=too-few-public-methods + def __init__(self, _argv: list[str], *, pass_fds: list[int], **_kwargs: object) -> None: + self.pid = 424242 + self.stdin: IO[bytes] | None = io.BytesIO() + self.terminated = False + self.wait_calls: list[float | None] = [] + held.append(os.dup(pass_fds[0])) + + def poll(self) -> None: + return None + + def terminate(self) -> None: + self.terminated = True + close_retained_writer() + + def wait(self, timeout: float | None = None) -> int: + self.wait_calls.append(timeout) + return 0 + + created: list[_RetainedWriterPopen] = [] + + def fake_popen(*args: object, **kwargs: object) -> _RetainedWriterPopen: + proc = _RetainedWriterPopen(*args, **kwargs) # type: ignore[arg-type] + created.append(proc) + return proc + + def call_spawn() -> None: + try: + spawn_daemon( + InputSchema.model_validate({"nodes": {}, "daemon": {"startup_timeout_seconds": 1}}) + ) + except (DaemonHandshakeError, DaemonHandshakeTimeoutError) as exc: + outcome.append(exc) + finally: + completed.set() + + monkeypatch.setattr(daemon_mod.subprocess, "Popen", fake_popen) + thread = threading.Thread(target=call_spawn) + thread.start() + completed_in_time = completed.wait(5) + if not completed_in_time: + close_retained_writer() + thread.join(timeout=1) + + assert completed_in_time + assert len(outcome) == 1 + assert isinstance(outcome[0], DaemonHandshakeTimeoutError) + assert outcome[0].details == { + "timeout_seconds": 1, + "worker_reaped": True, + "pid": 424242, + } + assert created[0].terminated is True + assert created[0].wait_calls == [10] + + +def test_reap_does_not_signal_non_positive_pid() -> None: + """A malformed Popen stand-in cannot turn timeout cleanup into group signalling.""" + + class _NonPositivePidPopen: # pylint: disable=too-few-public-methods + pid = 0 + + def poll(self) -> None: + return None + + def terminate(self) -> None: + pytest.fail("timeout cleanup must not signal a non-positive pid") + + assert daemon_mod._reap_timed_out_worker(_NonPositivePidPopen(), 1) is False # type: ignore[arg-type] + + +def test_reap_returns_false_when_kill_raises_oserror() -> None: + """A failed kill is contained so timeout reporting remains a domain error.""" + + class _KillOSErrorPopen: # pylint: disable=too-few-public-methods + pid = 424242 + + def poll(self) -> None: + return None + + def terminate(self) -> None: + raise subprocess.TimeoutExpired("worker", 1) + + def kill(self) -> None: + raise OSError("kill failed") + + assert daemon_mod._reap_timed_out_worker(_KillOSErrorPopen(), 1) is False # type: ignore[arg-type] diff --git a/tests/unit/test_envrender.py b/tests/unit/test_envrender.py index 816d037..18c10ae 100644 --- a/tests/unit/test_envrender.py +++ b/tests/unit/test_envrender.py @@ -1,12 +1,30 @@ +import json + import pytest -from tunstrap.envrender import render_env, format_exports -from tunstrap.schemas import OutputSchema, NodeOutput, KubeTargetOutput + +from tunstrap.envrender import ( + RUN_ENV_KEYS, + format_exports, + render_kube_env, + render_output_var, + render_unified_output, +) +from tunstrap.schemas import ( + FetchedFile, + InputSchema, + KubeTargetOutput, + NodeOutput, + OutputSchema, + TunnelWarning, +) + +pytestmark = pytest.mark.unit -def _kube_out(port, path): +def _kube_out(port, path, *, context="ctx"): return KubeTargetOutput( cluster_name="c", - context_name="ctx", + context_name=context, local_port=port, endpoint=f"https://127.0.0.1:{port}", tls_server_name="c", @@ -18,56 +36,286 @@ def _kube_out(port, path): ) -def test_render_ports_and_session(): +def _kube_out_full(port, path, *, context): + return _kube_out(port, path, context=context) + + +def test_render_unified_output_shape() -> None: + """Ports, kube references, and fetched-file references use the unified shape.""" out = OutputSchema( - connections={"h": NodeOutput(ports={"db-1": 5432})}, + connections={ + "node1": NodeOutput( + ports={"service1": 5432}, + kube_targets={ + "k3s": _kube_out_full( + 7000, "/s/tunnel-data/node1-k3s", context="tunstrap-node1-k3s" + ) + }, + fetch_files={ + "hosts": FetchedFile( + content_b64="aG9zdHM=", + size=6, + sha256="ab" * 32, + path="/s/tunnel-data/node1-hosts", + ) + }, + ) + }, pid=42, - session_dir="/run/s", + session_dir="/s", + started_at="2026-08-07T00:00:00Z", + ) + unified = render_unified_output(out) + assert unified["session"] == { + "session_dir": "/s", + "pid": 42, + "started_at": "2026-08-07T00:00:00Z", + "warnings": [], + } + node = unified["nodes"]["node1"] + assert node["ports"] == {"service1": "127.0.0.1:5432"} + assert node["kube"]["k3s"] == { + "path": "/s/tunnel-data/node1-k3s", + "context": "tunstrap-node1-k3s", + "endpoint": "https://127.0.0.1:7000", + } + assert node["fetch_files"]["hosts"] == { + "path": "/s/tunnel-data/node1-hosts", + "size": 6, + "sha256": "ab" * 32, + } + dumped = json.dumps(unified) + for leaked in ("client_certificate_data", "client_key_data", "content_b64"): + assert leaked not in dumped + + +def test_render_unified_output_multi_node() -> None: + """Node dimension is a nested key: two nodes, two independent bodies.""" + out = OutputSchema( + connections={ + "a": NodeOutput(ports={"db": 1}), + "b": NodeOutput(ports={"db": 2}), + }, + pid=1, + session_dir="/s", started_at="now", ) - env = render_env(out) - assert env["TUNSTRAP_SESSION_DIR"] == "/run/s" - assert env["TUNSTRAP_PID"] == "42" - assert env["TUNSTRAP_DB_1_PORT"] == "5432" - assert env["TUNSTRAP_DB_1_ENDPOINT"] == "127.0.0.1:5432" - assert "KUBECONFIG" not in env + unified = render_unified_output(out) + assert set(unified["nodes"]) == {"a", "b"} + assert unified["nodes"]["a"]["ports"]["db"] == "127.0.0.1:1" + assert unified["nodes"]["b"]["ports"]["db"] == "127.0.0.1:2" -def test_render_kube_sets_kubeconfig(): +def test_render_output_var_serializes_the_unified_shape() -> None: + """The output variable decodes to the unified output shape.""" + out = OutputSchema( + connections={"h": NodeOutput(ports={"db": 1})}, + pid=1, + session_dir="/s", + started_at="now", + ) + decoded = json.loads(render_output_var(out)) + assert decoded == render_unified_output(out) + + +def test_render_kube_env_zero_files_returns_empty() -> None: + """No kube_targets anywhere -> no keys at all.""" + out = OutputSchema( + connections={"h": NodeOutput(ports={"db": 1})}, + pid=1, + session_dir="/s", + started_at="now", + ) + assert render_kube_env(out) == {} + + +def test_render_kube_env_one_file_sets_path_not_paths() -> None: + """Exactly one materialized file: KUBECONFIG + KUBE_CONFIG_PATH, no _PATHS.""" + out = OutputSchema( + connections={"h": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7000, "/s/k3s")})}, + pid=1, + session_dir="/s", + started_at="now", + ) + env = render_kube_env(out) + assert env == {"KUBECONFIG": "/s/k3s", "KUBE_CONFIG_PATH": "/s/k3s"} + assert "KUBE_CONFIG_PATHS" not in env + + +def test_render_kube_env_two_files_sets_paths_not_path() -> None: + """Two materialized files use KUBE_CONFIG_PATHS and not KUBE_CONFIG_PATH.""" out = OutputSchema( connections={ - "h": NodeOutput( - ports={}, kube_targets={"k3s": _kube_out(7000, "/run/s/tunnel-data/k3s")} - ) + "a": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7000, "/s/a-k3s")}), + "b": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7001, "/s/b-k3s")}), }, pid=1, - session_dir="/run/s", + session_dir="/s", started_at="now", ) - env = render_env(out) - assert env["TUNSTRAP_K3S_KUBECONFIG"] == "/run/s/tunnel-data/k3s" - assert env["KUBECONFIG"] == "/run/s/tunnel-data/k3s" - assert env["TUNSTRAP_K3S_ENDPOINT"] == "https://127.0.0.1:7000" + env = render_kube_env(out) + assert env == { + "KUBECONFIG": "/s/a-k3s:/s/b-k3s", + "KUBE_CONFIG_PATHS": "/s/a-k3s:/s/b-k3s", + } + assert "KUBE_CONFIG_PATH" not in env -def test_render_kube_not_materialized_raises(): +def test_render_kube_env_not_materialized_raises() -> None: + """An unmaterialized target is rejected by render_kube_env itself.""" out = OutputSchema( connections={"h": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7000, None)})}, pid=1, - session_dir="/run/s", + session_dir="/s", started_at="now", ) with pytest.raises(ValueError, match="not materialized"): - render_env(out) + render_kube_env(out) -def test_render_requires_single_node(): - out = OutputSchema(connections={}, pid=1, session_dir="/s", started_at="now") - with pytest.raises(ValueError, match="exactly one node"): - render_env(out) +def test_render_kube_env_multi_node_not_materialized_raises() -> None: + """An unmaterialized target is rejected during multi-node aggregation.""" + out = OutputSchema( + connections={ + "a": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7000, "/s/a-k3s")}), + "b": NodeOutput(ports={}, kube_targets={"k3s": _kube_out(7001, None)}), + }, + pid=1, + session_dir="/s", + started_at="now", + ) + with pytest.raises(ValueError, match="not materialized"): + render_kube_env(out) def test_format_exports_quotes_safely(): txt = format_exports({"A": "x'y", "B": "z"}) assert "export A='x'\\''y'" in txt assert "export B='z'" in txt + + +def test_run_env_keys_is_session_scalars_plus_kube_channel() -> None: + """RUN_ENV_KEYS reserves the scalars and every scrubbed kube name. + + The scrub is unconditional, so the reservation cannot depend on a schema + declaring kube targets (issue #23). + """ + assert RUN_ENV_KEYS == { + "TUNSTRAP_SESSION_DIR", + "TUNSTRAP_PID", + "TUNSTRAP_OUTPUT_FILE", + "KUBECONFIG", + "KUBE_CONFIG_PATH", + "KUBE_CONFIG_PATHS", + } + + +def test_predicted_reserved_kube_names_equal_the_unconditional_scrub_set( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Mutation-anchor for issue #23: the kube names ``RUN_ENV_KEYS`` + reserves must be exactly the names ``_build_child_env`` scrubs + unconditionally. Two independent lists that have to agree is the defect + class; ``KUBE_ENV_NAMES`` is the single constant both read, and this test + fails the moment either side stops using it -- delete the reservation and + ``reserved`` shrinks; delete the scrub and ``scrubbed`` shrinks. Built on a + zero-kube-target schema (the input where the old conditional reservation + under-reserved) and an isolated ``os.environ`` so the only scrubbable names + are the three under test.""" + from tunstrap import cli as cli_mod + from tunstrap.cli import _build_child_env + from tunstrap.envrender import KUBE_ENV_NAMES + + kube_names = {"KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"} + # The constant is itself the pinned source of truth. + assert KUBE_ENV_NAMES == kube_names + + monkeypatch.setattr(cli_mod.os, "environ", {name: "inherited" for name in kube_names}) + out = OutputSchema( + connections={"a": NodeOutput(ports={"db": 1}, kube_targets={}, fetch_files={})}, + pid=1, + session_dir="/s", + started_at="now", + ) + actual = _build_child_env(out, output_var=None, input_env=None) + scrubbed = kube_names - set(actual) + reserved = RUN_ENV_KEYS - { + "TUNSTRAP_SESSION_DIR", + "TUNSTRAP_PID", + "TUNSTRAP_OUTPUT_FILE", + } + assert scrubbed == kube_names, "scrubber must remove all three unconditionally" + assert reserved == kube_names, "guard must reserve all three unconditionally" + + +def test_run_env_keys_covers_actual_injected_keys_under_cardinality_shrink( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Every key ``_build_child_env`` injects must be reserved beforehand. + + Adding an injected key without reserving it in ``RUN_ENV_KEYS`` + reopens issue #23: the pre-spawn collision guard would permit a NAME that + collides after spawn. The shrink fixture pins ``_kube_channel_keys``' set + behaviour, while the subset assertion pins injection ⊆ reservation. + """ + from tunstrap import cli as cli_mod + from tunstrap.cli import _build_child_env + + # _build_child_env starts from dict(os.environ) + # (tunstrap/cli.py::_build_child_env), so without + # isolating it first, `set(actual)` is the whole ambient environment + # (PATH, HOME, ...) and any comparison against it is meaningless in any + # real process. Isolate BEFORE calling it, not after: subtracting + # os.environ back out (`set(actual) - set(os.environ)`) is NOT an + # acceptable substitute -- a key that is both inherited AND injected (an + # operator-set KUBECONFIG, or a NAME matching --output-var) would be + # subtracted away too, silently under-checking exactly the collision + # this guard exists to catch. + monkeypatch.setattr(cli_mod.os, "environ", {}) + + # Input: two kube targets declared, on two nodes -- one optional and about + # to fail. The static reservation must still cover the output's exact keys. + schema = InputSchema.model_validate( + { + "nodes": { + "a": { + "host": "h1", + "user": "u", + "ssh_password": "p", + "kube_targets": {"k3s": {"kubeconfig_path": "/etc/k3s.yaml"}}, + }, + "b": { + "host": "h2", + "user": "u", + "ssh_password": "p", + "required": False, + "kube_targets": {"k3s": {"kubeconfig_path": "/etc/k3s.yaml"}}, + }, + } + } + ) + # Output: node "b" failed (required: false), only node "a"'s kube target + # actually materialized -- output cardinality (1) SHRANK below input + # cardinality (2). This is the real _build_child_env sees post-spawn. + out = OutputSchema( + connections={ + "a": NodeOutput( + ports={}, kube_targets={"k3s": _kube_out(7000, "/run/s/tunnel-data/k3s")} + ), + }, + pid=1, + session_dir="/run/s", + started_at="now", + warnings=[TunnelWarning(node="b", error="optional node refused the forward")], + ) + actual = _build_child_env(out, output_var=None, input_env=None) + declared_kube_target_count = sum(len(node.kube_targets or {}) for node in schema.nodes.values()) + actual_kube_target_count = sum(len(node.kube_targets) for node in out.connections.values()) + assert (declared_kube_target_count, actual_kube_target_count) == (2, 1) + # Subset, not equality: the static reservation legitimately claims MORE + # than the exact output export -- that asymmetry is the whole point. + assert set(actual) <= RUN_ENV_KEYS + # One materialized file selects the single-file channel, despite two + # declared kube targets before the optional node failed. + assert "KUBE_CONFIG_PATH" in actual + assert "KUBE_CONFIG_PATHS" not in actual diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py index 08e0278..4043b16 100644 --- a/tests/unit/test_exceptions.py +++ b/tests/unit/test_exceptions.py @@ -11,11 +11,11 @@ from tunstrap.exceptions import ( DaemonError, - SessionActive, - TunstrapError, RequiredTunnelFailure, SchemaValidationError, + SessionActive, TunnelStartupError, + TunstrapError, exit_code_for, ) @@ -55,6 +55,27 @@ def test_to_error_output_does_not_leak_secrets() -> None: assert "ssh_pkey" not in out["details"] +def test_to_error_output_recursively_scrubs_secrets() -> None: + """Nested validation details cannot retain SSH credentials.""" + err = SchemaValidationError( + "bad", + { + "nested": {"ssh_password": "nested-password", "safe": "value"}, + "errors": [ + {"input": {"ssh_pkey": "nested-key", "safe": "still-here"}}, + {"ssh_pkey_passphrase": "nested-passphrase"}, + ], + "nested_lists": [[{"ssh_password": "list-password", "safe": "list-safe"}]], + }, + ) + + assert err.to_error_output()["details"] == { + "nested": {"safe": "value"}, + "errors": [{"input": {"safe": "still-here"}}, {}], + "nested_lists": [[{"safe": "list-safe"}]], + } + + def test_session_active_exit_code_is_3() -> None: """SessionActive maps to exit code 3 and reports the correct error name.""" exc = SessionActive("daemon already running") diff --git a/tests/unit/test_fetcher_unit.py b/tests/unit/test_fetcher_unit.py index 48c2de0..225e3d5 100644 --- a/tests/unit/test_fetcher_unit.py +++ b/tests/unit/test_fetcher_unit.py @@ -7,6 +7,7 @@ from __future__ import annotations +import asyncio import base64 import hashlib from typing import Any @@ -91,7 +92,7 @@ def start_sftp_client(self) -> Any: async def test_empty_specs_short_circuits() -> None: """Empty fetch_files dict short-circuits without opening SFTP.""" conn = _FakeConn(asyncssh.ChannelOpenError(2, "should not be touched")) - results, failures = await fetch_files(conn, {}) # type: ignore[arg-type] + results, failures = await fetch_files(conn, {}, timeout=60) # type: ignore[arg-type] assert results == {} assert failures == [] @@ -105,6 +106,7 @@ async def test_happy_path_single_file() -> None: results, failures = await fetch_files( conn, # type: ignore[arg-type] {"kubeconfig": FileSpec(path="/k.yaml")}, + timeout=60, ) assert failures == [] ff = results["kubeconfig"] @@ -122,6 +124,7 @@ async def test_two_files_both_ok() -> None: results, failures = await fetch_files( conn, # type: ignore[arg-type] {"alpha": FileSpec(path="/a"), "beta": FileSpec(path="/b")}, + timeout=60, ) assert failures == [] assert results["alpha"].size == 3 @@ -136,6 +139,7 @@ async def test_enoent_required_adds_failure() -> None: results, failures = await fetch_files( conn, # type: ignore[arg-type] {"k": FileSpec(path="/missing", required=True)}, + timeout=60, ) assert failures == ["k"] assert results["k"].error == "SSH_FX_NO_SUCH_FILE" @@ -149,6 +153,7 @@ async def test_enoent_optional_does_not_fail_node() -> None: results, failures = await fetch_files( conn, # type: ignore[arg-type] {"k": FileSpec(path="/missing", required=False)}, + timeout=60, ) assert failures == [] assert results["k"].error == "SSH_FX_NO_SUCH_FILE" @@ -162,6 +167,7 @@ async def test_efbig_via_stat_skips_open() -> None: results, failures = await fetch_files( conn, # type: ignore[arg-type] {"k": FileSpec(path="/big", required=True)}, + timeout=60, ) assert failures == ["k"] assert results["k"].error == "EFBIG" @@ -177,6 +183,7 @@ async def test_efbig_via_read_overflow() -> None: results, failures = await fetch_files( conn, # type: ignore[arg-type] {"k": FileSpec(path="/grew", required=True)}, + timeout=60, ) assert failures == ["k"] assert results["k"].error == "EFBIG" @@ -192,6 +199,7 @@ async def test_channel_open_failure_marks_all_files() -> None: "a": FileSpec(path="/a", required=True), "b": FileSpec(path="/b", required=False), }, + timeout=60, ) assert results["a"].error == "ChannelOpenError" assert results["b"].error == "ChannelOpenError" @@ -207,6 +215,66 @@ async def test_permission_denied_classified() -> None: results, failures = await fetch_files( conn, # type: ignore[arg-type] {"k": FileSpec(path="/secret", required=True)}, + timeout=60, ) assert results["k"].error == "SSH_FX_PERMISSION_DENIED" assert failures == ["k"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("stalled_stage", ["stat", "open", "read"]) +async def test_each_file_timeout_covers_all_sftp_stages(stalled_stage: str) -> None: + """Each-file timeout covers every generic SFTP stage. + + Removing the wait_for around the per-file operation leaves this coroutine + pending until pytest cancels it instead of returning the TimeoutError result. + """ + never = asyncio.Event() + + class _PendingFile: + async def __aenter__(self) -> "_PendingFile": + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + async def read(self, _size: int) -> bytes: + await never.wait() + return b"" + + class _PendingSFTP: + async def stat(self, _path: str) -> _FakeStat: + if stalled_stage == "stat": + await never.wait() + return _FakeStat(1) + + def open(self, _path: str, _mode: str) -> _PendingFile: + if stalled_stage == "open": + return _PendingOpen() + return _PendingFile() + + async def __aenter__(self) -> "_PendingSFTP": + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + class _PendingOpen(_PendingFile): + async def __aenter__(self) -> "_PendingOpen": + await never.wait() + return self + + try: + results, failures = await asyncio.wait_for( + fetch_files( + _FakeConn(_PendingSFTP()), # type: ignore[arg-type] + {"file": FileSpec(path="/remote")}, + timeout=0.01, + ), + timeout=0.1, + ) + except TimeoutError: + pytest.fail("fetch_files did not enforce its configured timeout") + + assert failures == ["file"] + assert results["file"].error == "TimeoutError" diff --git a/tests/unit/test_identity.py b/tests/unit/test_identity.py index 3d02193..a997189 100644 --- a/tests/unit/test_identity.py +++ b/tests/unit/test_identity.py @@ -2,6 +2,8 @@ from __future__ import annotations +import errno +import fcntl import os import subprocess import sys @@ -11,11 +13,14 @@ from tunstrap.identity import ( IdentityCheckResult, + _process_exists, acquire_session_lock, release_session_lock, verify_session, ) +pytestmark = pytest.mark.unit + def _spawn_locker(session_dir: Path) -> subprocess.Popen[bytes]: """Child that acquires session.lock and sleeps, holding the flock.""" @@ -57,6 +62,48 @@ def test_verify_session_not_found_when_lock_free(tmp_path: Path) -> None: assert verify_session(tmp_path, 12345) == IdentityCheckResult.not_found +@pytest.mark.parametrize("pid", [0, -1], ids=["zero", "minus-one"]) +def test_process_exists_refuses_non_positive_pid(pid: int) -> None: + """A non-positive pid is a group/broadcast selector, not a liveness check. + + ``os.kill(-1, 0)`` probes *every* process the caller can signal and answers + True for as long as any one of them exists — which is always — while + ``os.kill(0, 0)`` targets the caller's own process group. Either way the + probe cannot distinguish "the recorded daemon is alive" from "something is", + which is what let a ``daemon.pid`` of ``-1`` through the gate as ``match``. + The pid is therefore not a process the verifier will confirm. + """ + assert _process_exists(pid) is False + + +@pytest.mark.parametrize("pid", [0, -1], ids=["zero", "minus-one"]) +def test_verify_session_not_found_for_non_positive_pid(tmp_path: Path, pid: int) -> None: + """A held lock whose body matches changes nothing: the pid is still refused. + + This is the exact shape of the reported defect: a hostile ``session.lock`` + body of ``-1`` held against a recorded pid of ``-1`` used to verify as + ``match``, because ``_process_exists(-1)`` answered True and the lock body + then compared equal. Once the pid is refused at the liveness probe the lock + is never consulted, so the answer is ``not_found``. That shape is not a + safe preserve in general — ``cli._stop_resolved`` treats + ``reason == "not found"`` as resolved and deletes the session — which is + exactly why ``stop_session`` does not rely on it for a non-positive pid: + its entry guard returns ``identity check unavailable`` before + ``verify_session`` is consulted at all, so the hostile value is preserved + rather than cleaned. What this test pins is the verifier's own refusal, + independent of that guard. + """ + lock_path = tmp_path / "session.lock" + lock_path.write_text(f"{pid}\n") + fd = os.open(lock_path, os.O_RDWR) + fcntl.flock(fd, fcntl.LOCK_EX) # hold the lock so _check_lock takes its held branch + try: + assert verify_session(tmp_path, pid) == IdentityCheckResult.not_found + finally: + fcntl.flock(fd, fcntl.LOCK_UN) + os.close(fd) + + def test_acquire_is_mutually_exclusive(tmp_path: Path) -> None: fd = acquire_session_lock(tmp_path) try: @@ -66,8 +113,188 @@ def test_acquire_is_mutually_exclusive(tmp_path: Path) -> None: release_session_lock(fd, tmp_path) +def test_acquire_session_lock_writes_complete_pid_after_short_writes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The lock PID is complete when os.write accepts one byte per call. + + Replacing ``write_all(fd, ...)`` in ``acquire_session_lock`` with a direct + ``os.write(fd, ...)`` silently records only the first PID byte. This + stand-in makes that real file outcome observable without relying on a + naturally occurring short write. + """ + real_write = os.write + + def one_byte_write(fd: int, data: object) -> int: + real_write(fd, bytes(data)[:1]) # type: ignore[arg-type] + return 1 + + monkeypatch.setattr(os, "write", one_byte_write) + fd = acquire_session_lock(tmp_path) + try: + assert (tmp_path / "session.lock").read_bytes() == f"{os.getpid()}\n".encode("ascii") + finally: + release_session_lock(fd, tmp_path) + + def test_release_unlinks_lockfile(tmp_path: Path) -> None: fd = acquire_session_lock(tmp_path) assert (tmp_path / "session.lock").exists() release_session_lock(fd, tmp_path) assert not (tmp_path / "session.lock").exists() + + +def test_verify_session_treats_symlinked_lock_as_unavailable(tmp_path: Path) -> None: + """A symlinked session.lock is reported ``unavailable`` rather than followed. + + Mirrors ``acquire_session_lock``'s ``O_NOFOLLOW`` on the verify path: + ``_check_lock`` opens the lock read-only to probe flock state, and without + ``O_NOFOLLOW`` it would follow a symlink and probe flock on an arbitrary + attacker-chosen file. ``ELOOP`` from ``O_NOFOLLOW`` is absorbed by the + existing ``except OSError`` arm and surfaced as ``unavailable``. Removing the + flag makes the open follow the symlink, the free flock then resolves to + ``not_found`` -- a different result, which is what this assertion pins. + """ + victim = tmp_path / "victim" + victim.write_bytes(b"probe-target\n") + (tmp_path / "session.lock").symlink_to(victim) + + assert verify_session(tmp_path, os.getpid()) == IdentityCheckResult.unavailable + + +def test_acquire_refuses_symlink_lock_leaving_target_intact(tmp_path: Path) -> None: + """A symlinked session.lock is refused and its target is never truncated. + + The core of issue #25: ``acquire_session_lock`` opened the lock path without + ``O_NOFOLLOW`` and then ``ftruncate``-d the resulting fd, so a symlinked + ``session.lock`` let an attacker truncate an arbitrary victim file the + runner could open. The security property is not merely that an exception is + raised but that the victim's bytes are byte-for-byte intact afterwards -- + a refusal that still destroyed the target would be no fix at all. + """ + victim = tmp_path / "victim" + payload = b"sensitive-bytes-that-must-survive-the-acquire\n" + victim.write_bytes(payload) + victim.chmod(0o600) + lock = tmp_path / "session.lock" + lock.symlink_to(victim) + + with pytest.raises(OSError) as excinfo: + acquire_session_lock(tmp_path) + assert excinfo.value.errno == errno.ELOOP, "O_NOFOLLOW specifically must reject the symlink" + + assert victim.read_bytes() == payload, "symlink target was truncated" + assert lock.is_symlink(), "the symlink was replaced instead of refused" + + +def test_acquire_refuses_foreign_owned_lock( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A pre-existing session.lock owned by another uid is refused, untruncated. + + ``O_NOFOLLOW`` rejects a symlinked lock but says nothing about a regular + file some other uid planted in a writable root; without the ``fstat`` + ownership check the daemon would happily ``ftruncate`` that file and write + its pid into it. Stand-in: an unprivileged test runner cannot ``chown`` a + file to another uid, so the foreign ownership is reported by patching + ``os.fstat`` for the lock's inode to return a uid that is not the file's real + owner -- the inequality ``st.st_uid != os.getuid()`` is the guard under test, + and forging the reported owner (rather than process-wide ``getuid``) keeps + the failure local to this one fd and lets the bytes-survive assertion below + pin the real security property. + """ + lock_path = tmp_path / "session.lock" + lock_path.write_bytes(b"hostile\n") + real_fstat = os.fstat + lock_ino = lock_path.stat().st_ino + foreign_uid = os.getuid() + 1 + + def fake_fstat(fd: int) -> os.stat_result: + st = real_fstat(fd) + if st.st_ino == lock_ino: + return os.stat_result( + ( + st.st_mode, + st.st_ino, + st.st_dev, + st.st_nlink, + foreign_uid, + st.st_gid, + st.st_size, + st.st_atime, + st.st_mtime, + st.st_ctime, + ) + ) + return st + + monkeypatch.setattr(os, "fstat", fake_fstat) + + with pytest.raises(OSError, match="not a singly-linked regular file"): + acquire_session_lock(tmp_path) + + # A refusal is only a fix if nothing was truncated: the guard fires before + # ftruncate, so the planted body survives verbatim. + assert lock_path.read_bytes() == b"hostile\n" + + +def test_acquire_refuses_non_regular_lock(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A session.lock that is not a regular file is refused. + + ``O_NOFOLLOW`` bars symlinks but a path that resolves to some other + non-regular type a foreign uid could plant in a writable root (the realistic + one is a foreign-owned regular file, covered above) must still be rejected + before ``ftruncate``. Stand-in: a non-regular mode is reported via + ``os.fstat`` since a test runner cannot materialise a device node in + ``tmp_path``; the guard under test is ``stat.S_ISREG(st.st_mode)``. + """ + (tmp_path / "session.lock").write_bytes(b"x\n") + real_fstat = os.fstat + lock_ino = (tmp_path / "session.lock").stat().st_ino + + def fake_fstat(fd: int) -> os.stat_result: + st = real_fstat(fd) + if st.st_ino == lock_ino: + # Report a directory mode (S_IFDIR) for the lock fd. + return os.stat_result( + ( + 0o040700, + st.st_ino, + st.st_dev, + st.st_nlink, + st.st_uid, + st.st_gid, + st.st_size, + st.st_atime, + st.st_mtime, + st.st_ctime, + ) + ) + return st + + monkeypatch.setattr(os, "fstat", fake_fstat) + + with pytest.raises(OSError, match="not a singly-linked regular file"): + acquire_session_lock(tmp_path) + + +def test_acquire_refuses_hardlinked_lock_leaving_victim_intact(tmp_path: Path) -> None: + """A session.lock hardlinked to a runner-owned victim is refused, unharmed. + + The sibling of the symlink vector, and the one the other two guards miss: + a hardlink is not a symlink, so ``O_NOFOLLOW`` stays silent, and it shares + the victim's inode, so ``S_ISREG`` and the ownership check both pass -- the + victim really is a regular file really owned by us. Only ``st_nlink`` tells + the two names apart. Needs no stand-in: an unprivileged runner can create a + real hardlink, so this drives the true precondition. Drop the ``st_nlink`` + check and the victim below is truncated to the daemon pid. + """ + victim = tmp_path / "victim.txt" + victim.write_bytes(b"KEEP-ME" * 8) + before = victim.read_bytes() + os.link(victim, tmp_path / "session.lock") + + with pytest.raises(OSError, match="not a singly-linked regular file"): + acquire_session_lock(tmp_path) + + assert victim.read_bytes() == before, "the hardlinked victim was truncated" diff --git a/tests/unit/test_init_version.py b/tests/unit/test_init_version.py new file mode 100644 index 0000000..86516a8 --- /dev/null +++ b/tests/unit/test_init_version.py @@ -0,0 +1,53 @@ +"""``tunstrap/__init__.py`` lazy ``__version__`` resolution. + +Covers the two branches the lazy PEP 562 ``__getattr__`` adds and the prior +``test_version_flag`` could not reach: the ``PackageNotFoundError`` fallback +(when the distribution metadata is absent) and the ``AttributeError`` for any +other attribute name. These pay no cost at import time — that is guarded +separately by ``test_tofu_proxy.test_importing_proxy_does_not_pull_in_cli_or_heavy_deps``. +Code: tunstrap/__init__.py +""" + +from __future__ import annotations + +import importlib.metadata + +import pytest + +import tunstrap + +pytestmark = pytest.mark.unit + + +def test_version_falls_back_when_distribution_not_found( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A missing distribution yields ``0.0.0+unknown``, not a traceback. + + Reaches the ``PackageNotFoundError`` branch that the lazy getter handles: + a source checkout with no installed distribution metadata must still import + and report a placeholder. Without the branch, ``tunstrap.__version__`` would + propagate the ``PackageNotFoundError``. + """ + + def _raise(_name: str) -> str: + raise importlib.metadata.PackageNotFoundError("tunstrap") + + # The getter does ``from importlib.metadata import version`` on each access, + # so patching the attribute on the module is picked up. Module __dict__ may + # already hold a cached ``__version__`` from a prior access; drop it so the + # getter re-runs. + monkeypatch.setattr(importlib.metadata, "version", _raise) + monkeypatch.delitem(tunstrap.__dict__, "__version__", raising=False) + assert tunstrap.__version__ == "0.0.0+unknown" + + +def test_unknown_attribute_raises_attribute_error() -> None: + """Any name other than ``__version__`` is rejected, not silently faked. + + PEP 562 ``__getattr__`` is only consulted for missing attributes; the getter + must raise ``AttributeError`` for everything it does not provide, so a typo + surfaces normally instead of returning ``None`` or the version. + """ + with pytest.raises(AttributeError, match="no attribute"): + _ = tunstrap.does_not_exist # type: ignore[attr-defined] diff --git a/tests/unit/test_kube_identity_collision.py b/tests/unit/test_kube_identity_collision.py new file mode 100644 index 0000000..50cd597 --- /dev/null +++ b/tests/unit/test_kube_identity_collision.py @@ -0,0 +1,168 @@ +"""Regression test for issue #15's collision trap. + +k3s ships current-context/cluster/user all named "default". Two k3s targets +therefore collide on the upstream names verbatim -- that is the case this +test drives, not a kind-style already-unique name (kind's context is +`kind-`, so a kind-based test would pass without proving anything; +see the ticket's "Testing trap" section). + +Two k3s-style upstream kubeconfigs with identical context/cluster/user names +("default") are driven through `run_kube_targets` for two different node +names. Each output's context_name/cluster_name/user identity is the +deterministic `tunstrap--` name, distinct per node even though +both fixtures carry identical upstream names -- proven both in the +extracted `KubeTargetOutput` fields and in the serialized kubeconfig +document, which is what a `KUBECONFIG`-list consumer actually parses. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from tunstrap.kube import run_kube_targets +from tunstrap.schemas import KubeTarget + +pytestmark = pytest.mark.unit + +_K3S_STYLE = """\ +apiVersion: v1 +clusters: +- cluster: + server: https://{ip}:6443 + certificate-authority-data: Y2EtZGF0YQ== + name: default +contexts: +- context: {{cluster: default, user: default}} + name: default +current-context: default +kind: Config +preferences: {{}} +users: +- name: default + user: + client-certificate-data: Y2VydC1kYXRh + client-key-data: a2V5LWRhdGE= +""" + + +class _FakeListener: + def __init__(self, port: int) -> None: + self._port = port + + def get_port(self) -> int: + return self._port + + def close(self) -> None: + return None + + async def wait_closed(self) -> None: + return None + + +class _FakeConn: + """Stubs the two asyncssh calls run_kube_targets uses: sftp + forward.""" + + def __init__(self, file_bytes: bytes, local_port: int) -> None: + self._file_bytes = file_bytes + self._local_port = local_port + + def start_sftp_client(self) -> Any: + conn = self + + class _CM: + async def __aenter__(self) -> Any: + class _Sftp: + async def stat(self, _path: str) -> Any: + class _S: + size = len(conn._file_bytes) + + return _S() + + def open(self, _path: str, _mode: str) -> Any: + data = conn._file_bytes + + class _FH: + async def __aenter__(self) -> Any: + class _R: + async def read(self, _n: int) -> bytes: + return data + + return _R() + + async def __aexit__(self, *_a: Any) -> None: + return None + + return _FH() + + return _Sftp() + + async def __aexit__(self, *_a: Any) -> None: + return None + + return _CM() + + async def forward_local_port(self, *_a: Any, **_k: Any) -> _FakeListener: + return _FakeListener(self._local_port) + + +async def _probe_ok(_host: str, _port: int) -> bytes: + return b"DERCERT" + + +@pytest.mark.asyncio +async def test_two_k3s_style_targets_get_distinct_deterministic_identities( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Two nodes whose upstream k3s kubeconfigs both name everything 'default'. + + Each output's context_name/cluster_name is renamed to the deterministic + `tunstrap--kube` identity, distinct per node -- resolving the + exact collision a `KUBECONFIG` merge of the two raw upstream configs + could not, since both would otherwise report + context_name == cluster_name == "default". + """ + monkeypatch.setattr( + "tunstrap.kube.sans_from_cert", + lambda _der: (["node.example.net"], []), + ) + + node_a_conn = _FakeConn(_K3S_STYLE.format(ip="192.0.2.10").encode(), 40001) + node_b_conn = _FakeConn(_K3S_STYLE.format(ip="192.0.2.20").encode(), 40002) + target = {"kube": KubeTarget.model_validate({"kubeconfig_path": "/etc/rancher/k3s/k3s.yaml"})} + + outputs_a, failures_a, _ = await run_kube_targets( + node_a_conn, target, connect_timeout=5, probe=_probe_ok, node_name="node-a" + ) + outputs_b, failures_b, _ = await run_kube_targets( + node_b_conn, target, connect_timeout=5, probe=_probe_ok, node_name="node-b" + ) + assert failures_a == [] + assert failures_b == [] + out_a = outputs_a["kube"] + out_b = outputs_b["kube"] + + # The collision trap: upstream names are identical on both sides. + assert out_a.context_name != out_b.context_name, ( + "both targets report the SAME context_name " + f"({out_a.context_name!r}) -- a KUBECONFIG merge of the two " + "materialized files would collide on this name" + ) + assert out_a.cluster_name != out_b.cluster_name + assert out_a.context_name == "tunstrap-node-a-kube" + assert out_b.context_name == "tunstrap-node-b-kube" + assert out_a.cluster_name == "tunstrap-node-a-kube" + assert out_b.cluster_name == "tunstrap-node-b-kube" + + # The rename must also reach the serialized document, not just the + # extracted KubeTargetOutput fields (dump_kubeconfig is what a + # KUBECONFIG-list consumer actually parses). + import base64 + + dumped_a = base64.b64decode(out_a.content_b64).decode() + dumped_b = base64.b64decode(out_b.content_b64).decode() + assert "current-context: tunstrap-node-a-kube" in dumped_a + assert "current-context: tunstrap-node-b-kube" in dumped_b + assert "name: default" not in dumped_a + assert "name: default" not in dumped_b diff --git a/tests/unit/test_kube_parse_invariants.py b/tests/unit/test_kube_parse_invariants.py new file mode 100644 index 0000000..d1fd04b --- /dev/null +++ b/tests/unit/test_kube_parse_invariants.py @@ -0,0 +1,192 @@ +"""Two parse_kubeconfig properties that look like dead weight and are not. + +Regression: `parse_kubeconfig` was a single CC-23 function until it was split +into an orchestrator plus five section parsers (`_load_root`, `_context_refs`, +`_cluster_section`, `_user_section`, `_ignored_contexts`). The split was proven +behaviour-preserving by a throwaway 93-case characterization harness, but that +harness is not in the repo, so two properties it pinned were left unguarded. +Both survive a reading of the code that concludes they are redundant: + +1. The `str()` calls on `cluster_name`/`user_name` are load-bearing. ruamel is + loaded in round-trip mode, so a *quoted* YAML scalar comes back as a + `ScalarString` subclass of `str`, not a `str`. `str(x)` therefore changes + the field's runtime type, while `server=server` deliberately does not. + "Both are already `str`, drop the call" is the natural cleanup and it is + wrong. The measured consequence is a type change in `KubeconfigView`, which + feeds `KubeTargetOutput` and the `--output-var` projection. + +2. Validation order across the five helpers is observable. Every failure mode + raises the same type (`KubeParseError`), so reordering two section calls, or + the three `_string_field` extractions in the constructor call, changes only + the *message* a caller sees for an input with more than one defect. Callers + surface that message as the `kube_target` warning text. + +Code: tunstrap/kube.py::parse_kubeconfig, +tunstrap/kube.py::_cluster_section, tunstrap/kube.py::_user_section, +tunstrap/kube.py::_string_field +Assertion: names the current context resolves to are plain `str` while the +scalars copied straight out of the document are not; and for an input with two +defects, the *first* check in source order is the one that reports. Both use +exact type identity / exact message equality, never `isinstance` or substring, +so a wrong-but-similar value cannot satisfy them. +Method: parse in-module byte literals (the quoting is the point, so it must be +visible at the assertion rather than hidden in a fixture file) and read the +runtime types back off the returned view, comparing against the raw scalars +still reachable through `view.doc`. +""" + +from __future__ import annotations + +import pytest + +from tunstrap.kube import KubeParseError, parse_kubeconfig + +pytestmark = pytest.mark.unit + +# Every scalar double-quoted, so ruamel yields DoubleQuotedScalarString for all +# of them and the only thing that can flatten one back to `str` is an explicit +# coercion in parse_kubeconfig. +QUOTED_KUBECONFIG = ( + b"apiVersion: v1\n" + b"kind: Config\n" + b'current-context: "prod"\n' + b"contexts:\n" + b'- name: "prod"\n' + b" context:\n" + b' cluster: "c1"\n' + b' user: "u1"\n' + b"clusters:\n" + b'- name: "c1"\n' + b" cluster:\n" + b' server: "https://192.0.2.1:6443"\n' + b' certificate-authority-data: "Y0E="\n' + b"users:\n" + b'- name: "u1"\n' + b" user:\n" + b' client-certificate-data: "Y1I="\n' +) + + +def _kubeconfig(*, clusters: str, users: str) -> bytes: + """A kubeconfig whose current context references cluster `c1` and user `u1`.""" + return ( + "apiVersion: v1\n" + "kind: Config\n" + "current-context: prod\n" + "contexts:\n" + "- name: prod\n" + " context: {cluster: c1, user: u1}\n" + f"{clusters}" + f"{users}" + ).encode() + + +CLUSTER_OK = "clusters:\n- name: c1\n cluster: {server: 'https://192.0.2.1:6443'}\n" +CLUSTER_ABSENT = "clusters: []\n" +CLUSTER_BAD_CA = ( + "clusters:\n- name: c1\n" + " cluster: {server: 'https://192.0.2.1:6443', certificate-authority-data: 7}\n" +) +USER_OK = "users:\n- name: u1\n user: {}\n" +USER_ABSENT = "users: []\n" +USER_BAD_CERT = "users:\n- name: u1\n user: {client-certificate-data: 7}\n" +USER_BAD_KEY = "users:\n- name: u1\n user: {client-key-data: 7}\n" +USER_BAD_CERT_AND_KEY = ( + "users:\n- name: u1\n user: {client-certificate-data: 7, client-key-data: 8}\n" +) + + +def test_context_refs_are_flattened_but_copied_scalars_are_not() -> None: + """`str()` on the context's cluster/user refs changes the type; server keeps its own.""" + view = parse_kubeconfig(QUOTED_KUBECONFIG) + doc = view.doc + assert isinstance(doc, dict) + ctx_body = doc["contexts"][0]["context"] + raw_cluster_ref = ctx_body["cluster"] + raw_user_ref = ctx_body["user"] + raw_server = doc["clusters"][0]["cluster"]["server"] + + # Premise: round-trip mode really does hand back str SUBCLASSES here. If + # ruamel ever stops doing so this fails first, with a clear reason, rather + # than making the real assertions below silently vacuous. + assert type(raw_cluster_ref) is not str + assert type(raw_user_ref) is not str + assert type(raw_server) is not str + + # Flattened by the explicit str() coercions. + assert type(view.cluster_name) is str + assert type(view.user_name) is str + + # Not coerced: these are the document's own scalars, handed through as-is. + assert type(view.server) is type(raw_server) + assert type(view.context_name) is not str + assert type(view.certificate_authority_data) is not str + + # The coercion must preserve the value, not merely the type. + assert view.cluster_name == "c1" + assert view.user_name == "u1" + assert view.server == "https://192.0.2.1:6443" + + +@pytest.mark.parametrize( + ("clusters", "users", "expected"), + [ + pytest.param( + CLUSTER_ABSENT, + USER_ABSENT, + "cluster 'c1' not found", + id="cluster-resolved-before-user", + ), + pytest.param( + CLUSTER_BAD_CA, + USER_BAD_CERT, + "'c1' certificate-authority-data must be a string, got int", + id="ca-extracted-before-client-certificate", + ), + pytest.param( + CLUSTER_OK, + USER_BAD_CERT_AND_KEY, + "'u1' client-certificate-data must be a string, got int", + id="client-certificate-extracted-before-client-key", + ), + ], +) +def test_first_defect_in_source_order_is_the_one_reported( + clusters: str, users: str, expected: str +) -> None: + """With two defects present, the earlier check reports and the later one stays silent.""" + with pytest.raises(KubeParseError) as excinfo: + parse_kubeconfig(_kubeconfig(clusters=clusters, users=users)) + assert str(excinfo.value) == expected + + +@pytest.mark.parametrize( + ("clusters", "users", "expected"), + [ + pytest.param(CLUSTER_OK, USER_ABSENT, "user 'u1' not found", id="user-missing"), + pytest.param( + CLUSTER_OK, + USER_BAD_CERT, + "'u1' client-certificate-data must be a string, got int", + id="client-certificate-malformed", + ), + pytest.param( + CLUSTER_OK, + USER_BAD_KEY, + "'u1' client-key-data must be a string, got int", + id="client-key-malformed", + ), + ], +) +def test_the_later_check_of_each_ordered_pair_really_fires_on_its_own( + clusters: str, users: str, expected: str +) -> None: + """Positive control for the ordering table: each deferred check is real. + + Without this, deleting a later check outright would leave every ordering + row above green -- the row would be satisfied by the earlier defect alone + and could no longer distinguish "checked second" from "never checked". + """ + with pytest.raises(KubeParseError) as excinfo: + parse_kubeconfig(_kubeconfig(clusters=clusters, users=users)) + assert str(excinfo.value) == expected diff --git a/tests/unit/test_kube_rename.py b/tests/unit/test_kube_rename.py new file mode 100644 index 0000000..28e1a51 --- /dev/null +++ b/tests/unit/test_kube_rename.py @@ -0,0 +1,306 @@ +"""rename_identities: deterministic tunstrap-- identity rename. + +The current-context's cluster/user/context are renamed to the shared +deterministic name ``tunstrap--``. The fetched kubeconfig is +untrusted, so a name already present in that reserved namespace is rejected +as a ``KubeParseError`` -- never silently renamed around -- and a rejection +leaves the document unmutated. +""" + +from __future__ import annotations + +import copy + +import pytest + +from tunstrap.kube import KubeParseError, rename_identities + +pytestmark = pytest.mark.unit + + +def _doc() -> dict[str, object]: + return { + "current-context": "default", + "contexts": [ + {"name": "default", "context": {"cluster": "default", "user": "default"}}, + {"name": "other", "context": {"cluster": "other-c", "user": "other-u"}}, + ], + "clusters": [ + {"name": "default", "cluster": {"server": "https://127.0.0.1:1"}}, + {"name": "other-c", "cluster": {"server": "https://127.0.0.1:2"}}, + ], + "users": [{"name": "default", "user": {}}, {"name": "other-u", "user": {}}], + } + + +def _doc_with_pre_existing_identity(slot: str) -> dict[str, object]: + """A doc whose active triple is 'default' but ``slot`` already holds the name. + + ``rename_identities(doc, 'node', 'kube')`` would generate + ``tunstrap-node-kube``; planting that name in one of the three collections + is the upstream-reserved-namespace collision the function must reject. + """ + doc: dict[str, object] = { + "current-context": "default", + "contexts": [{"name": "default", "context": {"cluster": "default", "user": "default"}}], + "clusters": [{"name": "default", "cluster": {"server": "https://127.0.0.1:1"}}], + "users": [{"name": "default", "user": {}}], + } + reserved = "tunstrap-node-kube" + ctx_list = doc["contexts"] + clu_list = doc["clusters"] + usr_list = doc["users"] + assert isinstance(ctx_list, list) and isinstance(clu_list, list) and isinstance(usr_list, list) + if slot == "contexts": + ctx_list.append({"name": reserved, "context": {"cluster": "x", "user": "x"}}) + elif slot == "clusters": + clu_list.append({"name": reserved, "cluster": {"server": "https://x"}}) + elif slot == "users": + usr_list.append({"name": reserved, "user": {}}) + else: # pragma: no cover - test helper guard + raise AssertionError(f"unknown slot {slot!r}") + return doc + + +def test_renames_current_context_cluster_and_user_to_shared_name() -> None: + """All three identity fields get the same tunstrap-- value.""" + doc = _doc() + new_name = rename_identities(doc, "node-a", "kube") + assert new_name == "tunstrap-node-a-kube" + assert doc["current-context"] == new_name + ctx = doc["contexts"][0] + assert ctx["name"] == new_name + assert ctx["context"]["cluster"] == new_name + assert ctx["context"]["user"] == new_name + assert doc["clusters"][0]["name"] == new_name + assert doc["users"][0]["name"] == new_name + + +def test_ignored_entries_are_left_untouched() -> None: + """Non-current context/cluster/user entries survive byte-stable.""" + doc = _doc() + rename_identities(doc, "node-a", "kube") + assert doc["contexts"][1] == { + "name": "other", + "context": {"cluster": "other-c", "user": "other-u"}, + } + assert doc["clusters"][1]["name"] == "other-c" + assert doc["users"][1]["name"] == "other-u" + + +def test_two_nodes_same_upstream_names_get_distinct_results() -> None: + """The same input produces a different name for each node.""" + assert rename_identities(_doc(), "a", "kube") != rename_identities(_doc(), "b", "kube") + + +def test_ignored_context_sharing_active_cluster_keeps_valid_reference() -> None: + """Shared cluster/user references in ignored contexts are updated.""" + doc: dict[str, object] = { + "current-context": "default", + "contexts": [ + {"name": "default", "context": {"cluster": "default", "user": "default"}}, + {"name": "staging", "context": {"cluster": "default", "user": "default"}}, + ], + "clusters": [{"name": "default", "cluster": {"server": "https://127.0.0.1:1"}}], + "users": [{"name": "default", "user": {}}], + } + new_name = rename_identities(doc, "node-a", "kube") + staging = doc["contexts"][1] + assert staging["name"] == "staging" + assert staging["context"]["cluster"] == new_name + assert staging["context"]["user"] == new_name + + +@pytest.mark.parametrize( + "slot", + [ + pytest.param("clusters", id="pre-existing-clusters-entry"), + pytest.param("users", id="pre-existing-users-entry"), + pytest.param("contexts", id="pre-existing-contexts-entry"), + ], +) +def test_pre_existing_generated_name_is_rejected(slot: str) -> None: + """A reserved-namespace collision in any of the three collections is rejected. + + The fetched kubeconfig is untrusted; a name already present in tunstrap's + ``tunstrap--`` namespace is either misconfiguration or an + attempt to shadow the identity tunstrap is about to create. Without this + guard the entry is duplicated (two clusters/users/contexts with the same + name) and which one the patched context resolves to becomes + order-dependent. The fix is rejection (``KubeParseError``), not + uniquifying, because the deterministic name is a consumer-facing literal. + """ + doc = _doc_with_pre_existing_identity(slot) + with pytest.raises(KubeParseError) as excinfo: + rename_identities(doc, "node", "kube") + message = str(excinfo.value) + assert "tunstrap-node-kube" in message + # The message must tell an operator why the untrusted file is rejected, in + # terms of tunstrap's reserved namespace -- not a bare "already exists". + assert "reserved" in message or "tunstrap-" in message + + +def test_rejection_leaves_document_unmutated() -> None: + """The collision check fires before any name/reference is rewritten. + + A rejection that already mutated the active context or its cluster/user + entries would leave a half-renamed document for the caller to discover. + Snapshot the doc, attempt the rename, assert byte-equality with the + snapshot. + """ + doc = _doc_with_pre_existing_identity("clusters") + before = copy.deepcopy(doc) + with pytest.raises(KubeParseError): + rename_identities(doc, "node", "kube") + assert doc == before + + +def test_rejection_message_is_operator_facing() -> None: + """The error message names the colliding identity and the reserved namespace. + + Per-target handling surfaces ``str(exc)`` verbatim as the kube_target + warning text, so the message must read as an operator-facing sentence -- + not an internal code reference -- and must name the literal name that + collided so the operator can find and rename the offending upstream entry. + """ + doc = _doc_with_pre_existing_identity("clusters") + with pytest.raises(KubeParseError) as excinfo: + rename_identities(doc, "node", "kube") + message = str(excinfo.value) + assert "tunstrap-node-kube" in message + # No raw repr/type/exception-class noise; a sentence an operator can act on. + assert "KubeParseError" not in message + + +# --- Structural-defect guards (issue #26) ------------------------------------- +# +# rename_identities is a public function (exported via __all__) whose declared +# input is "the fetched kubeconfig ... untrusted input". It used to defend its +# structural assumptions with `assert`; under `python -O` those vanish and a +# DIRECT caller's malformed document degrades into a bare +# KeyError/TypeError/AttributeError, because the public contract would no +# longer hold. Each structural defect below must therefore raise KubeParseError +# so the contract is honoured for direct callers and `python -O` cannot erase +# the check. +# +# These seven raises are NOT reachable from run_kube_targets for the structural +# inputs: run_kube_targets always runs parse_kubeconfig on the same document +# first, and that dominates every case below (non-string current-context, +# absent context, non-mapping body, non-string cluster/user refs, missing +# cluster/user) before rename_identities sees the doc. They are defence-in-depth +# for direct/public callers. The only raise reachable from run_kube_targets is +# the reserved-namespace collision, whose daemon_error/exit-4 teardown path +# (the broad _worker._run guard turning one bad target into a whole-node +# teardown) is covered by test_pre_existing_identity_surfaces_as_per_target_warning +# in tests/unit/test_kube_run.py, not here. + + +def test_non_string_current_context_raises_kubeparseerror() -> None: + """A non-string current-context is a typed KubeParseError, not an AssertionError. + + Under ``python -O`` the old ``assert isinstance(current, str)`` disappeared + and ``_find_named(contexts, 42)`` returned None, so the failure surfaced as + the missing-context branch instead of the real defect. + """ + doc: dict[str, object] = {"current-context": 42, "contexts": [], "clusters": [], "users": []} + with pytest.raises(KubeParseError) as excinfo: + rename_identities(doc, "node", "kube") + assert "not a string" in str(excinfo.value) + + +def test_current_context_missing_from_contexts_raises_kubeparseerror() -> None: + """A current-context that names no entry in contexts raises KubeParseError.""" + doc: dict[str, object] = { + "current-context": "ghost", + "contexts": [{"name": "default", "context": {"cluster": "default", "user": "default"}}], + "clusters": [], + "users": [], + } + with pytest.raises(KubeParseError) as excinfo: + rename_identities(doc, "node", "kube") + assert "ghost" in str(excinfo.value) + + +def test_non_mapping_context_body_raises_kubeparseerror() -> None: + """A context entry whose ``context`` is not a mapping raises KubeParseError.""" + doc: dict[str, object] = { + "current-context": "default", + "contexts": [{"name": "default", "context": "not-a-mapping"}], + "clusters": [], + "users": [], + } + with pytest.raises(KubeParseError) as excinfo: + rename_identities(doc, "node", "kube") + assert "default" in str(excinfo.value) + + +def test_non_string_cluster_reference_raises_kubeparseerror() -> None: + """A context whose ``cluster`` ref is not a string raises KubeParseError.""" + doc: dict[str, object] = { + "current-context": "default", + "contexts": [{"name": "default", "context": {"cluster": 7, "user": "default"}}], + "clusters": [], + "users": [], + } + with pytest.raises(KubeParseError) as excinfo: + rename_identities(doc, "node", "kube") + assert "cluster" in str(excinfo.value) + assert "not a string" in str(excinfo.value) + + +def test_non_string_user_reference_raises_kubeparseerror() -> None: + """A context whose ``user`` ref is not a string raises KubeParseError.""" + doc: dict[str, object] = { + "current-context": "default", + "contexts": [{"name": "default", "context": {"cluster": "default", "user": 7}}], + "clusters": [], + "users": [], + } + with pytest.raises(KubeParseError) as excinfo: + rename_identities(doc, "node", "kube") + assert "user" in str(excinfo.value) + assert "not a string" in str(excinfo.value) + + +def test_cluster_reference_not_in_clusters_raises_kubeparseerror() -> None: + """A context pointing at a cluster absent from ``clusters`` raises KubeParseError. + + Also pins the all-rejections-leave-doc-unchanged invariant: the existence + lookup was moved ahead of every mutation (it used to sit between the active + context's name rewrite and its cluster/user name rewrite), so this rejection + must not leave a half-renamed document. + """ + doc: dict[str, object] = { + "current-context": "default", + "contexts": [ + {"name": "default", "context": {"cluster": "missing-cluster", "user": "default"}} + ], + "clusters": [{"name": "default", "cluster": {"server": "https://127.0.0.1:1"}}], + "users": [{"name": "default", "user": {}}], + } + before = copy.deepcopy(doc) + with pytest.raises(KubeParseError) as excinfo: + rename_identities(doc, "node", "kube") + assert "missing-cluster" in str(excinfo.value) + assert doc == before, "rejection must leave the document unmutated" + + +def test_user_reference_not_in_users_raises_kubeparseerror() -> None: + """A context pointing at a user absent from ``users`` raises KubeParseError. + + Same all-rejections-leave-doc-unchanged invariant as the cluster case: the + existence lookup was relocated ahead of every mutation. + """ + doc: dict[str, object] = { + "current-context": "default", + "contexts": [ + {"name": "default", "context": {"cluster": "default", "user": "missing-user"}} + ], + "clusters": [{"name": "default", "cluster": {"server": "https://127.0.0.1:1"}}], + "users": [{"name": "default", "user": {}}], + } + before = copy.deepcopy(doc) + with pytest.raises(KubeParseError) as excinfo: + rename_identities(doc, "node", "kube") + assert "missing-user" in str(excinfo.value) + assert doc == before, "rejection must leave the document unmutated" diff --git a/tests/unit/test_kube_run.py b/tests/unit/test_kube_run.py index 31cebfa..2114c40 100644 --- a/tests/unit/test_kube_run.py +++ b/tests/unit/test_kube_run.py @@ -111,8 +111,170 @@ async def test_run_kube_target_success(monkeypatch: pytest.MonkeyPatch) -> None: assert out.content_b64 # non-empty patched kubeconfig +@pytest.mark.asyncio +async def test_run_kube_target_reports_renamed_identity(monkeypatch: pytest.MonkeyPatch) -> None: + """Output identity names are deterministic and node-qualified.""" + monkeypatch.setattr( + "tunstrap.kube.sans_from_cert", + lambda _der: (["dev-kube-1", "192.0.2.11"], []), + ) + conn = _FakeConn((FIXTURES / "single_internal_ip.yaml").read_bytes()) + outputs, _, _ = await run_kube_targets( + conn, + {"k3s": KubeTarget.model_validate({"kubeconfig_path": "/etc/k3s.yaml"})}, + connect_timeout=5, + probe=_probe_ok, + node_name="edge", + ) + out = outputs["k3s"] + assert out.context_name == "tunstrap-edge-k3s" + assert out.cluster_name == "tunstrap-edge-k3s" + + def test_default_probe_is_callable() -> None: """A default TLS probe is exported for production use.""" from tunstrap.kube import default_san_probe assert callable(default_san_probe) + + +# A fetched kubeconfig that already carries the name tunstrap would generate +# for (node="edge", target="k3s") in its clusters list. This is the +# reserved-namespace shadow the rename guard must reject; the test proves the +# rejection reaches the operator as a per-target warning, not an unhandled +# traceback. node="edge" + target key "k3s" -> "tunstrap-edge-k3s". +_PRE_EXISTING_IDENTITY_KUBE = b"""\ +apiVersion: v1 +clusters: +- cluster: + server: https://192.0.2.10:6443 + certificate-authority-data: Y2EtZGF0YQ== + name: default +- cluster: + server: https://192.0.2.99:6443 + certificate-authority-data: Y2EtZGF0YQ== + name: tunstrap-edge-k3s +contexts: +- context: {cluster: default, user: default} + name: default +current-context: default +kind: Config +preferences: {} +users: +- name: default + user: + client-certificate-data: Y2VydC1kYXRh + client-key-data: a2V5LWRhdGE= +""" + + +@pytest.mark.asyncio +async def test_pre_existing_identity_surfaces_as_per_target_warning( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A reserved-namespace collision surfaces as a per-target warning, not a crash. + + ``rename_identities`` raises ``KubeParseError``; ``run_kube_targets`` must + catch it and report ``kube_target : `` exactly like a fetch or + parse failure, fold it into ``required_failures`` only when the target is + required (the default), and close the listener it opened. Without the + try/except, the error would propagate unhandled out of + ``run_kube_targets``: ``KubeParseError`` is not in + ``_NODE_STARTUP_ERRORS``, so ``_start_one`` would not catch it, and the + worker's top-level ``except Exception`` guard in ``_worker._run`` would + catch it instead -- reporting a generic ``daemon_error`` IPC frame (exit + 4) while tearing down *every* node via ``manager.stop_all()``. That is a + worse outcome than a per-target warning (it loses the whole tunnel set), + not merely a CLI traceback. + """ + monkeypatch.setattr( + "tunstrap.kube.sans_from_cert", + lambda _der: (["dev-kube-1"], []), + ) + conn = _FakeConn(_PRE_EXISTING_IDENTITY_KUBE) + outputs, required_failures, warnings = await run_kube_targets( + conn, + {"k3s": KubeTarget.model_validate({"kubeconfig_path": "/etc/k3s.yaml"})}, + connect_timeout=5, + probe=_probe_ok, + node_name="edge", + ) + assert outputs == {}, outputs + assert required_failures == ["k3s"], required_failures + assert any("k3s" in w.error and "tunstrap-edge-k3s" in w.error for w in warnings), [ + w.error for w in warnings + ] + + +@pytest.mark.asyncio +async def test_non_selected_context_warning_discloses_reference_rewrite( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The per-context warning must not claim the context is merely 'ignored'. + + ``multi_context.yaml``'s non-selected context ``kubernetes-admin@kubernetes`` + shares the active triple's cluster/user, so its references ARE rewritten + during the rename. The warning wording must say plainly that the context + was not selected but its cluster/user references are rewritten when they + point at the renamed entries -- the old ``ignored context ''`` wording + and the module docstring's ``left byte-stable`` claim both misdescribed + this and are the subject of issue #20's defect 2. + """ + monkeypatch.setattr( + "tunstrap.kube.sans_from_cert", + lambda _der: (["dev-kube-1"], []), + ) + conn = _FakeConn((FIXTURES / "multi_context.yaml").read_bytes()) + _, _, warnings = await run_kube_targets( + conn, + {"k3s": KubeTarget.model_validate({"kubeconfig_path": "/etc/k3s.yaml"})}, + connect_timeout=5, + probe=_probe_ok, + node_name="edge", + ) + ctx_warnings = [w.error for w in warnings if "kubernetes-admin@kubernetes" in w.error] + assert ctx_warnings, [w.error for w in warnings] + wording = ctx_warnings[0] + assert "non-selected" in wording, wording + assert "rewritten" in wording, wording + # The misleading bare 'ignored context' wording is gone. + assert "ignored context" not in wording, wording + + +@pytest.mark.asyncio +async def test_rewrite_disclosure_not_emitted_when_target_fails_downstream( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The rewrite disclosure must not fire when the target later fails. + + The non-selected-context warning claims the context's ``cluster``/``user`` + references were rewritten. That disclosure is only true on the success + path -- ``rename_identities`` must actually have run. This target drives a + failure that occurs *after* the ignored-context loop's old position but + *before* the rename: ``multi_context.yaml`` (two contexts) with an apiserver + SAN probe that yields no usable name and ``insecure_fallback`` left false, + so ``_resolve_tls`` returns ``(None, False)`` and the target fails at the + "no usable TLS name" branch -- after ``_split_host_port`` and before + ``rename_identities``. No rewrite happened, so no warning may carry the + rewrite wording. (Issue #20 defect 1: the loop used to sit before the + failure paths and pre-emptively disclosed a rewrite that never occurred.) + """ + monkeypatch.setattr( + "tunstrap.kube.sans_from_cert", + lambda _der: ([], []), + ) + conn = _FakeConn((FIXTURES / "multi_context.yaml").read_bytes()) + outputs, required_failures, warnings = await run_kube_targets( + conn, + {"k3s": KubeTarget.model_validate({"kubeconfig_path": "/etc/k3s.yaml"})}, + connect_timeout=5, + probe=_probe_ok, + node_name="edge", + ) + # The downstream TLS failure is real: nothing produced, target required. + assert outputs == {}, outputs + assert required_failures == ["k3s"], required_failures + assert any("no usable TLS name" in w.error for w in warnings), [w.error for w in warnings] + # The false disclosure must be absent: no rewrite ran. + rewrite_warnings = [w.error for w in warnings if "rewritten" in w.error] + assert rewrite_warnings == [], [w.error for w in warnings] diff --git a/tests/unit/test_manager_fetch.py b/tests/unit/test_manager_fetch.py index a3d6a2a..cb61228 100644 --- a/tests/unit/test_manager_fetch.py +++ b/tests/unit/test_manager_fetch.py @@ -8,6 +8,8 @@ from __future__ import annotations +import base64 +from pathlib import Path from typing import Any import pytest @@ -23,6 +25,7 @@ NodeOutput, OutputSchema, ) +from tunstrap.session import SessionDir pytestmark = pytest.mark.unit @@ -69,7 +72,9 @@ async def test_no_fetch_files_skips_fetcher(monkeypatch: pytest.MonkeyPatch) -> """When fetch_files is None the fetcher is never invoked.""" called: list[Any] = [] - async def fake_fetch_files(conn: Any, specs: Any) -> tuple[dict[str, FetchedFile], list[str]]: + async def fake_fetch_files( + conn: Any, specs: Any, *, timeout: float + ) -> tuple[dict[str, FetchedFile], list[str]]: called.append((conn, specs)) return {}, [] @@ -85,21 +90,28 @@ async def fake_fetch_files(conn: Any, specs: Any) -> tuple[dict[str, FetchedFile @pytest.mark.asyncio async def test_fetch_files_results_populate_node_output( - monkeypatch: pytest.MonkeyPatch, + monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - """Fetcher results land in NodeOutput.fetch_files unchanged.""" + """Fetcher results are materialized to their fetch-prefixed leaf, path set.""" fake_result = {"kubeconfig": FetchedFile(content_b64="YQ==", size=1, sha256="ca97")} - async def fake_fetch_files(conn: Any, specs: Any) -> tuple[dict[str, FetchedFile], list[str]]: + async def fake_fetch_files( + conn: Any, specs: Any, *, timeout: float + ) -> tuple[dict[str, FetchedFile], list[str]]: return fake_result, [] monkeypatch.setattr(manager_mod, "fetch_files", fake_fetch_files) _patch_transport(monkeypatch, _FakeConn()) - mgr = TunnelManager(_input(fetch={"kubeconfig": FileSpec(path="/k")})) - out = await mgr.start_all_and_build_output(pid=1, session_dir="/tmp/x") + session = SessionDir.create(supplied=None, base=tmp_path) + mgr = TunnelManager(_input(fetch={"kubeconfig": FileSpec(path="/k")}), session=session) + out = await mgr.start_all_and_build_output(pid=1, session_dir=session.session_dir) assert isinstance(out, OutputSchema) - assert out.connections["a"].fetch_files == fake_result + materialized = out.connections["a"].fetch_files["kubeconfig"] + expected_path = str(Path(session.session_dir) / "tunnel-data" / "fetch-a-kubeconfig") + assert materialized.path == expected_path + assert Path(expected_path).read_bytes() == base64.b64decode("YQ==") + assert materialized.content_b64 == "YQ==" @pytest.mark.asyncio @@ -107,7 +119,9 @@ async def test_required_file_failure_aborts(monkeypatch: pytest.MonkeyPatch) -> """A required-file failure aborts the node and closes its connection.""" fake_conn = _FakeConn() - async def fake_fetch_files(conn: Any, specs: Any) -> tuple[dict[str, FetchedFile], list[str]]: + async def fake_fetch_files( + conn: Any, specs: Any, *, timeout: float + ) -> tuple[dict[str, FetchedFile], list[str]]: return {"k": FetchedFile(error="SSH_FX_NO_SUCH_FILE")}, ["k"] monkeypatch.setattr(manager_mod, "fetch_files", fake_fetch_files) @@ -123,7 +137,9 @@ async def fake_fetch_files(conn: Any, specs: Any) -> tuple[dict[str, FetchedFile async def test_soft_fail_file_keeps_node_success(monkeypatch: pytest.MonkeyPatch) -> None: """An optional-file error keeps the node in OutputSchema.""" - async def fake_fetch_files(conn: Any, specs: Any) -> tuple[dict[str, FetchedFile], list[str]]: + async def fake_fetch_files( + conn: Any, specs: Any, *, timeout: float + ) -> tuple[dict[str, FetchedFile], list[str]]: return {"k": FetchedFile(error="SSH_FX_NO_SUCH_FILE")}, [] monkeypatch.setattr(manager_mod, "fetch_files", fake_fetch_files) @@ -142,7 +158,9 @@ async def test_fetch_skipped_when_forward_fails(monkeypatch: pytest.MonkeyPatch) fake_conn = _FakeConn() fetch_called: list[bool] = [] - async def fake_fetch_files(conn: Any, specs: Any) -> tuple[dict[str, FetchedFile], list[str]]: + async def fake_fetch_files( + conn: Any, specs: Any, *, timeout: float + ) -> tuple[dict[str, FetchedFile], list[str]]: fetch_called.append(True) return {}, [] @@ -173,7 +191,9 @@ async def test_fetch_transport_failure_stops_forwarder_and_aborts( """A transport-level failure in the fetcher aborts and closes resources.""" fake_conn = _FakeConn() - async def fake_fetch_files(conn: Any, specs: Any) -> tuple[dict[str, FetchedFile], list[str]]: + async def fake_fetch_files( + conn: Any, specs: Any, *, timeout: float + ) -> tuple[dict[str, FetchedFile], list[str]]: raise ConnectionResetError("peer closed mid-fetch") monkeypatch.setattr(manager_mod, "fetch_files", fake_fetch_files) diff --git a/tests/unit/test_manager_materialize.py b/tests/unit/test_manager_materialize.py new file mode 100644 index 0000000..19a6e7a --- /dev/null +++ b/tests/unit/test_manager_materialize.py @@ -0,0 +1,108 @@ +"""TunnelManager materialization keeps kubeconfigs and fetched files distinct. + +Code: tunstrap/manager.py. +""" + +from __future__ import annotations + +import base64 +from pathlib import Path + +import pytest + +from tunstrap.manager import TunnelManager +from tunstrap.schemas import FetchedFile, InputSchema, KubeTargetOutput +from tunstrap.session import SessionDir + +pytestmark = pytest.mark.unit + + +def _manager(session: SessionDir) -> TunnelManager: + """Build a manager whose session is available to the materializers.""" + schema = InputSchema.model_validate( + { + "nodes": { + "node": { + "host": "host", + "user": "user", + "ssh_password": "password", + "fetch_files": {"file": {"path": "/etc/file"}}, + } + } + } + ) + return TunnelManager(schema, session=session) + + +def _kube(content: bytes) -> KubeTargetOutput: + """Return a minimal materializable kube target carrying ``content``.""" + return KubeTargetOutput( + cluster_name="cluster", + context_name="context", + local_port=6443, + endpoint="https://127.0.0.1:6443", + tls_server_name=None, + certificate_authority_data="ca", + client_certificate_data="cert", + client_key_data="key", + content_b64=base64.b64encode(content).decode(), + ) + + +def test_materialize_kube_target_uses_kube_namespace(tmp_path: Path) -> None: + """A kube target is written to a kube-prefixed tunnel-data leaf with mode 0600.""" + session = SessionDir.create(supplied=None, base=tmp_path) + kube = {"config": _kube(b"patched-kubeconfig")} + + _manager(session)._materialize_kube_targets(session, "node", kube) + + path = Path(kube["config"].path or "") + assert path == Path(session.session_dir) / "tunnel-data" / "kube-node-config" + assert path.read_bytes() == b"patched-kubeconfig" + assert path.stat().st_mode & 0o777 == 0o600 + session.cleanup() + + +def test_materialize_fetch_file_uses_fetch_namespace(tmp_path: Path) -> None: + """A fetched file is written to a fetch-prefixed tunnel-data leaf with mode 0600.""" + session = SessionDir.create(supplied=None, base=tmp_path) + fetched = { + "config": FetchedFile( + content_b64=base64.b64encode(b"fetched-file").decode(), size=12, sha256="a" * 64 + ) + } + + _manager(session)._materialize_fetch_files(session, "node", fetched) + + path = Path(fetched["config"].path or "") + assert path == Path(session.session_dir) / "tunnel-data" / "fetch-node-config" + assert path.read_bytes() == b"fetched-file" + assert path.stat().st_mode & 0o777 == 0o600 + session.cleanup() + + +def test_materializers_keep_same_named_fetch_bytes_out_of_kubeconfig(tmp_path: Path) -> None: + """Same logical names in both kinds retain separate on-disk bytes and paths.""" + session = SessionDir.create(supplied=None, base=tmp_path) + manager = _manager(session) + fetched_bytes = b"fetched-file" + kube_bytes = b"patched-kubeconfig client_key_data: private-key" + fetched = { + "config": FetchedFile( + content_b64=base64.b64encode(fetched_bytes).decode(), + size=len(fetched_bytes), + sha256="a" * 64, + ) + } + kube = {"config": _kube(kube_bytes)} + + manager._materialize_fetch_files(session, "node", fetched) + manager._materialize_kube_targets(session, "node", kube) + + fetch_path = Path(fetched["config"].path or "") + kube_path = Path(kube["config"].path or "") + assert fetch_path != kube_path + assert fetch_path.read_bytes() == fetched_bytes + assert fetch_path.read_bytes() != kube_bytes + assert kube_path.read_bytes() == kube_bytes + session.cleanup() diff --git a/tests/unit/test_manager_pkey.py b/tests/unit/test_manager_pkey.py index 38ff872..f173713 100644 --- a/tests/unit/test_manager_pkey.py +++ b/tests/unit/test_manager_pkey.py @@ -11,9 +11,9 @@ import asyncssh import pytest +from tests.unit.conftest import make_node from tunstrap.schemas import InputSchema from tunstrap.ssh import _load_client_keys -from tests.unit.conftest import make_node pytestmark = pytest.mark.unit diff --git a/tests/unit/test_manager_required.py b/tests/unit/test_manager_required.py index c2b0fac..6069358 100644 --- a/tests/unit/test_manager_required.py +++ b/tests/unit/test_manager_required.py @@ -63,7 +63,9 @@ async def fake_open_local_forwards( ) -> tuple[dict[str, int], list[Any]]: return {"p": 40000}, [] - async def fake_fetch_files(conn: Any, specs: Any) -> tuple[dict[str, Any], list[str]]: + async def fake_fetch_files( + conn: Any, specs: Any, *, timeout: float + ) -> tuple[dict[str, Any], list[str]]: return {}, [] monkeypatch.setattr(manager_mod, "open_connection", fake_open_connection) diff --git a/tests/unit/test_output_schema.py b/tests/unit/test_output_schema.py index 36ba346..fc0656c 100644 --- a/tests/unit/test_output_schema.py +++ b/tests/unit/test_output_schema.py @@ -46,6 +46,15 @@ def test_fetched_file_rejects_both_branches() -> None: FetchedFile(content_b64="YQ==", size=1, sha256="x", error="x") +def test_fetched_file_path_defaults_none_and_is_not_part_of_the_xor() -> None: + """path defaults to None and is set independently by materialization, + mirroring KubeTargetOutput.path -- it is not part of the success/error xor.""" + ff = FetchedFile(content_b64="YQ==", size=1, sha256="ca978112...") + assert ff.path is None + materialized = ff.model_copy(update={"path": "/s/tunnel-data/a-kubeconfig"}) + assert materialized.path == "/s/tunnel-data/a-kubeconfig" + + def test_node_output_default_fetch_files_empty() -> None: """NodeOutput.fetch_files defaults to an empty dict when omitted.""" no = NodeOutput(ports={"p": 40000}) diff --git a/tests/unit/test_readme_env_output.py b/tests/unit/test_readme_env_output.py new file mode 100644 index 0000000..e5acce0 --- /dev/null +++ b/tests/unit/test_readme_env_output.py @@ -0,0 +1,74 @@ +"""Keep README's ``start --output env`` variable table aligned with its emitter.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from tunstrap.cli import _session_scalars +from tunstrap.envrender import render_kube_env +from tunstrap.schemas import OutputSchema + +pytestmark = pytest.mark.unit + +README = Path(__file__).resolve().parents[2] / "README.md" + + +def _output(kube_paths: list[str]) -> OutputSchema: + """Make an emitted success envelope with exactly the requested kube files.""" + kube = { + f"kube{index}": { + "cluster_name": f"cluster{index}", + "context_name": f"context{index}", + "local_port": 6400 + index, + "endpoint": f"https://127.0.0.1:{6400 + index}", + "tls_server_name": "example.test", + "certificate_authority_data": "ca", + "client_certificate_data": "cert", + "client_key_data": "key", + "content_b64": "config", + "path": path, + } + for index, path in enumerate(kube_paths) + } + return OutputSchema.model_validate( + { + "connections": {"node": {"ports": {}, "kube_targets": kube}}, + "pid": 42, + "session_dir": "/tmp/session", + "started_at": "2026-08-10T00:00:00Z", + } + ) + + +def _readme_env_variable_names() -> set[str]: + """Extract the Variable column from README's emitted-variable table.""" + match = re.search( + r"Variables emitted.*?\n\n\| Variable \| Meaning \|\n\|---\|---\|\n(?P(?:\|.*\|\n)+)", + README.read_text(), + ) + assert match is not None, "README is missing the start --output env variable table" + return { + row.split("|")[1].strip().strip("`") + for row in match.group("rows").splitlines() + if row.strip() + } + + +def test_readme_env_table_matches_every_key_start_output_env_can_emit() -> None: + """README lists the actual scalar plus conditional kube-channel key union. + + ``RUN_ENV_KEYS`` is deliberately not used: it reserves all scrubbed + kube names for ``run --output-var`` collisions, while this table documents + only keys ``start --output env`` can actually emit. The union of zero, one, + and two materialized kube files covers its cardinality branches. This test + compares only variable names, not the mutually exclusive emission of + ``KUBE_CONFIG_PATH`` and ``KUBE_CONFIG_PATHS``. + """ + actual = set(_session_scalars(_output([]))) + actual.update(render_kube_env(_output(["/tmp/one"]))) + actual.update(render_kube_env(_output(["/tmp/one", "/tmp/two"]))) + + assert _readme_env_variable_names() == actual diff --git a/tests/unit/test_ruff_s101_coupling.py b/tests/unit/test_ruff_s101_coupling.py new file mode 100644 index 0000000..2c500d4 --- /dev/null +++ b/tests/unit/test_ruff_s101_coupling.py @@ -0,0 +1,141 @@ +"""Guard the wiring of the S101 (``assert``) production ban in ruff. + +``[tool.ruff.lint]`` opts into S101 via ``extend-select`` so production modules +can never reintroduce an ``assert``: the codebase documents twice +(``tunstrap/cli.py``, ``tunstrap/daemon.py``) that an ``assert`` raises +``AssertionError`` outside the ``TunstrapError`` handler -- so it escapes as a +traceback -- and that ``python -O`` erases the check altogether, leaving a bare +``TypeError``/``AttributeError`` in its place. The ban is relaxed for tests, +which are themselves built on ``assert``, via the ``tests/**/*.py`` +per-file-ignores entry. + +Nothing enforced the coupling: a comment above the per-file-ignores table says +S101 stays enabled for production, but comments do not enforce anything, and a +contributor appending ``"S101"`` to any production per-file-ignores entry would +silently punch a hole in the ban for that file. ``"tunstrap/kube.py" = +["SIM117"]`` is the obvious place someone might do it. A test is the only +enforcement that fails loudly. + +Lives in the unit tier (not e2e) on purpose: the e2e job ``needs: unit``, so a +divergence fails the unit job early. Reads ``pyproject.toml`` as TEXT rather +than importing ``tomllib``: the CI matrix runs Python 3.10 +(``.github/workflows/test.yml``: ``python-version: ["3.10", "3.11", "3.12", +"3.13"]``) and ``tomllib`` is 3.11+, so a ``tomllib``-based guard would error on +collection on the 3.10 leg -- exactly the leg it must run on. A plain-text scan +is the same approach ``tests/unit/test_ci_version_coupling.py`` takes and is +precise here because TOML rule codes are quoted strings (``"S101"``), so a +quoted-substring match cannot false-positive on a longer code such as +``"S1010"``. Tables are resolved sectionally (a ``[tool.ruff.lint]`` body ends +at the next ``[`` header) so the extend-select check is not confused by the +``per-file-ignores`` sub-table that follows it. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.unit] + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +_PYPROJECT = REPO_ROOT / "pyproject.toml" + +# A TOML table header: ``[tool.ruff.lint]`` -> group(1) = ``tool.ruff.lint``. +_TABLE_HEADER = re.compile(r"^\s*\[([^\]]+)\]\s*$") +# extend-select = [...] inside the [tool.ruff.lint] body. Single-line array +# (black/ruff-format keep short arrays unwrapped); a multi-line array fails the +# match loudly so the guard is updated to the new shape rather than passing +# silently. +_EXTEND_SELECT = re.compile(r"^extend-select\s*=\s*\[([^\]]*)\]", re.MULTILINE) +# A per-file-ignores entry: "glob" = [ ... ]. group(2) = glob, group(3) = array +# body (single-line, same rationale as above). +_PF_ENTRY = re.compile(r'^"([^"]+)"\s*=\s*\[([^\]]*)\]') + + +def _table_body(text: str, table: str) -> str: + """Return the body of a TOML ``[table]``, up to the next table header. + + pyproject.toml tables are ``[a.b]`` headers; a sub-table ``[a.b.c]`` ends + the parent's body, so the [tool.ruff.lint] body excludes the + [tool.ruff.lint.per-file-ignores] entries that follow it. Fails the test + (not silently returns empty) if the table is absent. + """ + lines = text.splitlines() + in_table = False + out: list[str] = [] + for line in lines: + m = _TABLE_HEADER.match(line) + if m: + if in_table: + break # next header ends this table's body + if m.group(1) == table: + in_table = True + continue + if in_table: + out.append(line) + if not in_table: + pytest.fail(f"could not find [{table}] table in {_PYPROJECT}") + return "\n".join(out) + + +def test_s101_is_in_ruff_extend_select() -> None: + """S101 (the assert rule) is explicitly opted into via extend-select. + + ``extend-select`` (not ``select``) is used so the rest of the bandit S-group + stays off and only the production assert ban is pulled in. Pinning + ``"S101"`` here pins the ban's on-switch: removing it would silently + re-allow ``assert`` across every production module. + + Fails-when-broken verbatim red recorded in the task report: removed + ``"S101"`` from ``extend-select = ["S101"]`` (leaving ``extend-select = + []``); this test failed with the missing-from-extend-select message below. + """ + body = _table_body(_PYPROJECT.read_text(), "tool.ruff.lint") + m = _EXTEND_SELECT.search(body) + assert m is not None, ( + f"could not parse extend-select = [...] from [tool.ruff.lint] in " + f"{_PYPROJECT}; the key has been renamed, removed, or the array wrapped " + f"across lines. Update this guard to the new shape." + ) + assert '"S101"' in m.group(1), ( + f'"S101" is missing from extend-select in {_PYPROJECT}. The production ' + f"assert ban depends on S101 being opted in here; without it ruff " + f"silently accepts `assert` in production modules." + ) + + +def test_no_production_per_file_ignore_lists_s101() -> None: + """No per-file-ignores entry other than tests/** may relax S101. + + The ``tests/**`` entry is the sanctioned escape hatch: the test suite is + built on ``assert``. Every other glob in + ``[tool.ruff.lint.per-file-ignores]`` targets production code, and any of + them silently appending ``"S101"`` would punch a hole in the ban for that + file -- the exact regression this guard exists to catch. + ``"tunstrap/kube.py" = ["SIM117"]`` is the obvious place someone might do + it. + + Fails-when-broken verbatim red recorded in the task report: appended + ``"S101"`` to ``"tunstrap/kube.py" = ["SIM117"]`` (making it + ``["SIM117", "S101"]``); this test failed naming ``tunstrap/kube.py`` as + the offender. + """ + body = _table_body(_PYPROJECT.read_text(), "tool.ruff.lint.per-file-ignores") + offenders: list[str] = [] + for line in body.splitlines(): + m = _PF_ENTRY.match(line.strip()) + if m is None: + continue + glob = m.group(1) + array = m.group(2) + if glob.startswith("tests"): + continue # sanctioned escape hatch for the assert-based test suite + if '"S101"' in array: + offenders.append(f'"{glob}" = [{array}]') + assert not offenders, ( + f"production per-file-ignores entries in {_PYPROJECT} relax S101 (the " + f"assert ban): {offenders}. The S101 ignore belongs only under the " + f"tests/** entry; remove it from the production glob(s) above." + ) diff --git a/tests/unit/test_schemas.py b/tests/unit/test_schemas.py index f56bfc4..1ed15c0 100644 --- a/tests/unit/test_schemas.py +++ b/tests/unit/test_schemas.py @@ -10,6 +10,7 @@ import pytest from pydantic import ValidationError +from tests.unit.conftest import make_node from tunstrap.schemas import ( DaemonOptions, InputSchema, @@ -19,7 +20,6 @@ SSHOptions, TunnelWarning, ) -from tests.unit.conftest import make_node pytestmark = pytest.mark.unit @@ -85,6 +85,17 @@ def test_daemon_options_auto_stop_idle_seconds_default_null() -> None: assert opts.auto_stop_idle_seconds is None +def test_daemon_options_startup_timeout_defaults_to_conservative_five_minutes() -> None: + """The IPC deadline is long enough for normal multi-node remote startup.""" + assert DaemonOptions().startup_timeout_seconds == 300 + + +def test_daemon_options_startup_timeout_rejects_zero() -> None: + """A non-positive IPC deadline would make startup unable to make progress.""" + with pytest.raises(ValidationError): + DaemonOptions(startup_timeout_seconds=0) + + def test_daemon_options_auto_stop_idle_seconds_accepts_positive_int() -> None: """A positive integer is accepted.""" opts = DaemonOptions(auto_stop_idle_seconds=60) diff --git a/tests/unit/test_schemas_fetch.py b/tests/unit/test_schemas_fetch.py index 758fdd2..9af33fc 100644 --- a/tests/unit/test_schemas_fetch.py +++ b/tests/unit/test_schemas_fetch.py @@ -10,8 +10,8 @@ import pytest from pydantic import ValidationError -from tunstrap.schemas import FileSpec, InputSchema, NodeInput from tests.unit.conftest import make_node +from tunstrap.schemas import FileSpec, InputSchema, NodeInput pytestmark = pytest.mark.unit diff --git a/tests/unit/test_schemas_kube.py b/tests/unit/test_schemas_kube.py index 6552afb..26293cb 100644 --- a/tests/unit/test_schemas_kube.py +++ b/tests/unit/test_schemas_kube.py @@ -13,8 +13,8 @@ import pytest from pydantic import ValidationError -from tunstrap.schemas import InputSchema, KubeTarget, NodeInput from tests.unit.conftest import make_node +from tunstrap.schemas import InputSchema, KubeTarget, NodeInput pytestmark = pytest.mark.unit diff --git a/tests/unit/test_schemas_kube_naming_collision.py b/tests/unit/test_schemas_kube_naming_collision.py new file mode 100644 index 0000000..b9e8e73 --- /dev/null +++ b/tests/unit/test_schemas_kube_naming_collision.py @@ -0,0 +1,108 @@ +"""InputSchema rejects collisions in tunstrap-- names.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from tunstrap.schemas import InputSchema, materialized_file_name + +pytestmark = pytest.mark.unit + + +def test_naming_join_collision_across_nodes_is_rejected() -> None: + """Different hyphenated pairs rendering one identity are rejected.""" + with pytest.raises(ValidationError) as excinfo: + InputSchema.model_validate( + { + "nodes": { + "a-b": { + "host": "h1", + "user": "u", + "ssh_password": "p", + "kube_targets": {"c": {"kubeconfig_path": "/etc/x.yaml"}}, + }, + "a": { + "host": "h2", + "user": "u", + "ssh_password": "p", + "kube_targets": {"b-c": {"kubeconfig_path": "/etc/y.yaml"}}, + }, + } + } + ) + message = str(excinfo.value) + assert "tunstrap-a-b-c" in message + assert "a-b" in message and "c" in message + assert "a" in message and "b-c" in message + + +def test_non_colliding_hyphenated_names_are_accepted() -> None: + """Hyphens alone do not trigger the collision check.""" + InputSchema.model_validate( + { + "nodes": { + "node-one": { + "host": "h1", + "user": "u", + "ssh_password": "p", + "kube_targets": {"kube-a": {"kubeconfig_path": "/etc/x.yaml"}}, + }, + "node-two": { + "host": "h2", + "user": "u", + "ssh_password": "p", + "kube_targets": {"kube-b": {"kubeconfig_path": "/etc/y.yaml"}}, + }, + } + } + ) + + +def test_materialized_fetch_name_collision_across_nodes_is_rejected() -> None: + """Different hyphenated fetch pairs rendering one slot are rejected.""" + with pytest.raises(ValidationError) as excinfo: + InputSchema.model_validate( + { + "nodes": { + "a-b": { + "host": "h1", + "user": "u", + "ssh_password": "p", + "fetch_files": {"c": {"path": "/etc/a"}}, + }, + "a": { + "host": "h2", + "user": "u", + "ssh_password": "p", + "fetch_files": {"b-c": {"path": "/etc/b"}}, + }, + } + } + ) + message = str(excinfo.value) + assert "fetch-a-b-c" in message + assert "a-b" in message and "c" in message + assert "a" in message and "b-c" in message + + +def test_same_named_fetch_and_kube_targets_are_accepted() -> None: + """One node may use the same item name in each materialized-file kind.""" + schema = InputSchema.model_validate( + { + "nodes": { + "node": { + "host": "host", + "user": "user", + "ssh_password": "password", + "fetch_files": {"config": {"path": "/etc/config"}}, + "kube_targets": {"config": {"kubeconfig_path": "/etc/kubeconfig"}}, + } + } + } + ) + + assert set(schema.nodes["node"].fetch_files or {}) == {"config"} + assert set(schema.nodes["node"].kube_targets or {}) == {"config"} + assert materialized_file_name("fetch", "node", "config") == "fetch-node-config" + assert materialized_file_name("kube", "node", "config") == "kube-node-config" diff --git a/tests/unit/test_schemas_remote_targets.py b/tests/unit/test_schemas_remote_targets.py index aa37e10..781eaca 100644 --- a/tests/unit/test_schemas_remote_targets.py +++ b/tests/unit/test_schemas_remote_targets.py @@ -13,6 +13,8 @@ _parse_host_port, ) +pytestmark = pytest.mark.unit + class TestParseHostPort: """Coverage of the host:port parser.""" diff --git a/tests/unit/test_schemas_required.py b/tests/unit/test_schemas_required.py index 878c60a..c09c0f1 100644 --- a/tests/unit/test_schemas_required.py +++ b/tests/unit/test_schemas_required.py @@ -10,8 +10,8 @@ import pytest from pydantic import ValidationError -from tunstrap.schemas import InputSchema from tests.unit.conftest import make_node +from tunstrap.schemas import InputSchema pytestmark = pytest.mark.unit diff --git a/tests/unit/test_session_dir.py b/tests/unit/test_session_dir.py index 9757b09..1848cbe 100644 --- a/tests/unit/test_session_dir.py +++ b/tests/unit/test_session_dir.py @@ -11,13 +11,20 @@ from __future__ import annotations +import errno import os +import shutil import stat from pathlib import Path import pytest -from tunstrap.session import SessionDir, SessionError +from tunstrap.session import ( + SessionDir, + SessionError, + SessionIdentityUnreadable, + atomic_write, +) pytestmark = pytest.mark.unit @@ -40,6 +47,9 @@ def test_supplied_dir_cleanup_keeps_dir(tmp_path: Path) -> None: sd.cleanup() assert supplied.exists() assert not (supplied / "tunnel-data").exists() + # Tightening is the fix: a bare mkdir is 0o775 under umask 0o002, and create + # must clear the group/other write bits rather than refuse the dir. + assert stat.S_IMODE(supplied.stat().st_mode) & (stat.S_IWGRP | stat.S_IWOTH) == 0 def test_tunnel_data_is_0700(tmp_path: Path) -> None: @@ -56,8 +66,9 @@ def test_reclaims_existing_tunnel_data(tmp_path: Path) -> None: belongs to a dead session and is safe to reclaim. """ supplied = tmp_path / "work" + supplied.mkdir() data = supplied / "tunnel-data" - data.mkdir(parents=True) + data.mkdir() (data / "leftover").write_text("stale\n") sd = SessionDir.create(supplied=str(supplied), base=tmp_path) assert (supplied / "tunnel-data").is_dir() @@ -66,13 +77,20 @@ def test_reclaims_existing_tunnel_data(tmp_path: Path) -> None: def test_rejects_symlink_tunnel_data(tmp_path: Path) -> None: - """A symlinked tunnel-data is rejected (no symlink-following).""" + """A symlinked tunnel-data is rejected (no symlink-following). + + The bare ``mkdir()`` is deliberate: under umask 0o002 it yields 0o775, which + create tightens to 0o755 and then proceeds to ``_reclaim_data_slot`` -- so + the ``match`` proves this trips the tunnel-data guard, not the root guard + (a previous cycle shipped it raising "group- or world-writable" here while + staying green). + """ supplied = tmp_path / "work" supplied.mkdir() target = tmp_path / "elsewhere" target.mkdir() (supplied / "tunnel-data").symlink_to(target) - with pytest.raises(SessionError): + with pytest.raises(SessionError, match="tunnel-data is a symlink"): SessionDir.create(supplied=str(supplied), base=tmp_path) @@ -80,7 +98,7 @@ def test_write_identity_and_materialize(tmp_path: Path) -> None: """Identity files and a materialized file land in tunnel-data, mode 0600.""" sd = SessionDir.create(supplied=None, base=tmp_path) sd.write_identity(pid=4321) - path = sd.materialize("hub-k3s", b"kubeconfig-bytes") + path = sd.materialize_atomic("hub-k3s", b"kubeconfig-bytes") data_dir = Path(sd.session_dir) / "tunnel-data" assert (data_dir / "daemon.pid").read_text().strip() == "4321" assert Path(path).read_bytes() == b"kubeconfig-bytes" @@ -90,15 +108,205 @@ def test_write_identity_and_materialize(tmp_path: Path) -> None: def test_write_file_rejects_traversal_name(tmp_path: Path) -> None: """materialize() with a traversal name is rejected (defense in depth).""" sd = SessionDir.create(supplied=None, base=tmp_path) - with pytest.raises(SessionError): - sd.materialize("../escaped", b"x") + with pytest.raises(SessionError, match="unsafe materialized file name"): + sd.materialize_atomic("../escaped", b"x") def test_write_file_rejects_slash_name(tmp_path: Path) -> None: """materialize() with a nested path is rejected.""" sd = SessionDir.create(supplied=None, base=tmp_path) - with pytest.raises(SessionError): - sd.materialize("sub/dir", b"x") + with pytest.raises(SessionError, match="unsafe materialized file name"): + sd.materialize_atomic("sub/dir", b"x") + + +# --------------------------------------------------------------------------- +# atomic_write + SessionDir._write_file through fdio.write_all: issue #21 +# (parent dir mode, temp cleanup on failure, short-write loop, no-progress guard) +# --------------------------------------------------------------------------- + + +def test_atomic_write_creates_parent_dir_at_0700(tmp_path: Path) -> None: + """A missing parent is minted at 0700, not at the ambient umask. + + Defect 1 (issue #21): ``mkdir(parents=True, exist_ok=True)`` with no mode + left ``tunnel-data`` at ``0o777 & ~umask`` (``0o775`` under the Debian + default ``0o002``) -- group/world-readable -- while the 0600 file inside it + was correct. The parent hosts 0600 credentials, so it must match + ``SessionDir.create``'s 0700 when ``atomic_write`` is the one to mint it. + + REACHABILITY: in production this ``mkdir`` is a no-op -- ``tunnel-data`` is + always pre-created at 0700 by ``SessionDir.create`` (daemon) before any + call site reaches ``atomic_write`` (see test_atomic_write_does_not_widen_parent + and the blast-radius note). This test pins the direct-caller / + defence-in-depth case; it is NOT a live-hole reproduction. + """ + saved_umask = os.umask(0o002) + try: + target = tmp_path / "tunnel-data" / "output.json" + atomic_write(target, b'{"x": 1}\n') + parent_mode = stat.S_IMODE((tmp_path / "tunnel-data").stat().st_mode) + file_mode = stat.S_IMODE(target.stat().st_mode) + finally: + os.umask(saved_umask) + assert parent_mode == 0o700, f"parent mode {oct(parent_mode)} exposes group/world" + assert file_mode == 0o600 + + +def test_atomic_write_does_not_widen_parent(tmp_path: Path) -> None: + """When the parent already exists at 0700 (the production case), atomic_write's + ``mkdir(exist_ok=True)`` is a no-op and never widens the mode. + + Documents the reachability verdict: the daemon mints ``tunnel-data`` at 0700 + via ``SessionDir.create`` before the parent (start/run) ever calls + ``write_materialized_output`` -> ``atomic_write``, and ``materialize_atomic`` + runs in the daemon after the same ``create``. So ``path.parent`` is always + already correct and the new ``mode=0o700`` argument never re-runs in + production. The fix is defence-in-depth; it must not undo a correct mode. + """ + parent = tmp_path / "tunnel-data" + parent.mkdir(mode=0o700) + atomic_write(parent / "output.json", b"{}\n") + assert stat.S_IMODE(parent.stat().st_mode) == 0o700 + + +def test_atomic_write_unlinks_temp_so_retry_succeeds( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A failed write unlinks its temp, so the pid-pinned O_EXCL name does not + permanently block a same-pid retry. + + Defect 2 (issue #21): a write failure orphaned ``...tmp``. The + temp name is pinned to ``os.getpid()`` (different processes never compete + for it), so the *same*-process retry -- the realistic case -- hit + ``FileExistsError`` on O_EXCL forever. That was the opposite of the old + docstring's claim that O_EXCL "guards against a colliding concurrent writer". + """ + target = tmp_path / "out.json" + real_write = os.write + + def failing_write(fd: int, data: object) -> int: + raise OSError("simulated write failure (ENOSPC/EIO)") + + monkeypatch.setattr(os, "write", failing_write) + with pytest.raises(OSError, match="simulated write failure"): + atomic_write(target, b"first attempt fails") + + leftover = [p.name for p in tmp_path.iterdir() if p.name.endswith(".tmp")] + assert leftover == [], f"stale temp left behind: {leftover}" + + monkeypatch.setattr(os, "write", real_write) + atomic_write(target, b"second attempt, same pid") + assert target.read_bytes() == b"second attempt, same pid" + + +def test_atomic_write_cleanup_preserves_original_error_when_temp_gone( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The cleanup path must not mask the original failure if the temp is already + gone when it runs. + + Contract for the inner ``except FileNotFoundError: pass`` in atomic_write's + cleanup: if something between create and the unlink already removed the + temp, suppressing the missing-file error keeps the *original* failure + propagating instead of replacing it with a misleading ".tmp not found". + + Triggered with a stand-in os.replace that unlinks the temp then raises + (real os.replace is atomic and never does this); the point is to prove the + cleanup preserves the original error, not to model a realistic replace. + + Mutation signal: if the ``except FileNotFoundError: pass`` is deleted, + os.unlink raises FileNotFoundError and shadows the original OSError -- the + test sees FileNotFoundError instead of "replace failed" -> clean red. + """ + target = tmp_path / "gone.json" + + def unlink_then_fail(src: str, dst: str) -> None: + os.unlink(src) # temp now gone before cleanup runs + raise OSError("replace failed after temp removed") + + monkeypatch.setattr(os, "replace", unlink_then_fail) + with pytest.raises(OSError, match="replace failed after temp removed"): + atomic_write(target, b"data") + + +def test_atomic_write_loops_past_short_writes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Every byte is written even when os.write returns partial counts. + + Defect 3 (issue #21): the return value of os.write was ignored, so a short + write silently truncated. A short write to a regular file is hard to trigger + naturally, so this stand-in patches os.write to write one byte per call + (labelled: it simulates a filesystem handing back partial counts) -- the + same hazard ``fdio.write_all`` handles for IPC pipes and regular files. + """ + target = tmp_path / "short.json" + real_write = os.write + payload = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ" # 26 bytes + + def one_byte_write(fd: int, data: object) -> int: + real_write(fd, bytes(data)[:1]) # type: ignore[arg-type] + return 1 + + monkeypatch.setattr(os, "write", one_byte_write) + atomic_write(target, payload) + assert target.read_bytes() == payload + + +def test_atomic_write_raises_on_zero_progress_write( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A no-progress os.write (returns 0) raises OSError instead of looping forever. + + ``os.write`` on a blocking regular-file fd essentially never returns 0 for a + non-empty buffer, so this guard is defence-in-depth -- but a loop with no + no-progress guard would spin forever if it ever did, so the guard is + load-bearing for robustness. It exercises ``fdio.write_all``'s shared + ``if written <= 0`` check. + + Mutation signal (why this test exists): if the ``if written <= 0`` guard is + deleted, os.write returns 0, the loop slices ``view[0:]`` (unchanged) and + re-calls os.write; the stub raises on its second call, so the test sees + RuntimeError instead of OSError -- a clean red rather than a hang. + """ + target = tmp_path / "zero.json" + calls: list[int] = [] + + def zero_then_boom(fd: int, data: object) -> int: + calls.append(1) + if len(calls) == 1: + return 0 # no progress + raise RuntimeError("second os.write call must not happen") + + monkeypatch.setattr(os, "write", zero_then_boom) + with pytest.raises(OSError, match="no progress"): + atomic_write(target, b"ABC") + assert len(calls) == 1, "guard must fire on the first zero return" + + +def test_materialize_loops_past_short_writes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """SessionDir._write_file (behind write_identity) also loops past short + writes -- the second unchecked os.write site named in issue #21. + + Driven through ``write_identity`` because that is ``_write_file``'s only + caller. This pins the legacy metadata call path into the shared + ``fdio.write_all`` helper; materialization instead reaches that helper + through ``atomic_write``. + + Same partial-write stand-in as test_atomic_write_loops_past_short_writes. + """ + sd = SessionDir.create(supplied=None, base=tmp_path) + real_write = os.write + + def one_byte_write(fd: int, data: object) -> int: + real_write(fd, bytes(data)[:1]) # type: ignore[arg-type] + return 1 + + monkeypatch.setattr(os, "write", one_byte_write) + sd.write_identity(pid=1234567) + assert (Path(sd.session_dir) / "tunnel-data" / "daemon.pid").read_text() == "1234567\n" def test_rejects_relative_supplied_dir(tmp_path: Path) -> None: @@ -112,3 +320,415 @@ def test_accepts_absolute_supplied_dir(tmp_path: Path) -> None: abs_dir = tmp_path / "work" sd = SessionDir.create(supplied=str(abs_dir), base=tmp_path) assert Path(sd.session_dir) == abs_dir.resolve() + + +def test_cleanup_path_returns_empty_on_success(tmp_path: Path) -> None: + """A successful tunnel-data removal reports no survivors.""" + data = tmp_path / "tunnel-data" + data.mkdir() + (data / "daemon.pid").write_text("1\n") + assert SessionDir.cleanup_path(str(tmp_path)) == [] + assert not data.exists() + + +@pytest.mark.skipif(os.getuid() == 0, reason="root ignores directory write permission") +def test_cleanup_path_reports_survivor_when_removal_fails(tmp_path: Path) -> None: + """An unremovable tunnel-data is reported, not silently swallowed.""" + data = tmp_path / "tunnel-data" + data.mkdir() + (data / "stuck").write_text("x") + # Drop write permission on the parent so the child entry cannot be unlinked. + os.chmod(data, 0o500) + try: + survivors = SessionDir.cleanup_path(str(tmp_path)) + finally: + os.chmod(data, 0o700) + assert survivors == [str(data.resolve())] + assert data.exists() + + +def test_cleanup_path_missing_dir_is_not_a_failure(tmp_path: Path) -> None: + """A tunnel-data that was never created reports no survivors.""" + assert SessionDir.cleanup_path(str(tmp_path)) == [] + + +def test_remove_root_removes_everything(tmp_path: Path) -> None: + """remove_root deletes the whole minted root, not just tunnel-data.""" + root = tmp_path / "minted" + (root / "tunnel-data").mkdir(parents=True) + (root / "session.lock").write_text("1\n") + assert SessionDir.remove_root(str(root)) == [] + assert not root.exists() + + +def test_remove_root_missing_is_not_a_failure(tmp_path: Path) -> None: + """remove_root on an already-gone root reports no survivors and never raises.""" + assert SessionDir.remove_root(str(tmp_path / "gone")) == [] + + +def test_remove_root_resolves_its_argument(tmp_path: Path) -> None: + """remove_root normalises the path like cleanup_path and read_identity do. + + Reached through a symlink, an unresolved ``rmtree`` refuses to descend + (a symlink is not a directory), the error is swallowed by + ``ignore_errors=True``, and the follow-through ``stat()`` then finds the + target alive and reports the root as an unremovable survivor -- so ``run`` + prints "could not remove session root" for a root it never actually tried + to delete. + + The only caller passes a ``tempfile.mkdtemp`` path, which is always a real + directory, so this is a consistency fix rather than a live bug; the test + exists because a symlink is the one input that can tell the two + implementations apart. + + Fails with ``[]`` and a surviving target if ``.resolve()`` is + removed. + """ + real = tmp_path / "real-root" + real.mkdir() + (real / "tunnel-data").mkdir() + link = tmp_path / "link-root" + link.symlink_to(real, target_is_directory=True) + + assert SessionDir.remove_root(str(link)) == [] + assert not real.exists(), "the resolved root was not removed" + + +@pytest.mark.skipif(os.getuid() == 0, reason="root ignores directory execute permission") +def test_rmtree_reporting_reports_unstatable_survivor(tmp_path: Path) -> None: + """An unstatable leaf is reported rather than letting exists() raise.""" + middle = tmp_path / "parent" / "middle" + leaf = middle / "leaf" + leaf.mkdir(parents=True) + os.chmod(middle, 0o600) + try: + survivors = SessionDir._rmtree_reporting(leaf) + finally: + os.chmod(middle, 0o700) + assert survivors == [str(leaf)] + assert leaf.exists() + + +def test_missing_identity_is_not_reported_as_unreadable(tmp_path: Path) -> None: + """A file that was never written means nothing was ever recorded. + + This is the only one of the three ``read_identity`` failures that lets a + caller conclude no daemon is running, so it must stay distinguishable from + the other two. + """ + (tmp_path / "tunnel-data").mkdir() + + with pytest.raises(SessionError) as caught: + SessionDir.read_identity(str(tmp_path)) + + blocks_cleanup = "a missing identity was reported as unreadable, which blocks cleanup forever" + assert not isinstance(caught.value, SessionIdentityUnreadable), blocks_cleanup + + +def test_unreadable_identity_is_distinguishable_from_a_missing_one(tmp_path: Path) -> None: + """An identity we cannot read at all leaves the daemon's state unknown. + + Uses a directory where the file belongs, so the ``OSError`` is + ``IsADirectoryError`` — deterministic for any uid, unlike a chmod-based + setup which a root test runner would sail straight through. + """ + (tmp_path / "tunnel-data").mkdir() + (tmp_path / "tunnel-data" / "daemon.pid").mkdir() + + with pytest.raises(SessionIdentityUnreadable): + SessionDir.read_identity(str(tmp_path)) + + +def test_malformed_identity_is_distinguishable_from_a_missing_one(tmp_path: Path) -> None: + """A daemon recorded *something*; we just cannot turn it into a pid. + + A truncated write is the realistic shape, and it says the opposite of + "nothing is running": a daemon got far enough to open the file. + """ + (tmp_path / "tunnel-data").mkdir() + (tmp_path / "tunnel-data" / "daemon.pid").write_text("not-a-pid\n") + + with pytest.raises(SessionIdentityUnreadable): + SessionDir.read_identity(str(tmp_path)) + + +@pytest.mark.parametrize( + "body", + ["0\n", "-1\n", " -7 \n"], + ids=["zero", "minus-one", "negative-with-whitespace"], +) +def test_non_positive_identity_is_unreadable(tmp_path: Path, body: str) -> None: + """A non-positive pid is corrupt state, not a stop target. + + Under ``kill(2)`` a pid of 0 means the caller's own process group and a + negative pid a process group — with ``-1`` meaning *every* process the + caller can signal, a broadcast rather than a single group — so handing such + a value to ``os.kill`` widens a signal far beyond the recorded daemon, the + exact hazard ``_has_exited`` already guards ``waitpid`` against (where the + same encodings select a child group, or for ``-1`` any child). + ``read_identity`` is the gate that keeps a corrupt ``daemon.pid`` (or a + hostile ``--session-dir`` whose body is ``-1``) off the kill path entirely, + so 0 and negatives are ``SessionIdentityUnreadable``: the corrupt-state + answer that makes both ``run`` and ``stop`` preserve rather than signal or + clean up. + + Whitespace is part of the case because the reader does ``int(raw.strip())``, + which would otherwise turn ``" -7 \\n"`` into a perfectly valid, perfectly + dangerous ``-7``. + """ + (tmp_path / "tunnel-data").mkdir() + (tmp_path / "tunnel-data" / "daemon.pid").write_text(body) + + with pytest.raises(SessionIdentityUnreadable): + SessionDir.read_identity(str(tmp_path)) + + +# --- issue #25: caller-supplied root validation (mirror _reclaim_data_slot) --- + + +def test_create_clears_group_write_on_supplied_root(tmp_path: Path) -> None: + """A group-writable supplied root is tightened, not refused. + + Directory write is authority over *entries* (unlink/rename a planted + ``session.lock`` or ``tunnel-data``), which no fd-level inode check reaches; + the root guard is therefore not redundant given ``O_NOFOLLOW`` + ``fstat``. + But refusal is the wrong enforcement -- the mode cannot tell a user-private + group from a shared one -- so create clears the write bit instead. The final + mode is exactly 0o755: only the write bits are cleared, read/exec preserved. + """ + supplied = tmp_path / "work" + supplied.mkdir() + supplied.chmod(0o775) + sd = SessionDir.create(supplied=str(supplied), base=tmp_path) + sd.cleanup() + assert stat.S_IMODE(supplied.stat().st_mode) == 0o755 + + +def test_create_clears_world_write_on_supplied_root(tmp_path: Path) -> None: + """A world-writable supplied root is tightened to 0o755, not refused.""" + supplied = tmp_path / "work" + supplied.mkdir() + supplied.chmod(0o777) + sd = SessionDir.create(supplied=str(supplied), base=tmp_path) + sd.cleanup() + assert stat.S_IMODE(supplied.stat().st_mode) == 0o755 + + +def test_create_refuses_foreign_owned_supplied_root( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A supplied root owned by another uid is refused. + + Mirrors ``_reclaim_data_slot``'s ownership check. The root is created 0o700 + so the write-bit guard does NOT fire, and only its *reported* owner is forged + foreign -- that is what independently pins the ownership guard: remove it and + ``SessionDir.create`` sails through to success (a ``getuid``-patch would + instead be caught by ``acquire_session_lock``'s own uid check and mask the + missing root guard, which is the false-pass shape a prior cycle shipped). + + Stand-in: an unprivileged runner cannot ``chown`` to another uid, so the + foreign owner is reported by patching ``os.fstat`` for the root's inode -- + mechanism-stable now that the guard is fd-based (``os.fstat`` rather than + ``Path.stat``). + """ + supplied = tmp_path / "work" + supplied.mkdir(mode=0o700) + real_fstat = os.fstat + root_ino = supplied.stat().st_ino + foreign_uid = os.getuid() + 1 + + def fake_fstat(fd: int) -> os.stat_result: + st = real_fstat(fd) + if st.st_ino == root_ino: + return os.stat_result( + ( + st.st_mode, + st.st_ino, + st.st_dev, + st.st_nlink, + foreign_uid, + st.st_gid, + st.st_size, + st.st_atime, + st.st_mtime, + st.st_ctime, + ) + ) + return st + + monkeypatch.setattr(os, "fstat", fake_fstat) + with pytest.raises(SessionError, match="not owned by the current user"): + SessionDir.create(supplied=str(supplied), base=tmp_path) + + +def test_create_accepts_0755_supplied_root(tmp_path: Path) -> None: + """A 0755 root (group read+exec, no write) is accepted unchanged at 0o755. + + Regression guard on two sides: several existing tests mint ``tmp_path/"work"`` + under a 022 umask, which yields exactly 0755, so checking anything beyond the + write bits would break that legitimate shape; and only the write bits are + cleared, so an implementation that force-chmods to 0700 fails the equality. + """ + supplied = tmp_path / "work" + supplied.mkdir() + supplied.chmod(0o755) + sd = SessionDir.create(supplied=str(supplied), base=tmp_path) + sd.cleanup() + assert supplied.exists() + assert stat.S_IMODE(supplied.stat().st_mode) == 0o755 + + +def test_create_accepts_0700_supplied_root(tmp_path: Path) -> None: + """A 0700 supplied root is accepted (regression guard).""" + supplied = tmp_path / "work" + supplied.mkdir() + supplied.chmod(0o700) + sd = SessionDir.create(supplied=str(supplied), base=tmp_path) + sd.cleanup() + assert supplied.exists() + + +def test_create_fresh_root_under_zero_umask_does_not_self_reject( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A freshly created supplied root must not fail its own write-bits check. + + Under umask 0 the default ``mkdir`` mode (0777) would produce a group- and + world-writable root that validation would then refuse -- a self-inflicted + denial of service. ``SessionDir.create`` therefore creates the root with an + explicit ``0o700`` so a directory it just minted can never carry group/other + write bits regardless of the inherited umask. The fix's own constraint: a + freshly created root must not be able to fail its own validation. + """ + fresh_parent = tmp_path / "fresh-parent" + fresh = fresh_parent / "fresh-root" + assert not fresh.exists() + old_umask = os.umask(0) + try: + SessionDir.create(supplied=str(fresh), base=tmp_path).cleanup() + finally: + os.umask(old_umask) + assert fresh.exists() + mode = stat.S_IMODE(fresh.stat().st_mode) + assert mode & (stat.S_IWGRP | stat.S_IWOTH) == 0, oct(mode) + parent_mode = stat.S_IMODE(fresh_parent.stat().st_mode) + assert parent_mode & (stat.S_IWGRP | stat.S_IWOTH) == 0, oct(parent_mode) + + +def test_create_refuses_symlink_lock_and_preserves_victim(tmp_path: Path) -> None: + """End-to-end: a symlinked session.lock surfaces as SessionError, victim intact. + + Drives the real entry point (``SessionDir.create``, as ``_worker.main`` + calls it) so the OSError ``acquire_session_lock`` raises is shown to be + translated to the domain ``SessionError`` every other session refusal uses, + and the victim file -- the actual security property -- survives unchanged. + """ + victim = tmp_path / "victim" + payload = b"do-not-truncate-this-file\n" + victim.write_bytes(payload) + work = tmp_path / "work" + work.mkdir() + (work / "session.lock").symlink_to(victim) + with pytest.raises(SessionError, match="cannot acquire session lock"): + SessionDir.create(supplied=str(work), base=tmp_path) + assert victim.read_bytes() == payload + + +def test_create_refuses_root_that_cannot_be_tightened( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A root whose write bits cannot be cleared is refused, not accepted. + + Two failure modes that both must surface as ``could not be tightened``: + + (a) ``fchmod`` itself raises (a read-only or ACL-locked filesystem). The + write bits must still be set afterwards -- a partial in-place change would + be a quiet security regression. + + (b) ``fchmod`` returns success but the bits survive (an ACL mask or exotic + filesystem that silently no-ops the call). This pins the re-stat: an + implementation that drops it would see the no-op ``fchmod`` succeed and + accept the still-writable root, shipping a guard that tightens nothing. + """ + # --- (a) fchmod raises; bits unchanged afterwards ----------------------- + supplied = tmp_path / "raise-root" + supplied.mkdir() + supplied.chmod(0o775) + root_ino = supplied.stat().st_ino + real_fchmod = os.fchmod + + def raising_fchmod(fd: int, mode: int) -> None: + if os.fstat(fd).st_ino == root_ino: + raise PermissionError(errno.EPERM, "simulated fchmod refusal") + return real_fchmod(fd, mode) + + monkeypatch.setattr(os, "fchmod", raising_fchmod) + with pytest.raises(SessionError, match="could not be tightened"): + SessionDir.create(supplied=str(supplied), base=tmp_path) + # Write bits must still be set: a partial in-place change would be a quiet + # security regression. + leftover = stat.S_IMODE(supplied.stat().st_mode) + assert leftover & (stat.S_IWGRP | stat.S_IWOTH) + monkeypatch.undo() # restore os.fchmod before variant (b) reuses the tree + + # --- (b) fchmod no-ops; forged re-fstat still reports the write bits ----- + supplied_b = tmp_path / "silent-root" + supplied_b.mkdir() + supplied_b.chmod(0o775) + root_ino_b = supplied_b.stat().st_ino + real_fstat = os.fstat + + def lying_fstat(fd: int) -> os.stat_result: + st = real_fstat(fd) + if st.st_ino == root_ino_b: + # Always report the group/other write bits as set, even after the + # real fchmod has cleared them on disk. + forced = st.st_mode | stat.S_IWGRP | stat.S_IWOTH + return os.stat_result( + ( + forced, + st.st_ino, + st.st_dev, + st.st_nlink, + st.st_uid, + st.st_gid, + st.st_size, + st.st_atime, + st.st_mtime, + st.st_ctime, + ) + ) + return st + + monkeypatch.setattr(os, "fstat", lying_fstat) + with pytest.raises(SessionError, match="could not be tightened"): + SessionDir.create(supplied=str(supplied_b), base=tmp_path) + + +def test_validated_path_rejects_symlinked_tunnel_data(tmp_path: Path) -> None: + """Materialization refuses a ``tunnel-data`` swapped for a symlink. + + ``_validated_path``'s ``path.resolve().parent != self._data.resolve()`` + check is a no-op when ``tunnel-data`` itself is the symlink (both sides + resolve through it), so the explicit ``is_symlink`` guard is what actually + keeps a patched kubeconfig out of attacker-controlled space. The property + under test is not just that an exception is raised but that nothing is + written into the symlink target -- a refusal that still wrote the file would + be no fix at all. + """ + supplied = tmp_path / "work" + supplied.mkdir() + sd = SessionDir.create(supplied=str(supplied), base=tmp_path) + data = Path(sd.session_dir) / "tunnel-data" + assert data.is_dir() + target = tmp_path / "attacker-sink" + target.mkdir() + shutil.rmtree(data) + data.symlink_to(target) + + with pytest.raises(SessionError, match="tunnel-data is a symlink"): + sd.materialize_atomic("hub-k3s", b"patched-kubeconfig-with-client_key_data") + + # The sink must still be empty: the patched kubeconfig never landed. + assert list(target.iterdir()) == [], "materialized bytes reached the symlink target" + sd.cleanup() diff --git a/tests/unit/test_stop_session.py b/tests/unit/test_stop_session.py new file mode 100644 index 0000000..957aba5 --- /dev/null +++ b/tests/unit/test_stop_session.py @@ -0,0 +1,357 @@ +"""The silent stop primitive. + +Validates: stop_session performs the stop and returns a StopOutcome for every +branch, and writes absolutely nothing to stdout or stderr — that silence is +what lets `run` keep fd 1 for the child while `stop` still prints its JSON. +Code: tunstrap/session.py +Assertion: each identity/kill scenario yields the documented StopOutcome, and +capsys shows empty out and err. +Method: monkeypatch session.verify_session and session.os.kill; no real +processes and no real signals, so this passes unchanged on macOS. + +These are the ``stop``-verb tests: the daemon is *not* a child of the stopping +process, so ``no_child_pid`` below pins ``os.waitpid`` to ECHILD for the whole +module; the grace-poll cases exercise the signal-0 fallback in ``_has_exited``, +but the non-positive-pid ``stop_session`` cases return at the entry guard +before any probe runs. The child topology — ``run``, where the daemon is a +Popen child and its pid survives its exit as a zombie — cannot be modelled with +a patched ``os.kill`` at all, and lives in test_stop_session_child.py against +a real process. +""" + +from __future__ import annotations + +import errno +import signal +from typing import Any + +import pytest + +from tunstrap import session as session_mod +from tunstrap.identity import IdentityCheckResult +from tunstrap.session import StopOutcome, stop_session + +pytestmark = pytest.mark.unit + +PID = 4242 +SESSION = "/nonexistent/session" + + +@pytest.fixture(autouse=True) +def no_child_pid(monkeypatch: pytest.MonkeyPatch) -> None: + """PID is nobody's child here; keep that explicit rather than accidental. + + Without this the real ``os.waitpid`` would run inside the test process, + which is both nondeterministic and, if PID ever were a live child of + pytest, actively harmful. + """ + + def _echild(_pid: int, _options: int) -> tuple[int, int]: + raise ChildProcessError(errno.ECHILD, "No child processes") + + monkeypatch.setattr(session_mod.os, "waitpid", _echild) + + +def _fixed_check(result: IdentityCheckResult) -> Any: + return lambda _session_dir, _pid: result + + +def _checks(*results: IdentityCheckResult) -> Any: + seq = list(results) + + def _check(_session_dir: str, _pid: int) -> IdentityCheckResult: + return seq.pop(0) if len(seq) > 1 else seq[0] + + return _check + + +@pytest.mark.parametrize( + "check, expected", + [ + (IdentityCheckResult.not_found, StopOutcome(False, "not found")), + (IdentityCheckResult.mismatch, StopOutcome(False, "identity mismatch")), + ( + IdentityCheckResult.unavailable, + StopOutcome(False, "identity check unavailable"), + ), + ], +) +def test_identity_branches( + monkeypatch: pytest.MonkeyPatch, + check: IdentityCheckResult, + expected: StopOutcome, +) -> None: + """A non-matching identity is reported and no signal is ever sent.""" + sent: list[int] = [] + monkeypatch.setattr(session_mod, "verify_session", _fixed_check(check)) + monkeypatch.setattr(session_mod.os, "kill", lambda _p, s: sent.append(s)) + assert stop_session(SESSION, PID, 10, force=True) == expected + assert sent == [], "must not signal a process it could not identify" + + +def test_sigterm_on_already_dead_process_is_stopped( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A ProcessLookupError on SIGTERM means it is already gone: stopped, unforced.""" + monkeypatch.setattr(session_mod, "verify_session", _fixed_check(IdentityCheckResult.match)) + calls: list[int] = [] + + def _kill(_pid: int, sig: int) -> None: + calls.append(sig) + raise ProcessLookupError + + monkeypatch.setattr(session_mod.os, "kill", _kill) + assert stop_session(SESSION, PID, 10, force=True) == StopOutcome(True) + assert calls == [signal.SIGTERM] + + +def test_exits_within_grace_is_stopped(monkeypatch: pytest.MonkeyPatch) -> None: + """A daemon that dies during the grace poll yields stopped=True, forced=False.""" + monkeypatch.setattr(session_mod, "verify_session", _fixed_check(IdentityCheckResult.match)) + calls: list[int] = [] + + def _kill(_pid: int, sig: int) -> None: + calls.append(sig) + if sig == 0: + raise ProcessLookupError + + monkeypatch.setattr(session_mod.os, "kill", _kill) + monkeypatch.setattr(session_mod.time, "sleep", lambda _s: None) + assert stop_session(SESSION, PID, 10, force=True) == StopOutcome(True) + assert calls[0] == signal.SIGTERM + assert signal.SIGKILL not in calls, "must not escalate when the grace poll succeeded" + + +def test_grace_poll_uses_strict_deadline_and_half_second_interval( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A completed poll sleeps 0.5 seconds and equality with the deadline ends grace.""" + monkeypatch.setattr(session_mod, "verify_session", _fixed_check(IdentityCheckResult.match)) + monotonic_values = iter([0.0, 0.0, 1.0]) + monkeypatch.setattr(session_mod.time, "monotonic", lambda: next(monotonic_values)) + sleeps: list[float] = [] + monkeypatch.setattr(session_mod.time, "sleep", lambda seconds: sleeps.append(seconds)) + calls: list[int] = [] + monkeypatch.setattr(session_mod.os, "kill", lambda _pid, sig: calls.append(sig)) + + assert stop_session(SESSION, PID, 1, force=True) == StopOutcome(True, forced=True) + assert calls == [signal.SIGTERM, 0, signal.SIGKILL] + assert sleeps == [0.5] + + +def test_not_force_reports_still_alive(monkeypatch: pytest.MonkeyPatch) -> None: + """force=False after an expired grace reports 'still alive' and never SIGKILLs.""" + monkeypatch.setattr(session_mod, "verify_session", _fixed_check(IdentityCheckResult.match)) + calls: list[int] = [] + monkeypatch.setattr(session_mod.os, "kill", lambda _p, s: calls.append(s)) + assert stop_session(SESSION, PID, 0, force=False) == StopOutcome(False, "still alive") + assert calls == [signal.SIGTERM] + + +def test_identity_changed_during_grace(monkeypatch: pytest.MonkeyPatch) -> None: + """A pid recycled during grace is refused, not SIGKILLed.""" + monkeypatch.setattr( + session_mod, + "verify_session", + _checks(IdentityCheckResult.match, IdentityCheckResult.mismatch), + ) + calls: list[int] = [] + monkeypatch.setattr(session_mod.os, "kill", lambda _p, s: calls.append(s)) + assert stop_session(SESSION, PID, 0, force=True) == StopOutcome( + False, "identity changed during grace" + ) + assert calls == [signal.SIGTERM] + + +def test_forced_kill(monkeypatch: pytest.MonkeyPatch) -> None: + """A daemon that survives the grace and still owns the session is SIGKILLed.""" + monkeypatch.setattr(session_mod, "verify_session", _fixed_check(IdentityCheckResult.match)) + calls: list[int] = [] + monkeypatch.setattr(session_mod.os, "kill", lambda _p, s: calls.append(s)) + assert stop_session(SESSION, PID, 0, force=True) == StopOutcome(True, forced=True) + assert calls == [signal.SIGTERM, signal.SIGKILL] + + +def test_reaped_child_ends_the_poll_even_though_signal_zero_succeeds( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The zombie case, stated as a unit: waitpid decides, signal 0 does not. + + ``os.kill`` here never raises, which is precisely how an unreaped child + behaves — its pid answers signal 0 until somebody collects it. Revert + ``_has_exited`` to a bare ``os.kill(pid, 0)`` and this poll runs to the + deadline and escalates, so the outcome becomes ``StopOutcome(True, + forced=True)`` and the SIGKILL assertion fails too. + """ + monkeypatch.setattr(session_mod, "verify_session", _fixed_check(IdentityCheckResult.match)) + calls: list[int] = [] + monkeypatch.setattr(session_mod.os, "kill", lambda _p, s: calls.append(s)) + monkeypatch.setattr(session_mod.os, "waitpid", lambda pid, _options: (pid, 0)) + monkeypatch.setattr(session_mod.time, "sleep", lambda _s: None) + + assert stop_session(SESSION, PID, 10, force=True) == StopOutcome(True) + assert calls == [signal.SIGTERM], "a reaped child must not be escalated to SIGKILL" + + +def test_still_running_child_keeps_polling(monkeypatch: pytest.MonkeyPatch) -> None: + """waitpid returning 0 means "not yet", not "gone". + + Fails if the reap is written as a bare ``os.waitpid(...)`` whose return + value is discarded: the very first poll would then report success and the + outcome would lose its ``forced=True``. + """ + monkeypatch.setattr(session_mod, "verify_session", _fixed_check(IdentityCheckResult.match)) + monotonic_values = iter([0.0, 0.0, 1.0]) + monkeypatch.setattr(session_mod.time, "monotonic", lambda: next(monotonic_values)) + monkeypatch.setattr(session_mod.time, "sleep", lambda _s: None) + calls: list[int] = [] + monkeypatch.setattr(session_mod.os, "kill", lambda _p, s: calls.append(s)) + monkeypatch.setattr(session_mod.os, "waitpid", lambda _pid, _options: (0, 0)) + + assert stop_session(SESSION, PID, 1, force=True) == StopOutcome(True, forced=True) + assert calls == [signal.SIGTERM, signal.SIGKILL] + + +def test_waitpid_failure_falls_back_instead_of_escaping( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A waitpid error is absorbed and the signal-0 probe still runs. + + stop_session raising would short-circuit ``_teardown_run_inner`` before it + removes tunnel-data, so ``run`` would leak the session on a kernel-level + oddity. Delete the ``except OSError`` arm and this test errors out with the + OSError instead of comparing an outcome; narrow the arm to + ``ChildProcessError`` and it errors the same way. + """ + monkeypatch.setattr(session_mod, "verify_session", _fixed_check(IdentityCheckResult.match)) + monotonic_values = iter([0.0, 0.0, 1.0]) + monkeypatch.setattr(session_mod.time, "monotonic", lambda: next(monotonic_values)) + monkeypatch.setattr(session_mod.time, "sleep", lambda _s: None) + calls: list[int] = [] + monkeypatch.setattr(session_mod.os, "kill", lambda _p, s: calls.append(s)) + + def _einval(_pid: int, _options: int) -> tuple[int, int]: + raise OSError(errno.EINVAL, "Invalid argument") + + monkeypatch.setattr(session_mod.os, "waitpid", _einval) + + assert stop_session(SESSION, PID, 1, force=True) == StopOutcome(True, forced=True) + assert calls == [signal.SIGTERM, 0, signal.SIGKILL], "the signal-0 probe must still run" + + +@pytest.mark.parametrize("pid", [0, -1], ids=["zero", "minus-one"]) +def test_non_positive_pid_never_reaches_waitpid(monkeypatch: pytest.MonkeyPatch, pid: int) -> None: + """A corrupt daemon.pid must not turn the reap into a process-group wait. + + Targets ``_has_exited`` directly rather than routing through + ``stop_session``: ``_has_exited`` is a module-level function a future + caller could reach without ``stop_session``'s entry guard, so its own + ``pid > 0`` guard is pinned here independently. The two guards protect + different syscalls — ``_has_exited``'s keeps a non-positive pid off + ``waitpid`` (0 reaps any child in the caller's group, a negative pid a + child group, letting a corrupt ``daemon.pid`` steal ``run``'s foreground + child and its exit status), while ``stop_session``'s entry guard keeps it + off ``os.kill`` and ``verify_session``. Drop ``_has_exited``'s ``pid > 0`` + guard and the recorder below captures the call. + + The contract for a non-positive pid is ``False``: the reap is skipped, and + the signal-0 fallback — a no-op stand-in here so no real process group is + touched — raises nothing, so no exit is ever proven. ``True`` is reserved + for a *proven* exit: a successful reap, or ``ProcessLookupError`` from the + probe. ``False`` is the fail-safe direction, because a ``True`` would let + the grace poll report a daemon stopped that was never identified. Real + ``os.kill(0, 0)`` and ``os.kill(-1, 0)`` succeed on any live host, so the + stand-in matches the host's answer rather than inventing one. + """ + waited: list[int] = [] + monkeypatch.setattr( + session_mod.os, + "waitpid", + lambda _pid, _options: (waited.append(_pid), (_pid, 0))[1], + ) + monkeypatch.setattr(session_mod.os, "kill", lambda _p, _s: None) + + assert session_mod._has_exited(pid) is False + assert waited == [], "waitpid must never be handed a pid that selects a process group" + + +@pytest.mark.parametrize("pid", [0, -1], ids=["zero", "minus-one"]) +def test_stop_session_never_signals_a_non_positive_pid( + monkeypatch: pytest.MonkeyPatch, pid: int +) -> None: + """A non-positive pid is refused at ``stop_session``'s entry, before any signal. + + ``os.kill(-1, SIGTERM)`` delivers the signal to every process the caller can + address; ``os.kill(0, SIGTERM)`` to the whole process group. That is the + blast radius of a corrupt ``daemon.pid`` once it reaches the kill path. + ``read_identity`` is the gate that keeps such a value out in production, but + ``stop_session`` re-asserts ``pid > 0`` at its entry — before + ``verify_session``'s own probe and before any real signal — so a direct + caller that bypasses ``read_identity`` still cannot widen a signal. The + outcome is unresolved (``identity check unavailable``), so the caller + preserves rather than deletes, matching ``read_identity``'s disposal for the + same value. + + ``verify_session`` is pinned permissive even though the entry guard returns + before it: it documents that the guard does not lean on the gate below it. + The recorder captures ``(pid, sig)`` so a variant that signals the wrong pid + is caught alongside one that signals at all. + """ + monkeypatch.setattr(session_mod, "verify_session", _fixed_check(IdentityCheckResult.match)) + monkeypatch.setattr(session_mod.time, "sleep", lambda _s: None) + sent: list[tuple[int, int]] = [] + monkeypatch.setattr(session_mod.os, "kill", lambda p, sig: sent.append((p, sig))) + + outcome = stop_session(SESSION, pid, 1, force=True) + + assert sent == [], f"os.kill was called for pid={pid}: {sent}" + assert outcome == StopOutcome(False, "identity check unavailable") + + +def test_stop_session_writes_nothing( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """The primitive is silent on every branch: that is its whole purpose.""" + for check in IdentityCheckResult: + monkeypatch.setattr(session_mod, "verify_session", _fixed_check(check)) + monkeypatch.setattr(session_mod.os, "kill", lambda _p, _s: None) + stop_session(SESSION, PID, 0, force=True) + + def _already_dead(_pid: int, _sig: int) -> None: + raise ProcessLookupError + + monkeypatch.setattr(session_mod, "verify_session", _fixed_check(IdentityCheckResult.match)) + monkeypatch.setattr(session_mod.os, "kill", _already_dead) + stop_session(SESSION, PID, 0, force=True) + + def _dies_during_grace(_pid: int, sig: int) -> None: + if sig == 0: + raise ProcessLookupError + + monkeypatch.setattr(session_mod, "verify_session", _fixed_check(IdentityCheckResult.match)) + monkeypatch.setattr(session_mod.os, "kill", _dies_during_grace) + monkeypatch.setattr(session_mod.time, "sleep", lambda _s: None) + stop_session(SESSION, PID, 10, force=True) + + monkeypatch.setattr(session_mod, "verify_session", _fixed_check(IdentityCheckResult.match)) + monkeypatch.setattr(session_mod.os, "kill", lambda _p, _s: None) + stop_session(SESSION, PID, 0, force=False) + + monkeypatch.setattr( + session_mod, + "verify_session", + _checks(IdentityCheckResult.match, IdentityCheckResult.mismatch), + ) + stop_session(SESSION, PID, 0, force=True) + + def _dies_on_sigkill(_pid: int, sig: int) -> None: + if sig == signal.SIGKILL: + raise ProcessLookupError + + monkeypatch.setattr(session_mod, "verify_session", _fixed_check(IdentityCheckResult.match)) + monkeypatch.setattr(session_mod.os, "kill", _dies_on_sigkill) + stop_session(SESSION, PID, 0, force=True) + + captured = capsys.readouterr() + assert captured.out == "", f"stop_session wrote to stdout: {captured.out!r}" + assert captured.err == "", f"stop_session wrote to stderr: {captured.err!r}" diff --git a/tests/unit/test_stop_session_child.py b/tests/unit/test_stop_session_child.py new file mode 100644 index 0000000..968e95d --- /dev/null +++ b/tests/unit/test_stop_session_child.py @@ -0,0 +1,124 @@ +"""``stop_session`` against a real, unreaped child process. + +Validates: when the daemon is a child of the stopping process — which is +exactly ``run``'s topology, since ``spawn_daemon`` uses ``subprocess.Popen`` +and never waits — a clean SIGTERM shutdown is detected within the grace +window and reported as the unforced success ``StopOutcome(True)``. + +Why this file exists: ``test_stop_session.py`` monkeypatches ``os.kill``, so +a zombie is unrepresentable there. It cannot distinguish "the process is +gone" from "the process exited but its pid is still allocated because nobody +reaped it", and that distinction *was* the bug: ``os.kill(zombie, 0)`` +succeeds, so the poll ran the full 10s grace and then reported +``identity changed during grace`` on every successful ``run``. + +Code: tunstrap/session.py (_has_exited, stop_session) +Assertion: the call returns StopOutcome(True) and takes far less than the +grace window it was given. +Method: a real child that takes the session flock and dies on SIGTERM, +started with ``subprocess.Popen`` and deliberately never waited on, so the +kernel really does leave a zombie behind — no mocking at any layer. + +How these fail if the defect returns: drop the reap from ``_has_exited`` and +``os.kill(pid, 0)`` keeps succeeding against the zombie for the whole grace +window. ``test_clean_child_stop_is_prompt`` then blows its 5s budget against +a 10s grace, and ``test_clean_child_stop_reports_unforced_success`` gets +``StopOutcome(False, "identity changed during grace")`` because the post-grace +re-check finds the flock released. Both assertions are load-bearing. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import time +from pathlib import Path +from typing import Iterator + +import pytest + +from tunstrap.identity import IdentityCheckResult, verify_session +from tunstrap.session import StopOutcome, stop_session + +pytestmark = pytest.mark.unit + +# Long enough that burning it is unmistakable, short enough that a regression +# does not stall the suite for a minute. +GRACE = 10 +# A healthy stop costs one 0.5s poll interval; it is sleep-bound, not CPU-bound, +# so this budget holds on a slow runner while staying far below GRACE. +BUDGET = 5.0 + + +def _spawn_lock_holder(session_dir: Path) -> subprocess.Popen[bytes]: + """Child holding session.lock that exits cleanly on SIGTERM. + + Never waited on by the test, so after it dies it stays in the process + table as a zombie child of pytest — the exact condition ``run``'s CLI + creates for the daemon. + """ + code = ( + "import sys, signal, time;" + "from tunstrap.identity import acquire_session_lock;" + "acquire_session_lock(sys.argv[1]);" + "signal.signal(signal.SIGTERM, lambda *_a: sys.exit(0));" + "print('locked', flush=True);" + "time.sleep(60)" + ) + proc = subprocess.Popen( + [sys.executable, "-c", code, str(session_dir)], + stdout=subprocess.PIPE, + ) + assert proc.stdout is not None + assert proc.stdout.readline() == b"locked\n" + return proc + + +@pytest.fixture(name="lock_holder") +def _lock_holder(tmp_path: Path) -> Iterator[subprocess.Popen[bytes]]: + proc = _spawn_lock_holder(tmp_path) + # Guard the premise: if identity did not verify, every assertion below + # would pass or fail for the wrong reason. + assert verify_session(tmp_path, proc.pid) == IdentityCheckResult.match + try: + yield proc + finally: + proc.kill() + # poll() rather than wait(): stop_session has normally reaped the pid + # already, and poll() turns the resulting ECHILD into a returncode + # instead of blocking or raising. + proc.poll() + if proc.returncode is None: # pragma: no cover - only on a failed stop + proc.wait(timeout=10) + if proc.stdout is not None: + proc.stdout.close() + + +def test_clean_child_stop_is_prompt(tmp_path: Path, lock_holder: subprocess.Popen[bytes]) -> None: + """Stopping a child that exits on SIGTERM does not wait out the grace window.""" + started = time.monotonic() + stop_session(str(tmp_path), lock_holder.pid, GRACE, force=True) + elapsed = time.monotonic() - started + assert elapsed < BUDGET, f"stop_session burned {elapsed:.1f}s of a {GRACE}s grace window" + + +def test_clean_child_stop_reports_unforced_success( + tmp_path: Path, lock_holder: subprocess.Popen[bytes] +) -> None: + """The in-grace success branch is reachable: no reason, no SIGKILL escalation.""" + outcome = stop_session(str(tmp_path), lock_holder.pid, GRACE, force=True) + assert outcome == StopOutcome(True), f"expected a clean stop, got {outcome}" + + +def test_stopped_child_pid_is_reaped(tmp_path: Path, lock_holder: subprocess.Popen[bytes]) -> None: + """The pid is released, not merely observed: no zombie survives the stop. + + Fails if ``_has_exited`` is rewritten to detect the exit without reaping + (reading /proc state, say), which would leave the process-table entry — and + the pid it pins — in place: a second ``waitpid`` would then return the pid + instead of raising ECHILD. + """ + stop_session(str(tmp_path), lock_holder.pid, GRACE, force=True) + with pytest.raises(ChildProcessError): + os.waitpid(lock_holder.pid, os.WNOHANG) diff --git a/tests/unit/test_tofu_proxy.py b/tests/unit/test_tofu_proxy.py new file mode 100644 index 0000000..7cdb6a4 --- /dev/null +++ b/tests/unit/test_tofu_proxy.py @@ -0,0 +1,532 @@ +"""``tunstrap_tofu`` console-entry unit tests. + +Covers the three branches of the proxy (`tunstrap/tofu_proxy.py`): + 1. pass-through when ``TUNSTRAP_INPUT`` is unset/empty, + 2. pass-through for no-cluster subcommands (``init``/``version``/…), with the + ``-chdir`` gap fixed by parsing argv past global flags, + 3. the tunnelled branch, which reuses ``run``'s hardened path in-process with + ``KUBECONFIG`` suppressed so a broken ``config_path`` chain cannot fall + back to an inherited or injected value. + +Also guards the cost discipline: importing the proxy module must not pull in +``tunstrap.cli`` or any heavy dependency, so the pass-through branches pay only +interpreter startup plus the package ``__init__``. + +No cluster, docker or network: ``spawn_daemon``/``Popen``/``_teardown_run`` are +monkeypatched exactly as in ``test_cli_run_output_var.py``, and ``os.execvp`` +is intercepted so the pass-through branches never actually replace the process. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest + +import tunstrap.tofu_proxy as proxy +from tests.unit.conftest import cleaning_teardown +from tunstrap import cli as cli_mod +from tunstrap.run_invocation import run_via_env_input + +pytestmark = pytest.mark.unit + +VAR = "TUNSTRAP_INPUT" + + +class _ExecvpCalled(Exception): + """Sentinel raised by the fake execvp so main() does not continue past it.""" + + +def _node(**overrides: Any) -> dict[str, Any]: + base: dict[str, Any] = { + "host": "h.example.net", + "user": "u", + "ssh_password": "p", + "remote_targets": {"db": "127.0.0.1:5432"}, + } + base.update(overrides) + return base + + +def _payload(nodes: dict[str, Any] | None = None) -> str: + return json.dumps({"nodes": nodes if nodes is not None else {"node": _node()}}) + + +def _conn(**ports: int) -> dict[str, Any]: + return {"ports": dict(ports), "fetch_files": {}, "kube_targets": {}} + + +def _success(connections: dict[str, Any], *, session_dir: str) -> dict[str, Any]: + return { + "kind": "success", + "payload": { + "connections": connections, + "pid": 99, + "session_dir": session_dir, + "started_at": "2026-07-31T00:00:00Z", + }, + } + + +class FakePopen: + last_env: dict[str, str] | None = None + + def __init__(self, cmd: list[str], env: dict[str, str] | None = None) -> None: + FakePopen.last_env = env + self.returncode = 0 + + def wait(self) -> int: + return self.returncode + + def send_signal(self, signum: int) -> None: + """Accept forwarded signals; the fake child ignores them.""" + + +@pytest.fixture(name="capturing_execvp") +def _capturing_execvp(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]: + """Replace ``os.execvp`` with a recorder that raises instead of replacing us. + + Records the ``argv`` list (whose ``[0]`` is conventionally the program name, + so it already carries ``tofu``); ``prog`` is constant and asserted separately + by ``test_exec_tofu_calls_os_execvp_with_tofu_argv``. + """ + seen: list[list[str]] = [] + + def _fake_execvp(prog: str, argv: list[str]) -> None: + del prog # asserted in the dedicated _exec_tofu test + seen.append(list(argv)) + raise _ExecvpCalled("execvp") + + # Patch the ``os`` reference the proxy module looks ``execvp`` up on. + monkeypatch.setattr(proxy.os, "execvp", _fake_execvp) + return seen + + +@pytest.fixture(name="spawn") +def _spawn(monkeypatch: pytest.MonkeyPatch) -> list[Any]: + """Install the run-path stubs (spawn_daemon/Popen/_teardown_run) shared with + the output-var suite, so the tunnelled branch can be exercised without a + real daemon or child.""" + seen: list[Any] = [] + + def _install(message: dict[str, Any]) -> None: + def _spawn_daemon( + schema: Any, session_dir: str | None = None, *, input_env: str | None = None + ) -> dict[str, Any]: + seen.append(schema) + return message + + monkeypatch.setattr(cli_mod, "spawn_daemon", _spawn_daemon) + + monkeypatch.setattr(cli_mod.subprocess, "Popen", FakePopen) + monkeypatch.setattr(cli_mod, "_teardown_run", cleaning_teardown) + FakePopen.last_env = None + seen.append(_install) # seen[0] is the installer; schemas follow + return seen + + +def _run_main(argv: list[str]) -> None: + """Invoke the proxy entry with a controlled argv (prog name is arbitrary).""" + sys.argv = ["tunstrap_tofu", *argv] + proxy.main() + + +# --------------------------------------------------------------------------- # +# Branch 1: pass-through when TUNSTRAP_INPUT is unset / empty / whitespace. +# --------------------------------------------------------------------------- # + + +def test_passthrough_when_input_env_unset_execs_tofu( + monkeypatch: pytest.MonkeyPatch, capturing_execvp: list[list[str]] +) -> None: + """No payload → the proxy must exec tofu untouched and never reach run.""" + monkeypatch.delenv(VAR, raising=False) + with pytest.raises(_ExecvpCalled): + _run_main(["plan", "-out=x"]) + assert capturing_execvp == [["tofu", "plan", "-out=x"]] + + +def test_passthrough_when_input_env_empty_execs_tofu( + monkeypatch: pytest.MonkeyPatch, capturing_execvp: list[list[str]] +) -> None: + monkeypatch.setenv(VAR, "") + with pytest.raises(_ExecvpCalled): + _run_main(["plan"]) + assert capturing_execvp == [["tofu", "plan"]] + + +def test_passthrough_when_input_env_whitespace_execs_tofu( + monkeypatch: pytest.MonkeyPatch, capturing_execvp: list[list[str]] +) -> None: + monkeypatch.setenv(VAR, " \n\t ") + with pytest.raises(_ExecvpCalled): + _run_main(["plan"]) + assert capturing_execvp == [["tofu", "plan"]] + + +# --------------------------------------------------------------------------- # +# Branch 2: pass-through for no-cluster subcommands, with the -chdir gap fixed. +# --------------------------------------------------------------------------- # + + +# The bypass set the proxy ships, pinned exhaustively. Behaviour must match the +# shell shim (``case "$1" in init|-version)``) plus the no-cluster extras +# (``version`` subcommand, ``-help``, no subcommand), so that everything else +# TUNNELS when TUNSTRAP_INPUT is set. Tunelling everything-not-bypassed is the +# load-bearing half: TUNSTRAP_INPUT is set only for commands the consumer +# deliberately listed in Terragrunt's ``commands``, so the proxy must honour a +# deliberate opt-in (e.g. ``output`` — the e2e tier at test_terragrunt_apply.py +# lists it and asserts the tunnelled row) rather than second-guess it with an +# allow-list of its own. +_BYPASS_CASES = [ + (["init"], "auto-init shares the plan command's env_vars (measured fact 4)"), + (["version"], "version subcommand (modern tofu); no cluster contact"), + (["-version"], "version flag prints and exits; no cluster contact"), + (["-help"], "help prints and exits; no cluster contact"), + ([], "no subcommand prints help; no cluster contact"), + (["validate"], "validate checks against installed provider schemas only; no cluster"), + (["fmt"], "fmt touches only local .tf files; no cluster"), + # The documented gap the shell shim could not close (its ``case "$1"`` saw + # ``-chdir`` as the first token, so it tunnelled a needless init): + (["-chdir=somewhere", "init"], "-chdir=DIR init: shell matched $1 only"), + (["-chdir", "somewhere", "init"], "-chdir DIR init (space form)"), + (["--chdir=somewhere", "init"], "long --chdir=DIR init"), + (["-chdir=somewhere", "-version"], "-chdir=DIR -version"), +] +_TUNNEL_CASES = [ + # The provider-API commands the recipe enumerates as needing a tunnel: + "plan", + "apply", + "destroy", + "refresh", + "import", + "console", + # Commands that read state/files only — these tunnel too, NOT because they + # need the cluster, but because TUNSTRAP_INPUT being set means the CONSUMER + # asked for them (Terragrunt's ``commands`` list is the authority). The + # proxy must not veto that: + "output", + "show", + "state", + "taint", + "untaint", + "providers", + "test", + # An unknown subcommand tunnels rather than guesses — failing loudly inside + # run/tofu beats silently bypassing something the consumer opted into. + "weird-unknown-cmd", +] + + +@pytest.mark.parametrize(("argv", "_why"), _BYPASS_CASES) +def test_should_bypass_returns_true_for_the_pinned_bypass_set(argv: list[str], _why: str) -> None: + """Every bypass row bypasses (decided structurally, past global flags).""" + del _why + # pylint: disable=protected-access + assert proxy._should_bypass(argv) is True + + +@pytest.mark.parametrize("cmd", _TUNNEL_CASES) +def test_should_bypass_returns_false_for_everything_else(cmd: str) -> None: + """Everything not in the bypass set tunnels — incl. consumer opt-ins. + + This is the row that caught the v1 allow-list defect: an allow-list of + ``{plan,apply,…}`` bypassed ``output``/``validate``/``test``, vetoing a + consumer that had deliberately listed them in Terragrunt's ``commands``. + """ + # pylint: disable=protected-access + assert proxy._should_bypass([cmd]) is False + + +def test_should_bypass_is_not_a_substring_match() -> None: + """``init`` as a flag value is consumed, not read as the subcommand.""" + # pylint: disable=protected-access + assert proxy._should_bypass(["-chdir", "init", "plan"]) is False + assert proxy._should_bypass(["-chdir=init", "plan"]) is False + + +@pytest.mark.parametrize(("argv", "_why"), _BYPASS_CASES) +def test_bypass_decision_execs_tofu( + monkeypatch: pytest.MonkeyPatch, + capturing_execvp: list[list[str]], + argv: list[str], + _why: str, +) -> None: + """A bypass-row argv with TUNSTRAP_INPUT set execs tofu untouched.""" + del _why + monkeypatch.setenv(VAR, _payload()) + with pytest.raises(_ExecvpCalled): + _run_main(argv) + assert capturing_execvp == [["tofu", *argv]] + + +@pytest.mark.parametrize("cmd", ["plan", "output", "test", "-chdir=x plan"]) +def test_tunnel_decision_runs_tofu_in_process( + monkeypatch: pytest.MonkeyPatch, + capturing_execvp: list[list[str]], + spawn: list[Any], + cmd: str, + tmp_path: Path, +) -> None: + """Everything outside the bypass set tunnels — honouring the consumer opt-in. + + ``output`` is the casualty that caught the v1 allow-list: the e2e tier lists + it in ``commands`` and asserts the tunnelled row, which an allow-list of + cluster-only commands would have bypassed. + """ + monkeypatch.setenv(VAR, _payload()) + spawn[0](_success({"node": _conn(db=5432)}, session_dir=str(tmp_path))) + with pytest.raises(SystemExit) as excinfo: # run_command exits with child code + _run_main(cmd.split()) + assert excinfo.value.code == 0 + assert capturing_execvp == [], "a tunnel-row command must not exec tofu directly" + + +# --------------------------------------------------------------------------- # +# Branch 3: the tunnelled path — in-process, KUBECONFIG suppressed. +# --------------------------------------------------------------------------- # + + +def test_tunnelled_invokes_run_with_input_env_and_output_var( + monkeypatch: pytest.MonkeyPatch, + capturing_execvp: list[list[str]], + spawn: list[Any], + tmp_path: Path, +) -> None: + """The proxy works even if Click's callback attribute is unavailable. + + The proxy is a programmatic caller, not a Click extension: it must use the + plain run implementation directly rather than smuggling a private control + flag through ``run_command.callback``. Replacing the callback with ``None`` + is the mutation this test rejects. + """ + monkeypatch.setattr(cli_mod.run_command, "callback", None) + monkeypatch.setenv(VAR, _payload()) + spawn[0](_success({"node": _conn(db=5432)}, session_dir=str(tmp_path))) + with pytest.raises(SystemExit): + _run_main(["plan"]) + assert capturing_execvp == [] + + +def test_tunnelled_suppresses_kubeconfig_in_child_env( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any], tmp_path: Path +) -> None: + """A single-node kube payload must NOT leave KUBECONFIG in tofu's env, but + MUST still carry KUBE_CONFIG_PATH -- Mode A's provider channel. + + ``suppress_kubeconfig`` drops only the injected KUBECONFIG (issue #14): + dropping KUBE_CONFIG_PATH/_PATHS too would make Mode A -- the plan-safe, + env-native kube delivery the recipe tells consumers to use through this + exact entry point -- unusable through the proxy. ``_build_child_env`` + always drops any inherited KUBECONFIG/KUBE_CONFIG_PATH/_PATHS before + injection, on both paths, so a stray operator environment can never + contribute to the child's kube channel either. + + This is the one place the assertion can fire: the input has one node with + a kube target, so ``render_kube_env`` WOULD inject KUBECONFIG; only + suppression removes it. + """ + # An operator-inherited KUBECONFIG would mask a missing suppression only + # if render_kube_env did not inject; here it does inject (kube target + # present), so the assertion catches both an inherited and an injected + # KUBECONFIG. Clear the inherited one to isolate the injected path. + monkeypatch.delenv("KUBECONFIG", raising=False) + monkeypatch.setenv( + VAR, + _payload({"node": _node(kube_targets={"k3s": {"kubeconfig_path": "/etc/k3s.yaml"}})}), + ) + # KubeTargetOutput requires the three credential fields; the projection drops + # them from TF_VAR_tunstrap, but the success envelope still carries them. + kube = { + "cluster_name": "c", + "context_name": "ctx", + "local_port": 41111, + "endpoint": "https://127.0.0.1:41111", + "tls_server_name": "node", + "certificate_authority_data": "Y2E=", + "client_certificate_data": "Y2VydA==", + "client_key_data": "a2V5", + "content_b64": "a3ViZWNvbmZpZw==", + "path": "/s/tunnel-data/node-k3s", + } + session_dir = str(tmp_path) + spawn[0]( + _success( + {"node": {"ports": {}, "fetch_files": {}, "kube_targets": {"k3s": kube}}}, + session_dir=session_dir, + ) + ) + with pytest.raises(SystemExit) as excinfo: + _run_main(["plan"]) + assert excinfo.value.code == 0 + assert FakePopen.last_env is not None + assert "KUBECONFIG" not in FakePopen.last_env, ( + "KUBECONFIG leaked into tofu's env: a broken config_path chain would " + "silently reach the cluster via this fallback" + ) + assert FakePopen.last_env["KUBE_CONFIG_PATH"] == kube["path"], ( + "KUBE_CONFIG_PATH must survive suppression: it is Mode A's provider " + "channel through tunstrap_tofu (issue #14)" + ) + # The structured channel the module decodes config_path from is still present. + assert "TF_VAR_tunstrap" in FakePopen.last_env + # The non-KUBECONFIG scalars are untouched (suppression is targeted). + assert FakePopen.last_env.get("TUNSTRAP_SESSION_DIR") == session_dir + + +def test_tunnelled_drops_an_inherited_kubeconfig_in_the_multi_node_case( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any], tmp_path: Path +) -> None: + """An operator-inherited KUBECONFIG is dropped in the multi-node case too. + + This fixture's connections are ports-only (no kube_targets), so + ``render_kube_env`` injects nothing to overwrite the inherited value -- + the removal observed here is ``_build_child_env``'s unconditional + pre-injection scrub of inherited KUBECONFIG/KUBE_CONFIG_PATH/_PATHS, not + the ``suppress_kubeconfig`` pop (that scrub fires regardless of + ``suppress_kubeconfig``, so this test would pass with it False too). The + parametrized ``test_inherited_kube_env_never_survives_even_without_kube_targets`` + in ``test_cli_run_output_var.py`` pins that property directly at the + ``_build_child_env`` level; this test is its proxy-path duplicate, + observed through the real ``tunstrap_tofu`` entry point end to end. + """ + # Inherited operator KUBECONFIG present. + monkeypatch.setenv("KUBECONFIG", "/tmp/operator/.kube/config-some-other-cluster") + monkeypatch.setenv(VAR, _payload({"a": _node(), "b": _node()})) + spawn[0](_success({"a": _conn(db=5432), "b": _conn(db=5433)}, session_dir=str(tmp_path))) + with pytest.raises(SystemExit) as excinfo: + _run_main(["plan"]) + assert excinfo.value.code == 0 + assert FakePopen.last_env is not None + assert "KUBECONFIG" not in FakePopen.last_env, ( + "inherited operator KUBECONFIG survived the multi-node path: a broken " + "chain could reach the operator's own cluster via this fallback" + ) + # Multi-node still gets the structured channel and the three session + # survivors, but no target-scoped scalar (there is no node dimension for + # TUNSTRAP__* to disambiguate, so none must ever reappear). + assert "TF_VAR_tunstrap" in FakePopen.last_env + survivors = {"TUNSTRAP_SESSION_DIR", "TUNSTRAP_PID", "TUNSTRAP_OUTPUT_FILE", VAR} + leaked_scalars = [ + k for k in FakePopen.last_env if k.startswith("TUNSTRAP_") and k not in survivors + ] + assert leaked_scalars == [] + + +def test_tunnelled_propagates_child_exit_code( + monkeypatch: pytest.MonkeyPatch, spawn: list[Any], tmp_path: Path +) -> None: + """The child's exit code reaches the caller verbatim (outside reserved set).""" + monkeypatch.setenv(VAR, _payload()) + spawn[0](_success({"node": _conn(db=5432)}, session_dir=str(tmp_path))) + FakePopen.last_env = None + + class _Exit42(FakePopen): + def __init__(self, cmd: list[str], env: dict[str, str] | None = None) -> None: + super().__init__(cmd, env) + self.returncode = 42 + + monkeypatch.setattr(cli_mod.subprocess, "Popen", _Exit42) + with pytest.raises(SystemExit) as excinfo: + _run_main(["plan"]) + assert excinfo.value.code == 42 + + +def test_run_via_env_input_preserves_the_exit_64_usage_contract( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A ``click.UsageError`` from ``run`` surfaces as exit 64, not a traceback. + + ``run_via_env_input`` calls the plain run implementation outside Click's group, + so the ``_UsageExit64`` wrapper that normally turns a ``UsageError`` into + exit 64 does not apply. Without an explicit guard the error propagates as a + raw traceback. Triggered here with an ``--output-var`` name that collides + with a key ``run`` injects (``KUBECONFIG`` for a kube-target payload): the + collision is detected pre-spawn, so no daemon is orphaned either. + """ + monkeypatch.setenv( + VAR, _payload({"node": _node(kube_targets={"k3s": {"kubeconfig_path": "/etc/k3s.yaml"}})}) + ) + + def _spawn_must_not_run(*args: object, **kwargs: object) -> object: + raise AssertionError("spawn_daemon must not be reached on a pre-spawn usage error") + + monkeypatch.setattr(cli_mod, "spawn_daemon", _spawn_must_not_run) + with pytest.raises(SystemExit) as excinfo: + run_via_env_input(VAR, "KUBECONFIG", ["true"]) # KUBECONFIG collides + assert excinfo.value.code == 64 + + +# --------------------------------------------------------------------------- # +# Cost discipline: importing the proxy must not pull in heavy deps. +# --------------------------------------------------------------------------- # + + +def test_importing_proxy_does_not_pull_in_cli_or_heavy_deps() -> None: + """The pass-through branches must pay no cli/click/pydantic/asyncssh import. + + Fresh interpreter, imports ONLY ``tunstrap.tofu_proxy``: none of the heavy + modules the tunnelled branch uses may be loaded. This is the deterministic + guard on the cost discipline; the measured per-invocation timing lives in + the task report. ``importlib.metadata`` is included so a regression to an + eager ``__version__`` lookup in ``tunstrap/__init__.py`` (which costs ~41 ms) + fails here too. + """ + blocked = { + "tunstrap.cli", + "click", + "pydantic", + "asyncssh", + "cryptography", + "ruamel", + "importlib.metadata", + } + script = ( + "import sys, tunstrap.tofu_proxy as p; " + f"loaded = sorted({blocked!r} & set(sys.modules)); " + "import json; print(json.dumps(loaded))" + ) + result = subprocess.run( + [sys.executable, "-c", script], capture_output=True, text=True, check=True + ) + loaded = json.loads(result.stdout) + msg = f"proxy module import pulled in heavy deps: {result.stdout}\nstderr: {result.stderr}" + assert loaded == [], msg + + +def test_exec_tofu_calls_os_execvp_with_tofu_argv( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The pass-through primitive execs ``tofu`` with the prog name prepended.""" + seen: dict[str, object] = {} + + def _fake(prog: str, argv: list[str]) -> None: + seen["prog"] = prog + seen["argv"] = list(argv) + raise _ExecvpCalled("execvp") # so the NoReturn helper does not sys.exit + + monkeypatch.setattr(proxy.os, "execvp", _fake) + with pytest.raises(_ExecvpCalled): + proxy._exec_tofu(["plan", "-out=x"]) # pylint: disable=protected-access + assert seen == {"prog": "tofu", "argv": ["tofu", "plan", "-out=x"]} + + +def test_exec_tofu_missing_binary_exits_127_without_stdout( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """A missing tofu is a shell-style launch failure, not a traceback.""" + + def _missing(_prog: str, _argv: list[str]) -> None: + raise FileNotFoundError("tofu not found") + + monkeypatch.setattr(proxy.os, "execvp", _missing) + with pytest.raises(SystemExit) as excinfo: + proxy._exec_tofu(["plan"]) # pylint: disable=protected-access + captured = capsys.readouterr() + assert excinfo.value.code == 127 + assert captured.out == "" + assert captured.err == "tunstrap_tofu: cannot execute tofu: tofu not found\n" diff --git a/tests/unit/test_tracked_documentation_paths.py b/tests/unit/test_tracked_documentation_paths.py new file mode 100644 index 0000000..b117115 --- /dev/null +++ b/tests/unit/test_tracked_documentation_paths.py @@ -0,0 +1,86 @@ +"""Reject unpublishable local paths and ignored-document references. + +Docs that need a literal local-home example may opt in with the HTML comment +````. The exception is limited +to that document and only exempts home-path examples; ignored references and +unresolved findings citations still fail. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.unit + +REPO_ROOT = Path(__file__).resolve().parents[2] +_HOME_PATH_RE = re.compile("/" + r"home/[^/\s]+/") +_ARTIFACTS_PATH = "docs" + "/artifacts/" +_DOC_PATH_RE = re.compile(r"(? list[Path]: + """Return every tracked regular file, relative to the repository root.""" + tracked = subprocess.run( + ["git", "ls-files", "-z"], + cwd=REPO_ROOT, + capture_output=True, + check=True, + ).stdout + return [REPO_ROOT / path for path in tracked.decode().split("\0") if path] + + +def _is_ignored(path: str) -> bool: + """Return whether Git's ignore rules exclude ``path``.""" + return ( + subprocess.run( + ["git", "check-ignore", "--no-index", "--quiet", "--", path], + cwd=REPO_ROOT, + check=False, + ).returncode + == 0 + ) + + +def _unresolved_references(content: str, tracked: set[Path]) -> set[str]: + """Find ignored path references and bare findings names absent from Git.""" + violations = { + reference for reference in _DOC_PATH_RE.findall(content) if _is_ignored(reference) + } + tracked_names = {path.name for path in tracked} + violations.update( + name for name in _FINDINGS_NAME_RE.findall(content) if name not in tracked_names + ) + return violations + + +def test_tracked_files_contain_no_local_home_paths_or_artifact_citations() -> None: + """Public tracked content cannot depend on local machines or ignored files.""" + violations: list[str] = [] + tracked = _tracked_files() + tracked_set = set(tracked) + for path in tracked: + try: + content = path.read_text() + except UnicodeDecodeError: + continue + relative_path = path.relative_to(REPO_ROOT) + allows_home_path = relative_path.parts[0] == "docs" and _ALLOW_HOME_PATH_MARKER in content + has_home_path = _HOME_PATH_RE.search(content) and not allows_home_path + has_artifact_citation = path.name != ".gitignore" and _ARTIFACTS_PATH in content + references = ( + set() if path.name == ".gitignore" else _unresolved_references(content, tracked_set) + ) + if has_home_path or has_artifact_citation or references: + violations.append(f"{relative_path}: {sorted(references)}") + + assert not violations, ( + "tracked files contain local-home paths, ignored-document citations, " + "or unresolved findings references: " + f"{violations}" + ) diff --git a/tunstrap/__init__.py b/tunstrap/__init__.py index b270180..1c9708d 100644 --- a/tunstrap/__init__.py +++ b/tunstrap/__init__.py @@ -1,12 +1,33 @@ -"""Public package entry point. Only ``__version__`` is exposed.""" +"""Public package entry point. Only ``__version__`` is exposed. + +``__version__`` is resolved lazily via PEP 562 ``__getattr__``: the +``importlib.metadata`` import (and its ``version("tunstrap")`` call) is deferred +until ``__version__`` is first read. Importing this package therefore does not +pay the ~41 ms ``importlib.metadata`` startup cost, which matters for the +``tunstrap_tofu`` pass-through branches — they ``execvp`` ``tofu`` without ever +reading ``__version__``, so they get the package import essentially free. + +The only consumer is the ``--version`` flag (``cli.py``), which resolves +``__version__`` on demand through a lazy callback. +""" from __future__ import annotations -from importlib.metadata import PackageNotFoundError, version +# ``__version__`` is provided dynamically by ``__getattr__`` below (PEP 562), +# so ``from tunstrap import *`` resolves it correctly at runtime; pylint's +# static check cannot see that, hence the targeted disable. +__all__ = ["__version__"] # pylint: disable=undefined-all-variable + -try: - __version__ = version("tunstrap") -except PackageNotFoundError: # source checkout without install - __version__ = "0.0.0+unknown" +def __getattr__(name: str) -> str: + """Resolve ``__version__`` lazily; reject any other attribute access.""" + if name == "__version__": + # Imported inside the getter so a plain ``import tunstrap`` never loads + # importlib.metadata. pylint: disable=import-outside-toplevel. + from importlib.metadata import PackageNotFoundError, version -__all__ = ["__version__"] + try: + return version("tunstrap") + except PackageNotFoundError: # source checkout without install + return "0.0.0+unknown" + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/tunstrap/_worker.py b/tunstrap/_worker.py index 2ab54e2..a4241e3 100644 --- a/tunstrap/_worker.py +++ b/tunstrap/_worker.py @@ -21,14 +21,16 @@ from tunstrap.activity import ActivityTracker from tunstrap.exceptions import DaemonError, SessionActive +from tunstrap.fdio import ShortWriteError, write_all from tunstrap.manager import TunnelManager -from tunstrap.schemas import ErrorOutput, InputSchema, OutputSchema +from tunstrap.schemas import ErrorOutput, InputSchema from tunstrap.session import SessionDir _SCHEMA_MAX_BYTES = 8 * 1024 * 1024 # 8 MiB is more than enough for any sane input def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse worker-only arguments so the public CLI remains the user interface.""" parser = argparse.ArgumentParser(prog="tunstrap._worker", add_help=False) parser.add_argument("--ipc-fd", type=int, required=True) parser.add_argument("--session-dir", default=None) @@ -65,12 +67,12 @@ def _read_schema_from_stdin() -> InputSchema: def _write_message(fd: int, message: dict[str, Any]) -> None: + """Finish partial writes so the parent receives a complete IPC frame.""" payload = (json.dumps(message) + "\n").encode("utf-8") - while payload: - written = os.write(fd, payload) - if written <= 0: - raise DaemonError("short write to IPC pipe", {"remaining": len(payload)}) - payload = payload[written:] + try: + write_all(fd, payload) + except ShortWriteError as exc: + raise DaemonError("short write to IPC pipe", {"remaining": exc.remaining}) from exc def _report_pre_run_failure(ipc_fd: int, exc: BaseException) -> None: @@ -87,6 +89,7 @@ def _report_pre_run_failure(ipc_fd: int, exc: BaseException) -> None: async def _run(args: argparse.Namespace, session: SessionDir) -> int: + """Run the detached daemon and return a status the parent can map reliably.""" try: schema = _read_schema_from_stdin() except (DaemonError, ValidationError, UnicodeDecodeError, json.JSONDecodeError) as exc: @@ -127,7 +130,9 @@ async def _run(args: argparse.Namespace, session: SessionDir) -> int: session.cleanup() return 2 - assert isinstance(result, OutputSchema) + # result is OutputSchema here: start_all_and_build_output returns + # OutputSchema | ErrorOutput, and the ErrorOutput branch above returns 2, + # so mypy narrows the union to OutputSchema without a runtime check. _write_message( args.ipc_fd, {"kind": "success", "payload": result.model_dump(mode="json")}, diff --git a/tunstrap/activity.py b/tunstrap/activity.py index ca5ed64..7b7b18d 100644 --- a/tunstrap/activity.py +++ b/tunstrap/activity.py @@ -33,6 +33,7 @@ class _IdleConnectionTracker(asyncssh.SSHPortForwardTracker): """ def __init__(self, aggregate: ActivityTracker) -> None: + """Bind each AsyncSSH tracker to its shared idle accounting state.""" self._aggregate = aggregate self._opened = False self._closed = False @@ -69,6 +70,7 @@ class ActivityTracker: """ def __init__(self) -> None: + """Start idle accounting at construction so an unused daemon can expire.""" self._active_count = 0 self._last_activity_at = time.monotonic() diff --git a/tunstrap/cli.py b/tunstrap/cli.py index 2883fc0..bc5c7e7 100644 --- a/tunstrap/cli.py +++ b/tunstrap/cli.py @@ -4,30 +4,51 @@ import json import os +import re import signal import subprocess import sys -import time -from typing import Callable, TypeVar +import tempfile +from collections.abc import Callable +from typing import Any, NoReturn, TypeVar import click -from pydantic import ValidationError -from tunstrap import __version__ -from tunstrap.cli_input import build_single_node_schema +from tunstrap.cli_input import ( + build_flag_schema, + build_schema_from_env, + build_start_schema, + connection_flags_present, +) from tunstrap.daemon import spawn_daemon -from tunstrap.envrender import format_exports, render_env +from tunstrap.envrender import ( + KUBE_ENV_NAMES, + RUN_ENV_KEYS, + format_exports, + materialized_output_path, + render_kube_env, + render_output_var, + render_start_json, + write_materialized_output, +) from tunstrap.exceptions import ( DaemonError, + DaemonHandshakeError, TunstrapError, - SchemaValidationError, exit_code_for, ) from tunstrap.identity import IdentityCheckResult, verify_session -from tunstrap.schemas import DaemonOptions, InputSchema, OutputSchema -from tunstrap.session import SessionDir, SessionError +from tunstrap.schemas import OutputSchema +from tunstrap.session import ( + SessionDir, + SessionError, + SessionIdentityUnreadable, + StopOutcome, + stop_session, +) _FC = TypeVar("_FC", bound=Callable[..., object]) +_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") class _UsageExit64(click.Group): @@ -41,6 +62,7 @@ class _UsageExit64(click.Group): """ def main(self, *args: object, **kwargs: object) -> object: # type: ignore[override] + """Force usage errors through the CLI's documented sysexits-compatible path.""" kwargs["standalone_mode"] = False try: return super().main(*args, **kwargs) # type: ignore[call-overload] @@ -49,8 +71,34 @@ def main(self, *args: object, **kwargs: object) -> object: # type: ignore[overr sys.exit(64) +def _show_version(ctx: click.Context, _param: click.Parameter, value: bool) -> None: + """Lazy ``--version`` callback: resolve ``__version__`` only when invoked. + + Replaces ``@click.version_option(__version__, ...)`` so importing ``cli`` + does not trigger ``importlib.metadata`` (the package ``__init__`` resolves + ``__version__`` lazily too). Every ``tunstrap`` invocation that does not pass + ``--version`` skips the cost entirely. + """ + if not value or ctx.resilient_parsing: + return + # ``__version__`` is provided dynamically by tunstrap/__init__.py's PEP 562 + # __getattr__; pylint cannot see it statically (false-positive E0611). + # pylint: disable-next=import-outside-toplevel,no-name-in-module + from tunstrap import __version__ + + click.echo(f"tunstrap, version {__version__}") + ctx.exit() + + @click.group(cls=_UsageExit64) -@click.version_option(__version__, prog_name="tunstrap") +@click.option( + "--version", + is_flag=True, + is_eager=True, + expose_value=False, + callback=_show_version, + help="Show the version and exit.", +) def main() -> None: """tunstrap: SSH tunnel manager for ephemeral environments.""" @@ -81,50 +129,100 @@ def _connection_options(func: _FC) -> _FC: return func -def _conn_flags_present( - *, - ssh_key: str | None, - ssh_key_passphrase: str | None, - ssh_password_stdin: bool, - targets: tuple[str, ...], - kube: tuple[str, ...], - fetch: tuple[str, ...], -) -> bool: - return any([ssh_key, ssh_key_passphrase, ssh_password_stdin, targets, kube, fetch]) +def _session_scalars(out: OutputSchema) -> dict[str, str]: + """The three survivor scalars, shared to avoid a second hardcoded copy.""" + return { + "TUNSTRAP_SESSION_DIR": out.session_dir, + "TUNSTRAP_PID": str(out.pid), + "TUNSTRAP_OUTPUT_FILE": materialized_output_path(out.session_dir), + } -def _schema_from_flags( - connection: str, - *, - ssh_key: str | None, - ssh_key_passphrase: str | None, - ssh_password_stdin: bool, - targets: tuple[str, ...], - kube: tuple[str, ...], - fetch: tuple[str, ...], - auto_stop_idle_seconds: int | None, - materialize: bool, - log_file: str | None, - force_materialize: bool = False, -) -> InputSchema: - ssh_password: str | None = None - if ssh_password_stdin: - ssh_password = sys.stdin.readline().rstrip("\n") - daemon = DaemonOptions( - auto_stop_idle_seconds=auto_stop_idle_seconds, - materialize=materialize or force_materialize, - log_file=log_file, - ) - return build_single_node_schema( - connection=connection, - ssh_key=ssh_key, - ssh_key_passphrase=ssh_key_passphrase, - ssh_password=ssh_password, - targets=targets, - kube=kube, - fetch=fetch, - daemon_opts=daemon, +def _emit_start_result(message: dict[str, Any], output_fmt: str) -> None: + """Write ``start``'s envelope to stdout, then exit with the mapped code. + Only ``--output env`` renders shell exports; ``write_materialized_output`` + writes ``output.json``. ``TUNSTRAP_OUTPUT_FILE`` has ``run``'s contract; + JSON projects on ``path is not None``. One session per daemon makes this + match ``daemon.materialize``; unmaterialized retain ``content_b64``; + success and unrecognised kinds return, so Click exits 0. + """ + kind = message["kind"] + if kind == "success" and output_fmt == "env": + out = OutputSchema.model_validate(message["payload"]) + write_materialized_output(out) + env = _session_scalars(out) + env.update(render_kube_env(out)) + sys.stdout.write(format_exports(env)) + elif kind == "success": + out = OutputSchema.model_validate(message["payload"]) + sys.stdout.write(json.dumps(render_start_json(out)) + "\n") + else: + sys.stdout.write(json.dumps(message["payload"]) + "\n") + sys.stdout.flush() + code = {"required_failure": 2, "daemon_error": 4, "session_active": 3}.get(kind) + if code is not None: + sys.exit(code) + + +def _start_recovery_handles(message: object) -> tuple[str, int] | None: + """Return usable handles from a success envelope that failed after spawning. + + These fields are read without validating the whole payload because payload + validation is itself one of the post-spawn operations that can fail. A + non-string path or non-positive/bool pid is not safe to print as a recovery + handle, so a malformed envelope retains the generic error contract. + """ + if not isinstance(message, dict) or message.get("kind") != "success": + return None + payload = message.get("payload") + if not isinstance(payload, dict): + return None + session_dir = payload.get("session_dir") + pid = payload.get("pid") + if ( + not isinstance(session_dir, str) + or not isinstance(pid, int) + or isinstance(pid, bool) + or pid <= 0 + ): + return None + return session_dir, pid + + +def _report_start_post_spawn_failure( + exc: BaseException, message: object, supplied_session_dir: str | None +) -> None: + """Report an output failure without discarding a live daemon's handles. + + ``start`` is detached and does not own teardown. For a success envelope, + the worker has already reported the authoritative root, so pre-minting adds + nothing here; it would only help on the handshake-failure path, which this + change does not address. + """ + details: dict[str, object] = {"type": type(exc).__name__} + handles = _start_recovery_handles(message) + if handles is not None: + session_dir, pid = handles + details["session_dir"] = session_dir + details["pid"] = pid + _warn_preserved( + session_dir, + f"output failed after daemon start: {type(exc).__name__}: {exc}", + None, + verb="start", + ) + elif supplied_session_dir is not None: + details["session_dir"] = supplied_session_dir + _warn_preserved( + supplied_session_dir, + f"output failed after daemon start: {type(exc).__name__}: {exc}", + None, + verb="start", + ) + sys.stdout.write( + json.dumps(DaemonError("unexpected failure during start", details).to_error_output()) + "\n" ) + sys.stdout.flush() @main.command("start") @@ -139,7 +237,7 @@ def _schema_from_flags( show_default=True, ) @click.option("--session-dir", "session_dir", default=None) -def start_command( # pylint: disable=too-many-arguments,too-many-branches,too-many-statements +def start_command( # pylint: disable=too-many-locals connection: str | None, extra: tuple[str, ...], ssh_key: str | None, @@ -158,73 +256,25 @@ def start_command( # pylint: disable=too-many-arguments,too-many-branches,too-m try: if extra: raise click.UsageError("`--` invokes a child command; use `tunstrap run ... -- CMD`") - conn_flags = _conn_flags_present( + schema = build_start_schema( + connection, ssh_key=ssh_key, ssh_key_passphrase=ssh_key_passphrase, ssh_password_stdin=ssh_password_stdin, targets=targets, kube=kube, fetch=fetch, + auto_stop_idle_seconds=auto_stop_idle_seconds, + materialize=materialize, + log_file=log_file, + output_fmt=output_fmt, ) - if connection is None and conn_flags: - raise click.UsageError("connection flags require a USER@HOST[:PORT] argument") - - if connection is not None: - # Flag mode: check that stdin is empty (conflict guard) - stdin_peek = sys.stdin.read() if not ssh_password_stdin else "" - if stdin_peek.strip(): - raise click.UsageError( - "cannot combine a connection argument with JSON on stdin; " - "use flags or stdin, not both" - ) - schema = _schema_from_flags( - connection, - ssh_key=ssh_key, - ssh_key_passphrase=ssh_key_passphrase, - ssh_password_stdin=ssh_password_stdin, - targets=targets, - kube=kube, - fetch=fetch, - auto_stop_idle_seconds=auto_stop_idle_seconds, - materialize=materialize, - log_file=log_file, - force_materialize=(output_fmt == "env"), - ) - else: - raw = sys.stdin.read() - if not raw.strip(): - raise SchemaValidationError( - "no input: provide USER@HOST[:PORT] or JSON on stdin", {} - ) - try: - payload = json.loads(raw) - except json.JSONDecodeError as exc: - raise SchemaValidationError( - "stdin is not valid JSON", {"position": exc.pos} - ) from exc - try: - schema = InputSchema.model_validate(payload) - except ValidationError as exc: - raise SchemaValidationError( - "input does not satisfy the InputSchema contract", - {"errors": json.loads(exc.json())}, - ) from exc - message = spawn_daemon(schema, session_dir=session_dir) - kind = message["kind"] - if kind == "success" and output_fmt == "env": - out = OutputSchema.model_validate(message["payload"]) - sys.stdout.write(format_exports(render_env(out))) - else: - sys.stdout.write(json.dumps(message["payload"]) + "\n") - sys.stdout.flush() - if kind == "required_failure": - sys.exit(2) - if kind == "daemon_error": + try: + _emit_start_result(message, output_fmt) + except Exception as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught + _report_start_post_spawn_failure(exc, message, session_dir) sys.exit(4) - if kind == "session_active": - sys.exit(3) - # kind == "success" → exit 0 (default) except click.UsageError: raise except TunstrapError as exc: @@ -247,14 +297,298 @@ def start_command( # pylint: disable=too-many-arguments,too-many-branches,too-m sys.exit(4) +def _split_run_args( + args: tuple[str, ...], *, input_env: str | None +) -> tuple[str | None, list[str]]: + """Split ``run``'s single variadic into (CONNECTION, child command). + + Click distributes post-``--`` tokens over the declared positionals in + order, so a separate CONNECTION positional binds the child's program name: + ``run --input-env X -- tofu plan`` yields ``connection='tofu'``. One + variadic split after parsing is the only surface that can express "no + connection" while keeping the documented ``run USER@HOST -- CMD`` form and + its integration tests working. + """ + connection: str | None = None + rest: tuple[str, ...] = args + if input_env is None: + if not args: + raise click.UsageError("run requires USER@HOST[:PORT] or --input-env VAR") + connection = args[0] + rest = args[1:] + cmd = list(rest) + # Click consumes the first `--` and only the first, so a doubled separator + # leaves a literal "--" at the head of the child command. + if cmd and cmd[0] == "--": + cmd = cmd[1:] + if not cmd: + raise click.UsageError("run requires a command: tunstrap run USER@HOST ... -- CMD [ARGS]") + return connection, cmd + + +def _validate_output_var(name: str) -> None: + """Reject an ``--output-var`` NAME that is invalid or would collide. + + Evaluated pre-spawn, because the output schema does not exist yet and a + usage error must never be able to orphan a daemon. Collision with an + unrelated inherited variable is a documented overwrite; only the keys + ``run`` itself injects or scrubs are protected. + """ + if not _ENV_NAME_RE.match(name): + raise click.UsageError(f"--output-var NAME must match [A-Za-z_][A-Za-z0-9_]*; got {name!r}") + if name in RUN_ENV_KEYS: + raise click.UsageError( + f"--output-var {name} collides with an environment key run already injects" + ) + + +def _reject_flags_under_input_env( + *, + conn_flags: bool, + auto_stop_idle_seconds: int | None, + grace_seconds_set: bool, + materialize: bool, + log_file: str | None, +) -> None: + """Every flag ``--input-env`` makes redundant is a usage error (64). + + Rejected rather than given a precedence order: the payload's ``daemon`` + block is complete and authoritative, so there must be exactly one place to + look when a tunnel misbehaves. The daemon flags need their own rule + because ``_connection_options`` attaches them but + ``cli_input.connection_flags_present`` deliberately excludes them. + """ + if conn_flags: + raise click.UsageError( + "--input-env supplies the full InputSchema; connection flags are redundant" + ) + if auto_stop_idle_seconds is not None: + raise click.UsageError( + "--auto-stop-idle-seconds conflicts with --input-env; " + "set daemon.auto_stop_idle_seconds in the payload" + ) + if grace_seconds_set: + raise click.UsageError( + "--grace-seconds conflicts with --input-env; " + "set daemon.shutdown_grace_seconds in the payload" + ) + if log_file is not None: + raise click.UsageError( + "--log-file conflicts with --input-env; set daemon.log_file in the payload" + ) + if materialize: + raise click.UsageError("--materialize conflicts with --input-env; run always materializes") + + +def _build_child_env( + output: OutputSchema, + *, + output_var: str | None, + input_env: str | None, + suppress_kubeconfig: bool = False, +) -> dict[str, str]: + """Inherited env, scrubbed of the input payload, plus the exported channels. + + Session scalars (``_session_scalars``) + kube channel are both unconditional on node count. + + ``input_env`` names the variable holding the InputSchema, whose ``ssh_pkey`` + is an SSH private key. ``run`` is the one component that knows this variable + is secret-bearing, and the child is ``tofu``, which hands its environment to + every provider plugin, ``external`` data source and ``local-exec`` + provisioner — so it is removed here. The removal happens *before* anything is + injected, so an operator who passes the same NAME to both flags gets the + projected output rather than the untouched secret restored under it. + + ``output_var`` carries ``render_output_var``'s projection, not the whole + envelope: its consumer persists the value into an OpenTofu plan file. + + ``suppress_kubeconfig`` drops only the *injected* ``KUBECONFIG``, keeping + ``KUBE_CONFIG_PATH``/``KUBE_CONFIG_PATHS`` -- Mode A's proxy channel. Providers + never read plain ``KUBECONFIG`` (measured in #15); the guard protects + ``kubectl``/``helm`` CLI children and ``local-exec`` provisioners. Inherited + names of all three are always dropped before injection, on both paths. + """ + child_env = dict(os.environ) + if input_env is not None: + child_env.pop(input_env, None) + for key in KUBE_ENV_NAMES: + child_env.pop(key, None) + child_env.update(_session_scalars(output)) + child_env.update(render_kube_env(output)) + if suppress_kubeconfig: + child_env.pop("KUBECONFIG", None) + if output_var is not None: + child_env[output_var] = render_output_var(output) + return child_env + + +def _mint_session_dir(session_dir: str | None) -> tuple[str, str | None]: + """Return (the session path to use, the root ``run`` minted, or ``None``). + + ``run`` must know the session path **before** spawning. When the caller + supplies none, the worker generates it + (``tunstrap/session.py::SessionDir``), so the + parent could only recover the path by parsing the success envelope — which + makes cleanup depend on the very object whose validation can fail. Minting + it here makes the path a precondition of spawning. + + ``SessionDir.create`` accepts a supplied absolute path, creates it 0700 + when absent, and when present requires it owned by the current user and + clears its group/other write bits -- so an empty pre-created directory + under any umask is valid worker input. A supplied path sets + ``generated=False``; ``run`` removes only the root it minted itself. + """ + if session_dir is not None: + return session_dir, None + minted = tempfile.mkdtemp(prefix="tunstrap-run-") + return minted, minted + + +def _discard_minted_root(minted_root: str | None) -> None: + """Remove a session root ``run`` minted but never spawned into. Never raises.""" + if minted_root is not None: + SessionDir.remove_root(minted_root) + + +def _fail_before_child(exc: TunstrapError) -> NoReturn: + """Report a ``run`` failure raised before the child started, then exit. + + stderr rather than stdout because under the tofu-proxy pattern fd 1 belongs + to the child; the exit code is the exception's mapped one, never a generic + failure code. Shared by all three pre-child handlers so that they differ + only in the cleanup each one owes. + """ + sys.stderr.write(json.dumps(exc.to_error_output()) + "\n") + sys.exit(exit_code_for(exc)) + + +def _report_unexpected(exc: BaseException) -> None: + """Report an unexpected post-spawn failure as DaemonError JSON on stderr. + + Mirrors ``start``'s top-level guard (``tunstrap/cli.py::start_command``) except for the + channel: under the tofu-proxy pattern fd 1 belongs to the child, so ``run`` + never writes a diagnostic to stdout. + """ + sys.stderr.write( + json.dumps( + DaemonError( + "unexpected failure during run", {"type": type(exc).__name__} + ).to_error_output() + ) + + "\n" + ) + + +def _run_child( + payload: Any, + cmd: list[str], + *, + output_var: str | None, + input_env: str | None, + suppress_kubeconfig: bool = False, +) -> int: + """Validate the success payload, materialize output.json, run the child. + + Every statement here runs inside ``_supervise_child``'s teardown ``try``, + including ``OutputSchema.model_validate`` — which is exactly the case an + earlier design left unguarded: a malformed success payload orphaned the + daemon, because the session path was recovered from that same payload. + Materialization runs before the child starts, so ``TUNSTRAP_OUTPUT_FILE`` + always names a file that already exists. + """ + out = OutputSchema.model_validate(payload) + write_materialized_output(out) + child_env = _build_child_env( + out, + output_var=output_var, + input_env=input_env, + suppress_kubeconfig=suppress_kubeconfig, + ) + try: + # Popen + .wait() (not subprocess.run) so SIGINT/SIGTERM can be + # forwarded to the child. Caught narrowly here, not around this whole + # function: an OSError from write_materialized_output above must not + # be misreported as "failed to launch command". + # pylint: disable-next=consider-using-with + proc = subprocess.Popen(cmd, env=child_env) + except OSError as exc: + sys.stderr.write(f"run: failed to launch command: {exc}\n") + return 127 + + def _forward(signum: int, _frame: object) -> None: + """Forward termination to the child so its process semantics remain visible.""" + try: + proc.send_signal(signum) + except ProcessLookupError: + pass + + signal.signal(signal.SIGINT, _forward) + signal.signal(signal.SIGTERM, _forward) + returncode = proc.wait() + if returncode < 0: + # Popen reports "killed by signal N" as -N, and sys.exit hands that to + # the OS, which truncates modulo 256 -- a SIGTERMed child surfaced as + # 241. 128+N is the shell convention every caller already reads out of + # $?, so a wrapped tofu killed by a signal reports 143, not 241. + return 128 - returncode + return returncode + + +def _supervise_child( # pylint: disable=too-many-arguments + payload: Any, + cmd: list[str], + *, + output_var: str | None, + input_env: str | None, + session_dir: str, + grace_seconds: int, + minted_root: str | None, + suppress_kubeconfig: bool = False, +) -> int: + """Own the whole post-spawn window; the daemon is stopped on every path. + + The ``try`` opens on the first statement and the caller invokes this with + nothing between it and a successful ``spawn_daemon`` — the payload is read + out of the envelope beforehand, because an argument expression is evaluated + in the caller and so would sit outside this window. Handlers are saved into + a list *inside* the ``try``, so even a failure capturing them leaves the + teardown reachable, and the restoration loop is nested in its own ``try`` + whose ``finally`` performs the teardown. + + No ``except OSError`` here: ``_run_child`` handles the one OSError this + window reports as "failed to launch command" (``Popen``) internally, so + any other OSError reaches ``run_command``'s generic handler as ``DaemonError`` + instead, not misattributed to the child command. + """ + saved: list[tuple[int, Any]] = [] + try: + for signum in (signal.SIGINT, signal.SIGTERM): + saved.append((signum, signal.getsignal(signum))) + return _run_child( + payload, + cmd, + output_var=output_var, + input_env=input_env, + suppress_kubeconfig=suppress_kubeconfig, + ) + finally: + try: + # A distinct name: `signum` above is bound to signal.Signals, and + # reusing it here for the plain int in `saved` fails mypy --strict. + for saved_signum, handler in saved: + signal.signal(saved_signum, handler) + finally: + _teardown_run(session_dir, grace_seconds, minted_root=minted_root) + + @main.command("run") -@click.argument("connection", required=True) @_connection_options +@click.option("--input-env", "input_env", default=None, metavar="VAR") +@click.option("--output-var", "output_var", default=None, metavar="NAME") @click.option("--session-dir", "session_dir", default=None) @click.option("--grace-seconds", "grace_seconds", type=int, default=10, show_default=True) -@click.argument("command", nargs=-1, type=click.UNPROCESSED) -def run_command( # pylint: disable=too-many-arguments,too-many-locals - connection: str, +@click.argument("args", nargs=-1, type=click.UNPROCESSED) +def run_command( # pylint: disable=too-many-locals,too-many-statements ssh_key: str | None, ssh_key_passphrase: str | None, ssh_password_stdin: bool, @@ -264,82 +598,343 @@ def run_command( # pylint: disable=too-many-arguments,too-many-locals auto_stop_idle_seconds: int | None, materialize: bool, log_file: str | None, + input_env: str | None, + output_var: str | None, session_dir: str | None, grace_seconds: int, - command: tuple[str, ...], + args: tuple[str, ...], ) -> None: - """Open a tunnel, run CMD with TUNSTRAP_*/KUBECONFIG injected, then tear down.""" - cmd = list(command) - if cmd and cmd[0] == "--": - cmd = cmd[1:] - if not cmd: - raise click.UsageError("run requires a command: tunstrap run USER@HOST ... -- CMD [ARGS]") + """Open a tunnel, run CMD with TUNSTRAP_*/KUBECONFIG injected, then tear down. + The Click command owns only CLI-shaped arguments. Keeping the operational + implementation plain lets programmatic callers share its checks and + cleanup without treating Click's callback attribute as an internal API. + """ + context = click.get_current_context(silent=True) + grace_seconds_set = ( + context is not None + and context.get_parameter_source("grace_seconds") != click.core.ParameterSource.DEFAULT + ) + _run_command( + ssh_key=ssh_key, + ssh_key_passphrase=ssh_key_passphrase, + ssh_password_stdin=ssh_password_stdin, + targets=targets, + kube=kube, + fetch=fetch, + auto_stop_idle_seconds=auto_stop_idle_seconds, + materialize=materialize, + log_file=log_file, + input_env=input_env, + output_var=output_var, + session_dir=session_dir, + grace_seconds=grace_seconds, + grace_seconds_set=grace_seconds_set, + args=args, + suppress_kubeconfig=False, + ) + + +def _run_command( # pylint: disable=too-many-locals,too-many-statements + ssh_key: str | None, + ssh_key_passphrase: str | None, + ssh_password_stdin: bool, + targets: tuple[str, ...], + kube: tuple[str, ...], + fetch: tuple[str, ...], + auto_stop_idle_seconds: int | None, + materialize: bool, + log_file: str | None, + input_env: str | None, + output_var: str | None, + session_dir: str | None, + grace_seconds: int, + grace_seconds_set: bool, + args: tuple[str, ...], + *, + suppress_kubeconfig: bool, +) -> None: + """Open a tunnel, run CMD with TUNSTRAP_*/KUBECONFIG injected, then tear down. + + Input is either a USER@HOST[:PORT] positional plus flags, or the complete + InputSchema JSON in the environment variable named by --input-env. `--` is + mandatory whenever the child command or any of its arguments begins with + `-`. + """ + connection, cmd = _split_run_args(args, input_env=input_env) try: - schema = _schema_from_flags( - connection, - ssh_key=ssh_key, - ssh_key_passphrase=ssh_key_passphrase, - ssh_password_stdin=ssh_password_stdin, - targets=targets, - kube=kube, - fetch=fetch, - auto_stop_idle_seconds=auto_stop_idle_seconds, - materialize=materialize, - log_file=log_file, - force_materialize=True, - ) - message = spawn_daemon(schema, session_dir=session_dir) + if input_env is not None: + _reject_flags_under_input_env( + conn_flags=connection_flags_present( + ssh_key=ssh_key, + ssh_key_passphrase=ssh_key_passphrase, + ssh_password_stdin=ssh_password_stdin, + targets=targets, + kube=kube, + fetch=fetch, + ), + auto_stop_idle_seconds=auto_stop_idle_seconds, + grace_seconds_set=grace_seconds_set, + materialize=materialize, + log_file=log_file, + ) + schema = build_schema_from_env(input_env) + grace_seconds = schema.daemon.shutdown_grace_seconds + # The one place `run` mutates the supplied schema. It is an + # invariant of the verb, not a flag precedence rule: + # render_kube_env needs a materialized kubeconfig path + # (envrender.py), and an unmaterialized target would hand + # --output-var consumers `path: null` and the kubernetes/helm + # providers an empty config_path. + schema.daemon.materialize = True + elif connection is not None: + schema = build_flag_schema( + connection, + ssh_key=ssh_key, + ssh_key_passphrase=ssh_key_passphrase, + ssh_password_stdin=ssh_password_stdin, + targets=targets, + kube=kube, + fetch=fetch, + auto_stop_idle_seconds=auto_stop_idle_seconds, + materialize=materialize, + log_file=log_file, + force_materialize=True, + ) + else: # pragma: no cover - _split_run_args already rejected this arity + raise click.UsageError("run requires USER@HOST[:PORT] or --input-env VAR") + if output_var is not None: + _validate_output_var(output_var) except TunstrapError as exc: - sys.stderr.write(json.dumps(exc.to_error_output()) + "\n") - sys.exit(exit_code_for(exc)) + # The validation window: nothing has been minted and nothing spawned, + # so there is nothing to clean up. A click.UsageError is unrelated to + # TunstrapError and passes this handler untouched. + _fail_before_child(exc) + + # The spawn window opens here. Minting is its first statement and the last + # before the spawn itself, so every check above ran before the first side + # effect, and both names below are bound on every path that can reach the + # handlers -- `spawn_daemon` is the only source of either exception. + session_path, minted_root = _mint_session_dir(session_dir) + try: + message = spawn_daemon(schema, session_dir=session_path, input_env=input_env) + except DaemonHandshakeError as exc: + # Parent-side, past the detach: `Popen` has already launched a worker, + # so one may be running and holding the session lock. Taking the + # worker-authored path below would delete a live daemon's directory and + # leave nothing able to stop it. + _teardown_run(session_path, grace_seconds, minted_root=minted_root) + _fail_before_child(exc) + except TunstrapError as exc: + # Worker-authored, or raised before the detach: nothing of ours is + # running, so there is only the empty minted directory to remove. + _discard_minted_root(minted_root) + _fail_before_child(exc) + + try: + kind = message["kind"] + payload = message["payload"] + except Exception as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught + # An envelope we cannot index leaves us unable to tell whether a worker + # is live, and an orphan is the one outcome this window exists to + # prevent — so tear down rather than guess. Harmless when no daemon + # ran: _teardown_run_inner simply finds no recorded pid. + _teardown_run(session_path, grace_seconds, minted_root=minted_root) + _report_unexpected(exc) + sys.exit(4) - kind = message["kind"] if kind != "success": - sys.stderr.write(json.dumps(message["payload"]) + "\n") + # No daemon of ours is running on these paths, and session_active means + # the pid under the session dir belongs to somebody else's live + # session, so teardown here would stop a daemon we do not own. + _discard_minted_root(minted_root) + sys.stderr.write(json.dumps(payload) + "\n") sys.exit({"required_failure": 2, "session_active": 3, "daemon_error": 4}.get(kind, 4)) - out = OutputSchema.model_validate(message["payload"]) - child_env = {**os.environ, **render_env(out)} - resolved_session_dir = out.session_dir + # Nothing whatsoever between a successful spawn and the try that owns + # teardown: _supervise_child opens it on its first statement. + try: + returncode = _supervise_child( + payload, + cmd, + output_var=output_var, + input_env=input_env, + session_dir=session_path, + grace_seconds=grace_seconds, + minted_root=minted_root, + suppress_kubeconfig=suppress_kubeconfig, + ) + except TunstrapError as exc: + # An expected outcome keeps its own exit code via exit_code_for, not + # the generic exit 4 below. Nothing in the post-spawn window raises a + # TunstrapError today; this is future-proofing for one that does. + sys.stderr.write(json.dumps(exc.to_error_output()) + "\n") + returncode = exit_code_for(exc) + except Exception as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught + # Teardown has already run in _supervise_child's finally. + _report_unexpected(exc) + returncode = 4 + sys.exit(returncode) - prev_int = signal.getsignal(signal.SIGINT) - prev_term = signal.getsignal(signal.SIGTERM) + +def _teardown_run(session_dir: str, grace_seconds: int, *, minted_root: str | None) -> None: + """Stop the daemon and clean up without propagating any exception or using stdout. + + Under the tofu-proxy pattern fd 1 belongs to the child, so every teardown + diagnostic is attempted on stderr and none of them changes the exit code: + a child that ran and returned 7 still exits 7, even if teardown or its + diagnostic is interrupted. + + A raising teardown preserves the session data, exactly as a reported stop + failure does, and for a stronger reason: ``StopOutcome(False, …)`` means we + know the daemon survived, while an exception — ``stop_session`` failing on + a recycled pid, or a second Ctrl-C landing inside the grace poll — means we + know nothing about its state. Removing the root would take the identity + file with it and leave a possibly-live daemon nobody can find. Nothing + else here can realistically raise: ``read_identity`` raises only + ``SessionError`` and its ``SessionIdentityUnreadable`` subclass, both of + which the inner function handles by name, and ``cleanup_path`` and + ``remove_root`` are non-raising by construction (``session.py:_rmtree_reporting``). + """ try: - # Popen + .wait() (not subprocess.run) so SIGINT/SIGTERM can be - # forwarded to the child while it runs in the foreground. - proc = subprocess.Popen( # noqa: SIM115 # pylint: disable=consider-using-with - cmd, env=child_env + _teardown_run_inner(session_dir, grace_seconds, minted_root=minted_root) + except BaseException as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught + _warn_preserved(session_dir, f"teardown failed: {type(exc).__name__}: {exc}", minted_root) + + +def _warn_preserved( + session_dir: str, cause: str, minted_root: str | None, *, verb: str = "run" +) -> None: + """Report an unresolved teardown and the command that finishes it by hand. + + One wording for both ways teardown can end without a confirmed stop — a + reported failure and a raised one — because they carry the same operator + consequence: a daemon that may still be running, whose identity file under + ``session_dir`` is the only handle on it, so the data is kept rather than + deleted. + + The command is exactly what ``stop`` accepts: ``--session-dir`` and + ``--grace-seconds``, nothing else. ``stop`` already forces unconditionally + (``stop_command`` passes ``force=True``), so there is no ``--force`` to + offer — and a diagnostic naming a flag that does not exist is worse than + none, because the operator follows it and gets a usage error. + + ``stop`` removes ``tunnel-data`` but never its own ``--session-dir`` + argument, which is normally the operator's own directory; it cannot tell a + caller-minted temp root from one somebody cares about. The caller that + minted a root supplies it here, so the disposal note is emitted only then. + """ + recovery = f"{verb}: {cause}; preserving session data. Recover with: " + recovery += f"tunstrap stop --session-dir {session_dir}\n" + if minted_root is not None: + recovery += ( + f"{verb}: {minted_root} was created by {verb} and is not removed by that " + f"command; delete it once the daemon is dealt with\n" ) + _warn(recovery) - def _forward(signum: int, _frame: object) -> None: - try: - proc.send_signal(signum) - except ProcessLookupError: - pass - signal.signal(signal.SIGINT, _forward) - signal.signal(signal.SIGTERM, _forward) - returncode = proc.wait() - except OSError as exc: - sys.stderr.write(f"run: failed to launch command: {exc}\n") - returncode = 127 - finally: - signal.signal(signal.SIGINT, prev_int) - signal.signal(signal.SIGTERM, prev_term) - _teardown_run(resolved_session_dir, grace_seconds) - sys.exit(returncode) +def _warn(message: str) -> None: + """Attempt a teardown diagnostic without allowing a closed stderr to escape.""" + try: + sys.stderr.write(message) + except BaseException: # noqa: BLE001, S110 # pylint: disable=broad-exception-caught + pass -def _teardown_run(session_dir: str, grace_seconds: int) -> None: - """Stop the daemon for session_dir and remove its tunnel-data. Best-effort.""" +def _teardown_run_inner(session_dir: str, grace_seconds: int, *, minted_root: str | None) -> None: + """Stop the daemon, remove tunnel-data and any minted root; report on stderr.""" try: pid = SessionDir.read_identity(session_dir) - except SessionError: - SessionDir.cleanup_path(session_dir) + except SessionIdentityUnreadable as exc: + # Something is recorded there and we cannot address it: no pid means no + # stop, and no way to prove nothing is running. Identical unknown state + # to a failed stop, so identical answer — preserve rather than delete. + # Must precede the SessionError arm; it is a subclass of it. + _warn_preserved(session_dir, f"cannot read the daemon identity: {exc}", minted_root) return - _kill_with_identity(pid, grace_seconds, force=True, session_dir=session_dir) - SessionDir.cleanup_path(session_dir) + except SessionError: + # No identity file at all: nothing to stop. Not an error — with the + # session path minted before the spawn, a missing identity no longer + # means the path is unknown, it means the daemon never recorded one. + pass + else: + outcome = stop_session(session_dir, pid, grace_seconds, force=True) + if not _stop_resolved(outcome): + _warn_preserved( + session_dir, f"daemon not stopped cleanly: {outcome.reason}", minted_root + ) + return + survivors = SessionDir.cleanup_path(session_dir) + if survivors: + _warn("run: could not remove: " + ", ".join(survivors) + "\n") + if minted_root is not None: + remaining = SessionDir.remove_root(minted_root) + if remaining: + _warn("run: could not remove session root: " + ", ".join(remaining) + "\n") + + +def _stop_resolved(outcome: StopOutcome) -> bool: + """True when the daemon is known to be gone, so its session data is safe to delete. + + The single expression of that rule. ``run``'s teardown and ``stop`` both + have to decide it, and stating it twice is how they drift — which is + exactly what happened: ``_teardown_run_inner`` preserved on an unresolved + outcome while ``stop`` deleted unconditionally, so following the recovery + command ``run`` prints destroyed the identity file the preservation existed + to keep. + + ``"not found"`` is a *resolved* outcome, not a failure: it means no daemon + is recorded as running, which is the normal shape when auto-stop-idle + already fired. Everything else with ``stopped=False`` leaves the daemon's + state unknown. + """ + return outcome.stopped or outcome.reason == "not found" + + +def _stop_outcome_json(outcome: StopOutcome) -> str: + """Render a StopOutcome as ``stop``'s documented stdout JSON, key for key. + + Key order and omission rules are a public contract, pinned byte for byte + across all seven outcomes by ``tests/unit/test_cli_stop_output.py``: + ``stopped`` first, then ``reason`` when there is one, then ``forced`` only + when True, then ``preserved`` only when the session data was kept. + + ``preserved`` is additive and omitted when false, so every previously + emitted shape — including the most-parsed ``{"stopped": true}`` — is + byte-identical to before. It is here rather than left for the caller to + infer because the rule is not derivable without string-matching + ``reason`` against ``"not found"``, and a caller that has to replicate an + internal reason string to learn whether state is still on disk is a caller + we have set up to break. + """ + body: dict[str, object] = {"stopped": outcome.stopped} + if outcome.reason is not None: + body["reason"] = outcome.reason + if outcome.forced: + body["forced"] = True + if not _stop_resolved(outcome): + body["preserved"] = True + return json.dumps(body) + + +def _emit_stop_outcome(outcome: StopOutcome, session_dir: str) -> None: + """Write ``stop``'s envelope on stdout, plus the stderr notice when data was kept. + + Both of ``stop``'s exits report through here, so an outcome cannot be + reported without the signal that belongs to it. The identity-read failures + used to render their own JSON literal inline, which is precisely how they + ended up preserving ``tunnel-data`` while emitting no ``preserved`` key — + a caller reading the envelope concluded the directory had been cleaned. + """ + sys.stdout.write(_stop_outcome_json(outcome)) + sys.stdout.write("\n") + sys.stdout.flush() + if not _stop_resolved(outcome): + _warn( + f"tunstrap stop: daemon not stopped: {outcome.reason}; " + f"session data preserved under {session_dir}\n" + ) @main.command("stop") @@ -350,12 +945,21 @@ def stop_command(session_dir: str, grace_seconds: int) -> None: try: pid = SessionDir.read_identity(session_dir) except SessionError as exc: - sys.stdout.write(json.dumps({"stopped": False, "reason": str(exc)})) - sys.stdout.write("\n") - sys.stdout.flush() - sys.exit(0) - _kill_with_identity(pid, grace_seconds, force=True, session_dir=session_dir) - SessionDir.cleanup_path(session_dir) + # All three identity-read failures — missing, unreadable, malformed — + # return before cleanup, so all three preserve and all three must say + # so. Deliberately not the split ``run`` makes: there + # ``SessionIdentityUnreadable`` decides whether to delete, while here + # nothing is deleted either way. + _emit_stop_outcome(StopOutcome(False, str(exc)), session_dir) + sys.exit(1) + outcome = stop_session(session_dir, pid, grace_seconds, force=True) + _emit_stop_outcome(outcome, session_dir) + if _stop_resolved(outcome): + # Deleting on an unresolved outcome would make the recovery command + # ``run`` prints destroy the identity file it was invoked to recover. + SessionDir.cleanup_path(session_dir) + return + sys.exit(1) @main.command("status") @@ -373,69 +977,5 @@ def status_command(session_dir: str) -> None: sys.stdout.flush() -def _kill_with_identity( # pylint: disable=too-many-return-statements - pid: int, grace_seconds: int, *, force: bool, session_dir: str -) -> bool: - check = verify_session(session_dir, pid) - if check == IdentityCheckResult.not_found: - sys.stdout.write(json.dumps({"stopped": False, "reason": "not found"})) - sys.stdout.write("\n") - sys.stdout.flush() - return False - if check == IdentityCheckResult.mismatch: - sys.stdout.write(json.dumps({"stopped": False, "reason": "identity mismatch"})) - sys.stdout.write("\n") - sys.stdout.flush() - return False - if check == IdentityCheckResult.unavailable: - sys.stdout.write(json.dumps({"stopped": False, "reason": "identity check unavailable"})) - sys.stdout.write("\n") - sys.stdout.flush() - return False - - try: - os.kill(pid, signal.SIGTERM) - except ProcessLookupError: - sys.stdout.write(json.dumps({"stopped": True})) - sys.stdout.write("\n") - sys.stdout.flush() - return True - - deadline = time.monotonic() + max(0, grace_seconds) - while time.monotonic() < deadline: - try: - os.kill(pid, 0) - except ProcessLookupError: - sys.stdout.write(json.dumps({"stopped": True})) - sys.stdout.write("\n") - sys.stdout.flush() - return True - time.sleep(0.5) - - if not force: - sys.stdout.write(json.dumps({"stopped": False, "reason": "still alive"})) - sys.stdout.write("\n") - sys.stdout.flush() - return False - - recheck = verify_session(session_dir, pid) - if recheck != IdentityCheckResult.match: - sys.stdout.write(json.dumps({"stopped": False, "reason": "identity changed during grace"})) - sys.stdout.write("\n") - sys.stdout.flush() - return False - try: - os.kill(pid, signal.SIGKILL) - except ProcessLookupError: - sys.stdout.write(json.dumps({"stopped": True})) - sys.stdout.write("\n") - sys.stdout.flush() - return True - sys.stdout.write(json.dumps({"stopped": True, "forced": True})) - sys.stdout.write("\n") - sys.stdout.flush() - return True - - if __name__ == "__main__": # pragma: no cover main() diff --git a/tunstrap/cli_input.py b/tunstrap/cli_input.py index f3ebf22..f8f08e9 100644 --- a/tunstrap/cli_input.py +++ b/tunstrap/cli_input.py @@ -1,10 +1,22 @@ -"""Build a single-node InputSchema from CLI flags (issue #6).""" +"""Build an InputSchema from the CLI's input channels (issue #6). + +One module per input channel would be three modules for one concern. Its five +``build_*`` functions support the three channels a caller can supply an +``InputSchema`` through — connection flags, stdin JSON, and an environment +variable. ``build_start_schema`` is instead the ``start`` verb's selector +between its flags and stdin channels. Schema decoding and validation failures +raise ``SchemaValidationError`` (exit 1); that selector raises +``click.UsageError`` (exit 64) for incompatible channel combinations. +""" from __future__ import annotations import json +import os +import sys from pathlib import Path +import click from pydantic import ValidationError from tunstrap.exceptions import SchemaValidationError @@ -21,6 +33,7 @@ def parse_endpoint(endpoint: str) -> tuple[str, str, int]: def _split_host_port(hostpart: str, original: str) -> tuple[str, int]: + """Split literals before validation can report IPv6 and port errors precisely.""" if hostpart.startswith("["): # IPv6 literal: [addr] or [addr]:port end = hostpart.find("]") if end == -1: @@ -43,6 +56,7 @@ def _split_host_port(hostpart: str, original: str) -> tuple[str, int]: def _parse_port(raw: str, original: str) -> int: + """Reject invalid ports before SSH receives an unusable endpoint.""" try: port = int(raw) except ValueError as exc: @@ -69,6 +83,19 @@ def parse_named(items: tuple[str, ...], label: str) -> dict[str, str]: return out +def connection_flags_present( + *, + ssh_key: str | None, + ssh_key_passphrase: str | None, + ssh_password_stdin: bool, + targets: tuple[str, ...], + kube: tuple[str, ...], + fetch: tuple[str, ...], +) -> bool: + """Detect connection flags before channel selection can cause side effects.""" + return any([ssh_key, ssh_key_passphrase, ssh_password_stdin, targets, kube, fetch]) + + def build_single_node_schema( *, connection: str, @@ -119,5 +146,160 @@ def build_single_node_schema( ) except ValidationError as exc: raise SchemaValidationError( - "CLI input does not satisfy the schema", {"errors": json.loads(exc.json())} + "CLI input does not satisfy the schema", + {"errors": exc.errors(include_input=False, include_url=False, include_context=False)}, + ) from exc + + +def build_flag_schema( + connection: str, + *, + ssh_key: str | None, + ssh_key_passphrase: str | None, + ssh_password_stdin: bool, + targets: tuple[str, ...], + kube: tuple[str, ...], + fetch: tuple[str, ...], + auto_stop_idle_seconds: int | None, + materialize: bool, + log_file: str | None, + force_materialize: bool = False, +) -> InputSchema: + """Assemble the flag-mode ``InputSchema`` straight from Click's parameters. + + ``--ssh-password-stdin`` is read here rather than by the caller because the + password is one of the assembled fields, and reading it anywhere else would + put a second consumer on the same stdin the conflict guard inspects. + ``force_materialize`` is the verb-level override (``run`` always, ``start + --output env``); it can only turn materialization on, never off. + """ + ssh_password: str | None = None + if ssh_password_stdin: + ssh_password = sys.stdin.readline().rstrip("\n") + daemon = DaemonOptions( + auto_stop_idle_seconds=auto_stop_idle_seconds, + materialize=materialize or force_materialize, + log_file=log_file, + ) + return build_single_node_schema( + connection=connection, + ssh_key=ssh_key, + ssh_key_passphrase=ssh_key_passphrase, + ssh_password=ssh_password, + targets=targets, + kube=kube, + fetch=fetch, + daemon_opts=daemon, + ) + + +def build_start_schema( + connection: str | None, + *, + ssh_key: str | None, + ssh_key_passphrase: str | None, + ssh_password_stdin: bool, + targets: tuple[str, ...], + kube: tuple[str, ...], + fetch: tuple[str, ...], + auto_stop_idle_seconds: int | None, + materialize: bool, + log_file: str | None, + output_fmt: str, +) -> InputSchema: + """Build ``start`` input from its exclusive flags and stdin channels. + + ``--output env`` requires materialized kube paths in either channel. + ``--ssh-password-stdin`` exclusively owns stdin when flag input is used. + """ + if connection is None: + if connection_flags_present( + ssh_key=ssh_key, + ssh_key_passphrase=ssh_key_passphrase, + ssh_password_stdin=ssh_password_stdin, + targets=targets, + kube=kube, + fetch=fetch, + ): + raise click.UsageError("connection flags require a USER@HOST[:PORT] argument") + schema = build_schema_from_stdin(sys.stdin.read()) + if output_fmt == "env": + schema.daemon.materialize = True + return schema + + if not ssh_password_stdin and sys.stdin.read().strip(): + raise click.UsageError( + "cannot combine a connection argument with JSON on stdin; use flags or stdin, not both" + ) + return build_flag_schema( + connection, + ssh_key=ssh_key, + ssh_key_passphrase=ssh_key_passphrase, + ssh_password_stdin=ssh_password_stdin, + targets=targets, + kube=kube, + fetch=fetch, + auto_stop_idle_seconds=auto_stop_idle_seconds, + materialize=materialize, + log_file=log_file, + force_materialize=(output_fmt == "env"), + ) + + +def build_schema_from_stdin(raw: str) -> InputSchema: + """Decode and validate an ``InputSchema`` from ``start``'s stdin payload. + + The stdin twin of :func:`build_schema_from_env`, with the same three + failure shapes (absent, undecodable, contract violation) and the same + mandatory ``include_input=False`` scrub — a malformed node's offending + ``input`` is the ``ssh_pkey`` PEM itself. + """ + if not raw.strip(): + raise SchemaValidationError("no input: provide USER@HOST[:PORT] or JSON on stdin", {}) + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: + raise SchemaValidationError("stdin is not valid JSON", {"position": exc.pos}) from exc + try: + return InputSchema.model_validate(payload) + except ValidationError as exc: + raise SchemaValidationError( + "input does not satisfy the InputSchema contract", + {"errors": exc.errors(include_input=False, include_url=False, include_context=False)}, + ) from exc + + +def build_schema_from_env(var_name: str) -> InputSchema: + """Read, JSON-decode and validate an ``InputSchema`` from ``os.environ[var_name]``. + + ``run`` owns a child that inherits stdin, so stdin is unavailable to it as + a control channel; the environment is the remaining out-of-band input a + parent has. Every failure is a ``SchemaValidationError`` (exit 1) with the + same three shapes ``start``'s stdin path produces. + + ``exc.errors(include_input=False, ...)`` is mandatory: pydantic v2 error + entries embed the offending ``input``, which for a malformed node is the + ``ssh_pkey`` PEM itself, and ``TunstrapError._scrub`` cannot reach it. + """ + raw = os.environ.get(var_name, "") + if not raw.strip(): + raise SchemaValidationError( + f"environment variable {var_name} is unset or empty", {"var": var_name} + ) + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: + raise SchemaValidationError( + f"environment variable {var_name} is not valid JSON", + {"var": var_name, "position": exc.pos}, + ) from exc + try: + return InputSchema.model_validate(payload) + except ValidationError as exc: + raise SchemaValidationError( + "input does not satisfy the InputSchema contract", + { + "var": var_name, + "errors": exc.errors(include_input=False, include_url=False, include_context=False), + }, ) from exc diff --git a/tunstrap/daemon.py b/tunstrap/daemon.py index b7ff736..4e6c7dc 100644 --- a/tunstrap/daemon.py +++ b/tunstrap/daemon.py @@ -4,11 +4,13 @@ import json import os +import select import subprocess import sys +import time from typing import IO, Any -from tunstrap.exceptions import DaemonError +from tunstrap.exceptions import DaemonHandshakeError, DaemonHandshakeTimeoutError from tunstrap.schemas import InputSchema @@ -22,23 +24,61 @@ def _open_log_target(path: str | None) -> int | IO[bytes]: """ if path is None: return subprocess.DEVNULL - return open(path, "ab", buffering=0) # noqa: SIM115 # closed by caller + return open(path, "ab", buffering=0) # closed by caller + + +def _worker_env(input_env: str | None) -> dict[str, str]: + """Copy the parent environment minus the one variable known to be secret-bearing. + + ``input_env`` is the name ``--input-env`` was actually given, not a + literal: the option takes an arbitrary name, so a scrub keyed on + ``TUNSTRAP_INPUT`` would leave the SSH private key PEM in the environment + of a long-lived detached process for every other name. Mirrors + ``cli._build_child_env``, which scrubs the same name from ``tofu``'s + environment — one rule, both children. + + **Deliberately a filtered copy, not a minimal environment.** The worker + receives its schema over stdin and needs nothing else *from tunstrap*, but + it is still an ordinary process in the operator's session: it resolves + imports through ``PYTHONPATH``, may authenticate through ``SSH_AUTH_SOCK``, + and reaches the network under the operator's proxy and CA-bundle settings. + Handing it a minimal environment would trade a bounded, demonstrated leak + for an unbounded set of setups that silently stop working. It would also + buy no privilege boundary: worker and parent run as the same uid, and + ``/proc//environ`` is owner-readable, so anyone who can read the + worker's environment can already read the parent's. The named variable is + different in kind — it is tunstrap's own injected secret, and tunstrap is + the only component that knows it is secret-bearing. + """ + env = dict(os.environ) + if input_env is not None: + env.pop(input_env, None) + return env -def spawn_daemon(schema: InputSchema, session_dir: str | None = None) -> dict[str, Any]: +def spawn_daemon( + schema: InputSchema, session_dir: str | None = None, *, input_env: str | None = None +) -> dict[str, Any]: """Spawn the worker, send the schema, read the IPC response, return it. - Returns the structured IPC message for any of the three worker outcomes: - ``success``, ``required_failure``, ``daemon_error``. Callers dispatch on - ``message["kind"]`` and map to CLI exit codes. - - Raises ``DaemonError`` only when the parent itself cannot complete the - handshake (empty pipe, malformed JSON, unknown kind). + Returns the structured IPC message for any of the four worker outcomes: + ``success``, ``required_failure``, ``daemon_error``, ``session_active``. + Callers dispatch on ``message["kind"]`` and map to CLI exit codes. Those + are worker-authored: the worker cleaned up after itself before writing the + frame, so no daemon of ours survives them. + + **This function is not atomic, and the seam is ``Popen``.** Before it, no + worker exists and a failure leaves nothing behind. After it, the worker is + launched and detached — so every failure from there on is *parent-side* and + raises ``DaemonHandshakeError``, telling the caller a daemon may be running + and must be stopped rather than abandoned. Callers that treat any spawn + failure as "no daemon exists" orphan the worker on exactly those paths. """ + worker_env = _worker_env(input_env) ipc_read_fd, ipc_write_fd = os.pipe() log_target = _open_log_target(schema.daemon.log_file) try: - proc = subprocess.Popen( # noqa: SIM115 # pylint: disable=consider-using-with # detached; never wait()ed + proc = subprocess.Popen( # pylint: disable=consider-using-with # detached; never wait()ed [ sys.executable, "-m", @@ -52,6 +92,7 @@ def spawn_daemon(schema: InputSchema, session_dir: str | None = None) -> dict[st pass_fds=[ipc_write_fd], start_new_session=True, close_fds=True, + env=worker_env, ) finally: os.close(ipc_write_fd) @@ -61,7 +102,15 @@ def spawn_daemon(schema: InputSchema, session_dir: str | None = None) -> dict[st else: log_target.close() - assert proc.stdin is not None + # Everything below this line runs with a detached worker already alive. + if proc.stdin is None: + # Unreachable with stdin=PIPE, but it must not be an `assert`: that is + # an AssertionError, which is outside TunstrapError and so escapes the + # CLI's handler as a traceback, and `python -O` erases the check + # altogether, leaving an AttributeError on the next line instead. + os.close(ipc_read_fd) + raise DaemonHandshakeError("worker stdin pipe unavailable", {}) + try: proc.stdin.write(schema.model_dump_json().encode("utf-8")) proc.stdin.close() @@ -71,26 +120,107 @@ def spawn_daemon(schema: InputSchema, session_dir: str | None = None) -> dict[st proc.stdin = None _ = exc # discarded; we surface via the IPC read path below - return _read_ipc_response(ipc_read_fd) + return _read_ipc_response( + ipc_read_fd, + proc, + timeout=schema.daemon.startup_timeout_seconds, + reap_timeout=schema.daemon.shutdown_grace_seconds, + ) -def _read_ipc_response(read_fd: int) -> dict[str, Any]: - """Block on the IPC pipe until EOF, parse, and return the message.""" +def _kill_timed_out_worker(proc: subprocess.Popen[bytes], reap_timeout: int) -> bool: + """Escalate a timed-out termination to SIGKILL without leaking OSError.""" + if proc.pid <= 0 or proc.poll() is not None: + return proc.poll() is not None + try: + proc.kill() + try: + proc.wait(timeout=reap_timeout) + return True + except subprocess.TimeoutExpired: + return False + except OSError: + return proc.poll() is not None + + +def _reap_timed_out_worker(proc: subprocess.Popen[bytes], reap_timeout: int) -> bool: + """Terminate a live worker from Popen, escalating once, and report reaping. + + ``Popen`` is the verified owner handle created in this process. Its positive + pid is checked before either signal so malformed stand-ins cannot target a + process group or a non-positive pid. Each wait is bounded by the configured + shutdown grace, keeping the parent-side startup failure bounded even if the + worker ignores both signals. + """ + if proc.pid <= 0 or proc.poll() is not None: + return proc.poll() is not None try: - with os.fdopen(read_fd, "rb") as reader: - raw = reader.read() + proc.terminate() + proc.wait(timeout=reap_timeout) + return True + except subprocess.TimeoutExpired: + return _kill_timed_out_worker(proc, reap_timeout) + except OSError: + return proc.poll() is not None + + +def _read_ipc_response( + read_fd: int, + proc: subprocess.Popen[bytes], + *, + timeout: int, + reap_timeout: int, +) -> dict[str, Any]: + """Read one worker IPC frame through EOF before the startup deadline. + + Called only after ``Popen`` has detached the worker, so every failure here + is parent-side by construction and raises ``DaemonHandshakeError``. A + truncated or unparsable frame is precisely the case where we cannot tell + whether a worker is live, which is why the caller must assume one is. On a + deadline expiry the parent owns the verified ``Popen`` handle, terminates + that worker, and waits for it before surfacing the distinct timeout error. + """ + deadline = time.monotonic() + timeout + try: + raw_parts: list[bytes] = [] + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError + readable, _, _ = select.select([read_fd], [], [], remaining) + if not readable: + raise TimeoutError + chunk = os.read(read_fd, 8192) + if not chunk: + break + raw_parts.append(chunk) + except TimeoutError as exc: + reaped = _reap_timed_out_worker(proc, reap_timeout) + raise DaemonHandshakeTimeoutError( + "worker IPC startup response timed out", + {"timeout_seconds": timeout, "worker_reaped": reaped, "pid": proc.pid}, + ) from exc except OSError as exc: - raise DaemonError("failed to read worker IPC pipe", {"errno": exc.errno}) from exc + raise DaemonHandshakeError("failed to read worker IPC pipe", {"errno": exc.errno}) from exc + finally: + try: + os.close(read_fd) + except OSError: + pass + + raw = b"".join(raw_parts) if not raw: - raise DaemonError("worker IPC pipe closed without a message", {}) + raise DaemonHandshakeError("worker IPC pipe closed without a message", {}) try: message: dict[str, Any] = json.loads(raw.decode("utf-8")) except json.JSONDecodeError as exc: - raise DaemonError("worker IPC produced invalid JSON", {"position": exc.pos}) from exc + raise DaemonHandshakeError( + "worker IPC produced invalid JSON", {"position": exc.pos} + ) from exc kind = message.get("kind") if kind in {"success", "required_failure", "daemon_error", "session_active"}: return message - raise DaemonError("unexpected IPC message kind", {"kind": str(kind)}) + raise DaemonHandshakeError("unexpected IPC message kind", {"kind": str(kind)}) diff --git a/tunstrap/envrender.py b/tunstrap/envrender.py index 603af8e..a6c9bb3 100644 --- a/tunstrap/envrender.py +++ b/tunstrap/envrender.py @@ -1,53 +1,173 @@ -"""Render an OutputSchema into TUNSTRAP_* environment variables (#6/#5).""" +"""Render an OutputSchema for ``run``'s env-native session and structured channels. -from __future__ import annotations - -import re - -from tunstrap.schemas import OutputSchema - -_NON_ALNUM = re.compile(r"[^A-Z0-9]") - - -def _key(name: str) -> str: - """Sanitise a target/kube name into an env-var segment (upper, _-joined).""" - return _NON_ALNUM.sub("_", name.upper()) +``render_kube_env`` builds the node-count-agnostic kube channel +(``KUBECONFIG``/``KUBE_CONFIG_PATH(S)``), unconditional on node count. +``render_unified_output``/``render_output_var`` build the node-qualified +structure -- keyed by node, with kube credentials removed -- that ``run`` +materializes to ``tunnel-data/output.json`` and optionally also exports under +``--output-var``. ``RUN_ENV_KEYS`` contains every key ``run`` injects or +scrubs, for the pre-spawn ``--output-var`` collision check. +""" +from __future__ import annotations -def render_env(output: OutputSchema) -> dict[str, str]: - """Build the TUNSTRAP_* env mapping for a single-node OutputSchema.""" - if len(output.connections) != 1: - raise ValueError("render_env requires exactly one node") - (node,) = output.connections.values() - - env: dict[str, str] = { - "TUNSTRAP_SESSION_DIR": output.session_dir, - "TUNSTRAP_PID": str(output.pid), - } - - def put(key: str, value: str) -> None: - if key in env: - raise ValueError(f"env key collision: {key}") - env[key] = value - - for tname, port in node.ports.items(): - base = _key(tname) - put(f"TUNSTRAP_{base}_HOST", "127.0.0.1") - put(f"TUNSTRAP_{base}_PORT", str(port)) - put(f"TUNSTRAP_{base}_ENDPOINT", f"127.0.0.1:{port}") - +import json +from pathlib import Path +from typing import Any + +from tunstrap.schemas import ( + OutputSchema, + UnifiedFetchRef, + UnifiedKubeRef, + UnifiedNode, + UnifiedSession, +) +from tunstrap.session import atomic_write + +KUBE_ENV_NAMES: frozenset[str] = frozenset({"KUBECONFIG", "KUBE_CONFIG_PATH", "KUBE_CONFIG_PATHS"}) +"""The three kube env var names ``run`` manages, as a single source of truth. + +``_build_child_env`` removes these from the inherited environment +*unconditionally* -- before any injection, regardless of schema -- so a stray +operator ``KUBECONFIG``/``KUBE_CONFIG_PATH``/``KUBE_CONFIG_PATHS`` can never +leak into or override the channel (the e2e tier asserts ``KUBECONFIG`` is +absent from ``tofu``'s env). Because the scrub is unconditional, +``RUN_ENV_KEYS`` must reserve the same set *unconditionally* too, or a +``--output-var KUBECONFIG`` would pass the pre-spawn collision guard and then +have the inherited value clobbered (issue #23). + +This is the *reserved and scrubbed* set, distinct from what +``_kube_channel_keys`` / ``render_kube_env`` *set*: the SET is +cardinality-dependent (1 file -> ``KUBECONFIG`` + ``KUBE_CONFIG_PATH``; +>=2 -> ``KUBECONFIG`` + ``KUBE_CONFIG_PATHS``; 0 -> nothing), but the SCRUB +always covers all three. One constant for both the guard and the scrubber is +what keeps that asymmetry from drifting back into two lists that must agree. +""" + +RUN_ENV_KEYS: frozenset[str] = ( + frozenset({"TUNSTRAP_SESSION_DIR", "TUNSTRAP_PID", "TUNSTRAP_OUTPUT_FILE"}) | KUBE_ENV_NAMES +) +"""Every environment key ``run`` injects or reserves by scrubbing.""" + + +def _kube_channel_keys(count: int) -> set[str]: + """Names of the kube-channel env keys the conditional contract exports. + + Precondition: ``count >= 1``. Exactly 1: KUBECONFIG + KUBE_CONFIG_PATH. >=2: + KUBECONFIG + KUBE_CONFIG_PATHS. KUBE_CONFIG_PATH and KUBE_CONFIG_PATHS are + never both present -- KUBE_CONFIG_PATH wins over KUBE_CONFIG_PATHS per the + measured OpenTofu kubernetes/helm provider precedence (docs/specs/ + 2026-08-10-issue15-provider-env-precedence.md), so exporting both once a + second file exists would silently hide every cluster but the first. The + union of every branch is ``KUBE_ENV_NAMES``; this function picks the + cardinality-correct subset to *set*, while the scrub reserves them all. + """ + if count == 1: + return {"KUBECONFIG", "KUBE_CONFIG_PATH"} + return {"KUBECONFIG", "KUBE_CONFIG_PATHS"} + + +def render_kube_env(output: OutputSchema) -> dict[str, str]: + """Build the node-count-agnostic kube channel: KUBECONFIG plus the + OpenTofu-provider-facing var the conditional contract picks. + + This channel has no node dimension: it collects one materialized path per + kube_target across every node, so it is safe to call for any node count. + """ kube_paths: list[str] = [] - for kname, target in node.kube_targets.items(): - base = _key(kname) - if target.path is None: - raise ValueError(f"kube target {kname!r} not materialized; cannot set KUBECONFIG") - put(f"TUNSTRAP_{base}_KUBECONFIG", target.path) - put(f"TUNSTRAP_{base}_ENDPOINT", target.endpoint) - kube_paths.append(target.path) - - if kube_paths: - put("KUBECONFIG", ":".join(kube_paths)) - return env + for node in output.connections.values(): + for kname, target in node.kube_targets.items(): + if target.path is None: + raise ValueError(f"kube target {kname!r} not materialized; cannot set KUBECONFIG") + kube_paths.append(target.path) + if not kube_paths: + return {} + joined = ":".join(kube_paths) + return {key: joined for key in _kube_channel_keys(len(kube_paths))} + + +def render_output_var(output: OutputSchema) -> str: + """Serialise the unified structure for ``--output-var``.""" + return json.dumps(render_unified_output(output), separators=(",", ":")) + + +def render_unified_output(output: OutputSchema) -> dict[str, Any]: + """Build the unified, node-qualified structure without content payloads. + + Callers must ensure fetched files are already materialized and carry their + path before calling this function. + """ + nodes: dict[str, object] = {} + for node_name, node in output.connections.items(): + kube = { + kname: UnifiedKubeRef( + path=target.path, context=target.context_name, endpoint=target.endpoint + ) + for kname, target in node.kube_targets.items() + } + ports = {tname: f"127.0.0.1:{port}" for tname, port in node.ports.items()} + fetch_files = { + fname: ( + UnifiedFetchRef(error=f.error) + if f.error is not None + else UnifiedFetchRef(path=f.path, size=f.size, sha256=f.sha256) + ) + for fname, f in node.fetch_files.items() + } + nodes[node_name] = UnifiedNode(ports=ports, kube=kube, fetch_files=fetch_files).model_dump( + exclude_none=True + ) + session = UnifiedSession( + session_dir=output.session_dir, + pid=output.pid, + started_at=output.started_at, + warnings=output.warnings, + ).model_dump(mode="json") + return {"session": session, "nodes": nodes} + + +def render_start_json(output: OutputSchema) -> dict[str, Any]: + """Build ``start --output json`` data without redundant materialized content. + + ``path is not None`` is the discriminator, not ``daemon.materialize``: + materialized kube and fetched-file entries use the same allow-lists as + ``run``; unmaterialized entries retain ``content_b64`` as stdout is their + only delivery channel. The two discriminators are equivalent today because + a session is bound once per daemon from ``schema.daemon.materialize``. + """ + payload: dict[str, Any] = output.model_dump(mode="json") + connections = payload["connections"] + for node_name, node in output.connections.items(): + rendered_targets = connections[node_name]["kube_targets"] + for target_name, target in node.kube_targets.items(): + if target.path is None: + continue + rendered_targets[target_name] = UnifiedKubeRef( + path=target.path, context=target.context_name, endpoint=target.endpoint + ).model_dump(exclude_none=True) + rendered_fetch_files = connections[node_name]["fetch_files"] + for fetch_name, fetched_file in node.fetch_files.items(): + if fetched_file.path is None: + continue + rendered_fetch_files[fetch_name] = UnifiedFetchRef( + path=fetched_file.path, size=fetched_file.size, sha256=fetched_file.sha256 + ).model_dump(exclude_none=True) + return payload + + +def materialized_output_path(session_dir: str) -> str: + """The deterministic path the materialization writer writes to; shared so + _build_child_env's TUNSTRAP_OUTPUT_FILE and the actual writer never + independently compute a different path for the same file. + """ + return str(Path(session_dir) / "tunnel-data" / "output.json") + + +def write_materialized_output(output: OutputSchema) -> None: + """Atomically write the unified output structure to its deterministic path.""" + atomic_write( + Path(materialized_output_path(output.session_dir)), render_output_var(output).encode() + ) def format_exports(env: dict[str, str]) -> str: diff --git a/tunstrap/exceptions.py b/tunstrap/exceptions.py index 98a35b9..552c606 100644 --- a/tunstrap/exceptions.py +++ b/tunstrap/exceptions.py @@ -7,9 +7,13 @@ _SECRET_KEYS = frozenset({"ssh_pkey", "ssh_password", "ssh_pkey_passphrase"}) -def _scrub(details: dict[str, Any]) -> dict[str, Any]: - """Return a copy of details with secret keys (ssh_pkey/etc) removed.""" - return {k: v for k, v in details.items() if k not in _SECRET_KEYS} +def _scrub(value: Any) -> Any: + """Return a copy of values with SSH secret keys removed at every depth.""" + if isinstance(value, dict): + return {key: _scrub(nested) for key, nested in value.items() if key not in _SECRET_KEYS} + if isinstance(value, list): + return [_scrub(nested) for nested in value] + return value class TunstrapError(Exception): @@ -46,8 +50,37 @@ class DaemonError(TunstrapError): """Generic daemon-side failure surfaced via the IPC handshake.""" +class DaemonHandshakeError(DaemonError): + """The *parent* could not complete the handshake with a worker it launched. + + The distinction from ``DaemonError`` is **who failed**, and it decides + whether a daemon is left running. A ``daemon_error`` IPC frame is + worker-authored: the worker reached its own guard, released the session + lock and removed its session dir before reporting, then exited — nothing + survives it. This one is raised only past the point where + ``subprocess.Popen`` has already detached the worker, so the worker may be + perfectly healthy, holding the session lock with tunnels open, while the + parent is the side that failed. Callers must stop it rather than delete its + session directory and walk away. + + A subclass so that every existing ``except DaemonError`` keeps working; it + needs its own ``_EXIT_CODES`` entry because ``exit_code_for`` keys on the + exact type. + """ + + +class DaemonHandshakeTimeoutError(DaemonHandshakeError): + """The parent exceeded the configured deadline for the worker IPC response.""" + + class KubeParseError(TunstrapError): - """A kubeconfig could not be parsed or lacked a usable current-context.""" + """A fetched kubeconfig could not be used as-is. + + Raised when a kubeconfig could not be parsed, lacked a usable + current-context, or already contained tunstrap's reserved + ``tunstrap--`` name in its ``clusters``/``users``/``contexts`` + (a reserved-namespace collision that is rejected, not uniquified). + """ class SessionActive(TunstrapError): @@ -60,6 +93,8 @@ class SessionActive(TunstrapError): KubeParseError: 2, SessionActive: 3, DaemonError: 4, + DaemonHandshakeError: 4, + DaemonHandshakeTimeoutError: 4, } diff --git a/tunstrap/fdio.py b/tunstrap/fdio.py new file mode 100644 index 0000000..05f91d5 --- /dev/null +++ b/tunstrap/fdio.py @@ -0,0 +1,27 @@ +"""Small stdlib-only primitives for writing complete byte sequences to file descriptors.""" + +from __future__ import annotations + +import os + + +class ShortWriteError(OSError): + """An ``os.write`` no-progress failure with the bytes still unwritten.""" + + def __init__(self, remaining: int) -> None: + super().__init__("os.write made no progress; cannot complete write") + self.remaining = remaining + + +def write_all(fd: int, content: bytes) -> None: + """Write all of ``content`` to ``fd``, looping past short writes. + + ``os.write`` may return fewer bytes than requested. A zero or negative + result cannot advance the loop, so it is reported rather than spinning. + """ + view = memoryview(content) + while view: + written = os.write(fd, view) + if written <= 0: + raise ShortWriteError(len(view)) + view = view[written:] diff --git a/tunstrap/fetcher.py b/tunstrap/fetcher.py index 00f8acd..3fa3e6d 100644 --- a/tunstrap/fetcher.py +++ b/tunstrap/fetcher.py @@ -50,11 +50,68 @@ def _classify_error(exc: BaseException) -> str: return type(exc).__name__ -async def fetch_files( # pylint: disable=too-many-branches # reason: per-file error attribution branches +async def _fetch_one(sftp: asyncssh.SFTPClient, spec: FileSpec, timeout: float) -> FetchedFile: + """Fetch one file, mapping every *expected* failure to a ``FetchedFile.error``. + + Owns the whole per-file decision tree — the 1 MiB cap checked twice (from + ``stat`` before opening, and again on the bytes actually read, because the + file may grow in between) and the transport-error classification. Anything + outside ``_SFTP_TRANSPORT_ERRORS`` is a programmer error and propagates, so + the caller's own handler decides what a dead channel means for the rest of + the batch. + """ + try: + + async def _read_remote_file() -> bytes | str: + """Keep stat and read in one timeout so metadata cannot hang startup.""" + stat = await sftp.stat(spec.path) + if stat.size is not None and stat.size > _MAX_FETCH_BYTES: + raise _CapExceeded + async with sftp.open(spec.path, "rb") as fh: + return await fh.read(_MAX_FETCH_BYTES + 1) + + data = await asyncio.wait_for(_read_remote_file(), timeout=timeout) + raw: bytes = data if isinstance(data, bytes) else data.encode() + if len(raw) > _MAX_FETCH_BYTES: + raise _CapExceeded + return FetchedFile( + content_b64=base64.b64encode(raw).decode("ascii"), + size=len(raw), + sha256=hashlib.sha256(raw).hexdigest(), + ) + except _CapExceeded: + return FetchedFile(error="EFBIG") + except _SFTP_TRANSPORT_ERRORS as exc: + return FetchedFile(error=_classify_error(exc)) + + +def _record_channel_failure( + specs: dict[str, FileSpec], + results: dict[str, FetchedFile], + required_failures: list[str], + code: str, +) -> None: + """Attribute a whole-channel failure to every spec not already resolved. + + Files fetched before the channel died keep their own result; the ones that + never got a turn inherit the channel's error code. When the channel fails + before the first file, that is simply every spec. + """ + for name, spec in specs.items(): + if name in results: + continue + results[name] = FetchedFile(error=code) + if spec.required: + required_failures.append(name) + + +async def fetch_files( conn: asyncssh.SSHClientConnection, specs: dict[str, FileSpec], + *, + timeout: float, ) -> tuple[dict[str, FetchedFile], list[str]]: - """Fetch all files for a node over a single SFTP channel.""" + """Fetch all files for a node over one SFTP channel within each-file timeout.""" if not specs: return {}, [] @@ -64,44 +121,17 @@ async def fetch_files( # pylint: disable=too-many-branches # reason: per-file try: sftp_cm = conn.start_sftp_client() except _SFTP_TRANSPORT_ERRORS as exc: - code = _classify_error(exc) - for name, spec in specs.items(): - results[name] = FetchedFile(error=code) - if spec.required: - required_failures.append(name) + _record_channel_failure(specs, results, required_failures, _classify_error(exc)) return results, required_failures try: async with sftp_cm as sftp: for name, spec in specs.items(): - try: - stat = await sftp.stat(spec.path) - if stat.size is not None and stat.size > _MAX_FETCH_BYTES: - raise _CapExceeded - async with sftp.open(spec.path, "rb") as fh: - data = await fh.read(_MAX_FETCH_BYTES + 1) - raw: bytes = data if isinstance(data, bytes) else data.encode() - if len(raw) > _MAX_FETCH_BYTES: - raise _CapExceeded - results[name] = FetchedFile( - content_b64=base64.b64encode(raw).decode("ascii"), - size=len(raw), - sha256=hashlib.sha256(raw).hexdigest(), - ) - except _CapExceeded: - results[name] = FetchedFile(error="EFBIG") - if spec.required: - required_failures.append(name) - except _SFTP_TRANSPORT_ERRORS as exc: - results[name] = FetchedFile(error=_classify_error(exc)) - if spec.required: - required_failures.append(name) - except _SFTP_TRANSPORT_ERRORS as exc: - code = _classify_error(exc) - for name, spec in specs.items(): - if name not in results: - results[name] = FetchedFile(error=code) - if spec.required: + fetched = await _fetch_one(sftp, spec, timeout) + results[name] = fetched + if fetched.error is not None and spec.required: required_failures.append(name) + except _SFTP_TRANSPORT_ERRORS as exc: + _record_channel_failure(specs, results, required_failures, _classify_error(exc)) return results, required_failures diff --git a/tunstrap/identity.py b/tunstrap/identity.py index 6e4f004..2349e54 100644 --- a/tunstrap/identity.py +++ b/tunstrap/identity.py @@ -8,10 +8,14 @@ from __future__ import annotations import enum +import errno import fcntl import os +import stat from pathlib import Path +from tunstrap.fdio import write_all + _LOCK_NAME = "session.lock" @@ -33,21 +37,46 @@ def _lock_path(session_dir: str | Path) -> Path: def acquire_session_lock(session_dir: str | Path) -> int: """Exclusively flock ``session.lock`` non-blocking; record pid; return fd. - Raises ``BlockingIOError`` if another live process already holds it. The - fd must stay open for the holder's lifetime; the kernel releases the flock - automatically when the process exits, clean or not. + Raises ``BlockingIOError`` if another live process already holds it. Raises + ``OSError`` (``errno.EPERM``) if the lock file is unsafe to truncate: + ``O_NOFOLLOW`` rejects a symlinked lock at the ``open`` itself (the issue + #25 vector -- without it a symlink let an attacker truncate an arbitrary + victim the runner could open), and the post-open ``fstat`` rejects anything + that is not a regular file owned by the current user. ``O_NOFOLLOW`` alone is + not enough: a regular file some other uid planted in a writable root would + still be opened and truncated, which is why the ``S_ISREG`` + ownership + check is not redundant. ``st_nlink != 1`` is refused for the sibling vector + the other two miss: a *hardlink* is not a symlink, so ``O_NOFOLLOW`` is + silent, and it shares the victim's inode, so ``S_ISREG`` and the ownership + check both pass on a runner-owned victim -- the ``ftruncate`` below would + then destroy it. A lock this call is entitled to truncate has exactly one + link; anything else is a second name for a file that is not ours to clear. + ``SessionDir._secure_supplied_root`` only stops such a link being planted + *after* tunstrap first runs, so this is the check that covers one planted + before it. The fd must stay open for the holder's lifetime; the kernel + releases the flock automatically when the process exits, clean or not. """ path = _lock_path(session_dir) - fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o600) + fd = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600) try: + st = os.fstat(fd) + if not stat.S_ISREG(st.st_mode) or st.st_uid != os.getuid() or st.st_nlink != 1: + raise OSError( + errno.EPERM, + "session.lock is not a singly-linked regular file owned by the current user", + str(path), + ) fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError: + except OSError: + # Covers the fstat refusal above and BlockingIOError (a subclass) from a + # held flock: either way nothing was recorded, so close the fd we opened + # and let the caller's translation decide the domain error. os.close(fd) raise # Truncate + write only AFTER winning the lock, so a losing racer's open() # can never clobber the winner's recorded pid. os.ftruncate(fd, 0) - os.write(fd, f"{os.getpid()}\n".encode("ascii")) + write_all(fd, f"{os.getpid()}\n".encode("ascii")) os.fsync(fd) return fd @@ -77,7 +106,12 @@ def verify_session(session_dir: str | Path, pid: int) -> IdentityCheckResult: def _check_lock(lock_path: Path, pid: int) -> IdentityCheckResult: """Determine identity from flock state and the recorded PID.""" try: - fd = os.open(lock_path, os.O_RDONLY) + # ``O_NOFOLLOW`` so a symlinked lock is reported unavailable rather than + # followed -- a symlinked lock is never legitimate (the daemon never + # writes one), and following it would probe flock state on an arbitrary + # attacker-chosen file. ``ELOOP`` from the symlink is absorbed by the + # existing ``except OSError`` below. + fd = os.open(lock_path, os.O_RDONLY | os.O_NOFOLLOW) except OSError: return IdentityCheckResult.unavailable try: @@ -100,7 +134,20 @@ def _check_lock(lock_path: Path, pid: int) -> IdentityCheckResult: def _process_exists(pid: int) -> bool: - """True iff a process with the given PID currently exists.""" + """True iff a process with the given PID currently exists. + + A non-positive pid is never a process: ``kill(2)`` reads 0 as "the + caller's process group" and negatives as a process-group selector (with + ``-1`` meaning *every* process the caller can signal). ``os.kill`` with + such a pid is therefore a group/broadcast probe that answers True for as + long as any signalable process exists — which is always — so it cannot + confirm "the recorded daemon is alive". Refusing it here is what keeps a + corrupt ``daemon.pid`` (or a hostile ``--session-dir``) of ``-1`` from + verifying as ``match``; ``stop_session`` re-asserts ``pid > 0`` at its + entry as defence in depth. + """ + if pid <= 0: + return False try: os.kill(pid, 0) except ProcessLookupError: diff --git a/tunstrap/kube.py b/tunstrap/kube.py index 7e48ca9..03c48b6 100644 --- a/tunstrap/kube.py +++ b/tunstrap/kube.py @@ -1,10 +1,14 @@ """Kube mode: parse a remote kubeconfig, choose a TLS server name, patch it. One kube_target maps to exactly one cluster: the kubeconfig's -current-context. Other contexts/clusters are ignored and left byte-stable -in the patched output. The fetched kubeconfig is untrusted input: it is -parsed in ruamel round-trip/safe mode and parse failures become a typed -KubeParseError (never a daemon crash). +current-context. Other contexts are not selected, but their ``cluster``/ +``user`` references are rewritten when they point at the renamed entries, so +a document in which several contexts share one cluster or user stays +internally consistent after the rename; everything else in the file is left +untouched. The fetched kubeconfig is untrusted input: it is parsed in ruamel +round-trip mode, parse failures become a typed ``KubeParseError``, and a +generated ``tunstrap--`` identity that already exists in the +file is rejected (not uniquified) for the same reason -- never a daemon crash. """ from __future__ import annotations @@ -39,6 +43,7 @@ "dump_kubeconfig", "parse_kubeconfig", "patch_view", + "rename_identities", "run_kube_targets", "sans_from_cert", ] @@ -49,11 +54,18 @@ class KubeconfigView: """Extracted current-context view plus the live parsed document. `doc` is the round-trip ruamel document used later for in-place patching - (comments/key order preserved). The scalar fields are the extracted + (comments/key order preserved). It is always a mapping: ``_load_root`` + rejects a non-mapping root as a ``KubeParseError`` before a view is ever + built, so callers (and the type checker) can rely on that without a runtime + re-check. ``cluster_body`` is the live cluster mapping the current context + resolves to (the same object ``patch_view`` rewrites in place); carrying it + avoids re-resolving the cluster by name and re-checking what + ``_cluster_section`` already validated. The scalar fields are the extracted current-context cluster/user material. """ - doc: object + doc: dict[str, object] + cluster_body: dict[str, object] context_name: str cluster_name: str user_name: str @@ -65,6 +77,7 @@ class KubeconfigView: def _yaml() -> YAML: + """Use round-trip YAML so patching a fetched config preserves safe syntax.""" y = YAML(typ="rt") y.preserve_quotes = True return y @@ -76,18 +89,61 @@ def parse_kubeconfig(raw: bytes) -> KubeconfigView: Raises KubeParseError on malformed YAML, missing current-context, or an unresolvable cluster/user reference. """ + doc = _load_root(raw) + + current = doc.get("current-context") + if not current or not isinstance(current, str): + raise KubeParseError("kubeconfig has no current-context") + + # Equivalent to the former `doc.get("contexts") or []`: a truthy non-list + # (mapping, string, ...) never survived anyway, because _find_named below + # returns None for it and that raises. Narrowing here instead lets + # _ignored_contexts take a real list without re-guarding the type. + _contexts_raw = doc.get("contexts") + contexts: list[object] = _contexts_raw if isinstance(_contexts_raw, list) else [] + + cluster_name, user_name = _context_refs(contexts, current) + cluster_body, server = _cluster_section(doc, cluster_name) + user_body = _user_section(doc, user_name) + ignored = _ignored_contexts(contexts, current) + + return KubeconfigView( + doc=doc, + cluster_body=cluster_body, + context_name=current, + cluster_name=str(cluster_name), + user_name=str(user_name), + server=server, + certificate_authority_data=_string_field( + cluster_body, "certificate-authority-data", cluster_name + ), + client_certificate_data=_string_field(user_body, "client-certificate-data", user_name), + client_key_data=_string_field(user_body, "client-key-data", user_name), + ignored_contexts=ignored, + ) + + +def _load_root(raw: bytes) -> dict[str, object]: + """Load raw kubeconfig bytes as a round-trip YAML mapping. + + The returned document is the live ruamel object later patched in place, so + it must stay the round-trip type (comments, quoting and key order intact). + """ try: doc = _yaml().load(io.BytesIO(raw)) except YAMLError as exc: raise KubeParseError(f"kubeconfig is not valid YAML: {exc}") from exc if not isinstance(doc, dict): raise KubeParseError("kubeconfig root is not a mapping") + return doc - current = doc.get("current-context") - if not current or not isinstance(current, str): - raise KubeParseError("kubeconfig has no current-context") - contexts = doc.get("contexts") or [] +def _context_refs(contexts: list[object], current: str) -> tuple[str, str]: + """Resolve the current context to the (cluster, user) names it references. + + A context body that is absent or not a mapping is treated as empty, which + then fails the missing-cluster-or-user check. + """ ctx = _find_named(contexts, current) if ctx is None: raise KubeParseError(f"current-context {current!r} not found in contexts") @@ -99,9 +155,15 @@ def parse_kubeconfig(raw: bytes) -> KubeconfigView: raise KubeParseError(f"context {current!r} missing cluster or user") if not isinstance(_cluster_name_raw, str) or not isinstance(_user_name_raw, str): raise KubeParseError(f"context {current!r} cluster or user is not a string") - cluster_name: str = _cluster_name_raw - user_name: str = _user_name_raw + return _cluster_name_raw, _user_name_raw + +def _cluster_section(doc: dict[str, object], cluster_name: str) -> tuple[dict[str, object], str]: + """Return the named cluster's body and its `server` URL. + + A cluster body that is absent or not a mapping is treated as empty, which + then fails the missing-server check. + """ cluster = _find_named(doc.get("clusters") or [], cluster_name) if cluster is None: raise KubeParseError(f"cluster {cluster_name!r} not found") @@ -112,31 +174,28 @@ def parse_kubeconfig(raw: bytes) -> KubeconfigView: server = cluster_body.get("server") if not server or not isinstance(server, str): raise KubeParseError(f"cluster {cluster_name!r} has no server") + return cluster_body, server + +def _user_section(doc: dict[str, object], user_name: str) -> dict[str, object]: + """Return the named user's body, or an empty mapping if it is not one. + + Unlike cluster/context, an empty user body is legitimate: every credential + field is optional (token/exec-plugin kubeconfigs carry none of them). + """ user = _find_named(doc.get("users") or [], user_name) if user is None: raise KubeParseError(f"user {user_name!r} not found") _user_body_raw = user.get("user") - user_body: dict[str, object] = _user_body_raw if isinstance(_user_body_raw, dict) else {} + return _user_body_raw if isinstance(_user_body_raw, dict) else {} + - ignored = [ +def _ignored_contexts(contexts: list[object], current: str) -> list[str]: + """Names of every context in the file other than the current one.""" + return [ str(c.get("name")) for c in contexts if isinstance(c, dict) and c.get("name") != current ] - return KubeconfigView( - doc=doc, - context_name=current, - cluster_name=str(cluster_name), - user_name=str(user_name), - server=server, - certificate_authority_data=_string_field( - cluster_body, "certificate-authority-data", cluster_name - ), - client_certificate_data=_string_field(user_body, "client-certificate-data", user_name), - client_key_data=_string_field(user_body, "client-key-data", user_name), - ignored_contexts=ignored, - ) - def _find_named(items: object, name: str) -> dict[str, object] | None: """Return the first list entry whose 'name' equals `name`, else None.""" @@ -207,14 +266,13 @@ def patch_view( Rewrites `server:` to the local forwarded endpoint. On secure patch sets `tls-server-name`. On insecure patch sets `insecure-skip-tls-verify: true` and removes `certificate-authority-data`. Other clusters are untouched. + + The cluster body mutated here is the live ruamel object ``parse_kubeconfig`` + already resolved and validated (it has a ``server`` and is a mapping), so no + re-resolution or re-validation is needed -- the ``KubeconfigView`` carries + that object as ``cluster_body``. """ - doc = view.doc - assert isinstance(doc, dict) - cluster = _find_named(doc.get("clusters") or [], view.cluster_name) - assert cluster is not None # parse_kubeconfig guaranteed this - body_raw = cluster["cluster"] - assert isinstance(body_raw, dict), "parse_kubeconfig guaranteed cluster.cluster is a dict" - body: dict[str, object] = body_raw + body = view.cluster_body body["server"] = f"https://127.0.0.1:{local_port}" if insecure: body["insecure-skip-tls-verify"] = True @@ -225,6 +283,145 @@ def patch_view( body["tls-server-name"] = tls_server_name +def _sweep_shared_refs( + contexts: list[object], + ctx_entry: dict[str, object], + old_cluster: str, + old_user: str, + new_name: str, +) -> None: + """Repoint ``cluster``/``user`` on non-selected contexts that shared them. + + The current-context's own entry (``ctx_entry``) has already been renamed in + place; every OTHER context whose ``cluster``/``user`` pointed at the same + entry is repointed at ``new_name`` so a multi-context document that shares + one cluster or user stays internally consistent after the rename. Entries + that are not mappings, or whose ``context`` body is not a mapping, are + skipped: the active context was already validated by the caller, and these + foreign/stale entries are left untouched on purpose. + """ + for entry in contexts: + if entry is ctx_entry or not isinstance(entry, dict): + continue + other_body = entry.get("context") + if not isinstance(other_body, dict): + continue + if other_body.get("cluster") == old_cluster: + other_body["cluster"] = new_name + if other_body.get("user") == old_user: + other_body["user"] = new_name + + +def rename_identities( + doc: dict[str, object], + node: str, + target: str, +) -> str: + """Rename the current-context's cluster/user/context to a deterministic name. + + ``tunstrap--`` is used for cluster, user, and context alike. + Other contexts keep their own names, but their ``cluster``/``user`` + references are rewritten when they point at the renamed entries, so a + document in which several contexts share one cluster or user stays + internally consistent after the rename (the non-selected contexts are NOT + left byte-stable -- only their own ``name`` is preserved). + + The fetched kubeconfig is untrusted input. Every structural defect -- a + non-string current-context, a current-context absent from ``contexts``, a + non-mapping context body, cluster/user references that are not strings or + that name entries missing from ``clusters``/``users`` -- raises + ``KubeParseError``, as does a reserved-namespace collision where the file + already contains the generated ``tunstrap--`` name in any of + ``clusters``/``users``/``contexts``. These are typed raises rather than + ``assert``: this function is public (exported via ``__all__``), so its + contract must hold for direct callers and ``python -O`` cannot be allowed + to erase the check. For the only in-tree caller the seven structural guards + are defence-in-depth: ``run_kube_targets`` always runs ``parse_kubeconfig`` + on the same document first, and that dominates every structural case here + -- both functions narrow ``contexts`` with the same ``isinstance`` check, + resolve names with the same first-match ``_find_named``, and nothing + mutates ``current-context``/``contexts``/``clusters``/``users`` between the + two calls (``patch_view`` only rewrites the live cluster body's + server/TLS fields). The sole raise reachable from ``run_kube_targets`` is + therefore the reserved-namespace collision. All rejections run before any + mutation, so a rejection leaves ``doc`` unchanged. + + A collision means the upstream file is using tunstrap's reserved + ``tunstrap--`` namespace -- a misconfiguration or a shadowing + attempt -- and renaming around it would hide that. Uniquifying (``-2`` + suffixes) is rejected on purpose: the deterministic name is a + consumer-facing literal (see ``docs/recipe_terragrunt.md``) and must not + become non-deterministic. + """ + new_name = f"tunstrap-{node}-{target}" + current = doc.get("current-context") + if not isinstance(current, str): + raise KubeParseError( + f"kubeconfig current-context is not a string, got {type(current).__name__}" + ) + contexts_raw = doc.get("contexts") + contexts: list[object] = contexts_raw if isinstance(contexts_raw, list) else [] + ctx_entry = _find_named(contexts, current) + if ctx_entry is None: + raise KubeParseError(f"current-context {current!r} not found in contexts") + ctx_body_raw = ctx_entry.get("context") + if not isinstance(ctx_body_raw, dict): + raise KubeParseError(f"context {current!r} body is not a mapping") + old_cluster = ctx_body_raw.get("cluster") + old_user = ctx_body_raw.get("user") + if not isinstance(old_cluster, str): + raise KubeParseError( + f"context {current!r} cluster reference is not a string, " + f"got {type(old_cluster).__name__}" + ) + if not isinstance(old_user, str): + raise KubeParseError( + f"context {current!r} user reference is not a string, got {type(old_user).__name__}" + ) + + # Reserved-namespace guard. Runs before any mutation so a rejection leaves + # the document untouched. ``_find_named`` returns first-match, which is + # exactly the existence probe needed here: ANY entry (the current triple's + # own included) bearing the generated name means the untrusted upstream is + # already in tunstrap's namespace and must be rejected, not worked around. + if ( + _find_named(doc.get("contexts") or [], new_name) is not None + or _find_named(doc.get("clusters") or [], new_name) is not None + or _find_named(doc.get("users") or [], new_name) is not None + ): + raise KubeParseError( + f"identity name {new_name!r} already exists in the fetched kubeconfig; " + "the upstream file must not use tunstrap's reserved " + "'tunstrap--' namespace" + ) + + # Resolve the cluster/user entries the current context names. These lookups + # run before any mutation for the same reason as the collision guard above: + # a half-renamed document on rejection would be worse than the original. + cluster_entry = _find_named(doc.get("clusters") or [], old_cluster) + if cluster_entry is None: + raise KubeParseError( + f"context {current!r} references cluster {old_cluster!r} not present in clusters" + ) + user_entry = _find_named(doc.get("users") or [], old_user) + if user_entry is None: + raise KubeParseError( + f"context {current!r} references user {old_user!r} not present in users" + ) + + ctx_entry["name"] = new_name + ctx_body_raw["cluster"] = new_name + ctx_body_raw["user"] = new_name + cluster_entry["name"] = new_name + user_entry["name"] = new_name + + doc["current-context"] = new_name + + _sweep_shared_refs(contexts, ctx_entry, old_cluster, old_user, new_name) + + return new_name + + def dump_kubeconfig(view: KubeconfigView) -> bytes: """Serialise the (patched) ruamel doc back to YAML bytes.""" buf = io.BytesIO() @@ -249,8 +446,8 @@ def _string_field(body: dict[str, object], field_name: str, owner: str) -> str: ProbeFn = Callable[[str, int], Awaitable[bytes]] -async def run_kube_targets( # pylint: disable=too-many-locals,too-many-branches # reason: per-target try/except/warning fan-out - conn: "asyncssh.SSHClientConnection", +async def run_kube_targets( # pylint: disable=too-many-locals # reason: per-target try/except/warning fan-out + conn: asyncssh.SSHClientConnection, kube_targets: dict[str, KubeTarget], *, connect_timeout: int, @@ -282,15 +479,6 @@ async def run_kube_targets( # pylint: disable=too-many-locals,too-many-branches required_failures.append(name) continue - for ignored in view.ignored_contexts: - warnings.append( - TunnelWarning( - node=node_name, - error=f"kube_target {name}: ignored context {ignored!r}", - skipped=False, - ) - ) - try: host, port = _split_host_port(view.server) except KubeParseError as exc: @@ -320,11 +508,38 @@ async def run_kube_targets( # pylint: disable=too-many-locals,too-many-branches await listener.wait_closed() continue - patch_view(view, local_port=local_port, tls_server_name=tls_name, insecure=insecure) - patched = dump_kubeconfig(view) + try: + patch_view(view, local_port=local_port, tls_server_name=tls_name, insecure=insecure) + new_identity = rename_identities(view.doc, node_name, name) + patched = dump_kubeconfig(view) + except KubeParseError as exc: + warnings.append(TunnelWarning(node=node_name, error=f"kube_target {name}: {exc}")) + if target.required: + required_failures.append(name) + listener.close() + await listener.wait_closed() + continue + # Disclosure runs only on the success path: by here ``rename_identities`` + # has actually rewritten the current-context's cluster/user, so a + # non-selected context sharing that cluster/user really did have its + # references rewritten. Emitting this earlier (before ``_split_host_port`` + # / TLS resolution / ``rename_identities``) would assert a rewrite that a + # later failure never performed -- issue #20 defect 1. + for ignored in view.ignored_contexts: + warnings.append( + TunnelWarning( + node=node_name, + error=( + f"kube_target {name}: non-selected context {ignored!r}" + " (cluster/user references rewritten when they point at" + " the renamed entries)" + ), + skipped=False, + ) + ) outputs[name] = KubeTargetOutput( - cluster_name=view.cluster_name, - context_name=view.context_name, + cluster_name=new_identity, + context_name=new_identity, local_port=local_port, endpoint=f"https://127.0.0.1:{local_port}", tls_server_name=None if insecure else tls_name, @@ -360,7 +575,7 @@ def _split_host_port(server: str) -> tuple[str, int]: return host, port if port is not None else 443 -async def _fetch_one(conn: "asyncssh.SSHClientConnection", path: str) -> bytes: +async def _fetch_one(conn: asyncssh.SSHClientConnection, path: str) -> bytes: """Read a single small file over SFTP (1 MiB cap), return raw bytes.""" async with conn.start_sftp_client() as sftp: stat = await sftp.stat(path) @@ -423,6 +638,7 @@ async def default_san_probe(host: str, port: int) -> bytes: """ def _connect() -> bytes: + """Isolate blocking certificate inspection from the daemon's event loop.""" ctx = _ssl.SSLContext(_ssl.PROTOCOL_TLS_CLIENT) ctx.check_hostname = False ctx.verify_mode = _ssl.CERT_NONE diff --git a/tunstrap/manager.py b/tunstrap/manager.py index a76f52d..9c0376c 100644 --- a/tunstrap/manager.py +++ b/tunstrap/manager.py @@ -21,6 +21,7 @@ NodeOutput, OutputSchema, TunnelWarning, + materialized_file_name, ) from tunstrap.session import SessionDir from tunstrap.ssh import close_transport, open_connection, open_local_forwards @@ -151,7 +152,11 @@ async def _start_one(self, name: str) -> _NodeRuntime: if node.fetch_files: try: - fetched, required_failures = await fetch_files(runtime.conn, node.fetch_files) + fetched, required_failures = await fetch_files( + runtime.conn, + node.fetch_files, + timeout=node.ssh_options.connect_timeout, + ) except _NODE_STARTUP_ERRORS as exc: runtime.error = str(exc) await close_transport(runtime.conn, runtime.listeners) @@ -159,6 +164,8 @@ async def _start_one(self, name: str) -> _NodeRuntime: runtime.listeners = [] return runtime runtime.fetched_files = fetched + if self._session is not None: + self._materialize_fetch_files(self._session, name, runtime.fetched_files) if required_failures: runtime.error = f"required fetch_files failed: {required_failures}" await close_transport(runtime.conn, runtime.listeners) @@ -184,11 +191,7 @@ async def _start_one(self, name: str) -> _NodeRuntime: runtime.kube_targets = kube_out runtime.kube_warnings = kube_warn if self._session is not None: - for kname, kout in kube_out.items(): - path = self._session.materialize( - f"{name}-{kname}", base64.b64decode(kout.content_b64) - ) - runtime.kube_targets[kname] = kout.model_copy(update={"path": path}) + self._materialize_kube_targets(self._session, name, runtime.kube_targets) if kube_required: runtime.error = f"required kube_targets failed: {kube_required}" await close_transport(runtime.conn, runtime.listeners) @@ -199,3 +202,25 @@ async def _start_one(self, name: str) -> _NodeRuntime: runtime.success = True self._runtimes.append(runtime) return runtime + + def _materialize_kube_targets( + self, session: SessionDir, node_name: str, kube_out: dict[str, KubeTargetOutput] + ) -> None: + """Atomically write each patched kubeconfig to its leaf and set ``.path``.""" + for kname, kout in kube_out.items(): + path = session.materialize_atomic( + materialized_file_name("kube", node_name, kname), base64.b64decode(kout.content_b64) + ) + kube_out[kname] = kout.model_copy(update={"path": path}) + + def _materialize_fetch_files( + self, session: SessionDir, node_name: str, fetched: dict[str, FetchedFile] + ) -> None: + """Atomically write each successful fetched file to its leaf and set ``.path``.""" + for fname, ff in fetched.items(): + if ff.error is not None or ff.content_b64 is None: + continue + path = session.materialize_atomic( + materialized_file_name("fetch", node_name, fname), base64.b64decode(ff.content_b64) + ) + fetched[fname] = ff.model_copy(update={"path": path}) diff --git a/tunstrap/run_invocation.py b/tunstrap/run_invocation.py new file mode 100644 index 0000000..8af0084 --- /dev/null +++ b/tunstrap/run_invocation.py @@ -0,0 +1,52 @@ +"""Programmatic entry points for ``run`` that must not depend on Click internals.""" + +from __future__ import annotations + +import sys + +import click + + +def run_via_env_input( + input_env: str, + output_var: str, + child_cmd: list[str], + *, + suppress_kubeconfig: bool = False, +) -> None: + """Run env-input mode without exposing a non-CLI parameter on ``run``. + + The OpenTofu proxy already has parsed its fixed input, output, and child + arguments. It calls the plain implementation so Click remains responsible + only for translating command-line arguments, while both paths retain the + same validation, spawn, and teardown behavior. + """ + # tofu_proxy's lazy import provides the pass-through fast path; this adds + # defence in depth for other programmatic callers. + from tunstrap.cli import _run_command # pylint: disable=import-outside-toplevel + + try: + _run_command( + ssh_key=None, + ssh_key_passphrase=None, + ssh_password_stdin=False, + targets=(), + kube=(), + fetch=(), + auto_stop_idle_seconds=None, + materialize=False, + log_file=None, + input_env=input_env, + output_var=output_var, + session_dir=None, + grace_seconds=10, + grace_seconds_set=False, + args=tuple(child_cmd), + suppress_kubeconfig=suppress_kubeconfig, + ) + except click.UsageError as exc: + # Programmatic calls bypass main's UsageError wrapper, but must retain + # its documented shell-compatible exit status. + exc.show() + sys.exit(64) + sys.exit(0) # pragma: no cover — _run_command always exits diff --git a/tunstrap/schemas.py b/tunstrap/schemas.py index 3df1283..2d4d3b1 100644 --- a/tunstrap/schemas.py +++ b/tunstrap/schemas.py @@ -22,6 +22,15 @@ def _validate_identifier_key(kind: str, name: str) -> None: raise ValueError(f"{kind} key {name!r}: must match ^[a-zA-Z_][a-zA-Z0-9_-]*$") +def materialized_file_name(kind: str, node_name: str, item_name: str) -> str: + """Render a materialized-file leaf name for validated item kind and names. + + The kind prefix makes names collision-free across kinds. Within-kind + uniqueness relies on the InputSchema validator failing closed. + """ + return f"{kind}-{node_name}-{item_name}" + + def _parse_host_port(value: str) -> tuple[str, int]: """Parse 'host:port' or '[ipv6]:port' into (host, port). @@ -67,12 +76,21 @@ class SSHOptions(BaseModel): class DaemonOptions(BaseModel): - """Daemon-side knobs: log file, shutdown grace, idle auto-stop.""" + """Daemon-side knobs: log file, startup/shutdown deadlines, idle auto-stop.""" model_config = ConfigDict(extra="forbid") log_file: str | None = None shutdown_grace_seconds: int = 10 + startup_timeout_seconds: int = Field( + default=300, + ge=1, + description=( + "Maximum time the parent waits for the worker's startup IPC response. " + "On expiry the parent terminates the worker and waits up to " + "shutdown_grace_seconds before killing it." + ), + ) auto_stop_idle_seconds: int | None = Field( default=None, ge=1, @@ -112,6 +130,7 @@ class FileSpec(BaseModel): @field_validator("path") @classmethod def _validate_absolute(cls, value: str) -> str: + """Reject shell-expanded paths because the daemon must address an exact remote file.""" if value.startswith("~"): raise ValueError("path must be literal (no '~' expansion)") if not value.startswith("/"): @@ -147,6 +166,7 @@ class KubeTarget(BaseModel): @field_validator("kubeconfig_path") @classmethod def _validate_absolute(cls, value: str) -> str: + """Reject shorthand paths before a remote kubeconfig request is constructed.""" if value.startswith("~"): raise ValueError("kubeconfig_path must be literal (no '~' expansion)") if not value.startswith("/"): @@ -190,6 +210,7 @@ class NodeInput(BaseModel): @field_validator("remote_targets", mode="before") @classmethod def _validate_remote_targets(cls, value: object) -> dict[str, RemoteTarget]: + """Normalize legacy strings at the boundary so workers see one target shape.""" if value is None: return {} if not isinstance(value, dict): @@ -211,8 +232,14 @@ def _validate_remote_targets(cls, value: object) -> dict[str, RemoteTarget]: try: parsed[handle] = RemoteTarget.model_validate(raw) except ValidationError as exc: + messages = "; ".join( + error["msg"] + for error in exc.errors( + include_input=False, include_url=False, include_context=False + ) + ) raise ValueError( - f"remote_targets[{handle!r}]: invalid dict form: {exc}" + f"remote_targets[{handle!r}]: invalid dict form: {messages}" ) from exc continue if not isinstance(raw, str): @@ -227,6 +254,7 @@ def _validate_remote_targets(cls, value: object) -> dict[str, RemoteTarget]: @field_validator("fetch_files") @classmethod def _validate_fetch_files(cls, value: dict[str, FileSpec] | None) -> dict[str, FileSpec] | None: + """Reject ambiguous or unbounded fetch maps before they reach one SFTP channel.""" if value is None: return None if len(value) == 0: @@ -242,6 +270,7 @@ def _validate_fetch_files(cls, value: dict[str, FileSpec] | None) -> dict[str, F def _validate_kube_targets( cls, value: dict[str, KubeTarget] | None ) -> dict[str, KubeTarget] | None: + """Keep kube names safe for their later materialized-file namespace.""" if value is None: return None if len(value) == 0: @@ -253,7 +282,8 @@ def _validate_kube_targets( return value @model_validator(mode="after") - def _validate_node_does_something(self) -> "NodeInput": + def _validate_node_does_something(self) -> NodeInput: + """Reject inert SSH sessions that would consume a daemon without output.""" if not self.remote_targets and not self.kube_targets and not self.fetch_files: raise ValueError( "node must define at least one of remote_targets, kube_targets, fetch_files" @@ -272,6 +302,7 @@ class InputSchema(BaseModel): @field_validator("nodes") @classmethod def _validate_auth(cls, value: dict[str, NodeInput]) -> dict[str, NodeInput]: + """Fail validation before a worker discovers it has no SSH credential source.""" for name, node in value.items(): _validate_identifier_key("node", name) if not node.ssh_pkey and not node.ssh_password: @@ -282,6 +313,37 @@ def _validate_auth(cls, value: dict[str, NodeInput]) -> dict[str, NodeInput]: ) return value + @model_validator(mode="after") + def _validate_kube_identity_names_are_unique(self) -> InputSchema: + """Reject colliding kube identities and materialized-file leaf names.""" + identity_seen: dict[str, tuple[str, str]] = {} + for node_name, node in self.nodes.items(): + for target_name in node.kube_targets or {}: + joined = f"tunstrap-{node_name}-{target_name}" + if joined in identity_seen: + other_node, other_target = identity_seen[joined] + raise ValueError( + f"kube identity name collision: ({node_name!r}, {target_name!r}) " + f"and ({other_node!r}, {other_target!r}) both render {joined!r}" + ) + identity_seen[joined] = (node_name, target_name) + + materialized_seen: dict[str, tuple[str, str, str]] = {} + for node_name, node in self.nodes.items(): + for kind, items in (("kube", node.kube_targets), ("fetch", node.fetch_files)): + for item_name in items or {}: + leaf = materialized_file_name(kind, node_name, item_name) + if leaf in materialized_seen: + other_kind, other_node, other_item = materialized_seen[leaf] + raise ValueError( + "materialized file name collision: " + f"({kind!r}, {node_name!r}, {item_name!r}) and " + f"({other_kind!r}, {other_node!r}, {other_item!r}) " + f"both render {leaf!r}" + ) + materialized_seen[leaf] = (kind, node_name, item_name) + return self + class FetchedFile(BaseModel): """Either a successful read (content_b64+size+sha256) or an error string.""" @@ -289,12 +351,14 @@ class FetchedFile(BaseModel): model_config = ConfigDict(extra="forbid") content_b64: str | None = None + path: str | None = None size: int | None = None sha256: str | None = None error: str | None = None @model_validator(mode="after") - def _validate_xor(self) -> "FetchedFile": + def _validate_xor(self) -> FetchedFile: + """Preserve an unambiguous success-or-error envelope for optional fetches.""" has_success = self.content_b64 is not None has_error = self.error is not None if has_success and has_error: @@ -362,6 +426,57 @@ class OutputSchema(BaseModel): warnings: list[TunnelWarning] = Field(default_factory=list) +class UnifiedKubeRef(BaseModel): + """Kube reference in the unified output: never credentials, never content.""" + + model_config = ConfigDict(extra="forbid") + + path: str | None + context: str + endpoint: str + + +class UnifiedSession(BaseModel): + """Session metadata block of the unified output.""" + + model_config = ConfigDict(extra="forbid") + + session_dir: str + pid: int + started_at: str + warnings: list[TunnelWarning] = Field(default_factory=list) + + +class UnifiedFetchRef(BaseModel): + """Fetched-file reference in the unified output: never content_b64.""" + + model_config = ConfigDict(extra="forbid") + + path: str | None = None + size: int | None = None + sha256: str | None = None + error: str | None = None + + +class UnifiedNode(BaseModel): + """One node's body in the unified output: ports, kube refs, fetch_files.""" + + model_config = ConfigDict(extra="forbid") + + ports: dict[str, str] = Field(default_factory=dict) + kube: dict[str, UnifiedKubeRef] = Field(default_factory=dict) + fetch_files: dict[str, UnifiedFetchRef] = Field(default_factory=dict) + + +class UnifiedOutput(BaseModel): + """The entire consumer-facing output: two reserved top-level keys.""" + + model_config = ConfigDict(extra="forbid") + + session: UnifiedSession + nodes: dict[str, UnifiedNode] + + class ErrorOutput(BaseModel): """Error envelope returned by ``tunstrap start`` on stdout.""" diff --git a/tunstrap/session.py b/tunstrap/session.py index d386755..da41cae 100644 --- a/tunstrap/session.py +++ b/tunstrap/session.py @@ -6,30 +6,116 @@ when the caller supplies it, cleanup removes only `tunnel-data/` (the caller's directory is never touched). `--session-dir` is untrusted: an existing tunnel-data that is a symlink, a non-directory, or not owned by the current -user is rejected. +user is rejected. A supplied root must be owned by the current user and has +its group/other write bits cleared on use, because it hosts 0600 credentials. """ from __future__ import annotations +import dataclasses import os import shutil +import signal +import stat import tempfile +import time from pathlib import Path from tunstrap.exceptions import SessionActive -from tunstrap.identity import acquire_session_lock, release_session_lock +from tunstrap.fdio import write_all +from tunstrap.identity import ( + IdentityCheckResult, + acquire_session_lock, + release_session_lock, + verify_session, +) _TUNNEL_DATA = "tunnel-data" +def atomic_write(path: Path, content: bytes) -> None: + """Write ``content`` to ``path`` (mode 0600) via temp file + ``os.replace``. + + True atomic replace, not ``_write_file``'s ``O_TRUNC``: a truncated file at + the final path is indistinguishable from a valid short one to a naive + reader, while a temp file plus ``os.replace`` guarantees only a complete + old or complete new file is ever observable at ``path`` -- load-bearing + for a process killed mid-write. + + The temp name is pinned to ``os.getpid()``, so distinct processes never + compete for it; ``O_EXCL`` therefore guards the *same* process against + re-entering on top of its own leftover temp, not a separate writer. Any + failure between the create and a successful ``os.replace`` unlinks the + temp first, so a same-pid retry is never permanently blocked by + ``O_EXCL``. The only way a stale temp survives is a hard crash + (``SIGKILL``) between ``os.open`` and the cleanup, which Python cannot + intercept; that residual is what would surface as ``FileExistsError`` on + a later same-pid call, flagging that a prior run died mid-write. + + The mode is fixed at the temp file's creation, so ``os.replace`` never + exposes a wider-than-0600 window. ``path.parent`` is created with mode + 0700 to match ``SessionDir.create``'s ``tunnel-data``. In production this + ``mkdir(exist_ok=True)`` is a no-op: every call site + (``materialize_atomic`` in the daemon, ``write_materialized_output`` in + the start/run parent) reaches ``atomic_write`` with ``path.parent`` + already minted at 0700 by ``SessionDir.create``, which runs in the daemon + before the parent ever writes. The ``mode=0o700`` here is therefore + defence-in-depth for a direct caller, not a live-hole fix. ``mkdir``'s + ``mode`` applies only to the leaf directory; that is sufficient because + ``path.parent`` is always exactly ``tunnel-data`` and its parent (the + session dir) is guaranteed to pre-exist at every call site, so no + intermediate component is ever created under the ambient umask. + """ + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + tmp_path = path.parent / f".{path.name}.{os.getpid()}.tmp" + fd = os.open(tmp_path, os.O_CREAT | os.O_WRONLY | os.O_EXCL, 0o600) + try: + write_all(fd, content) + os.replace(tmp_path, path) + except BaseException: + # Orphaning the temp would make O_EXCL reject every later same-pid + # retry (the name is pid-pinned); remove it so the next call starts + # clean. The inner FileNotFoundError suppress covers a real race, not a + # hypothetical one: the temp lives inside tunnel-data, and a concurrent + # teardown (``SessionDir.cleanup``'s rmtree) can remove it between the + # ``os.open`` above and this unlink -- an interrupted run is exactly + # when both happen at once. Without the suppress that ENOENT would + # raise and mask the original error we are about to re-raise. + try: + os.unlink(tmp_path) + except FileNotFoundError: + pass + raise + finally: + os.close(fd) + + class SessionError(Exception): """The session dir or its tunnel-data subdir failed validation.""" +class SessionIdentityUnreadable(SessionError): + """``daemon.pid`` could not be turned into a pid, and it is not simply absent. + + Split out because the three ways ``read_identity`` fails do not mean the + same thing to a caller deciding whether to delete state. A *missing* file + means nothing was ever recorded, so nothing is running. A file that cannot + be read (permissions, EIO, a directory in its place) or that holds + something that is not a pid (the shape a truncated write takes) means a + daemon got far enough to be there and we cannot address it — the daemon's + state is unknown, which is a reason to preserve, not to clean up. + + A subclass rather than a sibling so that every existing ``except + SessionError`` handler keeps its current behaviour; only callers that care + about the distinction have to name it. + """ + + class SessionDir: """Owns session.lock + the tunnel-data/ subdir for one daemon instance.""" def __init__(self, *, session_dir: Path, generated: bool, lock_fd: int) -> None: + """Retain lock and ownership metadata needed for later cleanup.""" self.session_dir = str(session_dir) self._root = session_dir self._generated = generated @@ -37,7 +123,7 @@ def __init__(self, *, session_dir: Path, generated: bool, lock_fd: int) -> None: self._lock_fd = lock_fd @classmethod - def create(cls, *, supplied: str | None, base: Path | None = None) -> "SessionDir": + def create(cls, *, supplied: str | None, base: Path | None = None) -> SessionDir: """Resolve the session dir, acquire session.lock, (re)create tunnel-data/. Raises ``SessionActive`` if a live daemon already holds the lock. @@ -51,7 +137,19 @@ def create(cls, *, supplied: str | None, base: Path | None = None) -> "SessionDi if not supplied_path.is_absolute(): raise SessionError("session dir must be an absolute path") root = supplied_path.resolve() - root.mkdir(parents=True, exist_ok=True) + # mkdir(parents=True, mode=...) applies mode to its leaf only; missing + # ancestors instead get 0o777 & ~umask. A group-writable ancestor lets + # another uid rename or replace root and defeats + # _secure_supplied_root's entry-level premise, so mint every missing + # component at 0700. + missing: list[Path] = [] + ancestor = root + while not ancestor.exists(): + missing.append(ancestor) + ancestor = ancestor.parent + for directory in reversed(missing): + directory.mkdir(mode=0o700, exist_ok=True) + cls._secure_supplied_root(root) generated = False try: @@ -61,6 +159,12 @@ def create(cls, *, supplied: str | None, base: Path | None = None) -> "SessionDi "session already active", {"session_dir": str(root)}, ) from exc + except OSError as exc: + # acquire_session_lock raises OSError on an unsafe lock file (a + # symlink, or a regular file not owned by us). Translate it to the + # domain SessionError every other session refusal uses, mirroring + # _reclaim_data_slot; the original OSError is chained for the cause. + raise SessionError(f"cannot acquire session lock at {root}: {exc}") from exc try: data = root / _TUNNEL_DATA @@ -88,15 +192,115 @@ def _reclaim_data_slot(data: Path) -> None: raise SessionError("tunnel-data exists and is not owned by this user") shutil.rmtree(data) + @staticmethod + def _secure_supplied_root(root: Path) -> None: + """Tighten a caller-supplied root: proven-owned, write bits cleared. + + Why this guard is NOT redundant given ``acquire_session_lock``'s + ``O_NOFOLLOW`` + ``fstat`` (issue #25 part (A)): that pair validates an + *inode* reached through an fd. Directory write permission, by contrast, + is authority over the *entries* of that directory -- create, unlink, + rename -- and is wholly independent of the mode and ownership of the + files inside it. No amount of fstat hardening on an opened fd reaches an + entry-level attack. With write access to the root another uid can unlink + the live ``session.lock`` (a fresh inode then passes every (A) check and + wins flock, since flock is per-inode), rename ``tunnel-data`` aside and + substitute a symlink (a rename within the parent needs write on the + parent only), or hardlink ``session.lock`` to a runner-owned victim. The + root guard is therefore the load-bearing premise of + ``_reclaim_data_slot``'s ``shutil.rmtree`` (lock exclusivity) and of + ``_validated_path``'s containment -- remove it and both collapse. + + Note the limit of *tightening* specifically: clearing the write bits + stops a hostile entry being planted from now on, but says nothing about + one planted before tunstrap first ran. The hardlink case is therefore + closed where it lands rather than here, by ``acquire_session_lock``'s + ``st_nlink`` refusal. + + Why refusal became tightening: the mode cannot distinguish a user-private + group (the Debian/Ubuntu default, zero cross-uid risk) from a genuinely + shared one, and as verified on a stock umask-0002 account refusing it + broke ``mkdir d && tunstrap run --session-dir d`` with a generic + ``DaemonError``. Refusing was the wrong enforcement because it rejected a + safe common case. + + Why tightening is legitimate: ownership is already proven before the + chmod (an unowned root is refused, never tightened), so the runner is + within its rights to set the mode. The tool already forces 0700 on a + root it creates itself and on ``tunnel-data``, so clearing write bits on + a supplied root is the same posture applied where the runner cannot pick + the initial mode. + + Why only the write bits (``S_IWGRP | S_IWOTH``) are cleared, and why + *pre-existing* parents are never inspected: clearing preserves read/exec, + so a legitimate 0755 root is left at 0755 rather than force-chmodded to + 0700. A root under a 1777 ``/tmp`` with its own mode is safe, and + inspecting pre-existing parents would break ``mkdtemp``-based tests and + reach outside what the runner owns. + + fd-based and TOCTOU-free: the mode is read and set through one fd held + open for the call, so a concurrent rename-symlink swap between a stat + and a chmod cannot retarget the change. After fchmod the mode is re-stat + through the same fd, and if the write bits survive (an ACL mask or an + exotic filesystem that silently ignores fchmod) the root is refused + rather than accepted on a no-op chmod -- a silently-failing fchmod must + not ship as "accept anything". + """ + try: + dirfd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC) + except OSError as exc: + raise SessionError(f"cannot open session dir {root}: {exc}") from exc + try: + st = os.fstat(dirfd) + if st.st_uid != os.getuid(): + raise SessionError("session dir is not owned by the current user") + if st.st_mode & (stat.S_IWGRP | stat.S_IWOTH): + try: + os.fchmod( + dirfd, + stat.S_IMODE(st.st_mode) & ~(stat.S_IWGRP | stat.S_IWOTH), + ) + except OSError as exc: + raise SessionError( + f"session dir {root} is group- or world-writable and " + f"could not be tightened: {exc}; run chmod go-w {root}" + ) from exc + # Re-stat through the same fd: an ACL mask or exotic filesystem + # can let fchmod succeed yet leave the bits set, and accepting + # that as a tightening would be a no-op guard. + if os.fstat(dirfd).st_mode & (stat.S_IWGRP | stat.S_IWOTH): + raise SessionError( + f"session dir {root} is group- or world-writable and " + f"could not be tightened (write bits survived fchmod); " + f"run chmod go-w {root}" + ) + finally: + os.close(dirfd) + def write_identity(self, *, pid: int) -> None: """Write daemon.pid (mode 0600) into tunnel-data/.""" self._write_file("daemon.pid", f"{pid}\n".encode("ascii")) - def materialize(self, name: str, content: bytes) -> str: - """Write `content` to tunnel-data/ (mode 0600); return the path.""" - return self._write_file(name, content) + def materialize_atomic(self, name: str, content: bytes) -> str: + """Write `content` to tunnel-data/ via atomic replace; return the path. - def _write_file(self, name: str, content: bytes) -> str: + Name-safety rules come from ``_validated_path``; the write itself uses + the true-atomic primitive (temp file + ``os.replace``) -- see ``atomic_write``. + """ + path = self._validated_path(name) + atomic_write(path, content) + return str(path) + + def _validated_path(self, name: str) -> Path: + """Confine materialized names despite separator and symlink tricks.""" + # ``path.resolve().parent != self._data.resolve()`` is a no-op when + # ``tunnel-data`` itself is the symlink -- both sides resolve through + # the attacker's link and compare equal. The explicit ``is_symlink`` + # check is therefore what actually keeps materialization inside the + # session dir; without it a substituted ``tunnel-data`` symlink would + # pass containment and write a patched kubeconfig into attacker space. + if self._data.is_symlink(): + raise SessionError("tunnel-data is a symlink; refusing to follow") if "/" in name or "\\" in name: raise SessionError(f"unsafe materialized file name: {name!r}") if name in (".", ".."): @@ -104,9 +308,14 @@ def _write_file(self, name: str, content: bytes) -> str: path = self._data / name if path.resolve().parent != self._data.resolve(): raise SessionError(f"unsafe materialized file name: {name!r}") + return path + + def _write_file(self, name: str, content: bytes) -> str: + """Write legacy session metadata only after applying containment checks.""" + path = self._validated_path(name) fd = os.open(path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600) try: - os.write(fd, content) + write_all(fd, content) finally: os.close(fd) return str(path) @@ -121,15 +330,198 @@ def cleanup(self) -> None: @staticmethod def read_identity(session_dir: str) -> int: - """Read the recorded pid from a session dir's tunnel-data/daemon.pid.""" + """Read the recorded pid from a session dir's tunnel-data/daemon.pid. + + Raises ``SessionIdentityUnreadable`` — a ``SessionError`` subclass — + for everything except a genuinely absent file, so a caller can tell + "nothing was ever recorded" from "there is something here I cannot + address". Every unreadable case shares the ``cannot read identity + from : …`` shape; only the tail differs (the underlying + ``OSError``/``ValueError`` text, or, for a non-positive value, the + explicit reason). + + A non-positive value is unreadable on purpose. Under ``kill(2)`` a pid + of 0 means the caller's own process group and a negative pid a process + group — with ``-1`` meaning *every* process the caller can signal, a + broadcast rather than a single group — so handing such a value to + ``os.kill`` widens a signal far beyond the recorded daemon. Under + ``waitpid(2)`` the same encodings select a child group (or, for ``-1``, + any child), the exact hazard ``_has_exited`` already guards against. An + attacker-controlled ``--session-dir`` (or a corrupt ``daemon.pid``) + could plant such a value deliberately; refusing it here is the gate that + keeps it off the kill path, which re-asserts ``pid > 0`` at its entry as + defence in depth. + """ data = Path(session_dir).resolve() / _TUNNEL_DATA try: - return int((data / "daemon.pid").read_text().strip()) - except (OSError, ValueError) as exc: + raw = (data / "daemon.pid").read_text() + except FileNotFoundError as exc: raise SessionError(f"cannot read identity from {data}: {exc}") from exc + except OSError as exc: + raise SessionIdentityUnreadable(f"cannot read identity from {data}: {exc}") from exc + try: + pid = int(raw.strip()) + except ValueError as exc: + raise SessionIdentityUnreadable(f"cannot read identity from {data}: {exc}") from exc + if pid <= 0: + raise SessionIdentityUnreadable( + f"cannot read identity from {data}: pid {pid} is not positive" + ) + return pid @classmethod - def cleanup_path(cls, session_dir: str) -> None: - """Remove /tunnel-data best-effort (stop-side cleanup).""" - data = Path(session_dir).resolve() / _TUNNEL_DATA - shutil.rmtree(data, ignore_errors=True) + def cleanup_path(cls, session_dir: str) -> list[str]: + """Remove ``/tunnel-data`` best-effort; return what survived. + + Never raises, so ``stop``'s behaviour is unchanged. The returned list + is empty on success and holds the still-present path when removal + failed, which is what gives ``run`` something to report on stderr — + the old ``ignore_errors=True`` discarded every error, making a + promise to report cleanup failures unsatisfiable. + """ + return cls._rmtree_reporting(Path(session_dir).resolve() / _TUNNEL_DATA) + + @classmethod + def remove_root(cls, root: str) -> list[str]: + """Remove a ``run``-minted session root entirely; return what survived. + + ``run`` supplies its own ``--session-dir``, which makes the worker's + ``SessionDir`` non-generated, so the worker never removes the root. + ``run`` therefore removes the root it minted itself, and only that one + — a caller-supplied ``--session-dir`` is never touched. + + ``.resolve()`` for parity with ``cleanup_path`` and ``read_identity``. + It is safe to follow a symlink here specifically because the only + caller passes a ``tempfile.mkdtemp`` path, which is always a real + directory this process just created — unlike ``--session-dir``, this + argument is never caller-controlled. + """ + return cls._rmtree_reporting(Path(root).resolve()) + + @staticmethod + def _rmtree_reporting(path: Path) -> list[str]: + """rmtree ignoring errors, then report the path if it survived. + + ``shutil.rmtree(onexc=...)`` is 3.12+ and ``onerror=`` is deprecated + from 3.12; with a 3.10 floor the portable outcome check is + "did the path go away?". + """ + shutil.rmtree(path, ignore_errors=True) + try: + path.stat() + except FileNotFoundError: + return [] + except OSError: + return [str(path)] + return [str(path)] + + +@dataclasses.dataclass(frozen=True) +class StopOutcome: + """What ``stop_session`` did, with no opinion about where to report it. + + ``reason`` is ``None`` for the two success shapes and otherwise carries + ``stop``'s documented wording verbatim. ``forced`` is True only when the + daemon had to be SIGKILLed after the grace period. + """ + + stopped: bool + reason: str | None = None + forced: bool = False + + +def _has_exited(pid: int) -> bool: + """True once ``pid`` has terminated, reaping it first when it is our child. + + ``os.kill(pid, 0)`` alone is not a liveness probe for a process we spawned. + ``run`` starts the daemon with ``subprocess.Popen`` + (``tunstrap/daemon.py::spawn_daemon``) and never + waits on it, so the daemon is a *child* of the CLI: when it exits it becomes + a zombie, and the pid stays allocated — and keeps answering signal 0 — until + somebody reaps it. The grace poll below therefore ran to its full deadline on + every clean shutdown, then found the flock already released and reported + "identity changed during grace" for a daemon that had exited in milliseconds. + + ``waitpid(WNOHANG)`` answers the question *and* frees the pid, so the + signal-0 probe becomes truthful again. Falling back to it on ``ECHILD`` + also keeps the answer independent of whether ``subprocess``'s own + ``_active`` bookkeeping happened to reap the daemon first — that is CPython + internals, not a promise across the supported 3.10-3.13 range. + + Only ``pid > 0`` reaches ``waitpid``: 0 and negatives select a process + *group*, which would let a corrupt ``daemon.pid`` reap ``run``'s foreground + child and steal its exit status. + """ + if pid > 0: + try: + return os.waitpid(pid, os.WNOHANG)[0] == pid + except OSError: + # ECHILD: not, or no longer, our child — the `stop` verb runs in a + # process that never spawned the daemon, and there signal 0 is + # already correct because nobody holds the exit status open. + # Anything else means the reap is simply unavailable. Neither may + # escape: a raising stop_session short-circuits _teardown_run + # before it removes tunnel-data. + pass + try: + os.kill(pid, 0) + except ProcessLookupError: + return True + return False + + +def stop_session( # pylint: disable=too-many-return-statements + session_dir: str, pid: int, grace_seconds: int, *, force: bool +) -> StopOutcome: + """Stop the daemon recorded for ``session_dir``. Performs the stop, writes nothing. + + Silent by design, because it has two callers wanting different channels: + ``cli.stop_command`` renders the returned outcome as ``stop``'s stdout JSON + (``cli._stop_outcome_json``), while ``cli._teardown_run_inner`` prints + nothing on success and stderr on failure, so a foreground child keeps fd 1 + to itself. Deciding here would serve only one of them. + + A non-positive ``pid`` is refused at function entry, before any syscall. + Under ``kill(2)`` such a value selects a process *group* (0 is the caller's + group; ``-1`` is every process the caller can signal), so + ``os.kill(-1, SIGTERM)`` would broadcast the signal. ``read_identity`` is + the gate that keeps a corrupt ``daemon.pid`` (or a hostile ``--session-dir``) + from reaching this function in production, and ``_process_exists`` refuses + the same value independently, but the guard here does not lean on either: + it runs first, so it also covers ``verify_session``'s own signal-0 probe, + and a direct caller that bypasses ``read_identity`` — or a host where the + upstream gates have failed — still cannot widen a signal. It returns + ``identity check unavailable``: an unresolved outcome, so the caller + preserves rather than deletes — the same disposal ``read_identity`` demands + for the identical value. + """ + if pid <= 0: + return StopOutcome(False, "identity check unavailable") + check = verify_session(session_dir, pid) + if check == IdentityCheckResult.not_found: + return StopOutcome(False, "not found") + if check == IdentityCheckResult.mismatch: + return StopOutcome(False, "identity mismatch") + if check == IdentityCheckResult.unavailable: + return StopOutcome(False, "identity check unavailable") + + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + return StopOutcome(True) + + deadline = time.monotonic() + max(0, grace_seconds) + while time.monotonic() < deadline: + if _has_exited(pid): + return StopOutcome(True) + time.sleep(0.5) + + if not force: + return StopOutcome(False, "still alive") + if verify_session(session_dir, pid) != IdentityCheckResult.match: + return StopOutcome(False, "identity changed during grace") + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + return StopOutcome(True) + return StopOutcome(True, forced=True) diff --git a/tunstrap/ssh.py b/tunstrap/ssh.py index b82893c..f4fdb21 100644 --- a/tunstrap/ssh.py +++ b/tunstrap/ssh.py @@ -9,7 +9,8 @@ from __future__ import annotations import socket -from typing import Any, Callable +from collections.abc import Callable +from typing import Any import asyncssh @@ -92,7 +93,7 @@ async def open_local_forwards( }, ) ports[handle] = actual_port - except BaseException: # pylint: disable=broad-exception-caught + except BaseException: # Caller never sees the listeners on failure; cleanup must cover # KeyboardInterrupt / CancelledError to avoid leaking SSH channels. # Re-raised immediately so the failure propagates intact. diff --git a/tunstrap/tofu_proxy.py b/tunstrap/tofu_proxy.py new file mode 100644 index 0000000..62d8668 --- /dev/null +++ b/tunstrap/tofu_proxy.py @@ -0,0 +1,171 @@ +"""``tunstrap_tofu`` console entry: the OpenTofu proxy, in-process. + +This is the in-package successor to the consumer-side shell shim (now retired +from the recipe; see the "Alternative: a shell shim for the fast path" section +of ``docs/recipe_terragrunt.md``). Shipping it as a second +``[project.scripts]`` entry point of this package means ``uv tool install`` +yields both ``tunstrap`` and ``tunstrap_tofu``, so Terragrunt's +``terraform_binary`` can point at a stable installed path with nothing copied +into the consumer's repo. + +Cost discipline. The pass-through branches (``TUNSTRAP_INPUT`` unset, or a +no-cluster subcommand like ``init``/``version``) ``execvp`` straight into +``tofu`` **without importing ``tunstrap.cli`` or any heavy dependency**. +``tunstrap/__init__.py`` resolves ``__version__`` lazily (PEP 562), so the +package import itself loads no ``importlib.metadata`` on this path. Measured +end-to-end via the installed entry point the fast path is **~25 ms** (≈17 ms +interpreter + a now-cheap package import + the execvp handoff) — about **12× +the ~2 ms shell shim**, i.e. **~74 ms added per ``terragrunt plan``** at three +fast-path hits, still noise beside an 8 s ``tofu init``. For a consumer for +whom every millisecond of the fast path matters, a 3-line shell shim remains +the lower-overhead option (see ``docs/recipe_terragrunt.md``). The tunnelled +branch imports ``tunstrap.cli`` lazily — that path already costs seconds for the +SSH handshake and the child, so the import is noise there. + +Terraform vocabulary lives here by deliberate owner decision; see the +"Shipping the shim" history in +``docs/specs/2026-07-31-run-env-io-and-tofu-proxy-design.md`` and the recipe's +"Why a console script (now)" section for the trade. +""" + +from __future__ import annotations + +import os +import sys + +_INPUT_ENV = "TUNSTRAP_INPUT" +_OUTPUT_VAR = "TF_VAR_tunstrap" +_TOFU = "tofu" + +# tofu subcommands that BYPASS the tunnel when TUNSTRAP_INPUT is set — the ones +# that provably do not contact the cluster. Behaviourally equivalent to the +# shell shim's ``case "$1" in init|-version)`` plus the no-cluster extras +# (``version`` subcommand, and ``-help``/no-subcommand, which ``_find_subcommand`` +# returns ``None`` for, also bypassing). ``init`` is the load-bearing entry: +# Terragrunt's extra_arguments.env_vars scopes TUNSTRAP_INPUT to the listed +# commands AND their automatic ``init`` (measured fact 4 in the design spec), so +# a ``terragrunt plan`` sets it for the auto-init too — without this bypass the +# auto-init would build a needless second tunnel. ``validate`` and ``fmt`` are +# the same kind of provable no-cluster-contact command as ``init``: ``validate`` +# checks the configuration against installed provider schemas only and never +# configures a provider (no cluster round-trip, unlike ``plan``/``apply``); +# ``fmt`` touches only local ``.tf`` files. Bypassing them avoids a pointless +# SSH tunnel plus kubeconfig materialization on every ``validate``/``fmt``. +# +# Tension: this bypasses TUNSTRAP_INPUT even when a consumer deliberately +# listed ``validate``/``fmt`` in Terragrunt's ``extra_arguments.commands`` — +# the opt-in the "everything else tunnels" rule below otherwise honours. An +# earlier allow-list version of this bypass set was rejected on exactly that +# opt-in-should-win reasoning; ``validate``/``fmt`` are added here as narrow, +# individually-justified exceptions (provably cluster-free, same as ``init``), +# not a reopening of that allow-list. +# +# Everything NOT in this set TUNNELS. TUNSTRAP_INPUT is set only for commands +# the consumer deliberately listed in Terragrunt's ``commands``, so the proxy +# must honour that opt-in (e.g. ``output`` — the e2e tier lists it in +# ``commands`` and asserts the tunnelled row) rather than second-guess it with a +# cluster-only allow-list of its own. An earlier allow-list version did exactly +# that and was a behaviour change, not the ``-chdir`` gap fix it posed as. +_BYPASS_COMMANDS = frozenset({"init", "version", "validate", "fmt"}) + +# Global flags that take their value as a SEPARATE argv token (``-chdir DIR``). +# Their ``=`` form (``-chdir=DIR``) is one token and is handled by the bare +# ``tok.startswith("-")`` skip below. ``-chdir`` is the flag the shell shim's +# literal-``$1`` match could not see past, so ``tofu -chdir=DIR init`` missed +# the bypass and built a needless tunnel — the documented gap this parser fixes. +_GLOBAL_VALUE_FLAGS = frozenset({"-chdir", "--chdir"}) + + +def main() -> int: + """``tunstrap_tofu`` entry point. + + Never returns on the pass-through branches: it ``execvp``s into ``tofu`` + and this process image is replaced. The tunnelled branch delegates to + ``run`` (in-process) and exits with the child's code, so it does not + return either. + """ + argv = sys.argv[1:] + raw = os.environ.get(_INPUT_ENV, "") + if not raw.strip(): + _exec_tofu(argv) + if _should_bypass(argv): + _exec_tofu(argv) + _run_tunnelled(argv) + return 0 # pragma: no cover — _run_tunnelled exits + + +def _should_bypass(argv: list[str]) -> bool: + """True iff the subcommand needs no tunnel (``TUNSTRAP_INPUT`` assumed set). + + The pinned bypass set: ``init``, ``version``, ``validate`` and ``fmt`` + subcommands, plus anything ``_find_subcommand`` returns ``None`` for + (``-version``/``-help`` global flags, or no subcommand at all). Everything + else tunnels. Pinned exhaustively by the ``_should_bypass`` table test; do + not broaden without updating it. + """ + subcmd = _find_subcommand(argv) + return subcmd is None or subcmd in _BYPASS_COMMANDS + + +def _exec_tofu(argv: list[str]) -> None: + """Replace this process with ``tofu``, argv untouched.""" + try: + os.execvp(_TOFU, [_TOFU, *argv]) + except OSError as exc: + sys.stderr.write(f"tunstrap_tofu: cannot execute tofu: {exc}\n") + sys.exit(127) + + +def _find_subcommand(argv: list[str]) -> str | None: + """Return the tofu subcommand, parsed past leading global flags. + + Mirrors tofu's own grammar: ``-version``/``-help`` as a global flag and an + empty command line short-circuit to "no subcommand" (no cluster contact); + ``-chdir DIR`` and ``-chdir=DIR`` are consumed so the real subcommand is + reached. The first token that is neither a consumed value-flag nor any + other ``-``-prefixed global flag is the subcommand. + + This is structural parsing, not a substring match: ``tofu -chdir init plan`` + consumes ``init`` as the chdir value and reports ``plan`` as the + subcommand, where a naive "``init`` anywhere in argv" predicate would + wrongly bypass. + """ + i = 0 + while i < len(argv): + tok = argv[i] + if tok in ("-version", "--version", "-help", "-h", "--help"): + return None + if tok in _GLOBAL_VALUE_FLAGS: + i += 2 # consume the flag and its separate value + continue + if tok.startswith("-"): + i += 1 # any other global flag (=form or bare); skip one token + continue + return tok + return None + + +def _run_tunnelled(argv: list[str]) -> None: + """Open the tunnel and run ``tofu`` as its child, in-process. + + Replaces ``exec tunstrap run --input-env … --output-var … -- env -u + KUBECONFIG tofu …``. Going in-process drops one process level (``sh`` → + ``tunstrap``) and lets ``run`` build the child environment directly, so + ``env -u KUBECONFIG`` becomes ``suppress_kubeconfig=True``: same property + (a broken ``config_path`` chain cannot fall back to an inherited or + injected ``KUBECONFIG``), no child-side wrapper. + + ``tunstrap.cli`` is imported here, on the tunnelled path only, so the + pass-through branches never pay for it. + """ + # Lazy on purpose: importing tunstrap.cli on the pass-through paths would + # cost ~180 ms (click/pydantic/asyncssh), defeating the entry point. + from tunstrap.run_invocation import run_via_env_input # pylint: disable=import-outside-toplevel + + run_via_env_input( + _INPUT_ENV, + _OUTPUT_VAR, + [_TOFU, *argv], + suppress_kubeconfig=True, + ) + sys.exit(0) # pragma: no cover — run_via_env_input exits diff --git a/vulture_whitelist.py b/vulture_whitelist.py deleted file mode 100644 index 9cd10fc..0000000 --- a/vulture_whitelist.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Vulture whitelist: symbols flagged as unused but kept on purpose. - -Pydantic ``@field_validator`` classmethods receive ``cls`` as their first -positional argument by API contract but do not reference it inside the -function body. Vulture cannot tell ``cls`` apart from a regular unused -local. List one reference per validator so vulture's 100%-confidence -"unused variable" finding is silenced without weakening ``min_confidence``. -""" - -from __future__ import annotations - -from tunstrap import schemas as _schemas - -# Each @field_validator below has a ``cls`` parameter we cannot omit. -_ = _schemas.FileSpec._validate_absolute # noqa: SLF001 -_ = _schemas.NodeInput._validate_fetch_files # noqa: SLF001 -_ = _schemas.InputSchema._validate_auth # noqa: SLF001