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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Apple Dev Container CLI (adevcontainer)

[![CI](https://github.com/wcgomes/apple-devcontainers/actions/workflows/ci.yml/badge.svg)](https://github.com/wcgomes/apple-devcontainers/actions/workflows/ci.yml)
[![tests](https://img.shields.io/badge/tests-1093%2B-brightgreen)](https://github.com/wcgomes/apple-devcontainers)
[![tests](https://img.shields.io/badge/tests-1105%2B-brightgreen)](https://github.com/wcgomes/apple-devcontainers)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

Native Swift CLI that reads `devcontainer.json` and runs development environments on Apple [`container`](https://github.com/apple/container).
Expand Down
19 changes: 17 additions & 2 deletions Sources/ADevContainerLib/Commands/PurgeCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,17 @@ public enum PurgeCommand {
/// (`config_volumes`, `workspace_volume`); image from runtime inspect.
/// Candidate volumes are deleted only when unreferenced after target container delete.
///
/// - Returns: `0` if all resources handled or already gone; `1` if a delete of an existing resource failed.
/// On a TTY, confirms before deletion (default no). Non-TTY proceeds without a prompt.
/// Recovery helpers are skipped with no prompt and no deletes.
///
/// - Returns: `0` if all resources handled, already gone, or the TTY confirmation was declined; `1` if a delete of an existing resource failed.
@discardableResult
public static func run(
name: String? = nil,
runtime: AppleContainerRuntime,
picker: InteractivePicker = .default
picker: InteractivePicker = .default,
isInteractive: Bool = AppleContainerConfig.stdinIsTTY(),
readLine: @escaping () -> String? = { Swift.readLine() }
) throws -> Int32 {
let info = try ManagedContainers.resolveSelection(
name: name,
Expand All @@ -30,6 +35,16 @@ public enum PurgeCommand {
return 0
}

if isInteractive {
let prompt = "Purge dev container \(info.id) and its unreferenced volumes and image? [y/N] "
FileHandle.standardError.write(Data(prompt.utf8))
let answer = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
if answer != "y" && answer != "yes" {
print("Purge cancelled")
return 0
}
}

let target = PurgeTarget(
container: info,
expectedContainerName: info.name,
Expand Down
3 changes: 2 additions & 1 deletion Sources/ADevContainerLib/Support/CommandSurface.swift
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ public enum CommandSurface {
exec [-it] [--name] [--] [cmd...] Run a command (or shell) in a managed dev container
stop [--name] Stop a managed dev container (name or picker)
delete [--name] Remove container only (not workspace volume)
purge [--name] Remove container, volumes (incl. *-ws), and config image
purge [--name] Remove container, volumes (incl. *-ws), and config image; confirms on a TTY
rebuild [--name] Force-rebuild a managed dev container from its current config
(same name; volumes preserved)
inspect [--name] Show identity, state, labels (from runtime + labels)
Expand Down Expand Up @@ -494,6 +494,7 @@ public enum CommandSurface {

Remove managed container, config named volumes (label), volume-mode
workspace volume (*-ws), and config image. Selection via --name/picker.
On a TTY, confirms before deletion (default no). Non-TTY proceeds without a prompt.
"""
case "inspect":
return """
Expand Down
203 changes: 202 additions & 1 deletion Tests/adevcontainerTests/AllCommandTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1886,9 +1886,210 @@ nonisolated(unsafe) let lifecycleTests: [(String, () throws -> Void)] = [
!mock.calls.contains { $0.arguments == ["volume", "delete", "other-vol"] },
"volumes not in labels must never be deleted"
)
})
}),
("purgeInteractiveDeclineSkipsDeletes", {
let previousEnabled = StatusPrinter.enabled
let previousPhase = StatusPrinter.hasEmittedPhase
defer {
StatusPrinter.enabled = previousEnabled
StatusPrinter.hasEmittedPhase = previousPhase
}
StatusPrinter.enabled = true
let id = "adev-purge-confirm"
let prompt = "Purge dev container \(id) and its unreferenced volumes and image? [y/N] "
let answers: [String?] = [nil, "", " ", "n", "no", "N", "NO", "maybe"]
for answer in answers {
let (runtime, mock) = makePurgeConfirmRuntime(id: id)
var stdout = ""
var stderr = ""
#if canImport(Darwin) || canImport(Glibc)
fflush(nil)
#endif
try withCapturedStdout({
try withCapturedStderr({
let code = try PurgeCommand.run(
name: id,
runtime: runtime,
isInteractive: true,
readLine: { answer }
)
#if canImport(Darwin) || canImport(Glibc)
fflush(nil)
#endif
try MiniTest.expectEqual(code, 0, "decline \(String(describing: answer))")
}, capture: &stderr)
}, capture: &stdout)
try MiniTest.expectEqual(stderr, prompt, "stderr for \(String(describing: answer))")
try MiniTest.expect(!stderr.contains("Purging"), "no purging phase for \(String(describing: answer))")
try MiniTest.expect(!stderr.contains("Deleting"), "no deleting phase for \(String(describing: answer))")
try MiniTest.expectEqual(stdout, "Purge cancelled\n", "stdout for \(String(describing: answer))")
try MiniTest.expect(
!mock.calls.contains { ["delete", "volume", "image"].contains($0.arguments.first ?? "") },
"no deletes for \(String(describing: answer))"
)
}
}),
("purgeInteractiveYesDeletes", {
let previousEnabled = StatusPrinter.enabled
let previousPhase = StatusPrinter.hasEmittedPhase
defer {
StatusPrinter.enabled = previousEnabled
StatusPrinter.hasEmittedPhase = previousPhase
}
StatusPrinter.enabled = true
let id = "adev-purge-confirm"
let prompt = "Purge dev container \(id) and its unreferenced volumes and image? [y/N] "
for answer in ["y", "Yes", " y "] {
let (runtime, mock) = makePurgeConfirmRuntime(id: id)
var stderr = ""
let code = try withCapturedPurgeStderr(capture: &stderr) {
try PurgeCommand.run(
name: id,
runtime: runtime,
isInteractive: true,
readLine: { answer }
)
}
try MiniTest.expectEqual(code, 0, "yes \(answer)")
try MiniTest.expect(stderr.contains(prompt), "prompt for \(answer)")
try MiniTest.expect(stderr.contains("Purging"), "purging phase for \(answer)")
if let promptAt = stderr.range(of: prompt), let purgingAt = stderr.range(of: "Purging") {
try MiniTest.expect(promptAt.upperBound <= purgingAt.lowerBound, "prompt before purging for \(answer)")
}
try MiniTest.expect(purgeConfirmDeleted(mock, id: id), "deletes run for \(answer)")
}
}),
("purgeNonInteractiveProceedsWithoutPrompt", {
let id = "adev-purge-confirm"
let (runtime, mock) = makePurgeConfirmRuntime(id: id)
var read = false
var stderr = ""
let code = try withCapturedPurgeStderr(capture: &stderr) {
try PurgeCommand.run(
name: id,
runtime: runtime,
isInteractive: false,
readLine: {
read = true
return "n"
}
)
}
try MiniTest.expectEqual(code, 0)
try MiniTest.expect(!read, "non-TTY must not read a line")
try MiniTest.expect(!stderr.contains("Purge dev container"), "non-TTY must not prompt")
try MiniTest.expect(purgeConfirmDeleted(mock, id: id))
}),
("purgeInteractiveSkipsRecoveryHelperWithoutPrompt", {
let id = "recovery-helper"
let (runtime, mock) = makePurgeConfirmRuntime(id: id, recoveryHelper: true)
var read = false
var stderr = ""
let code = try withCapturedPurgeStderr(capture: &stderr) {
try PurgeCommand.run(
name: id,
runtime: runtime,
isInteractive: true,
readLine: {
read = true
return "y"
}
)
}
try MiniTest.expectEqual(code, 0)
try MiniTest.expect(!read, "recovery helper skip must not prompt")
try MiniTest.expect(!stderr.contains("Purge dev container"))
try MiniTest.expect(
!mock.calls.contains { ["delete", "volume", "image"].contains($0.arguments.first ?? "") },
"recovery helper skip must not delete"
)
}),
("purgeHelpMentionsTTYConfirmation", {
let usage = CommandSurface.usageText()
try MiniTest.expect(usage.contains("purge [--name]"))
try MiniTest.expect(usage.contains("confirms on a TTY"))
let help = CommandSurface.commandHelpText("purge") ?? ""
try MiniTest.expect(help.contains("TTY"))
try MiniTest.expect(help.contains("confirms before deletion"))
try MiniTest.expect(help.contains("Non-TTY proceeds without a prompt"))
try MiniTest.expect(!help.contains("--yes"))
try MiniTest.expect(!usage.contains("--yes"))
}),
]

private final class PurgeConfirmDeleted: @unchecked Sendable {
var value = false
}

private func makePurgeConfirmRuntime(
id: String = "adev-purge-confirm",
recoveryHelper: Bool = false
) -> (runtime: AppleContainerRuntime, mock: MockProcessRunner) {
var labels: [String: String] = [
ContainerIdentity.labelManaged: ContainerIdentity.managedValue,
ContainerIdentity.labelConfigVolumes: "vol-confirm",
]
if recoveryHelper {
labels[RecoveryHelper.recoveryMarkerLabel] = RecoveryHelper.recoveryMarkerValue
}
let entry = MockProcessRunner.containerListJSON(
id: id, state: "stopped", labels: labels, image: "alpine:3.20"
)
let volumeListData = try! JSONSerialization.data(
withJSONObject: [["id": "vol-confirm"]] as [[String: Any]]
)
let deleted = PurgeConfirmDeleted()
let mock = MockProcessRunner()
mock.handlers = [
{ args in
if args.starts(with: ["list"]) {
let payload: [Any] = deleted.value ? [] : [entry]
let data = try! JSONSerialization.data(withJSONObject: payload)
return ProcessResult(exitCode: 0, stdout: data, stderr: Data())
}
if args.first == "delete" {
deleted.value = true
return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data())
}
if args == ["volume", "list", "--format", "json"] {
return ProcessResult(exitCode: 0, stdout: volumeListData, stderr: Data())
}
if args.starts(with: ["volume", "delete"]) {
return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data())
}
if args.starts(with: ["image", "delete"]) || args.starts(with: ["image", "rm"]) {
return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data())
}
return nil
}
]
let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock)
return (runtime, mock)
}

private func purgeConfirmDeleted(_ mock: MockProcessRunner, id: String) -> Bool {
let deletedContainer = mock.calls.contains { $0.arguments.first == "delete" && $0.arguments.contains(id) }
let deletedVolume = mock.calls.contains { $0.arguments == ["volume", "delete", "vol-confirm"] }
let deletedImage = mock.calls.contains {
$0.arguments == ["image", "delete", "alpine:3.20"] || $0.arguments == ["image", "rm", "alpine:3.20"]
}
return deletedContainer && deletedVolume && deletedImage
}

private func withCapturedPurgeStderr(
capture: inout String,
_ body: () throws -> Int32
) throws -> Int32 {
var code: Int32 = 1
try withCapturedStderr({
code = try body()
#if canImport(Darwin) || canImport(Glibc)
fflush(nil)
#endif
}, capture: &capture)
return code
}

/// Container list JSON with real volume mounts (RecoveryHelperTests shape) for purge attachment mocks.
private func purgeAttachedContainerJSON(
id: String,
Expand Down
38 changes: 31 additions & 7 deletions specs/managed-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ If no container exists, inspect MUST fail structurally or report not-found consi
| Bind-mount host paths | No |
| Global volume/image prune | No |

Those removals, and the existing MUST-remove scenarios in this requirement, apply only after an explicit yes on an interactive TTY, or when stdin is not a TTY. A declined or unanswered TTY confirmation MUST NOT remove them.

Features-derived tags keep that existing purge policy. Dockerfile-only creates MUST stamp the product Dockerfile tag so purge can treat it as the config image to delete.

**Identity and candidates (unchanged intent):**
Expand All @@ -114,12 +116,21 @@ Features-derived tags keep that existing purge policy. Dockerfile-only creates M
- Labels define the **candidate volume set only**. The product MUST NOT invent Compose `external` support, shared/private naming conventions, or new public `devcontainer.json` fields for this decision.
- Same resolved volume name MUST be treated as the same volume (Docker-like sharing). No separate “private copy” identity.

**Confirmation gate (MUST):**

- After managed selection, a recovery-helper skip MUST still exit 0 with no confirmation prompt and no deletes.
- On an interactive TTY, before any container, volume, or image delete and before the purging status, purge MUST write this raw stderr prompt (not StatusPrinter, not QUIET-gated), naming the container id: `Purge dev container <id> and its unreferenced volumes and image? [y/N] `.
- Only `y` or `yes` (case-insensitive) MUST proceed into the existing purge path.
- Empty input, `n`/`no`, EOF, or any other answer MUST cancel: no container, volume, or image deletes; stdout `Purge cancelled`; exit 0; no deleting phases.
- Non-TTY MUST NOT prompt and MUST keep the existing purge behavior (proceed to delete). Non-TTY MUST NOT fail for lack of confirmation. This requirement MUST NOT add `--yes`.

**Ordering and fail-safe (MUST):**

1. Resolve the managed target (and apply recovery-helper skip first when applicable — unchanged: skip helper, its referenced volumes, and its image; exit 0).
2. Delete the **target container** (force as today). Missing container after resolve MAY be skipped as today.
3. **If target container delete fails** (runtime error on an existing container the command attempted to delete), the command MUST **not** delete any candidate volumes, MUST return non-zero, and MAY still attempt image cleanup only if that does not undermine the volume fail-safe (preferred: treat container failure as hard failure and skip volume deletes entirely).
4. Only after the target container is gone (deleted successfully or already absent), evaluate each **distinct** candidate volume name from labels (`config_volumes` + optional `workspace_volume`).
1. Resolve the managed target (and apply recovery-helper skip first when applicable — unchanged: skip helper, its referenced volumes, and its image; exit 0; no confirmation prompt and no deletes).
2. Apply the confirmation gate when stdin is a TTY. Decline exits 0 with no deletes. Non-TTY skips the prompt and continues.
3. Delete the **target container** (force as today). Missing container after resolve MAY be skipped as today.
4. **If target container delete fails** (runtime error on an existing container the command attempted to delete), the command MUST **not** delete any candidate volumes, MUST return non-zero, and MAY still attempt image cleanup only if that does not undermine the volume fail-safe (preferred: treat container failure as hard failure and skip volume deletes entirely).
5. Only after the target container is gone (deleted successfully or already absent), evaluate each **distinct** candidate volume name from labels (`config_volumes` + optional `workspace_volume`).

**Volume attachment gate (MUST):**

Expand Down Expand Up @@ -151,7 +162,8 @@ For each existing candidate volume name:

| Condition | Volume deletes | Exit |
|-----------|----------------|------|
| Recovery helper selected | None (full skip) | 0 |
| TTY confirmation declined (empty, n/no, EOF, or other) | None | 0 |
| Recovery helper selected | None (full skip; no prompt) | 0 |
| Target container delete failed | None | non-zero |
| Attachment inspection failed for a candidate | Preserve affected volume(s) | non-zero |
| Shared volume(s) preserved with warning only | Skip those; delete unreferenced others | 0 if no hard failures |
Expand Down Expand Up @@ -218,9 +230,21 @@ For each existing candidate volume name:
- Then the command exits non-zero (hard failure)

#### Scenario: Recovery helper skip unchanged
- Given a marked recovery helper selected for purge
- Given a marked recovery helper selected for purge, including on an interactive TTY
- When the user runs `adevcontainer purge --name <helper>`
- Then the helper, its referenced workspace/config volumes, and its image are not removed, and the command exits 0
- Then the helper, its referenced workspace/config volumes, and its image are not removed, no confirmation prompt is shown, and the command exits 0

#### Scenario: TTY confirmation gates purge deletes
- Given a managed dev container selected for purge and an interactive TTY
- When the user runs `adevcontainer purge` targeting that container
- Then before any container, volume, or image delete and before the purging status, stderr shows `Purge dev container <id> and its unreferenced volumes and image? [y/N] `
- And only `y` or `yes` (case-insensitive) proceeds into the existing purge path
- And empty input, `n`/`no`, EOF, or any other answer cancels with stdout `Purge cancelled`, exit 0, and no deletes or deleting phases

#### Scenario: Non-TTY purge proceeds without a prompt
- Given a managed dev container selected for purge and non-interactive stdin
- When the user runs `adevcontainer purge` targeting that container
- Then no confirmation prompt is shown and the existing MUST-remove behavior applies

#### Scenario: delete remains container-only
- Given any managed container
Expand Down
Loading
Loading