fix(docker): close the five host-path follow-ups #22 left open #48
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/issue-47-host-path-followups"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #47 — the five non-blocking findings from @thisilike's approving review of #22, taken in one pass. They are independent of each other; the diff is grouped so each can be read on its own.
1.
applyPin's read guard is narrowed toDeno.errors.NotFoundpin_apply.ts. The catch swallowed every read failure. In productionhostFs.readthrows synchronously — the arrow evaluateshostPath(file)before a promise exists — so the drive-under-mounted-root refusal was never actually swallowed; what the narrowing buys is the permission-denied and directory cases. That is a behaviour change: a pin over a stack with one unreadable override file used to skip that file and proceed, and now fails the run, with the compensator rolling the edit back. Only a genuinely absent, untouched file is skipped. Two tests: a read rejecting asynchronously with a non-NotFounderror must still surface (it fails if the narrowing is reverted), and an untouched, genuinely absent file is still skipped.2.
envFilesjoin the one definition — and one container answers for themThe env files went to the host compose CLI as
--env-fileraw, unresolved and unchecked, whilecomposeEligibilitystat'd everything elseprojectArgsputs on the command line — and compose exits non-zero on an--env-fileit cannot open exactly as it does on a missing-f. Now every argument is checked, a missing env file gets its own verdict, and a path in both lists is a compose file first, matching the order the resolve loop already used.Which files those are is the answering container's to say, and after four review rounds it is the simplest rule on the table — the one @thisilike proposed: the first non-one-off container in the daemon's created-descending order answers, verbatim — label or no label.
.envand re-ups leaves untouched services holding the abandoned label, and resurrecting that file re-interpolates every service from it (--env-filesuppresses compose's own.envdiscovery), then strands the stack on the API updater the moment they delete it. The cost is owned in the docstring, in both directions: a newest container that lost the label by accident reads identically to a deliberate drop, nothing in the labels distinguishes them — and "none" is not a neutral answer either: it hands interpolation to the.envdiscovery the label was suppressing, a different value set is a different config hash, and the next update quietly recreates the affected services on.envvalues. Resurrecting an abandoned file is still the worse mistake.createdAt/name tie-break (Created * 1000is whole seconds; a name is not recency). The daemon's own list order already is recency, at its own resolution.com.docker.compose.oneoff:compose run --env-file scratch.envcreates the project's newest container, andlistStackspassesall=true, so an exited debugging one-off would otherwise set the env files of every later update and pin. Round 5 extends the skip to every project label, not just the env files:answeringContainerpicks the container once andconfigFiles,workingDirandenvFilesare all read off it — the stack page and the pin's validate fallback go through the same chooser and the sharedsplitLabelinstead of their owncontainers[0]splits — so an invocation can never pair a one-off's throwaway-f debug.yamlwith the live deploy's--env-file, an argv no deploy ever produced (and one whose deleted throwaway file would strand the stack even less clearably than the env case: compose's orphan detection skips one-offs, so--remove-orphanswill not clear the holder). A project of nothing but one-offs is not addressable — correctly, there is no deploy to address. The one-off fixture diverges in all three labels, so a read that takes even one off the wrong container fails the test.up -dnever rewrites a standing container's labels, and the compose invocation that would is on the path eligibility disables. So the missing-env-file verdict now names the container the label lives on and the host-side recovery (docker compose up -d --force-recreate, or--remove-orphansfor an orphaned holder), threaded asComposeProject.envFilesFrom. The stack page reads the same answer throughcomposeProject— themod.tsseam takes the already-split set by type, so a second label read does not compile.3.
effectiveHostRoottakes the union, notstringhostpath.tstyped{ host: { strategy: string | null } }whileHostStrategyis exported from@opsdeck/sdk— which typesctx.host.strategyasHostStrategy | nullitself, so the widening was purely local. As it was, renaming a union member left=== "direct"compiling and silently always-false, and everything now rides on that one equality. Same trade as deletingdirfromCommitLines: make it structural.composeEligibilityforwards the same ctx, so it narrows with it; both test helpers pass the union.4. The README sentence gets its missing clause
OPSDECK_HOST_ROOT=/fixes the paths, and reads genuinely work after it. Anything throughctx.hoststill does not: the probe proves every candidate —chroot, twonsentervariants, anddirect— by running ansh -ctest through it, so without a POSIX shell onPATHno candidate passes and the strategy stays unset.Concretely, on a Windows checkout the stack pages work and a pin dies at
h.step("validate")withhost command execution unavailable— honestly reported, edit rolled back by the compensator. A stack update never dies over it:composeAvailablereturns the reason and the run degrades to the engine-API recreate, loggingcompose unavailable … — using the engine API. README.md is now where the Windows setup is documented, so it says which features degrade and which fail, rather than lumping the updater — which succeeds — in with the failures.5. The graceful failure mode is written down
composeEligibility's docstring now records what the reasons are for: a non-null answer routes to the engine-API updater, so #22's working-directory stat narrows eligibility without failing a run. It also owns the trade honestly: eligibility supplies a stable answer, but no label rule can guarantee the set is always the deploy in force — a stale label makes the downgrade permanent, which is exactly why the verdict names the container and the recovery.deno task check,deno lintanddeno fmt --checkare green.deno task test: 494 passed | 1 failed — the failure isexternal_modules_test.ts"a module that loads after the app is built still gets its API routes", pre-existing on this Windows checkout (environment, not the diff). Every new env-file test verified load-bearing by reverting its fix in isolation: revertingansweringContainertocontainers[0]fails 3 (the diverged one-off fixture, the only-one-offs stack, and the page seam), restoring the fall-through fails 1, restoring the union fails 3, dropping the verdict suffix fails 1.Refs #20, #22.
🤖 Generated with Claude Code
Re-reviewed the whole diff at
d1f7d7cin a detached worktree, reproducing the claims rather than reading them. Most of this holds up. Two of the five items do not, and one behaviour change is undersold.Reproduced and confirmed
deno task check,deno lintanddeno fmt --checkclean.deno task test→ 488 passed | 0 failed | 1 ignored, exactly as stated. (Twoexternal_modules_test.tscases fail on my machine withgpg failed to sign the data— my git config, not this branch; green with signing off.)pin_apply_test.ts:201is load-bearing: revert toif (restore.has(file)) throw e;and it fails. The synchronous-throw analysis is right —hostFs.read's arrow evaluateshostPath(file)before returning a promise, so.catchnever attaches.--env-fileinto the label (so there is no relative-label regression to worry about), records multiple files comma-separated (matchingcomposeProject's split), does not record an auto-discovered.env(so the blast radius is only stacks deployed with an explicit--env-file), and does refuse a missing one —couldn't find env file: …. Both new eligibility tests are load-bearing.ctx.host.strategywas alreadyHostStrategy | nullinsdk/mod.ts:93, so the widening really was purely local — and the narrowing bites:=== "dircet"now fails withTS2367 … types 'HostStrategy | null' and '"dircet"' have no overlap, which it did not before.hostpath.tsis backend-only, so no module-builder bare-import exposure.composeEligibilitycall sites inactions.ts(206, 275, 557) route a non-null reason to the engine-API path.host command execution unavailableisHostUnavailableError's message andmoduleHostExec.runthrows it on a null strategy; the pin does die ath.step("validate")(neither pin action consultscomposeAvailable/composeEligibility);commitLinesgoes throughctx.host.run; file browsing isDeno.readTextFile(hostPath(p))(mod.ts:608,644) and stacks/logs aredockerFetch, so "browsing still works" is right.What does not hold
Details inline. Summary:
pin_apply_test.ts:227is a duplicate of the existing test at:159and passes with the fix reverted. Item 1 added one test, not two.composeEligibilityre-spellseffectiveHostRoot's ctx shape instead of sharing it — the drift this file argues against three times.Also worth noting: the new docstring flags the workingDir stat as "a behaviour change" and says nothing about the env-file stat, which is the change this PR is actually making, and the larger of the two.
@ -55,1 +52,3 @@them.that cannot exist. That fixes the _paths_, and reads genuinely work after it;anything that runs a command on the host still does not. Host exec proves eachcandidate (`chroot`, two `nsenter` variants, then spawning directly) by runningTwo problems, and this is the one item of the five whose entire content is precision.
The candidate list is wrong in the configuration this same sentence just prescribed. With
OPSDECK_HOST_ROOT=/,hostexec.ts:181gives:chrootand bothnsentervariants are never attempted. The conclusion (the strategy stays unset) survives; the reason given for it does not."not even the direct candidate passes" rests on an unsound premise.
directis not a POSIX construct —argv("direct", cmd, args)returns the command verbatim. The only POSIX thing in play is the probe's own choice ofsh. Wheresh.exeis resolvable (Git for Windows installed with the Unix tools on PATH, MSYS2, scoop),test ! -e /.dockerenv && test ! -e /run/.containerenvreturns 0, the strategy becomesdirect,effectiveHostRootreturns/— the same root the reader was just told to configure — and compose validation and the pin's git commit run against the WindowsdockerCLI. So "anything that runs a command on the host still does not" is conditional, not flat.Worth fixing at the source rather than only here:
hostpath.ts'sjoinHostPathdocstring ("the host-exec probe cannot answerdirect— every candidate strategy is a POSIX construct") andeffectiveHostRoot's ("On Windows null is the ONLY reachable state") both already carry this premise, and both use it to justify a refusal message. This PR now has the README repeating it.@ -85,2 +94,3 @@p: ComposeProject,ctx: { host: { strategy: string | null }; paths: { hostRoot: string } },ctx: {host: { strategy: HostStrategy | null };This re-spells
effectiveHostRoot's parameter shape instead of sharing it: two structurally identical inline types that now have to stay in sync by hand. That is the drift the docstrings in this file and inhostpath.tsargue against three times over — "a string parameter lets a fourth call site do it again", "copies drifting apart is how the last regression happened".Export the shape next to
effectiveHostRootand have both signatures plus both test helpers (compose_update_test.ts:115,hostpath_test.ts:110) take it. Then item 3's fix is structural in the way it claims to be, instead of being the same widening closed in two places.@ -91,3 +102,1 @@// validated AND stat'd with the config files: compose is addressed with// --project-directory, and a directory that is not there fails the real// update just as surely as a missing -f file.// reads the exact path that guard would write. Everything projectArgsTrue of this function, not of the codebase — and the PR body states it unqualified.
Both pin actions in
actions.tscallcomposeConfigArgs→projectArgsinsideh.step("validate")(:394,:508) and never consultcomposeEligibilityorcomposeAvailable. So one stale env-file label now gets two different answers:stack-updatesilently degrades to the engine API with a named verdict,stack-pinfails at validate with compose's raw stderr wrapped incompose-rejectedand rolls the edit back.Narrow the comment, or wire the guard into validate so the same condition reads the same way on both paths.
Separately, the docstring above singles out the workingDir stat as "a behaviour change" and is silent on the env-file stat — which is the change this PR is making, and the one that turns a loud error into a silent downgrade.
@ -94,3 +110,3 @@const hostRoot = effectiveHostRoot(ctx);const onHost = new Map<string, string>();for (const path of [p.workingDir, ...p.configFiles]) {for (const path of [p.workingDir, ...p.configFiles, ...p.envFiles]) {The env-file label is the one compose project label that is not uniform across a project's containers, and
composeProjectreads it fromstack.containers[0]— which is raw/containers/jsonorder (engine.ts:163-175groups by project and never sorts within one; created-descending in practice). So whichever container was recreated last decides the whole project'senvFiles.Live repro on this host, compose 5.5.0, two services,
brecreated with--env-file sub/rel.env:and driven through the real
composeProject+composeEligibilitywith that file since deleted:Same project, same compose files, the verdict flips on recreation order. Not hypothetical either — the
exchangeproject on this machine has exactly 1 of 31 containers carrying the label.Before this change that flip produced compose's own
couldn't find env file: …: loud, and it names the fix. After it, it produces a silent switch to the updater this file's own header describes as recreating from the OLD image'sinspect.Configand unable to pull from private registries — with the reason only inh.log, which non-admin observers do not get (expose: ["entities"]). That is a worse trade than "worse-but-working", and it is decided by a coin flip.Either of these would make it hold up:
envFilesfrom something stable rather thancontainers[0]— the union across the project's containers, or reusewantedEnvFiles, which already models the required/optional split this needs;@ -200,0 +224,4 @@}});Deno.test("a file that is genuinely absent is still skipped", async () => {This is the test at
:159with the filenames swapped.Same rule, same assertion shape, missing file first in both. I reverted the narrowing to
if (restore.has(file)) throw e;and ran the file:So the PR body's "Two tests" is one new test plus a rename of one that already passes on
mainuntouched. Either drop it, or make it earn the line — assert that the absent file isNotFoundspecifically and that a sibling failure in the same walk (EACCES,EISDIR) throws instead of being skipped. That is the half of the rule the narrowing actually changed and nothing covers it.Pushed
cd42477. All five inline findings taken; the reproductions all hold, and two of them changed what the fix is rather than how it is worded.1.
composeProjectno longer reads env files offcontainers[0](comment 852)The coin flip is the finding, and it is a worse one than the PR body's item 2 was a fix.
projectEnvFilestakes the union over the project's containers, walked by NAME so the order compose is handed does not depend on the daemon's, and preserving compose's own comma order within a label — for duplicate keys the last--env-filewins, so that order is semantic.Two tests, both load-bearing (they fail with
envFiles: splitLabel(first.labels?.[…])restored): the same two containers in either order give the same answer, and two containers with overlapping label lists union without duplicates.Worth being explicit that this is a wider answer than
containers[0], in both directions. Yourexchangecase — 1 of 31 containers carrying the label — now has that label govern the project, so compose is addressed with the--env-filethe deploy actually used instead of silently interpolating without it. That is the point. The cost is the other direction of the same fact: the project's eligibility now depends on that file still existing, where a stack whose newest container had lost the label would previously have sailed past the check. Stable and wide beats narrow by accident of ordering, and the docstring says so rather than leaving it to be discovered.I did not take the second option (drop a missing env file from the argv). Once the derivation is stable a missing env file is a real missing env file, and dropping it is not free:
--env-filesuppresses compose's default.envdiscovery, so dropping the last one silently swaps in a different set of values rather than none.2. The env-file stat is written up as the behaviour change it is (comment 853)
You were right that the docstring flagged the workingDir stat and stayed silent on the one this PR is making. It now names both, says which is bigger, and says what it costs: a stack with a gone env file used to reach compose and die on its own
couldn't find env file: …— loud, and it named the fix — and now degrades to the engine-API updater like every other missing path. It also records that the reason lands inh.logonly. That trade is only defensible because of item 1, and the docstring makes the dependency explicit.3. "Everything projectArgs puts on the command line is checked" is narrowed (comment 853)
Narrowed to this guard's own callers, with the divergence named: the pin actions build the same argv through
composeConfigArgsinsideh.step("validate")and do not consult the guard. I did not wire it in, and the comment now says why rather than leaving it looking like an oversight — a pin has already edited the operator's compose files and has no worse-but-working path to fall back to, so a missing path there must fail the run, and compose's own stderr names it better than a verdict would. The compensator rolls the edit back. Two paths, two answers, on purpose.4. The README clause, and the premise underneath it (comment 850)
Both halves conceded. With
OPSDECK_HOST_ROOT=/the candidate list is["direct"]— nochroot, nonsenter— anddirectis not a POSIX construct;argv("direct", …)returns the command verbatim, and the only POSIX thing in play is the probe's ownsh -ctest. The README now says that where anshis resolvable the test passes, the strategy becomesdirect, and host commands run against the WindowsdockerCLI; where there is none, the strategy stays unset and everything shelling out fails withhost command execution unavailable.Fixed at the source too, since you pointed out this PR was repeating a premise the code already had wrong twice:
joinHostPath's docstring now justifies the refusal by the pairing that is actually reachable (default/host/root+ a null strategy) and notes that taking the documented fix makes the refusal unreachable —"/"trims to an empty base;effectiveHostRoot's no longer claims null is the only state Windows can reach; the same sentence inhostpath_test.ts:98goes with them.5.
HostRootCtx(comment 854)Exported next to
effectiveHostRoot.composeEligibilityand both test helpers (compose_update_test.ts,hostpath_test.ts) take it. Item 3's fix is structural now instead of being the same widening closed in two places.6. The duplicate test (comment 851)
Dropped, and replaced with the half the narrowing actually changed, in one walk through the real
hostFs:/gone.ymlis genuinely absent and is skipped, and/isdir.ymlis a directory where a compose file should be. TheIsADirectorymust reach the caller; swallowing it reports the whole pin asno-image-line. Reverting toif (restore.has(file)) throw e;fails it — both files get skipped and the walk returns that wrong verdict. The absence half stays covered by the existing test above it, which is where it always was.deno task check,deno lintanddeno fmt --checkclean.deno task test→ 490 passed | 0 failed | 1 ignored. Every new test was checked against the reverted fix rather than assumed load-bearing.🤖 Generated with Claude Code
Re-reviewed
cd42477in a detached worktree, checking every claim against the code rather than the description.deno task check,deno lintanddeno fmt --checkare clean here.deno task testgives 488 passed / 2 failed / 1 ignored — both failures areexternal_modules_test.tsdying ongpg: signing failed: Timeoutin my environment, not this branch; 490 total matches your count.I reverted each of the four new tests' fixes one at a time:
envFiles: splitLabel(first.labels?.[…])fails bothcomposeProjectenv tests, dropping...p.envFilesfrom the resolve loop fails both eligibility tests, andif (restore.has(file)) throw e;fails bothpin_applytests. All four are load-bearing, as claimed.What holds: the README's host-exec claims are all true against
hostexec.ts—hostRoot === "/"really does reducecandidatesto["direct"](#runProbe), the proof really issh -c 'test ! -e /.dockerenv …'(#tryStrategy), andhost command execution unavailablereally is what the three shelling-out features hit (actions.ts:398/512,compose_update.ts:387,commit.ts), while stacks/files/logs go throughdockerFetchand work either way.joinHostPath's new closing sentence checks out:hostRoot.replace(/\/+$/, "")turns"/"into"", so the drive refusal is genuinely unreachable once the documented fix is applied.HostStrategyis a real four-member union inpackages/sdk/mod.ts:72and the readonly-to-mutable structural pass compiles. TheDeno.errors.NotFoundnarrowing is the right test for the only productionPinFs, and the secrecy claim in the eligibility docstring is accurate —composeActionsetsexpose: ["entities"], so a verdict carrying an env-file path stays admin-only.Item 1 is smaller than the write-up suggests, though:
hostFs.readis still a non-async arrow callinghostPath(file)beforeDeno.readTextFile, so it still throws synchronously and the drive-path refusal was never actually swallowed in production. What the narrowing really buys is the permission-denied andIsADirectorycases — which is worth having, and is also a behaviour change nothing in the PR body names: a pin over a stack with one unreadable override file used to skip it and proceed, and now fails the run.The env-file work is where I want changes. Item 1 of the follow-up comment fixed the derivation's stability and in doing so gave away its intent, and the same coin flip it removes is still live one function away.
1. A stale env file can override the current one, and container names decide which wins
projectEnvFilesunions across containers inlocaleCompare(name)order, and the docstring correctly states that for duplicate keys the last--env-filewins. So the merge precedence between two different env files is now decided by container name. Repro, run against this branch:That is the real shape, not a contrived one: the operator changes
--env-filefromold.envtonew.envand runscompose up -d; compose recreates only the services whose config changed, so an untouched service keeps a container carrying the old label. Undercontainers[0]the newest container's label won, which was unstable but at least tracked the operator's most recent intent. Under the union, the values they deliberately moved away from come back and, if the stale container sorts later, override the new ones. Rename the services and the answer flips.ContainerInfoalready carriescreatedAt, which is the field this decision actually wants. Either sort bycreatedAt(newest last, so newest wins, and the union is at least ordered by recency), or take the newest container that has the label — stable and intent-preserving, and it keeps theexchangecase you cite, since 1 of 31 containers carrying the label is still the only evidence there is.While that is open:
localeCompareis the wrong comparator for an order you have declared semantic. It is collation-dependent —["media-DB-1","media-api-1"]sorts to["media-api-1","media-DB-1"]underlocaleCompareand the other way under code-unit order. A precedence decision should not move with ICU data or locale.2. The union widens argv on the path you documented as having no fallback
composeConfigArgsisprojectArgsplusconfig -q, so both pin actions (actions.ts:397,actions.ts:511) now pass every unioned--env-fileintoh.step("validate"). A stack with one stale container whose label names a since-deleted env file previously validated fine when the newest container had no label; now compose exits non-zero on an--env-fileit cannot open, the pin fails, and the compensator rolls back an edit that would have applied. The docstring's cost analysis covers only the graceful eligibility path — the ungraceful one takes the same widening and gets a hard failure out of it. Please either say so there or reconsider the union in favour of the newest-labelled-container rule, which does not widen anything.3. The stack page still reads the label off
containers[0]mod.ts:633(not in this diff):wantedEnvFilesnormalises the spelling of the label; it has no opinion about which container the label came from. So the exact coin flip your docstring describes still governs what the stack page lists — and now the page and the updater derive the set differently, so a stack can show one env file and be validated against two. The PR body's framing ("the same label got a dedicated normalising function inwantedEnvFilesfor the stack page while staying raw for the invocation") reads as if the page side was already handled; it wasn't. Whichever rule item 1 lands on,composeProjectshould be the one place that answers this, withcollectStackFilestaking the answer from it.4. The docstring's cited cause cannot happen
The API updater does not do that.
update.ts:220buildscreateBody = { ...inspect.Config, Image: image, HostConfig: hostConfig, … }, andinspect.Config.Labelsrides along, so a recreate through the engine API preserves the compose labels — env-file label included. Splits are real (a partialcompose up -dwithout--env-file, as in the repro above), so the conclusion survives; the named mechanism is wrong. Given the last round's finding was this PR repeating premises the code already had wrong, a load-bearing docstring asserting a behaviour of a sibling file it does not have seems worth fixing at the source rather than shipping.5. Minor
compose_update_test.ts:40— "the env-file label is per-container" states an asymmetry that does not hold as cleanly as written.config_filessplits the same way: change the-fset, runcompose up -d, and services whose config did not change keep containers carrying the oldconfig_fileslabel. Reading it offcontainers[0]is defensible becausecontainers[0]is the newest, which is the same reason the old env-file code was defensible.pin_apply_test.ts:244—Deno.errors.IsADirectoryis POSIX-specific. Opening a directory on Windows surfaces as an access-denied error, and README documents a Windows checkout as a supported dev environment; CI is Linux, so this would only ever fail on someone's machine. Asserting "notNotFound" plus a non-null throw covers the same rule portably.Items 3, 4 and 5 of the original five (README clause,
HostRootCtx, eligibility docstring) I have no objection to — they say what the code does.@ -52,0 +62,4 @@* one project label that is not uniform across a project's containers:* compose records it only on containers created by an invocation that passed* `--env-file`, so recreating one service without it (the API updater does* exactly that) leaves the project split. containers[0] is raw"(the API updater does exactly that)" is not true of this codebase's API updater.
update.ts:220buildscreateBody = { ...inspect.Config, Image: image, HostConfig: hostConfig, … }, soinspect.Config.Labels— the env-file label included — is carried into the recreate.Splits are real (a partial
compose up -dwithout--env-fileleaves untouched services on the old label), so the conclusion stands; the mechanism named here is wrong. Worth fixing given last round's finding was this PR repeating premises the code already had wrong.@ -52,0 +85,4 @@function projectEnvFiles(stack: StackInfo): string[] {const out: string[] = [];const byName = [...stack.containers].sort((a, b) =>a.name.localeCompare(b.name)Blocking. This sort makes
--env-fileprecedence a function of container name. Proven on this branch: containersmedia-api-1(newer, labelnew.env) andmedia-zz-1(older, labelold.env) produce--env-file new.env --env-file old.env, and by the last-wins rule this docstring cites, the STALE file wins. Rename the services and the answer flips.The path there is ordinary: the operator changes
--env-file, runscompose up -d, and an unchanged service keeps a container carrying the old label.containers[0]was unstable but tracked the latest intent; the union throws that away.ContainerInfo.createdAtexists — sort by it (newest last) so recency decides precedence, or take the newest container that HAS the label, which is stable, intent-preserving, and still covers the 1-of-31exchangecase.Separately:
localeCompareis collation-dependent (["media-DB-1","media-api-1"]orders differently under it than under code units). An order you have declared semantic should not move with locale or ICU data.@ -94,0 +157,4 @@//// "Here" is the load-bearing word: this guard answers for the callers that// CHOOSE between compose and the engine API (the update and recreate// actions). The pin actions build the same argv through composeConfigArgsThis paragraph is right that the pin actions have no fallback — which is why the union in
projectEnvFilesdeserves a mention here too.composeConfigArgsisprojectArgs+config -q, so both pin actions now hand compose every unioned--env-file. A stack with one stale container pointing at a deleted env file used to validate fine (when the newest container carried no label) and now failsh.step("validate")outright, edit rolled back.The docstring above weighs the widening only against the graceful path. The ungraceful path takes the same widening and turns it into a failed run.
@ -36,2 +38,3 @@Deno.test("composeProject reads labels from the first container", () => {Deno.test("composeProject reads the project labels off the first container", () => {// configFiles and workingDir only — the env-file label is per-container and"the env-file label is per-container" overstates the asymmetry.
config_filessplits the same way: change the-fset and runcompose up -d, and services whose own config did not change keep containers with the oldconfig_fileslabel.Reading the other two labels off
containers[0]is defensible becausecontainers[0]is the newest container — which is exactly the property the old env-file code was relying on too.@ -200,0 +241,4 @@(e: Error) => e,);assert(err instanceof Deno.errors.IsADirectory,Deno.errors.IsADirectoryis POSIX-specific — reading a directory on Windows surfaces as an access-denied error, and README documents a Windows checkout as a supported dev environment. CI is Linux, so this only ever breaks on someone's machine.Asserting a non-null throw that is NOT
Deno.errors.NotFoundtests the same rule ("only absence is 'not here'") on every OS.Second pass over the same commit (
cd42477— nothing new pushed since my last review). This time against a real daemon and realdocker compose 5.5.0instead of reasoning from the argv, because item 1 of the previous round rests entirely on compose's own merge semantics and neither of us had actually run it. Everything below is a transcript, not an inference.Result: my finding 1 is worse than I described it, the previous round's derivation is strictly worse than the code it replaced in a case that occurs in normal operation, and the end state is unrecoverable through compose.
The premises, now measured
All four hold, so the parts of the design that rest on them are sound:
--env-file old.envfrom the project dir records/…/envtest/old.env. No relative-label case to worry about, soresolvePathSegmentsnever sees"relative"from this label..envis not recorded (label=[]after anup -dwith a.envpresent), so the blast radius really is only stacks deployed with an explicit--env-file.couldn't find env file: /…/envtest/gone.env.--env-filewins, as your docstring says:And
/v1.44/containers/json?all=truedoes answer created-descending, socontainers[0]was the newest container — which is the part that matters below.The repro, end to end
Two services,
aaainterpolating${MSG}andzzznot. Deploy with--env-file old.env; both containers get the label. The operator then moves tonew.envand re-ups — compose recreates onlyaaa, because only its config changed:Then they delete the service
zzzfrom the compose file entirely and re-up. Compose leaves the container standing — it is an orphan, andup -donly warns unless--remove-orphans:Fed through this branch's own code (real labels off the daemon, into
composeProject, out throughcomposeArgs):Run that argv against compose:
So OpsDeck now updates the stack with the values of the env file the operator abandoned, because
zzzsorts afteraaaand last wins. The rule this replaced got it right. That is not "wider in both directions" — in this case it is simply wrong where the old one was correct, and the old one was correct becausecontainers[0]is the newest container.And it cannot be healed
Delete the abandoned
old.env, which is the natural next thing an operator does, and the same argv gives:From there:
composeEligibilityreturnsenv file not found on host: …/old.envon every update and recreate, permanently. The stack silently loses the compose updater and drops to the engine-API path — the one that recreates from the OLD image'sinspect.Configdefaults, which is the whole reason the compose path is preferred.h.step("validate")fails, permanently. That stack can never be pinned again.up -d,down,restart— none of them touch an orphan's labels. The only recovery isdocker rmon a container nothing points at, or a--remove-orphansthey have no reason to run, and no message anywhere names either.downis not a fix either, in the sense that matters: the operator who wanted a fix would have to know the container exists.What I would take instead
The newest container that carries the label. It is stable (
createdAt, not daemon order, notlocaleCompare), it is whatcontainers[0]was already getting right, it fixes the actual coin flip you set out to fix (a newest container that lost the label falls through to the next one that has it, which is yourexchangecase), and it cannot widen the argv — so items 2 and 3 of my last review stop applying to it. One file in, one answer out, and no orphan from a deploy two months ago gets a vote.If you want to keep the union, then the orphan case has to be handled explicitly: filter to containers whose
servicestill appears in the compose file, or at minimum order bycreatedAtso recency wins the merge, and say in the docstring that a stale label can strand a project on the API updater.The other four findings from the previous review stand unchanged — the
mod.ts:633divergence, the pin-path widening, theupdate.tspremise in the docstring (createBody = { ...inspect.Config }carriesLabels, so the API updater is not what splits a project — this repro is), and the two minors.deno task check/lint/fmt --checkare still clean on this commit; test counts as before.Repro is five commands if you want it; the compose file is two services and the only trick is that exactly one of them interpolates the variable.
@ -52,0 +85,4 @@function projectEnvFiles(stack: StackInfo): string[] {const out: string[] = [];const byName = [...stack.containers].sort((a, b) =>a.name.localeCompare(b.name)Measured now, not inferred — real daemon, real
docker compose 5.5.0.A project with three containers, after the operator moved from
old.envtonew.envand later deleted the servicezzzfrom the compose file (its container survives as an orphan;up -donly warns):Through this branch's code:
projectEnvFiles=[new.env, old.env], and compose resolves that argv toMSG: from-old. The rule you replaced (containers[0], which/containers/json?all=trueconfirms is the NEWEST container) resolves toMSG: from-new.So in a case that arises from ordinary operation, the union is not wider — it is wrong where
containers[0]was right, becausezzzsorts last and the last--env-filewins.Then delete the abandoned
old.envand the same argv givescouldn't find env file: …/old.env, permanently: eligibility fails on every update, every pin's validate step fails, and nothing an operator does through compose clears it, because the label lives on an orphan.docker rmis the only recovery and nothing names it.The newest container that HAS the label fixes the coin flip you were after (a newest container that lost the label falls through), is stable on
createdAt, and widens nothing.@ -52,0 +89,4 @@);for (const c of byName) {for (const path of splitLabel(c.labels?.[ENV_FILE_LABEL] ?? "")) {if (!out.includes(path)) out.push(path);Dedupe is on the raw label string, while
wantedEnvFileskeys onposixPath(...). Compose writes one consistent spelling per host, so this is not reachable today — but it is the second place the two derivations of this label can disagree (see themod.ts:633finding), andcompose.ts's docstring documents exactly this bug class for the page: "a raw-key map listed it twice".If
composeProjectbecomes the single answer, as I suggested, this disappears with it.@ -83,0 +133,4 @@* Two of the three stats are behaviour changes, and the env files are the* bigger one. A stack whose workingDir is gone but whose compose files are* still there was eligible before and is not now. A stack with an env file* that is gone used to reach compose and die on its own"That trade only holds because the answer is STABLE" is the sentence the repro contradicts. The answer is now stable and stale: an orphan container from an abandoned deploy holds the label forever, so the verdict is not a coin flip any more — it is a permanent downgrade to the engine-API updater for a stack whose live services are all fine, plus a pin that can never validate again.
Stability was necessary, not sufficient. It has to be stable AND track the deploy that is actually in force.
The union over every container was stable but stale, and a real daemon shows it is strictly worse than the containers[0] rule it replaced in a case that arises from ordinary operation. Reproduced against docker compose 5.5.0: a project deployed with --env-file old.env, moved to new.env (compose recreates only the services whose config changed, so an untouched service keeps a container carrying old.env), then the untouched service deleted from the compose file — `up -d` leaves its container standing as an orphan with a warning. Fed through this branch's own composeProject/composeArgs, the union produced `--env-file new.env --env-file old.env`, and because the last --env-file wins the stack came up with MSG=from-old. containers[0], which is the newest container, produced MSG=from-new. Delete the abandoned old.env and the union's argv fails `config -q` with `couldn't find env file`, permanently: every update drops to the engine-API updater and no pin can ever validate again, with no compose command that clears it, because the label lives on an orphan. So the rule is now the label off the newest container that HAS one. It keeps what containers[0] got right (the newest container is the deploy in force) and fixes what it got wrong (a newest container recreated without --env-file dropped the whole project's env files while its siblings still carried the label), and it cannot widen the argv with a path no live deploy asked for — which matters because composeConfigArgs feeds the same answer to the pin actions' validate step, where there is no graceful fallback. Ties break on code-unit name order, not localeCompare: createdAt is whole seconds and one `up -d` starts a project's services inside one, and a precedence decision must not move with locale or ICU data. composeProject is now the only place this question is answered. wantedEnvFiles takes the set already split, so collectStackFiles feeds it composeProject(stack).envFiles instead of reading a container label a second time — the stack page and the compose updater can no longer list one env file and validate against another. The raw-string dedup that disagreed with wantedEnvFiles' posixPath keys goes with the union. Docstring corrections, all from the review: the API updater does NOT drop the label on a recreate (update.ts builds createBody from { ...inspect.Config }, which carries Labels), so the split is named by its real cause; the eligibility trade needs the answer to be current as well as stable, and says why; and the pin path's share of the same answer is written down where the graceful path's cost analysis already was. The test comment claiming config_files is uniform across a project is corrected — it splits the same way, and containers[0] is defensible for it because containers[0] is the newest. pin_apply_test's non-absence assertion no longer names Deno.errors.IsADirectory, which is POSIX-specific while README documents a Windows checkout as supported. "Threw, and not NotFound" is the whole rule and holds on every OS; still fails if the narrowing is reverted. deno task check, deno lint, deno fmt --check clean; deno task test 492 passed / 0 failed / 1 ignored. Each new test verified load-bearing by reverting its fix in isolation. Refs #20, #22, #47. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Pushed
ad22ed0. Took the rule you asked for — the newest container that HAS the label — and ran your repro against a real daemon before writing it, because you were right that neither of us should be reasoning from the argv.Your repro, reproduced
docker compose 5.5.0, engine 29.7.2. Two services,aaainterpolating${MSG},zzznot. Deploy onold.env; both containers get the label. Move tonew.envand re-up — compose recreates onlyaaa:Delete the service
zzzfrom the compose file and re-up. Compose leaves the container standing, warning only:Those real labels through the branch's own
composeProject→composeArgs, then each argv run against compose:And after deleting the abandoned
old.env, the validate step both pin actions run:So: confirmed in full, including the part I would have argued with. The union was not wider, it was wrong where
containers[0]was right, and the end state is exactly as unrecoverable as you described.What changed
1 —
projectEnvFilesis the newest labelled container. Notcontainers[0], not a union. It keeps whatcontainers[0]had right (the newest container is the deploy in force) and fixes what it had wrong (a newest container recreated without--env-filezeroed the project's env files while 30 siblings carried the label — theexchangecase), and it cannot put a path on the command line that no live deploy asked for. Ties break on code-unit name order, notlocaleCompare— you are right that a semantic order must not move with ICU data, and ties are ordinary here sincecreatedAtisCreated * 1000, a whole second, and oneup -dstarts a project's services inside one. The test row usesmedia-DB-1/media-aaa-1precisely because the two comparators disagree on it, so it fails if the comparator drifts back.2 — findings 2 and 3 of your first review stop applying, and the pin path is written down anyway. Nothing widens now, but
composeConfigArgsstill carries whatever this answers intoh.step("validate")with no fallback, so the eligibility docstring says so next to the graceful path's cost analysis — including the one case this rule does add an argumentcontainers[0]did not, and why that argument names the file the running services were interpolated with.3 —
composeProjectis the single answer.wantedEnvFilesnow takes the set already split, socollectStackFilespassescomposeProject(stack)?.envFiles ?? []instead of readingfirst?.labels?.[…]a second time atmod.ts:633. The page and the updater can no longer derive it differently. The raw-string dedup you flagged in the second review goes with the union — there is one label now, passed through verbatim, so compose's own last-wins ordering survives and there is nothing to key inconsistently.4 — the wrong premise is gone. You are right:
update.ts:220buildscreateBody = { ...inspect.Config, … }andLabelsrides along, so the API updater is not what splits a project. The docstring no longer names it; the split is attributed to the partialup -d, which is what the repro above actually does.5 — stability was necessary, not sufficient, and the eligibility docstring now says that in those terms: an orphan's label is perfectly stable and turns the graceful downgrade permanent. The trade only holds because the set stat'd is one live deploy's.
Minors. The
config_filescomment is corrected — it splits the same way, and reading it offcontainers[0]is defensible becausecontainers[0]is the newest, which is your point exactly.pin_apply_test.tsno longer namesDeno.errors.IsADirectory; it asserts a non-null throw that is notNotFound, which is the whole rule and holds on Windows. Still fails when the narrowing is reverted.On item 1 being smaller than the write-up claimed: agreed, and the behaviour change you named — a pin over a stack with one unreadable override file used to skip it and now fails the run — is real and was not in the body. It is deliberate (a silent skip there means reasoning from a stale copy on the write path), but it should have been stated, so it is in the commit message.
Verification
deno task check,deno lint,deno fmt --checkclean.deno task test→ 492 passed | 0 failed | 1 ignored.Every new test verified load-bearing by reverting its fix in isolation rather than assuming:
localeCompareunionlocaleCompareif (restore.has(file)) throw e;pin_applytestsThe repro is five commands and I still have them if you want the compose file; the only trick, as you said, is that exactly one service interpolates the variable.
🤖 Generated with Claude Code
Re-reviewed
ad22ed0in a detached worktree, against a real daemon (engine 29.7.2, compose 5.5.0) rather than from the argv. Everything below is a transcript.The three items I asked you to fix are fixed, and the verification you claim is real. But the new rule is not the rule I asked for, and it has a case where it is strictly worse than the
containers[0]rule it replaced — the mirror image of the orphan bug, reproduced end to end. Two more findings below it.Verified, not taken on trust
deno task check,deno lint,deno fmt --checkclean.deno task test→ 492 passed | 0 failed | 1 ignored, exactly your count. (My twoexternal_modules_test.tsfailures were my own globalcommit.gpgsignagain — I have fixed that at the source in #49 so neither of us pays for it a third time.)projectEnvFiles→containers[0]fails 4; tie-break →localeComparefails 1 (themedia-DB-1row, as designed); dropping the has-a-label filter fails 2;if (restore.has(file)) throw e;fails bothpin_applytests; dropping...p.envFilesfrom the resolve loop fails both eligibility tests. Your table is accurate.mod.ts:639is closed by the type, not just by a test. Reverting it tofirst?.labels?.[…]does not compile —TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string[]'. That is a better guard than the test, which exercisescomposeProjectdirectly and would have passed.HostRootCtxbites.=== "dircet"→TS2367 … types 'HostStrategy | null' and '"dircet"' have no overlap.candidates = hostRoot && hostRoot !== "/" ? ["chroot","nsfile","pidns","direct"] : ["direct"](#runProbe), the proof issh -c 'test ! -e /.dockerenv && test ! -e /run/.containerenv'(#tryStrategy),directreturns the command verbatim (argv), and the message ishost command execution unavailable(HostUnavailableError).update.tspremise is gone, theconfig_filescomment is corrected, andpin_apply_test.tsno longer duplicates:159or namesDeno.errors.IsADirectory. Secrecy claim holds:composeActionsetsexpose: ["entities"], so an env-file path in a verdict stays admin-only.docker compose runone-off without--env-filebecomes the newest container with no label, andcontainers[0]answers[]where yours correctly falls through. Confirmed on the daemon.What does not hold
Details inline. Summary:
--env-filegets the abandoned file resurrected — wrong interpolation now, and a permanently un-pinnable stack the moment they delete it.containers[0]was correct in this case. This is the same unrecoverable end state as the orphan bug, reached from the other direction.createdAtisCreated * 1000— whole seconds — so two deploys inside one second tie, and the name comparator picks the older label. Reproduced: this branch answersold.envwherecontainers[0]answersnew.env.compose run --env-filecontainer that is newest still speaks for the project. Thecom.docker.compose.oneofflabel is right there and unused.What I would take
Iterate the containers in the order
engine.tsalready hands you — the daemon's created-descending/containers/json— skip one-offs, and take the first container's label, present or absent:No
createdAt, no comparator, no tie to break, sub-second accurate, and it cannot invent an argument: absence of the label is an answer — the deploy in force passed no--env-file— rather than a gap to fill from an older container. It keeps the one-off fix that motivated this commit and drops finding 1 entirely.The cost is honest and yours to weigh: it does not rescue the
exchangecase, because that case and finding 1 are the same observation read two ways, and nothing in the labels distinguishes "lost the label by accident" from "deliberately stopped passing one". If you keep the fall-through anyway, then finding 1 has to be in the docstring next to the orphan paragraph, with the part that makes it serious: no OpsDeck action can clear the stale label, because plainup -ddoes not touch it and the compose path that would is the one eligibility has just disabled.deno task check/lint/fmt --checkare clean on this commit and every test is load-bearing; my objection is to the rule, not the workmanship.@ -52,0 +96,4 @@* `up -d` starts a project's services inside one — and a precedence* decision must not move with locale or ICU data.*/function projectEnvFiles(stack: StackInfo): string[] {Strictly worse than the rule this replaces, in a case that arises from ordinary operation: the operator stops passing
--env-file.They move the values into the project's
.env(compose's own default) and re-up. Compose recreates only the service whose config changed, so the untouched service keeps a container carrying the abandoned label — the same split as your orphan repro, from the other side. Real daemon, compose 5.5.0:Fed through this branch's own
composeProject→composeArgs:And what compose resolves for each argv:
So the next OpsDeck update silently reverts their migration:
--env-filealso suppresses compose's.envdiscovery, so every service — includingaaa, which is running on.envvalues — gets re-interpolated from the file they abandoned. The docstring's justification for this exact case ("names a file the running services were interpolated with", line 197) is false foraaa.Then they delete
old.env, which is the natural next step, and it is your orphan ending verbatim — except the container holding the stale label is a live service, so filtering orphans would not save it:Permanent silent downgrade to the engine-API updater, permanently un-pinnable, while their own
compose up -dis perfectly healthy. And OpsDeck cannot heal it:Only
--force-recreateclears the label,composeUpdoes not pass it, and the compose path is the one eligibility just disabled — so every route back is through the engine API, which preservesinspect.Config.Labels.containers[0]had no such state.The rule in the review body has none of this: absence of the label is an answer, not a gap.
@ -52,0 +88,4 @@* `--env-file` is the deploy in force. It fixes what containers[0] got wrong* — a newest container that lost the label falls through to the next one* that has it — without ever putting a path on the command line that no* live deploy asked for. Within the one label compose's own comma order is"without ever putting a path on the command line that no live deploy asked for" — this does not hold, and the orphan case is not closed.
The filter is "has a label", not "is a service of this project", so an orphan still gets a vote whenever it is the newest labelled container. That is easy to reach: a service added later with a different env file, then deleted from the compose file.
The deploy in force passes
old.env; the branch passesnew.env. Deletenew.envand it is the permanent ending again —eligibility: env file not found,config -q exit=1, operator's own argv exit 0.Same for one-offs, which the label already distinguishes and this ignores:
A debugging
compose run --env-file scratch.envtherefore sets the project's env files for every later update and pin, and the exited container keeps doing so (listStackspassesall=true). Skippingcom.docker.compose.oneoff === "True"is one line and is the same one that makes the rule in the review body work.@ -52,0 +102,4 @@if (!splitLabel(c.labels?.[ENV_FILE_LABEL] ?? "").length) continue;const better = !newest ||c.createdAt > newest.createdAt ||(c.createdAt === newest.createdAt && c.name < newest.name);The tie-break re-derives recency from a coarser field than the one you were handed, and gets it wrong.
createdAtisCreated * 1000(engine.ts:156) — whole seconds — and the docstring says ties are ordinary for exactly that reason. But/containers/jsonis answered created-descending, which is what makescontainers[0]the newest container and is the premise the paragraph above rests on. Sorting by the second-resolution field throws that ordering away, and the name comparator that replaces it has nothing to do with recency.Two deploys inside one second, real daemon, the changed service named
zzz:Name order picks
aaa, so the abandoned file wins.localeCompareversus code units is not the axis that matters here; both are wrong, and the test atcompose_update_test.tspins the comparator rather than the property.Iterating in the order
engine.tshands you and taking the first labelled container needs no comparator, no tie-break and nocreatedAt— and it is sub-second accurate. If that order is not trustworthy enough to decide this, it is not trustworthy enough forconfigFilesandworkingDireither, andcomposeProjecthas been reading those offcontainers[0]all along.@ -83,0 +159,4 @@* the operator has every reason to delete, and once they do, this returns a* reason on every update forever while all the live services are fine.* projectEnvFiles is what supplies both; the graceful path is only graceful* because the set it stats is one live deploy's."the graceful path is only graceful because the set it stats is one live deploy's" — that is the load-bearing sentence for trading compose's loud
couldn't find env filefor a silent downgrade, and the set is not always one live deploy's. It can be an orphan's (finding 3) or a file the deploy in force does not pass at all (finding 1), and in both the downgrade is permanent with nothing in the UI naming the container that caused it.Whichever rule you land on, this paragraph should say what an operator does about it. Right now the only recovery is
docker rmon a container nothing points at, or acompose up -d --force-recreatethey have no reason to run — and neither is named anywhere, including in theh.logline the verdict produces.@thisilike Round 4, at
5be0327. Took your rule, verbatim — no variant of mine this time.Finding 1 (resurrection on a deliberate
--env-filedrop) + finding 2 (tie-break)projectEnvFilesis now exactly the shape from your review body: iteratestack.containersin the order engine.ts hands over (the daemon's created-descending answer), skipcom.docker.compose.oneoff === "True", return the first remaining container's label verbatim — present or absent. No fall-through, no union, no comparator, nocreatedAt. Your.env-migration repro answers[]on this commit: the recreated label-less service is the newest non-one-off, and absence is its answer.The
exchangecase is given up, and the docstring owns that on your terms: nothing in the labels distinguishes "lost the label by accident" from "deliberately stopped passing one", and resurrecting an abandoned file is the worse mistake of the two. The tie-break and its comparator test died with the rule; the docstring now argues against re-deriving recency fromcreatedAt(whole seconds) instead of for a tie-break.Tests: "an absent label on the newest container is an answer, not a gap" fails with the fall-through restored; "the newest container's env-file answer is taken verbatim, no older label joins it" fails with the union restored.
Finding 3 (one-offs, orphans)
One-offs skipped by the label — the one line. "A one-off never speaks for the project's env files" fails with the skip removed (as does the rebuilt seam test in
compose_test.ts, whose old fixture assumed the fall-through and now uses the one-off case).The orphan-that-is-newest case is deliberately not filtered — your rule takes the newest non-one-off verbatim, and an orphan can be it. That hazard is now stated in both docstrings instead of being claimed away: the "without ever putting a path on the command line that no live deploy asked for" sentence is deleted, and
projectEnvFiles' docstring ends on the stale-label case and points at eligibility for what it costs.Finding 4 (three false docstring sentences, recovery unnamed)
All three sentences are gone — "without ever putting a path…", "…the file the running services were interpolated with", "…the set it stats is one live deploy's". The eligibility paragraph you quoted now says: stability is what
projectEnvFilessupplies; a set that is always the deploy in force is what no label rule can; the downgrade is PERMANENT because no OpsDeck action rewrites a standing container's labels and the compose invocation that would is on the path the verdict disables.And the recovery is in the verdict itself now, not just the docstring:
composeProjectthreadsenvFilesFrom(the answering container's name, only when there are env files) and a missing env file readswhich reaches the run log through the existing
compose unavailable (${reason})line — admin-only, same as before (expose: ["entities"]). The old exact-string test still passes unchanged (noenvFilesFrom, no suffix); a new test pins the container name and both recovery commands.Verification
deno task check,deno lint,deno fmt --checkclean.deno task test: 493 passed | 1 failed — the failure isexternal_modules_test.ts"a module that loads after the app is built still gets its API routes", pre-existing on this Windows checkout (environment, not the diff). Load-bearing, each revert in isolation against the two compose test files: drop the one-off skip → 2 fail, restore the fall-through → 1 fails, restore the union → 3 fail, drop the verdict suffix → 1 fails.PR description rewritten: item 2 now describes this rule and both owned costs, item 1 names the unreadable-override behaviour change, item 5 owns the stale-label permanence.
Re-reviewed
5be0327in a detached worktree, against a real daemon (engine 29.7.2, compose 5.5.0). Everything below is a transcript, not an inference.The rule is the one I asked for and the workmanship holds. One finding of substance: the one-off skip was applied to one of the three project labels, so
composeProjectcan now answer from two different containers at once — and a one-off's-fset reaches exactly the same permanent end state we spent two rounds on for--env-file. Reproduced end to end. Two smaller ones below it.Verified, not taken on trust
deno task check,deno lint,deno fmt --checkclean.deno task test→ 493 passed | 0 failed | 1 ignored here, so your one failure really is environmental — your count is otherwise exact.if (restore.has(file)) throw e;→ bothpin_applytests.mod.ts:640tofirst?.labels?.[…]givesTS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string[]'. Repo-wide there is now exactly one read ofcom.docker.compose.project.environment_file, incompose_update.ts.HostRootCtxbites andhostpath.tsis backend-only, so the@opsdeck/sdktype import carries no module-builder exposure. (system.ts:64still spellsstrategy: string | null, but that one is a response DTO with no equality riding on it — not the same drift.)com.docker.compose.oneoff=False,compose runcontainersTrue— so=== "True"is the right test, not a guess about spelling./containers/json?all=trueanswers created-descending (Createdequal for two services of oneup -d, as you say), andlistStacksnever sorts within a project.containers[0]is the newest.--env-filesuppresses.envdiscovery: with a.envpresent,--env-file old.env→MSG: from-old; no flag →MSG: from-dotenv.docker compose up -d --force-recreatewith no--env-filerewrote the label to empty on both standing containers, andup -d --remove-orphansremoved a genuine orphan. The verdict names commands that do what it says they do.composeActionsetsexpose: ["entities"], so an env-file path in a verdict stays admin-only.hostFs.readis still a non-async arrow evaluatinghostPath(file)first, so item 1's analysis of what the narrowing buys is right, and the new async-rejection test covers the shape the production one cannot reach.What does not hold
Details inline.
envFilesonly, so one compose invocation can now be addressed with one container's-f/--project-directoryand another's--env-file— and a throwaway-foverride from a debug one-off strands the stack permanently, less clearably than the env-file case. The docstring sentence claiming parity withconfigFiles/workingDiris false in exactly the case the skip was added for.host command execution unavailable. It never produces that message — it degrades to the engine API and succeeds. Two of the three items in that list are right..envdiscovery.Items 3, 4 and 5 of the original five (README's host-exec facts,
HostRootCtx, the eligibility docstring's graceful-failure paragraph) are accurate as written, and the stale-label permanence paragraph is now the honest version of it.@ -56,0 +56,4 @@through it. With a `sh.exe` on `PATH` (Git for Windows with the Unix tools,MSYS2) that test passes and host commands run against the Windows `docker` CLI;with no `sh` at all it fails, the strategy stays unset, and everything thatshells out — compose validation, the compose updater, and the git commit a pin"the compose updater" does not fail with that message — it never runs.
composeAvailable(mod.ts:118) callsctx.host.probe()and returns a reason string rather than running anything, and all three compose actions (actions.ts:206,275,557) treat a non-null reason as "use the engine API".host command execution unavailable: <reason>comes out ofHostExec.runalone (hostexec.ts:56), i.e. the pin's validate and commit steps.So on the setup this paragraph documents, a stack update succeeds, logging
compose unavailable (direct: …) — using the engine API. That is the useful fact for someone setting up a Windows checkout, and it is the opposite of what the sentence says. "compose validation" and "the git commit a pin makes" are both right; drop the updater from the list and say it degrades instead.@ -50,0 +71,4 @@** /containers/json answers created-descending and engine.ts never sorts* within a project, so the first container is the newest, at the daemon's* own resolution. That order is the same trust configFiles and workingDirThis sentence is now false in the one case the skip exists for.
composeProjectstill readsconfigFilesandworkingDiroffstack.containers[0]— including when that container is a one-off, which carriesproject.config_filesandproject.working_dirlabels of its own. So the two answers can come from two containers.Real daemon, this branch's own
listStacks→composeProject→composeArgs:No deploy ever used that pairing: the
-fset is a debugging one-off's, the--env-fileis the live deploy's. Astack-updatethere applies the debug override to every service.Then the operator deletes
debug.yaml, which is what a throwaway override is for:Same unrecoverable end state as the orphan case — permanent engine-API downgrade, permanently unpinnable — and less clearable: compose skips one-offs in orphan detection, so
up -d --remove-orphansleaves it standing (verified: it removed a real orphan and left all three one-offs). Onlydocker rmclears it, and nothing names it — the compose-file verdict has no suffix.The fix is smaller than the one already in the diff: choose the answering container once, and read all three labels off it.
projectEnvFilesthen collapses tosplitLabel(first.labels?.[ENV_FILE_LABEL] ?? ""), the parity sentence above becomes true again, and a project of nothing but one-offs answers null — correctly, there is no deploy to address. Notemod.ts:602andactions.ts:509also splitcontainers[0].configFilesthemselves, so if the answer is meant to be single-sourced, that is where the other two reads live.One more thing hiding it: the fixture at
compose_update_test.ts:136gives the one-off the sameconfigFilesas the service container, so no test can see the two labels diverge.@ -50,0 +95,4 @@** The honest cost: a newest container that LOST the label by accident (a* partial `up -d` without `--env-file`) reads identically to a deliberate* drop, and this rule answers "none" for both — nothing in the labels"this rule answers 'none' for both" is where the paragraph stops, and "none" is not neutral — it hands interpolation to compose's
.envdiscovery, which the sentence above correctly says--env-filewas suppressing. Measured on the same project:Different values means a different config hash, so the next
up -ddoesn't just fail to rescue the accident — it recreates the affected services with the.envvalues, quietly. The mirror hazard two paragraphs up is owned down to "once they delete it, compose would refuse the project outright"; this one deserves the same clause, since it is the direction the rule actually chose.@thisilike Round 5 addressed in
5974078, all three findings.1. The skip now covers every project label. Your suggested shape, almost verbatim:
answeringContainer(stack)picks the first non-one-off container once, andcomposeProjectreadsconfigFiles,workingDirandenvFilesall off it — so the half-addressed argv (a one-off's-f debug.yamlwith the live deploy's--env-file) cannot be built anymore, and a project of nothing but one-offs answers null. The other twocontainers[0]reads you located are single-sourced onto the same chooser: the stack page (mod.ts) and the stack-pin validate fallback (actions.ts) both callansweringContainerand the now-exportedsplitLabelinstead of splittingcontainers[0].configFilesthemselves. The parity sentence you flagged is gone withprojectEnvFilesitself — the docstring now states the one-container rule and owns the one-off-fstranding case, including that--remove-orphanscannot clear a one-off holder. Your fixture finding is fixed at the root: the one-off in the test now diverges in all three labels (-fset, working dir, env file), so a read that takes even one label off the wrong container fails it — reverting the chooser tocontainers[0]fails that test, the new only-one-offs test, and the page-seam test.2. README no longer claims the updater fails. The clause now says what you measured: a stack update never produces
host command execution unavailable—composeAvailablereturns the reason and the run degrades to the engine-API recreate, loggingcompose unavailable … — using the engine API— while a pin's compose validation and its git commit go through host exec directly and do fail with that message.3. The cost paragraph owns what "none" does. Next to the orphan paragraph, as asked: an absent label hands interpolation to the
.envdiscovery the label was suppressing, a different value set is a different config hash, and the next update quietly recreates the affected services on.envvalues — the rule's chosen direction, not just its refusal.Verification on this checkout:
deno task check,deno lint,deno fmt --checkclean;deno task test494 passed | 1 failed, the failure being the pre-existingexternal_modules_test.tsenvironment case. PR description updated to match.Re-reviewed
5974078in a detached worktree, against the same daemon (engine 29.7.2, compose 5.5.0). All three findings are fixed, and I reproduced the fix rather than reading it. Approving — three non-blocking follow-ups below, one of which is only prose deep.The finding, re-measured
Same rig as last round:
up -d --env-file old.env, then a one-off with-f compose.yaml -f debug.yaml --env-file scratch.envas the newest container, thendebug.yamldeleted. This branch's ownlistStacks→composeProject:The half-addressed argv is unbuildable and the deleted throwaway strands nothing. A project of nothing but one-offs (
odr5b) answersanswering: (none)/composeProject: null, as claimed.Verified, not taken on trust
deno task check,deno lint,deno fmt --checkclean. Tests: 494 passed | 0 failed | 1 ignored — your 494 with the environment failure removed. (Run the test step by hand asdeno test -A --ignore=packages/module-builder,.claude,data: a CLI--ignorereplacesdeno.json'sexclude, sodeno task testwalks.claude/worktrees/*— my problem, not yours, and #33's follow-up.)composeProject's chooser tostack.containers[0]fails exactly 3:a one-off never speaks for the project — any of its labels,a project of nothing but one-offs is not addressable, and the page-seam test incompose_test.ts. Your table is accurate, and the diverged fixture does what you say — all three labels differ, so a single wrong read fails it.answeringContainerreally is every project label's source, the two exported reads really do outlive the null verdict (the page lists compose files with noworkingDir; the pin fallback builds a bare-flist), and the.env-discovery cost paragraph matches what I measured last round (--env-file old.env→from-old, no flag →from-dotenv).composeAvailable(mod.ts:118) returns a reason without running anything, all three compose actions treat non-null as engine-API, andhost command execution unavailableonly escapesHostExec.run— the pin's validate and commit. The new "differs by feature" split is exact.docker psorphan behaviour from last round still applies and is now written down:--remove-orphansremoved a real orphan and left every one-off standing, so "will not clear one" is measured, not assumed.Follow-ups (non-blocking)
mod.ts:607tostack.containers[0]→ 494 passed, 0 failed. Same foractions.ts:511. Inline.StackDetailPage.svelte:132is a third read of the same label, offcontainers[0]raw:subtitle={data.stack.containers[0]?.configFiles ?? ""}. Not in this diff, andconfigFilesis not redacted, so with a one-off newest the page header namescompose.yaml,/tmp/debug.yamlwhile the file list below it — now correctly the answering container's — lists onlycompose.yaml. The same disagreement between page and answer, one layer up.@ -508,3 +512,2 @@await h.step("validate", async () => {const files = (first?.configFiles ?? "").split(",").map((f) => f.trim()).filter(Boolean);const files = splitLabel(first?.configFiles ?? "");Nit, pre-existing and only reachable when the chooser answers null (a project of nothing but one-offs, or an answering container with no
config_files— e.g. a hand-run container carrying only the project label):filesis then[]and the fallback runsdocker host-side compose config -qwith no-fat all, so compose resolves whatever sits in the host CWD or dies withno configuration file provided: not found. The edits are rolled back either way, but the operator gets an error about the wrong thing.The container-pin action's fallback already does the better thing —
edits.flatMap((e) => ["-f", e.file]), i.e. the files this run actually patched.patchesis in scope here and holds exactly that.@ -600,0 +604,4 @@// the same container every compose invocation is addressed from — a// one-off's own config_files/working_dir labels must not put files on// this page that no deploy uses (see answeringContainer)const first = answeringContainer(stack);This read and
actions.ts:511are the two the PR body calls single-sourced, and nothing enforces it. Revert either tostack.containers[0]and the whole suite stays green (494 passed | 0 failed) — the page-seam test incompose_test.tsexercisescomposeProject→wantedEnvFilesdirectly, notcollectStackFiles, so it cannot see this line.Worth contrasting with the env-file seam you closed one round ago, which a type refuses: reverting
wantedEnvFiles' argument givesTS2345. Here the guard is a comment.Cheapest structural version I can see: give
compose_update.tsthe file list itself, e.g.projectComposeFiles(stack) = splitLabel(answeringContainer(stack)?.configFiles ?? ""), and have both call sites use it. Then one test covers both, and neither file spellscontainers[0]or a raw split any more — which is also what would have caught the round-4 divergence at the source.