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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,9 @@ The `start`, `exec`, `stop`, `delete`, `purge`, `rebuild`, and `inspect` command
| `adevcontainer clone <git-url> [--vscode] [--resume <config-dir>]` | Clone a repository into a named volume and start its dev container |
| `adevcontainer exec [-it] [--name <name>] [--] [cmd…]` | Open a shell or run a command in a running managed container |
| `adevcontainer list [--json]` | List managed dev containers |
| `adevcontainer start [--name <name>] [--vscode] [--json] \| stop \| inspect [--name <name>]` | Manage a container by name or with the interactive picker |
| `adevcontainer start [--name <name>] [--vscode] [--json]` | Start a container by name or with the interactive picker |
| `adevcontainer stop [--name <name>]` | Stop a container by name or with the interactive picker |
| `adevcontainer inspect [--name <name>]` | Inspect a container by name or with the interactive picker |
| `adevcontainer delete [--name <name>]` | Remove a container, keeping its managed volumes |
| `adevcontainer purge [--name <name>]` | Remove a container, its managed volumes, and the image |
| `adevcontainer rebuild [--name <name>] [--vscode]` | Rebuild a managed container from its current configuration |
Expand Down
378 changes: 321 additions & 57 deletions Sources/ADevContainerLib/Commands/CloneCommand.swift

Large diffs are not rendered by default.

295 changes: 212 additions & 83 deletions Sources/ADevContainerLib/Commands/RebuildCommand.swift

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion Sources/ADevContainerLib/Commands/RecoveryOrchestrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -631,7 +631,8 @@ public enum RecoveryOrchestrator {
throw error
}
if let cli = error as? CLIError, cli.property == "volumes" {
try? prepared.session.cleanup()
// The retry already deleted the helper. Keep the published session so
// `rebuild --name` can resume without manufacturing a blank volume.
throw error
}
guard isRetryableHardFailure(error) else {
Expand Down
182 changes: 182 additions & 0 deletions Sources/ADevContainerLib/Commands/VolumeRecoveryResume.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import Foundation

/// Name-indexed pointer at a volume recovery session after the old container is gone.
///
/// Volume recovery normally resumes through a marked helper (`openRetry`). A process exit
/// after the old container is deleted can leave the secure session on disk and never create
/// that helper. This retain stores only non-secret identity so `rebuild --name` can open the
/// existing session, reuse the stamped workspace volume, and refuse to manufacture a blank one.
/// It never stores config bytes and never creates a container or volume.
public enum VolumeRecoveryResume {
public static let directoryPrefix = "adev-volume-recovery-"

public struct State: Equatable, Sendable, Codable {
public let containerName: String
public let containerID: String
public let labels: [String: String]
public let sessionID: String
public let authorName: String?
public let authorEmail: String?

public init(
containerName: String,
containerID: String,
labels: [String: String],
sessionID: String,
authorName: String?,
authorEmail: String?
) {
self.containerName = containerName
self.containerID = containerID
self.labels = labels
self.sessionID = sessionID
self.authorName = authorName
self.authorEmail = authorEmail
}

public var identity: GitAuthorIdentity {
GitAuthorIdentity(name: authorName, email: authorEmail)
}
}

public static func rootURL(fileManager: FileManager = .default) -> URL {
fileManager.temporaryDirectory
.appendingPathComponent("adevcontainer-volume-recovery", isDirectory: true)
}

public static func directoryURL(
forName name: String,
fileManager: FileManager = .default
) throws -> URL {
let safe = safeNameComponent(name)
guard !safe.isEmpty else {
throw CLIError(
code: CLIErrorCode.recoveryUnavailable,
message: "Volume recovery resume name is invalid"
)
}
return rootURL(fileManager: fileManager)
.appendingPathComponent(directoryPrefix + safe, isDirectory: true)
}

/// Publish the session id under the container name before the old container is deleted.
/// A later process exit can then resume without a live helper.
public static func retain(
container: ContainerInfo,
sessionID: String,
authorName: String?,
authorEmail: String?,
fileManager: FileManager = .default
) throws {
guard RecoveryConfigSession.isSafeSessionID(sessionID) else {
throw CLIError(
code: CLIErrorCode.recoveryUnavailable,
message: "Volume recovery resume session id is not safe"
)
}
let labels = RecoveryHelper.normalContainerLabels(container.labels)
guard RecoveryHelper.isEligible(labels: labels) else {
throw CLIError(
code: CLIErrorCode.recoveryUnavailable,
message: "Volume recovery resume requires clone-origin stamps"
)
}

if let previous = try? load(name: container.name, fileManager: fileManager),
previous.sessionID != sessionID,
RecoveryConfigSession.isSafeSessionID(previous.sessionID),
let previousDir = try? RecoveryConfigSession.directoryURL(
forSessionID: previous.sessionID,
fileManager: fileManager
)
{
try? fileManager.removeItem(at: previousDir)
}

let dir = try directoryURL(forName: container.name, fileManager: fileManager)
if fileManager.fileExists(atPath: dir.path) {
try fileManager.removeItem(at: dir)
}
try fileManager.createDirectory(
at: dir,
withIntermediateDirectories: true,
attributes: [.posixPermissions: NSNumber(value: 0o700)]
)

let state = State(
containerName: container.name,
containerID: container.id,
labels: labels,
sessionID: sessionID,
authorName: nonEmpty(authorName),
authorEmail: nonEmpty(authorEmail)
)
let data = try JSONEncoder().encode(state)
let meta = dir.appendingPathComponent("state.json", isDirectory: false)
try data.write(to: meta, options: .atomic)
try fileManager.setAttributes(
[.posixPermissions: NSNumber(value: 0o600)],
ofItemAtPath: meta.path
)
}

/// Load retained stamps when `rebuild --name` cannot find a live container or helper.
public static func load(
name: String,
fileManager: FileManager = .default
) throws -> State? {
let dir = try directoryURL(forName: name, fileManager: fileManager)
let meta = dir.appendingPathComponent("state.json", isDirectory: false)
guard fileManager.fileExists(atPath: meta.path) else { return nil }
let data: Data
do {
data = try Data(contentsOf: meta)
} catch {
return nil
}
guard let state = try? JSONDecoder().decode(State.self, from: data),
state.containerName == name,
RecoveryConfigSession.isSafeSessionID(state.sessionID),
RecoveryHelper.isEligible(labels: state.labels)
else {
try? cleanup(name: name, fileManager: fileManager)
return nil
}
return state
}

public static func cleanup(name: String, fileManager: FileManager = .default) throws {
let dir = try directoryURL(forName: name, fileManager: fileManager)
if fileManager.fileExists(atPath: dir.path) {
try fileManager.removeItem(at: dir)
}
}

public static func containerInfo(from state: State) -> ContainerInfo {
ContainerInfo(
id: state.containerID.isEmpty ? state.containerName : state.containerID,
name: state.containerName,
state: "exited",
labels: state.labels,
image: ""
)
}

private static func nonEmpty(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}

private static func safeNameComponent(_ name: String) -> String {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty,
!trimmed.contains("/"),
!trimmed.contains(".."),
trimmed != ".",
trimmed != ".."
else { return "" }
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_."))
let filtered = String(trimmed.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" })
return String(filtered.prefix(120))
}
}
26 changes: 23 additions & 3 deletions Sources/ADevContainerLib/Runtime/AppleContainerRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,23 @@ public struct AppleContainerRuntime: Sendable {
return false
}

/// Whether `name` is in a successful `volume list`. Throws if the list command fails
/// or its JSON cannot be parsed. Unlike `volumeExists`, that is not treated as absence.
public func volumePresence(_ name: String) throws -> Bool {
let list = try invoke(["volume", "list", "--format", "json"])
guard list.succeeded else {
throw mapFailure(list, action: "volume list")
}
let arr = try parseJSONArray(list.stdout)
for item in arr {
if let id = item["id"] as? String, id == name { return true }
if let n = item["name"] as? String, n == name { return true }
if let cfg = item["configuration"] as? [String: Any],
let n = cfg["name"] as? String, n == name { return true }
}
return false
}

public func deleteVolume(name: String) throws {
let result = try invoke(["volume", "delete", name], streamStderr: false)
try ensureSuccess(result, action: "volume delete \(name)")
Expand All @@ -697,11 +714,14 @@ public struct AppleContainerRuntime: Sendable {
public func create(
request: CreateRequest,
ensureVolumes: Bool = true,
initializeConfigVolumes: Bool = false
initializeConfigVolumes: Bool = false,
workspaceVolumeEnsured: ((Bool) -> Void)? = nil
) throws -> String {
// Ensure workspace named volume (clone / volume-mode)
// Ensure workspace named volume (clone / volume-mode).
// `true` means this call created it; `false` means it already existed and was reused.
if ensureVolumes, request.workspaceMountMode == .volume {
try ensureVolume(name: request.workspaceBindHost)
let created = try ensureVolume(name: request.workspaceBindHost)
workspaceVolumeEnsured?(created)
}
// Ensure named volumes from config mounts
var newConfigVolumes: [MountSpec] = []
Expand Down
68 changes: 67 additions & 1 deletion Sources/ADevContainerLib/Support/CommandSurface.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,16 @@ public enum CommandSurface {
i += 1
continue
}
if a == "--reuse-volume" {
flags.insert("reuse-volume")
i += 1
continue
}
if a == "--replace-volume" {
flags.insert("replace-volume")
i += 1
continue
}
if a == "--json" {
flags.insert("json")
i += 1
Expand Down Expand Up @@ -223,9 +233,23 @@ public enum CommandSurface {
i += 1
}

if flags.contains("reuse-volume"), flags.contains("replace-volume") {
throw conflictingWorkspaceVolumeFlagsError()
}

return ParsedArgs(workspace: workspace, name: name, resume: resume, flags: flags, passthrough: passthrough)
}

/// `--reuse-volume` and `--replace-volume` choose opposite workspace-volume actions.
public static func conflictingWorkspaceVolumeFlagsError() -> CLIError {
CLIError(
code: CLIErrorCode.usage,
property: "--reuse-volume",
message: "--reuse-volume and --replace-volume cannot be used together",
hint: "Pass only one: --reuse-volume keeps the existing workspace volume; --replace-volume deletes it and clones fresh"
)
}

/// Subcommand targeted by a `help <command>` invocation, or nil when `args`
/// is not `help <command>` (including bare `help`, which the caller routes to
/// main usage). `help <command>` MUST print the same command-specific help as
Expand Down Expand Up @@ -261,6 +285,22 @@ public enum CommandSurface {
hint: "Resume a retained checkout with: \(commandPrefix) clone <git-url> --resume <config-dir>"
)
}
if parsed.flags.contains("reuse-volume"), subcommand != "clone" {
throw CLIError(
code: CLIErrorCode.usage,
property: "--reuse-volume",
message: "--reuse-volume is only valid for clone",
hint: "Keep an existing workspace volume with: \(commandPrefix) clone <git-url> --reuse-volume"
)
}
if parsed.flags.contains("replace-volume"), subcommand != "clone" {
throw CLIError(
code: CLIErrorCode.usage,
property: "--replace-volume",
message: "--replace-volume is only valid for clone",
hint: "Replace an existing workspace volume with: \(commandPrefix) clone <git-url> --replace-volume"
)
}
}

/// `plugin` requires exactly one of `--install` or `--uninstall`.
Expand Down Expand Up @@ -332,6 +372,8 @@ public enum CommandSurface {
-w, --workspace <path> Workspace root for `up` only (default: cwd)
--name <container> Managed container name/id (exec/start/stop/delete/purge/rebuild/inspect)
--resume <config-dir> Resume clone from a retained config checkout (clone only)
--reuse-volume Keep an existing clone workspace volume (clone only)
--replace-volume Delete an existing clone workspace volume and clone fresh (clone only)
--json Machine-readable output (up, clone, list, rebuild); on start,
suppresses the interactive recovery prompt
--skip-pull Skip image pull on up/clone/rebuild
Expand Down Expand Up @@ -370,6 +412,11 @@ public enum CommandSurface {
Clone notes:
- Requires host git on PATH (config fetch + HTTPS credential fill)
- Full clone runs inside the container (volume-mode workspace volume)
- Existing workspace volume: a TTY without --json prompts
"Workspace volume <name> already exists. Reuse it? [Y/n]" (empty/Y reuses;
n/no replaces; EOF aborts and does not delete). Non-TTY and --json require
--reuse-volume or --replace-volume and do not delete. Reuse keeps the tree
and never deletes that volume, including on later failure.
- SSH: needs ssh-agent (SSH_AUTH_SOCK); create --ssh for later push
- HTTPS: host git credential fill one-shot; guest credential.helper store
- Auto-adds Features git:1 when config lacks git/common-utils
Expand Down Expand Up @@ -474,7 +521,7 @@ public enum CommandSurface {
"""
case "clone":
return """
\(cmd) clone <git-url> [--json] [--skip-pull] [--vscode] [--resume <config-dir>]
\(cmd) clone <git-url> [--json] [--skip-pull] [--vscode] [--resume <config-dir>] [--reuse-volume | --replace-volume]

Create a volume-mode managed dev container (workspace on a named volume).
Host git fetches config only; full clone runs inside the container.
Expand All @@ -493,6 +540,25 @@ public enum CommandSurface {
retained config checkout (the path printed by a prior non-TTY failure).
Used to retry a failed clone after editing the retained devcontainer.json.

--reuse-volume: keep an existing workspace volume without prompting.
--replace-volume: delete an existing workspace volume and clone into a new
empty one without prompting. The two flags cannot be combined.

If the workspace volume already exists and neither flag is set:
- A TTY without --json prompts "Workspace volume <name> already exists.
Reuse it? [Y/n]". Empty Enter or Y reuses. n/no replaces. EOF aborts
and does not delete the volume.
- Non-TTY or --json fails without prompting or deleting. The error hint
names both --reuse-volume and --replace-volume.

Reuse mounts the existing volume and does not git-clone over a tree that
already has entries (an empty volume, including lost+found only, is still
populated). It keeps that tree, applies author identity when .git exists,
overlays an edited devcontainer.json on --resume/recovery when that path
already exists, and never deletes the volume — including when create,
start, ownership, populate, or hooks later fail. Replace deletes then
creates; failure cleanup may delete only the volume this invocation created.

--vscode: best-effort open VS Code on the resolved remote folder after
success (same prereqs/soft-fail as up). postAttach is CLI attach after
waitFor (not --vscode-gated). Settings and
Expand Down
4 changes: 3 additions & 1 deletion Sources/adevcontainer/AdevcontainerMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ struct AdevcontainerMain {
skipPull: parsed.flags.contains("skip-pull"),
openVSCode: parsed.flags.contains("vscode"),
jsonOutput: parsed.flags.contains("json"),
resumeConfigDir: parsed.resume
resumeConfigDir: parsed.resume,
reuseVolume: parsed.flags.contains("reuse-volume"),
replaceVolume: parsed.flags.contains("replace-volume")
)
let result = try CloneCommand.run(options: opts, runtime: runtime)
if opts.jsonOutput {
Expand Down
Loading
Loading