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
61 changes: 59 additions & 2 deletions src/nullstate/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -478,18 +478,34 @@ def sandbox_down(
try:
backend = get_backend(name)
except KeyError as error:
raise typer.BadParameter(str(error)) from error
commands = _resolve_explicit_sandbox_container_down_commands(name)
if not commands:
raise typer.BadParameter(str(error)) from error
if dry_run:
console.print(render_commands(commands))
return
results = run_commands(commands)
failed = [result for result in results if result.returncode != 0]
if failed:
console.print("Sandbox stop failed. Re-run with --dry-run to inspect commands.", style="bold red")
raise typer.Exit(code=1)
console.print(f"Sandbox container stopped: {name}", style="bold green")
return

commands = backend.down_commands()
commands, target_detail = _resolve_sandbox_down_commands(backend)
if dry_run or not commands:
console.print(render_commands(commands))
if target_detail:
console.print(target_detail)
return
results = run_commands(commands)
failed = [result for result in results if result.returncode != 0]
if failed:
console.print("Sandbox stop failed. Re-run with --dry-run to inspect commands.", style="bold red")
raise typer.Exit(code=1)
console.print("Sandbox stop commands completed.", style="bold green")
if target_detail:
console.print(target_detail)
_print_next_steps(["nullstate sandbox status " + backend.name])


Expand Down Expand Up @@ -645,6 +661,47 @@ def _docker_container_exists(container_name: str) -> bool:
return result.returncode == 0 and container_name in {line.strip() for line in result.stdout.splitlines()}


def _resolve_sandbox_down_commands(backend, *, container_lister=None) -> tuple[list[list[str]], str]:
lister = container_lister or _list_sandbox_containers
container_names = lister(backend)
if container_names:
return backend.down_commands(container_names=container_names), "Target containers: " + ", ".join(container_names)
return backend.down_commands(), ""


def _resolve_explicit_sandbox_container_down_commands(container_name: str) -> list[list[str]]:
if _is_localstack_container_name(container_name):
return [["docker", "rm", "-f", container_name]]
return []


def _list_sandbox_containers(backend) -> list[str]:
base_name = backend.default_container_name()
image = backend.container_image()
if not base_name or not image:
return []
result = subprocess.run(
["docker", "ps", "-a", "--filter", f"name={base_name}", "--filter", f"ancestor={image}", "--format", "{{.Names}}"],
text=True,
capture_output=True,
check=False,
)
if result.returncode != 0:
return []
names = sorted({line.strip() for line in result.stdout.splitlines() if line.strip()})
return [name for name in names if name == base_name or name.startswith(f"{base_name}-")]


def _is_localstack_container_name(container_name: str) -> bool:
return (
container_name == "localstack"
or container_name.startswith("localstack-")
or container_name == "localstack-azure"
or container_name.startswith("localstack-azure-")
or container_name.startswith("nullstate-cli-localstack-")
)


def _localstack_azure_auth_env(backend_name: str, *, offline: bool) -> dict[str, str]:
if offline or backend_name != "localstack-azure":
return {}
Expand Down
11 changes: 10 additions & 1 deletion src/nullstate/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ def default_container_name(self) -> str | None:
return "localstack"
return None

def down_commands(self) -> list[list[str]]:
def down_commands(self, container_names: list[str] | None = None) -> list[list[str]]:
if container_names:
return [["docker", "rm", "-f", *container_names]]
if self.name == "localstack-azure":
return [["docker", "rm", "-f", "localstack-azure"]]
if self.name == "localstack-aws":
Expand All @@ -84,6 +86,13 @@ def down_commands(self) -> list[list[str]]:
return [["docker", "compose", "down"]]
return []

def container_image(self) -> str | None:
if self.name == "localstack-azure":
return "localstack/localstack-azure-alpha"
if self.name == "localstack-aws":
return "localstack/localstack"
return None


@dataclass(frozen=True)
class RuntimeProbe:
Expand Down
29 changes: 29 additions & 0 deletions tests/test_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,35 @@ def test_localstack_down_plan_stops_named_containers(self):
self.assertEqual(azure.down_commands(), [["docker", "rm", "-f", "localstack-azure"]])
self.assertEqual(aws.down_commands(), [["docker", "rm", "-f", "localstack"]])

def test_localstack_down_plan_accepts_discovered_container_names(self):
aws = get_backend("localstack-aws")

self.assertEqual(
aws.down_commands(container_names=["localstack", "localstack-20260510103849"]),
[["docker", "rm", "-f", "localstack", "localstack-20260510103849"]],
)

def test_sandbox_down_discovers_backend_containers(self):
from nullstate.cli import _resolve_sandbox_down_commands

aws = get_backend("localstack-aws")

commands, detail = _resolve_sandbox_down_commands(
aws,
container_lister=lambda _: ["localstack", "localstack-20260510103849"],
)

self.assertEqual(commands, [["docker", "rm", "-f", "localstack", "localstack-20260510103849"]])
self.assertIn("localstack-20260510103849", detail)

def test_sandbox_down_accepts_explicit_localstack_container_name(self):
from nullstate.cli import _resolve_explicit_sandbox_container_down_commands

self.assertEqual(
_resolve_explicit_sandbox_container_down_commands("localstack-20260510103849"),
[["docker", "rm", "-f", "localstack-20260510103849"]],
)

def test_sandbox_status_cli_includes_runtime_probe_rows(self):
completed = subprocess.run(
[sys.executable, "-m", "nullstate", "sandbox", "status", "localstack-azure"],
Expand Down
Loading