Repository navigation
Two defects that break the dev tasks and the e2e suite before dind is involved - #118
Conversation
`dev-add-docker` guarded itself with `grep -qw docker` over `devpod provider list`. devpod v0.26.1 colourises that table and emits the reset `ESC[m` immediately before each cell, so the character in front of `docker` is the letter `m` and `-w`'s left word boundary never matches: `grep -q` rc=0, `grep -qw` rc=1, and NO_COLOR does not help. Every machine that had already registered the provider re-ran `provider add` and got `fatal provider docker already exists`, taking `dev`, `dev-vs`, `dev-restart` and `dev-restart-vs` down with it. Dropping `-w` would fix today's rendering and leave us parsing a table devpod may re-render tomorrow. `provider list --output json` is the same information with no rendering to change. The other half is the shape of the bug rather than the bug: output the guard could not read came back as "no providers", and "no providers" means "add one" -- an unreadable answer became an action. Unreadable is now its own outcome. The fixtures are byte-for-byte recordings of devpod v0.26.1: the colourised table, a listing with docker registered, and one from a devpod home that has never had a provider added.
`test_purge_deletes_workspaces` called `devpod up ... --id e2e-test-purge` without `--ide none`, unlike every sibling. devpod built the workspace, then tried to open an editor, found no `xdg-open`, and exited 1. The test read that rc as "could not create test workspace" and skipped -- on any headless machine, container or not -- leaving the container it had just successfully made running and tracked by nothing. Two failures compounding. The missing flag is the smaller one: a create that failed after creating something is not a create that did nothing, and skipping a step the test cannot proceed without turns a leak into a line of grey text. Both are properties of the creation step, so the step is now one helper every e2e test goes through: no IDE ever, registered for cleanup before creation is attempted rather than after, and a non-zero rc fails with devpod's output attached instead of skipping. It needs no daemon to test, and the tests for it are unit tests. The sibling tests wrapped their assertions in `if result.returncode == 0`, which is the same defect with the volume down -- a failed create made the body vanish and the test pass. Those conditionals go too, along with the three hand-rolled copies of "list workspaces, but only if the listing read". An unreadable listing there was also becoming an empty one, which an assertion then reads as "the workspace is gone".
Reviewer's GuideRefactors DevPod provider detection and e2e workspace creation to use robust, machine-readable interfaces, adds unit-tested seams for both, and removes silent fallbacks where unreadable or failing commands were previously treated as benign empties or skips. Sequence diagram for dev-add-docker using devpod_provider CLIsequenceDiagram
participant PixiTask
participant devpod_provider_cli as DevpodProviderCLI
participant Devpod
PixiTask->>devpod_provider_cli: main(name=docker)
devpod_provider_cli->>devpod_provider_cli: ensure_provider(name)
devpod_provider_cli->>Devpod: list_provider_names()
Devpod-->>devpod_provider_cli: provider_list_json
devpod_provider_cli->>devpod_provider_cli: parse_provider_names(listing)
alt docker already registered
devpod_provider_cli-->>PixiTask: exit 0 (already registered)
else docker not registered
devpod_provider_cli->>Devpod: devpod provider add docker
Devpod-->>devpod_provider_cli: returncode
alt add succeeded
devpod_provider_cli-->>PixiTask: exit 0 (added)
else add failed
devpod_provider_cli-->>PixiTask: exit 1 (error)
end
else listing unreadable
devpod_provider_cli-->>PixiTask: exit 1 (UnreadableProviderList)
end
Sequence diagram for the new e2e workspace creation helpersequenceDiagram
actor Pytest
participant E2EHelper
participant CleanupRegistry
participant Devpod
Pytest->>E2EHelper: create_e2e_workspace(env, workspace_id)
E2EHelper->>CleanupRegistry: register_workspace_for_cleanup(workspace_id)
E2EHelper->>Devpod: devpod up . --ide none --id workspace_id
Devpod-->>E2EHelper: returncode, stdout, stderr
alt returncode == 0
E2EHelper-->>Pytest: workspace ready
else returncode != 0
E2EHelper-->>Pytest: pytest.fail(devpod_output)
end
File-Level Changes
Assessment against linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #118 +/- ##
==========================================
+ Coverage 90.21% 90.39% +0.17%
==========================================
Files 16 17 +1
Lines 1942 1978 +36
==========================================
+ Hits 1752 1788 +36
Misses 190 190
🚀 New features to boost your workflow:
|
`main` reached devpod through a module-level default, so the entry point the task invokes was the one piece of the guard no test could touch -- including the case that matters most, a devpod whose answer cannot be read having to stop the task rather than let it carry on as if the provider were missing. It takes the process boundary the same way the rest of the module does.
blooop
left a comment
There was a problem hiding this comment.
This was generated by AI during review.
Reviewed at b6bc2705a4910b77abf5510956528148f6acdc51 against merge-base cc1f11a4ca9badb6aa8a7a99d63bad483f2fbf11, by a reviewer that did not see this code written. Two axes, kept separate and not reranked against each other. The Spec axis was run as an independent fresh-context subagent and its findings re-verified here; the Standards axis is direct review. Every load-bearing claim in the PR body was re-measured on this host rather than taken on trust.
Preflight: ref resolves, diff non-empty (589+/106−, 10 files), all checks green (ci py310–py313, prek, codecov patch+project, GitGuardian), MERGEABLE.
What I verified on metal, independently
The PR's factual claims hold. All of them.
| Claim | Result |
|---|---|
devpod provider list --output json exists on v0.26.1 |
True. rc=0, object keyed by provider name. --output documented in provider list --help as "Can be json or plain". Measured on the shipped skevetter/devpod fork, not from loft-sh docs. |
The char before docker is m |
True. devpod provider list | cat -v → ^B^[[mdocker. grep -q docker rc=0, grep -qw docker rc=1, NO_COLOR=1 … grep -qw rc=1. |
| Fixtures are byte-for-byte recordings | True, and this is the strongest single piece of evidence in the PR. devpod provider list | md5sum = 6591bc70c8ea1db0cd6cdc9ca688dedc = md5sum test/fixtures/devpod/provider_list_plain.ansi. provider list --output json | md5sum = 1784f482e11548957571ae3d746351f8 = provider_list.json. Exact, both. |
Empty listing is {} |
True. Scratch DEVPOD_HOME → {}, rc=0, no trailing newline — matching provider_list_empty.json exactly. |
| New guard works cold and is idempotent | True. Scratch DEVPOD_HOME: first run installed provider: providerName=docker → added; second run → already registered, rc=0. Against the real devpod home: already registered, rc=0, provider set still ['docker']. |
| New module is 100% covered | True and meaningful, not line-touching — 20 passed, devlaunch/devpod_provider.py 36 stmts, 0 miss. |
| Defect 1 red reproduces | True. grep -qw docker against the committed recording → rc=1; grep -q → rc=0. |
Defect 2 red is FFss |
True, reproduced against the PR's own tests with the old creation step transcribed back in. |
devpod version range is safe for --output json |
True, and better than the PR claims: pyproject.toml:30 pins devpod = ">=0.26.1,<0.27", so the JSON flag cannot vanish under the task. |
On the new-module trade (defect 1), my honest verdict: the direction is right, the destination is wrong.
Choosing --output json over dropping -w is correct and principle 1 justifies it — a one-character fix would re-break the next time devpod re-renders that table, and the version pin does not protect against a rendering change. That part I would not argue with.
But devlaunch/ is the wrong home. [tool.hatch.build] include = ["devlaunch"] means this module ships in the wheel to every pixi global user of dl, and nothing user-facing imports it — its only caller is a dev-only pixi task. scripts/ already exists in this repo for exactly this. Shipping dev-task machinery in the production package is a principle-2 cost paid by users who will never call it.
Standards
-
devpod_provider.pybypassesrun_devpod, breaking an invariantdl.pydocuments about itself. (should-fix)
dl.py:825says, in its own docstring: "This is dl's only devpod spawn, so it is also the only place that can tell 'devpod is not installed' from 'devpod ran and failed'. The former is raised asDevpodNotInstalledrather than folded into a returncode: callers branch on returncode and would carry on as though devpod had answered." The new module callssubprocess.rundirectly (devpod_provider.py:57,:81). That sentence is now false, and the guard it describes is gone. Reproduced: withdevpodabsent,main()raises an uncaughtFileNotFoundErrortraceback rather than the cleanerror: …/ rc=1 its docstring promises. This is the PR's own thesis — an unreadable outcome must be its own outcome — with one case left uncovered. -
Speculative surface in a 116-line module doing one thing. (nit, but it is the size question)
parse_provider_namesis public with its own tests though onlylist_provider_namescalls it; the non-dictbranch (:46) is unreachable from real devpod output and is covered only by a synthetic listing;argparse(:97) is a full parser for one positional. Therun=subprocess.runseam is earned — it is what makes the tests daemon-free — but the rest is a smaller module wearing a larger one's clothes. -
main()catches(UnreadableProviderList, RuntimeError)where the first is a subclass of the second (:30,:108) — redundant, and the bareRuntimeErrorarm will swallow unrelatedRuntimeErrors aserror: …/rc=1. Alsoensure_provider's add-failure path (:81–83) runs withoutcapture_output, so itsRuntimeErrorcarries no devpod output — unlike every other error path in the module, which attaches it. (nit) -
Fixture protection is real but has one hole. (nit)
end-of-file-fixer's exclusion is genuinely load-bearing — I confirmed both.jsonfixtures end without a trailing newline, exactly as devpod emits them, which that hook would have "fixed".mixed-line-ending's exclusion is defensive only (devpod emits LF). Butcodespellruns--write-changeswithexclude: \.(svg|pyc|lock|json|hrd|pgm)$(.pre-commit-config.yaml:59-61) — that covers the two.jsoncaptures and notprovider_list_plain.ansi. A hook that rewrites files in place can still reach the evidence.fix-byte-order-markerlikewise still applies. Addtest/fixtures/devpod/to codespell's exclude to make the stated intent true. -
test/fixtures/e2e_helpers.pyis now two modules in one file — a pytest-fixture module (dl_no_ide,devpod_cleanup) and a plain helper library (create_e2e_workspace, which callspytest.failfrom module scope and takes an untypedcleanup). Divergent Change: it will now be edited for two unrelated reasons. (nit) -
workspace_ids()usescheck=True(test_full_workflow.py:41) — correct in refusing to read an unreadable listing as empty, which is the whole point, but a rawCalledProcessErroris a worse failure message than the explicit failurecreate_e2e_workspacebuilds three lines away. Inconsistent with the standard the same PR sets. (nit) -
Prose volume. The docstrings here are essays —
devpod_provider.pyis 23 lines of module docstring before the first import, andcreate_e2e_workspace's docstring is 25 lines for a 16-line function. They are good essays and they encode the reasoning that would otherwise be lost. But they duplicate the PR body and the ticket almost verbatim, and prose that restates a decision is prose that goes stale when the decision moves. Judgement call; I would keep them, and I flag the ratio. -
The same defect the PR eradicates is still live in production code, unfixed. (out of scope for #111 — file a follow-up)
dl.py:930-937:result = run_devpod(["list", "--output", "json"], capture=True) if result.returncode != 0 or not result.stdout.strip(): return [] try: data = json.loads(result.stdout) except json.JSONDecodeError: logging.error("Failed to parse devpod output") return []
Demonstrated live: a garbage listing →
[]; a rc=1 listing →[]. That is verbatim the shape the PR body names as the thing it is eliminating — "an outcome that could not be read is being silently read as 'nothing is there'" — andpurge_all_data()(dl.py:398) consumes it. Not this ticket's job, and the consequence in purge is benign ("No data to purge") rather than destructive. But it is worth saying plainly: the fix landed in a dev-only task and a test helper, while the shipped code still does it.
Spec
Measured against issue #111.
-
The ticket's named precondition is declared unnecessary. (blocking on merge order, not on content)
#111: "The e2e scoping merged in #103/PR #106 is what makes exercising this safe; build on it rather than reintroducing an unscoped path." PR #106 is stillOPEN, and this PR's body answers "This branch is offmainand does not depend on it."
Measured:grep -rn DEVPOD_HOMEover the PR head returns nothing.isolated_devlaunch_env(test/fixtures/git_fixtures.py:20) scopes onlyXDG_CACHE_HOME.purge_all_data()(dl.py:383) lists via unfiltereddevpod listanddevpod delete --forces every entry. Sodl --purge -yintest_purge_deletes_workspacesis bounded by nothing.
Half of this refutes itself, and the review says so:test_purge_cleans_cache— untouched by this PR, present verbatim at merge-base — already runsdl --purge -ywith no gate at all. The incremental blast radius of #118 is therefore ≈ zero; e2e is also deselected by default (addopts = "-m 'not e2e'"). What is new is that #118 makes the sibling purge test proceed rather than skip, and the PR then invites a reviewer to run that file. On a host without #106's scoping, accepting that invitation costs every workspace on the machine. Land #106 first. Do not merge this ahead of it, and do not follow the PR's "run it once to see 8 passed" suggestion without a scopedDEVPOD_HOME. -
"Neither mistake is available to write again" is an overclaim. (should-fix)
#111: "assert the workspace-creation step is not silently skippable."create_e2e_workspaceis a convention, not an enforcement — nothing asserts every creation site routes through it, and the next hand-rolleddevpod upreintroduces both mistakes. #106's own reasoning is the counterexample worth quoting: "a fixture is something a test has to ask for, and the test that must not forget is the one nobody has written yet" — which is why it chosepytest_configure. Either soften the claim in the docstring, or enforce it (a lint rule, or an autouse fixture that fails on a rawdevpod up). -
Defect 1's literal acceptance criterion is substituted, defensibly. (nit)
#111 asks for "a test that the 'provider already present' case is detected as present, fed devpod's real coloured output as a fixture." No test does that: the coloured recording instead assertsUnreadableProviderList, and "present" is asserted against the JSON capture. The same ticket paragraph authorises the machine-readable form, so this is a stronger answer than the one asked for — but it is a substitution, and worth naming rather than glossing. -
Scope growth: in scope, and it is the good kind. #111 frames both defects as one shape — "an outcome that could not be read is being silently read as 'nothing is there'". The
if result.returncode == 0:wrappers and the three hand-rolled listing reads are that shape, in the same file, in the tests the ticket is about. Decisive evidence for principle 2:test_full_workflow.pyis 61+/105−, net −44 lines. Scope grew; the code shrank. No objection. -
The #106 conflict prediction: right conclusion, wrong location.
git merge-treeof the two heads gives one conflict, intest/e2e/test_full_workflow.pylines 20–67 — the import header, not the creation sites (#106 never touches them).e2e_helpers.pyandconftest.pyauto-merge. A naive take-both resolution parses and keepsworkspace_ids()plus all four call sites.
No semantic conflict, and the mechanism is confirmed: #106 mutatesos.environinpytest_configure, which runs before collection, so the call sites'env={**os.environ, "XDG_CACHE_HOME": …}snapshot it afterwards and preserveDEVPOD_HOME/DEVPOD_SSH_CONFIG;workspace_ids()passes noenvand inherits. The prediction holds. -
Residual verification gap (should-fix, moderate). #111: "Do not run the full e2e suite on the host to verify." The author complied; so did this review. Nobody has yet seen
8 passedin place of7 passed 1 skipped. The unit seams are genuinely good and the defect-2 fatal is already recorded on metal in #96, so the gap is narrow — but the map's verify on metal principle is unmet for defect 2, and the single run that would close it is exactly the run finding 1 makes dangerous on an unscoped host. Ordering behind #106 closes both at once, which is the cleanest resolution available.
Verdict
Comment — approve on content, blocked on merge order.
This is careful work. The reasoning is sound, the recordings are real (I md5-matched all three against live devpod), the red transcripts reproduce, the fix is verified cold and idempotent on metal, and the test suite shrank while the coverage grew. Defect 1's choice of the machine-readable form over dropping -w is the right call under principle 1 and I would not relitigate it.
Blocking, in order:
- B1 — Do not merge before PR #106 (Spec 1). The ticket named #106's scoping as the precondition that makes this safe; the PR declares it unnecessary while adding an invitation to run the unscoped suite. The conflict is textual-only and the semantics compose cleanly (Spec 5), so the ordering costs almost nothing.
Non-blocking but I would want them before merge:
- S1 — route the new module through
run_devpod(or catchFileNotFoundError); a missing devpod currently produces a traceback, anddl.py:825's "only devpod spawn" docstring is now untrue. - Move the module out of
devlaunch/— it ships to everydluser via[tool.hatch.build] include = ["devlaunch"]and only a dev task calls it.scripts/is right there. - S4 — add
test/fixtures/devpod/tocodespell's exclude;--write-changescan still rewrite the.ansicapture. - Spec 2 — soften or enforce "neither mistake is available to write again".
Worth a follow-up ticket, not this PR: dl.py:930 still reads an unreadable devpod list as an empty one (Standards 8) — the exact defect this PR was written to eliminate, still in the shipped path.
Host state, before → after this review: devpod list unchanged (devlaunch-main-zovomobo, devlaunch-t1-vebilote, pythontemplate); provider set unchanged (['docker']); no container created or removed; the e2e suite was not run. ~/.ssh/config md5 moved da3e288c… → 6cf324ea… mid-review, but all three DevPod-managed blocks and the user's own homeassistant block are intact and no workspace was lost — attributable to concurrent devpod activity on the shared daemon, not to anything this review ran (writes here went only to a scratch DEVPOD_HOME, and the hash was stable across those).
# Conflicts: # test/e2e/test_full_workflow.py
Closes #111.
Two defects that break
pixi run test-e2e— and everypixi run dev*task —before docker-in-docker is anywhere near it. Both reproduce on the host; neither
is a dind problem. Found by the in-container prototype in #96, which had to step
over both to establish that the e2e suite runs inside the devcontainer at all.
They turn out to be the same defect twice: an outcome that could not be read is
being silently read as "nothing is there" — and "nothing is there" is then acted
on. A listing that failed to parse is not an empty listing. A create that failed
after creating something is not a create that did nothing.
1.
dev-add-dockerre-added a provider that already existeddevpod v0.26.1 colourises
provider listunconditionally and emits the resetsequence
ESC[mimmediately before each cell, so the character in front ofdockeris the letterm.grep -w's left word boundary never matches.Measured on this host:
grep -qrc=0,grep -qwrc=1;NO_COLOR=1does nothelp, devpod ignores it.
Blast radius: every task that depends on it —
dev,dev-vs,dev-restart,dev-restart-vs, andtest-e2eonce it lands — on every machine that has runonce, which after the first run is every machine. They die on
fatal provider docker already exists.The fix is not dropping
-w. That fixes today's rendering and leaves usparsing a table devpod is free to re-render tomorrow. devpod v0.26.1 has
provider list --output json, verified on the host: an object keyed by providername, no ANSI, no column layout. Principle 1 picks the form that has no rendering
to change. The guard moves into
devlaunch/devpod_provider.py, which also givesthe decision a seam it can be tested at — it had none as a shell one-liner.
The second half of the fix is the part that generalises: output the guard could
not read used to come back as "no providers registered", and no-providers means
add one, so an unreadable answer quietly became an action. Unreadable is now its
own outcome (
UnreadableProviderList) and so is a listing command that exitednon-zero.
2.
test_purge_deletes_workspacesskipped on every headless machine, leaking a container each timeIt called
devpod up ... --id e2e-test-purgewithout--ide none, unlikeevery sibling. devpod built the workspace, then tried to open an editor, found no
xdg-open, and exited 1:The test read that rc as
pytest.skip("Could not create test workspace")— so itskipped on any headless machine, container or not, while the container it had
just successfully made stayed behind, tracked by nothing. #96 saw the orphaned
e2e-test-purgeindocker ps -aafter each run.The missing flag is the smaller failure. The skip is the one that hurts: it
converts a leak into a line of grey text in a green run.
Fix: one
create_e2e_workspacehelper that every e2e creation site goesthrough, so neither mistake is writable again — no IDE ever, registered for
cleanup before creation is attempted rather than after (registering afterwards
registers only on the happy path, the one path that did not need it), and a
non-zero rc fails with devpod's output attached instead of skipping. It takes the
process boundary as a parameter, so its contract is covered by unit tests that
need no daemon.
Two siblings of the same defect went with it. The other creation sites wrapped
their assertions in
if result.returncode == 0:— a failed create made theassertion body vanish and the test pass. And three hand-rolled copies of "list the
workspaces, but only if the listing read" turned an unreadable listing into an
empty one, which the assertion below then reads as "the workspace is gone".
Test-first, and what the red looked like
Each defect was made to go red for its own reason before anything was fixed, by
transcribing the current behaviour into the new seam.
Defect 1 — the old guard, fed devpod's real recorded coloured table:
Defect 2 — the current creation step, against the new tests:
FFss.The twotests asserting "a failed create must not skip" were themselves skipped. A
step that skips cannot be told not to skip.
The fixtures are recordings, not approximations
test/fixtures/devpod/holds byte-for-byte captures of devpod v0.26.1 on thishost: the colourised table, a listing with
dockerregistered, and one from adevpod home that has never had a provider added. A hand-written approximation of
ANSI output is exactly how a bug like this survives being fixed once. The
whitespace-tidying pre-commit hooks are excluded from that directory for the same
reason — a hook that tidies the evidence is a hook that edits it. One test asserts
the recording really is colourised and that the character before
dockerreallyis
m.Verified on metal
pixi run dev-add-dockeragainst the real devpod home (already registered →no-op, rc=0), against an empty scratch
DEVPOD_HOME(added, rc=0), and the samescratch home a second time (already registered, rc=0). Cold add and idempotence
both hold; the real devpod home was only ever read.
devpod provider list --output jsonshape confirmed on the host, anddevpod up --helpconfirms--ide"If empty will use vscode locally or in browser" — soomitting it is opting into an editor.
format/ruff/ty/pylint10.00 /
prekall clean.efforts share. Defect 2's fatal is already recorded on metal in Can devpod up — and dl itself — run from inside the dind devcontainer? #96 and was not
re-created here.
Overlap with #106
PR #106 (ticket #103) scopes the e2e suite to a per-run
DEVPOD_HOME/DEVPOD_SSH_CONFIGinpytest_configureand is still open. This branch is offmainand does not depend on it. The two touchtest/e2e/test_full_workflow.pyfrom different angles — #106 changes the environment the suite runs in, this
changes how the suite creates a workspace — so expect a textual conflict at the
creation sites and no semantic one:
create_e2e_workspacetakesenvas aparameter, which is exactly where #106's scoped environment goes.
🤖 Generated with Claude Code
Summary by Sourcery
Introduce a robust DevPod provider guard and shared e2e workspace creation helper to prevent unreadable DevPod outputs and failed workspace creations from being silently treated as no-ops.
Bug Fixes:
Enhancements:
Build:
Tests:
Chores: