Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions lib/stack/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,14 +319,24 @@ def down(self, stacklet_id: str) -> dict:
stacklet_dir = Path(result.get("path", ""))
compose_file = docker.find_compose_file(stacklet_dir)
if compose_file:
if not (stacklet_dir / ".env").exists():
try:
self.stack.refresh_env(stacklet_id)
except ValueError:
pass
# Always re-render .env first, exactly as up does. compose reads
# the whole file before it acts, so a stale .env that is missing a
# newly-added variable (a merge that adds a mount, for example)
# makes every command invalid, stop included, and the stacklet
# becomes unstoppable until env is regenerated by hand. Refreshing
# only when .env is absent left that gap.
try:
self.stack.refresh_env(stacklet_id)
except ValueError:
pass
code, output = docker.compose_stop(compose_file)
return {"stacklet": stacklet_id, "action": "down",
"success": code == 0, "output": output}
result = {"stacklet": stacklet_id, "action": "down",
"success": code == 0, "output": output}
if code != 0:
# Surface the compose error instead of the caller's generic
# "unknown error", which hid the real cause.
result["error"] = output or "docker compose stop failed"
return result
return {"stacklet": stacklet_id, "action": "down", "success": True}

def _down_all(self) -> dict:
Expand Down
37 changes: 37 additions & 0 deletions tests/framework/test_cli_class.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,43 @@ def test_unknown_stacklet_fails(self, tmp_path):
result = cli.down("nope")
assert "error" in result

def test_down_refreshes_env_even_when_env_exists(self, tmp_path, monkeypatch):
"""A stale .env must be re-rendered before compose stop.

compose reads the whole file up front, so a .env missing a
variable a later merge added makes stop fail and the stacklet
unstoppable. up already refreshes; down must too. Refreshing only
when .env was absent was the gap that made memory unstoppable
after MEDIA_ARCHIVE_DIR was added to its compose mounts.
"""
cli, _ = _make_cli(tmp_path, {"myapp": {}})
sdir = tmp_path / "stacklets" / "myapp"
(sdir / "docker-compose.yml").write_text("services: {}\n")
(sdir / ".env").write_text("STALE=1\n") # exists, but out of date
refreshed = []
monkeypatch.setattr(cli.stack, "refresh_env",
lambda sid: refreshed.append(sid) or {})
with patch("stack.docker.compose_stop", return_value=(0, "")):
result = cli.down("myapp")
assert result["success"]
assert refreshed == ["myapp"], "down must re-render .env before compose acts"

def test_down_surfaces_the_compose_error(self, tmp_path, monkeypatch):
"""A failed stop reports the real cause, not "unknown error".

The result used to carry only `output`, which the caller dropped,
so the operator saw "unknown error" for a specific compose fault.
"""
cli, _ = _make_cli(tmp_path, {"myapp": {}})
sdir = tmp_path / "stacklets" / "myapp"
(sdir / "docker-compose.yml").write_text("services: {}\n")
monkeypatch.setattr(cli.stack, "refresh_env", lambda sid: {})
fault = "invalid spec: :/vault/media:ro: empty section between colons"
with patch("stack.docker.compose_stop", return_value=(1, fault)):
result = cli.down("myapp")
assert not result["success"]
assert fault in result["error"]


class TestCLIDownAll:
"""`stack down all` stops every currently-running stacklet in reverse
Expand Down
Loading