## Implementation Plan: Remove Stopped Containers ### 1. Overview Add an automatic cleanup of stopped containers in the reconciliation loop and expose a dedicated CLI command for manual pruning. The change involves three files and corresponding unit tests. ### 2. Changes #### A. Service Layer: `devplacepy/services/containers/service.py` - Add a new method `prune_stopped_instances(self)` to `ContainerService` that: 1. Calls `store.all_instances()`. 2. Filters for instances where `status == store.ST_STOPPED` and `desired_state == store.DESIRED_STOPPED` (constant check – use `store.DESIRED_STOPPED`; currently `DESIRED_STOPPED = 'stopped'` per `store.py` line ~30). 3. For each such instance: - If a Docker container still exists (check via `backend.status()` or treat as best-effort): - Call `await backend.rm(container_id, force=True)`. - Call `store.delete_instance(uid, deleted_by='auto_prune')` – ensure `delete_instance` accepts an optional `deleted_by` argument; if not, extend it (default `None` or system identifier). Soft-delete convention. 4. Log each removal with `self.log(f"pruned stopped instance {name}")`. - Integrate `prune_stopped_instances()` into `run_once()`. Call it after the existing reconcile loop and `image_prune()` call (if applicable). The `run_once` currently does reconcile only; we can call prune immediately after the main loop, before returning. Example placement in `run_once` (around line ~180 after status transitions): ```python await self.prune_stopped_instances() ``` #### B. CLI: `devplacepy/cli/containers.py` - Add a new subcommand `prune-stopped` to the existing `containers` group. Follow the existing pattern of `prune` (which does reconcile + image prune). The new command should: - Create a `ContainerService` instance. - Call `await service.prune_stopped_instances()`. - Add a short help text: `Remove all stopped container instances and their DB records.` Register with `@app.command()` inside the existing `containers` app. Example: ```python @containers_app.command() async def prune_stopped(): """Remove all stopped container instances.""" svc = ContainerService() async with svc: await svc.prune_stopped_instances() ``` #### C. Unit Tests: `tests/api/projects/containers/manage.py` - Add a test function `test_prune_stopped_instances` that: 1. Uses the existing test fixtures (e.g., `async_client`, `mock_backend`, `store`). 2. Creates a few stopped container records in the store (direct insert via `store.add_instance()` or API calls that result in stopped state). 3. Calls `ContainerService().prune_stopped_instances()`. 4. Verifies that the stopped instances are soft-deleted (`deleted_at` is set) and that `backend.rm()` was called for each (if using mock backend). 5. Verifies running instances are not affected. - Also verify the CLI command works by invoking the async client with a test runner (already present in the test suite). Could be a separate e2e test if desired. ### 3. Additional Safety - `store.delete_instance()` currently expects only `uid`. Optionally extend it to accept `deleted_by` parameter (soft delete). Update existing callers if needed (they currently do not pass it, so backward compatible). Add default `'auto_prune'` for the new use case. - Ensure `backend.rm()` is safe to call on containers that may already be removed (add `try/except` for `NotFound`/`APIError` – DockerCli already handles errors gracefully but log any exceptions). - Do not modify `all_instances()` or other listing surfaces – stopped containers remain visible in UIs for debugging but can be removed via the new mechanism. ### 4. Verification & Definition of Done - [ ] **Code changes implemented** as described above. - [ ] **Lint passes**: Run `pip install -q ruff && ruff check .` – no new violations. - [ ] **Unit tests pass**: Run `pip install -e '.[dev]' -q && make test-unit` – all existing and new tests pass. (Note: if the environment raises PEP 668 errors, set `PIP_REQUIRE_VIRTUALENV=0` or work inside a virtual environment.) - [ ] **Manual verification** (optional but recommended): - Using a local Docker instance or the fake backend, start a container, then stop it (change status to `ST_STOPPED`). Run `devplace containers prune-stopped` and confirm the container is removed from Docker and the DB record is soft-deleted. - Run the automatic reconcile (e.g., via `devplace containers reconcile` or waiting for periodic sync) and verify stopped containers are pruned without manual action. - [ ] **No regression**: Existing test suite (unit + any integration) passes. The change is additive; no existing behavior is altered.