From 7a237624dd110875ca9bb1a336562cb3a3b30bba Mon Sep 17 00:00:00 2001 From: Wyller Gomes Date: Thu, 1 Oct 2026 21:20:18 -0300 Subject: [PATCH 1/3] fix: resume volume rebuild and confirm clone volume reuse Volume-mode rebuild publishes a name index before delete and resumes when the container is gone and no helper exists. Clone prompts before deleting an existing workspace volume and does not clone over a kept tree. --- .../Commands/CloneCommand.swift | 378 +++++++-- .../Commands/RebuildCommand.swift | 295 +++++-- .../Commands/RecoveryOrchestrator.swift | 3 +- .../Commands/VolumeRecoveryResume.swift | 182 +++++ .../Runtime/AppleContainerRuntime.swift | 26 +- .../Support/CommandSurface.swift | 68 +- Sources/adevcontainer/AdevcontainerMain.swift | 4 +- .../CloneInVolumeTests.swift | 766 +++++++++++++++++- .../RebuildCommandPhaseTests.swift | 249 ++++++ specs/clone.md | 65 +- wiki/architecture.md | 4 +- wiki/conventions/cli-runtime-boundary.md | 19 +- wiki/domain/devcontainer-apple-gaps.md | 20 +- 13 files changed, 1903 insertions(+), 176 deletions(-) create mode 100644 Sources/ADevContainerLib/Commands/VolumeRecoveryResume.swift diff --git a/Sources/ADevContainerLib/Commands/CloneCommand.swift b/Sources/ADevContainerLib/Commands/CloneCommand.swift index 5148308..1908c42 100644 --- a/Sources/ADevContainerLib/Commands/CloneCommand.swift +++ b/Sources/ADevContainerLib/Commands/CloneCommand.swift @@ -10,19 +10,45 @@ public struct CloneOptions: Sendable { /// Retained config-only checkout directory for a non-interactive `--resume` retry. /// When set, clone skips the host git config fetch and re-resolves from this checkout. public var resumeConfigDir: String? + /// Keep an existing workspace volume without prompting. + public var reuseVolume: Bool + /// Delete an existing workspace volume and clone into a new empty one without prompting. + public var replaceVolume: Bool public init( gitURL: String, skipPull: Bool = false, openVSCode: Bool = false, jsonOutput: Bool = false, - resumeConfigDir: String? = nil + resumeConfigDir: String? = nil, + reuseVolume: Bool = false, + replaceVolume: Bool = false ) { self.gitURL = gitURL self.skipPull = skipPull self.openVSCode = openVSCode self.jsonOutput = jsonOutput self.resumeConfigDir = resumeConfigDir + self.reuseVolume = reuseVolume + self.replaceVolume = replaceVolume + } +} + +/// stderr Y/n for an existing clone workspace volume. Not QUIET-gated. +public struct WorkspaceVolumePrompt: Sendable { + public var readLine: @Sendable () -> String? + public var writeError: @Sendable (String) -> Void + + public init( + readLine: @escaping @Sendable () -> String?, + writeError: @escaping @Sendable (String) -> Void = { FileHandle.standardError.write(Data($0.utf8)) } + ) { + self.readLine = readLine + self.writeError = writeError + } + + public static var `default`: WorkspaceVolumePrompt { + WorkspaceVolumePrompt(readLine: { Swift.readLine() }) } } @@ -48,8 +74,12 @@ public enum CloneCommand { identityPrompt: IdentityPrompt = .default, isTTY: Bool = AppleContainerConfig.stdinIsTTY(), openEditorPrompt: RecoveryOpenEditorPrompt = .default, - editor: RecoveryEditor? = nil + editor: RecoveryEditor? = nil, + volumePrompt: WorkspaceVolumePrompt = .default ) throws -> CloneResult { + if options.reuseVolume && options.replaceVolume { + throw CommandSurface.conflictingWorkspaceVolumeFlagsError() + } // 1. Require host git before any work (config-only fetch + HTTPS credential fill). _ = try git.requireGit() @@ -166,7 +196,9 @@ public enum CloneCommand { localEnv: localEnv, hostResources: hostResources, fileManager: fileManager, - persistEditedConfig: persistEditedConfig + persistEditedConfig: persistEditedConfig, + isTTY: isTTY, + volumePrompt: volumePrompt ) } @@ -268,7 +300,9 @@ public enum CloneCommand { localEnv: [String: String], hostResources: any HostResourceProviding, fileManager: FileManager, - persistEditedConfig: Bool + persistEditedConfig: Bool, + isTTY: Bool, + volumePrompt: WorkspaceVolumePrompt ) throws -> CloneResult { let configPath = (checkoutDir as NSString).appendingPathComponent(configRelPath) @@ -333,6 +367,24 @@ public enum CloneCommand { ) } + // Ask before Features so a refused or cancelled choice does not pull images, + // and so a Features failure cannot follow a replace-delete. + let volumeDisposition = try Self.resolveWorkspaceVolumeDisposition( + name: identity.workspaceVolumeName, + options: options, + runtime: runtime, + isTTY: isTTY, + prompt: volumePrompt + ) + // Set only when this invocation's ensureVolume returns true on replace or a + // confirmed-missing create. A reused volume, or one ensureVolume only reused, + // is never deleted — including when the pre-check could not see it. + var createdWorkspaceVolume = false + func cleanupCreatedWorkspaceVolume() { + guard createdWorkspaceVolume else { return } + try? runtime.deleteVolume(name: identity.workspaceVolumeName) + } + var effectiveConfig = resolved.config let platform = ContainerPlatform.defaultLinuxPlatform var knownOCIUser: String?? = nil @@ -467,8 +519,12 @@ public enum CloneCommand { enableSSHForward: enableSSHForward ) - // Fresh workspace tree: volume may remain after container-only delete. - if try runtime.volumeExists(identity.workspaceVolumeName) { + switch volumeDisposition { + case .createFresh: + break + case .reuse: + StatusPrinter.status("Reusing workspace volume", item: identity.workspaceVolumeName) + case .replace: StatusPrinter.status("Replacing workspace volume", item: identity.workspaceVolumeName) try runtime.deleteVolume(name: identity.workspaceVolumeName) } @@ -477,12 +533,15 @@ public enum CloneCommand { StatusPrinter.status("Creating container", item: identity.containerName) let id: String do { - id = try runtime.create(request: request) + id = try runtime.create(request: request, workspaceVolumeEnsured: { created in + if volumeDisposition != .reuse { + createdWorkspaceVolume = created + } + }) } catch { - // `runtime.create` ensures the clone workspace volume before invoking the - // container create operation. Remove that empty/partial workspace before recovery - // so a later retry starts from the same clean clone semantics as a fresh invocation. - try? runtime.deleteVolume(name: identity.workspaceVolumeName) + // Delete only the workspace volume ensureVolume just created. A reused + // volume, or one this call did not create, stays. + cleanupCreatedWorkspaceVolume() throw BringUpRecovery.eligible(error) } @@ -507,7 +566,7 @@ public enum CloneCommand { ) } catch { try? runtime.delete(nameOrId: id, force: true) - try? runtime.deleteVolume(name: identity.workspaceVolumeName) + cleanupCreatedWorkspaceVolume() throw BringUpRecovery.eligible(error) } } @@ -518,7 +577,7 @@ public enum CloneCommand { try runtime.start(nameOrId: id) } catch { try? runtime.delete(nameOrId: id, force: true) - try? runtime.deleteVolume(name: identity.workspaceVolumeName) + cleanupCreatedWorkspaceVolume() throw BringUpRecovery.eligible(error) } @@ -541,72 +600,123 @@ public enum CloneCommand { ) } catch { try? runtime.delete(nameOrId: id, force: true) - try? runtime.deleteVolume(name: identity.workspaceVolumeName) + cleanupCreatedWorkspaceVolume() throw BringUpRecovery.eligible(error) } } - // 10. Full clone INSIDE container (guest git + host-resolved auth for HTTPS) - StatusPrinter.status("Populating workspace volume") - do { - try populateInContainer( - url: url, - urlKind: urlKind, - containerId: id, - workspaceFolder: effectiveConfig.workspaceFolder, - remoteUser: connectionUser, - containerEnv: effectiveConfig.containerEnv, - credentials: credentials, - runtime: runtime - ) - } catch { - try? runtime.delete(nameOrId: id, force: true) - try? runtime.deleteVolume(name: identity.workspaceVolumeName) - if let cli = error as? CLIError { - throw BringUpRecovery.eligible(cli) + // 10. Full clone INSIDE container, unless reuse is keeping an existing tree. + let keepExistingTree: Bool + if volumeDisposition == .reuse { + do { + keepExistingTree = try workspaceHasObjectEntries( + containerId: id, + workspaceFolder: effectiveConfig.workspaceFolder, + volumeName: identity.workspaceVolumeName, + runtime: runtime + ) + } catch { + try? runtime.delete(nameOrId: id, force: true) + cleanupCreatedWorkspaceVolume() + throw BringUpRecovery.eligible(error) } - throw BringUpRecovery.eligible(CLIError( - code: CLIErrorCode.populateFailed, - message: "Failed to populate workspace volume: \(error.localizedDescription)", - hint: "Check git access for the repository URL and that in-container git is available" - )) + } else { + keepExistingTree = false } - // 10a. Recovery/`--resume`: populate cloned the original remote config; overlay - // the retained/edited host bytes at the same relative path so a later open - // does not need to re-apply the recovery edit. - if persistEditedConfig { + if keepExistingTree { + StatusPrinter.status("Keeping existing workspace tree", item: identity.workspaceVolumeName) + } else { + StatusPrinter.status("Populating workspace volume") do { - try persistEditedConfigIntoWorkspace( - hostConfigPath: configPath, + try populateInContainer( + url: url, + urlKind: urlKind, containerId: id, workspaceFolder: effectiveConfig.workspaceFolder, - configRelPath: configRelPath, remoteUser: connectionUser, + containerEnv: effectiveConfig.containerEnv, + credentials: credentials, runtime: runtime ) } catch { try? runtime.delete(nameOrId: id, force: true) - try? runtime.deleteVolume(name: identity.workspaceVolumeName) + cleanupCreatedWorkspaceVolume() if let cli = error as? CLIError { throw BringUpRecovery.eligible(cli) } throw BringUpRecovery.eligible(CLIError( code: CLIErrorCode.populateFailed, - message: "Failed to persist edited devcontainer.json into the workspace: \(error.localizedDescription)", - hint: "The recovery edit could not be written after populate" + message: "Failed to populate workspace volume: \(error.localizedDescription)", + hint: "Check git access for the repository URL and that in-container git is available" )) } } - // 10b. Local git author identity inside the volume clone (both or neither). - applyAuthorIdentityInContainer( - identity: authorIdentity, - containerId: id, - workspaceFolder: effectiveConfig.workspaceFolder, - remoteUser: connectionUser, - runtime: runtime - ) + // 10a. Recovery/`--resume`: overlay the retained/edited host bytes when the + // destination already exists. Fresh populate always has that path; a reused + // tree is overlaid only when the path is already there. + if persistEditedConfig { + let dest = (effectiveConfig.workspaceFolder as NSString) + .appendingPathComponent(configRelPath) + let overlay: Bool + do { + overlay = keepExistingTree + ? try runtime.pathExistsInContainer(containerId: id, path: dest) + : true + } catch { + try? runtime.delete(nameOrId: id, force: true) + cleanupCreatedWorkspaceVolume() + throw BringUpRecovery.eligible(error) + } + if overlay { + do { + try persistEditedConfigIntoWorkspace( + hostConfigPath: configPath, + containerId: id, + workspaceFolder: effectiveConfig.workspaceFolder, + configRelPath: configRelPath, + remoteUser: connectionUser, + runtime: runtime + ) + } catch { + try? runtime.delete(nameOrId: id, force: true) + cleanupCreatedWorkspaceVolume() + if let cli = error as? CLIError { + throw BringUpRecovery.eligible(cli) + } + throw BringUpRecovery.eligible(CLIError( + code: CLIErrorCode.populateFailed, + message: "Failed to persist edited devcontainer.json into the workspace: \(error.localizedDescription)", + hint: "The recovery edit could not be written after populate" + )) + } + } + } + + // 10b. Local git author identity. On a kept tree, only when `.git` already exists. + let applyAuthor: Bool + if keepExistingTree { + let gitDir = (effectiveConfig.workspaceFolder as NSString).appendingPathComponent(".git") + do { + applyAuthor = try runtime.pathExistsInContainer(containerId: id, path: gitDir) + } catch { + try? runtime.delete(nameOrId: id, force: true) + cleanupCreatedWorkspaceVolume() + throw BringUpRecovery.eligible(error) + } + } else { + applyAuthor = true + } + if applyAuthor { + applyAuthorIdentityInContainer( + identity: authorIdentity, + containerId: id, + workspaceFolder: effectiveConfig.workspaceFolder, + remoteUser: connectionUser, + runtime: runtime + ) + } // 11. Create-path lifecycle hooks (same matrix as up), split at waitFor. do { @@ -617,7 +727,7 @@ public enum CloneCommand { ) } catch { // LifecycleRunner already deletes container on create-path failure - try? runtime.deleteVolume(name: identity.workspaceVolumeName) + cleanupCreatedWorkspaceVolume() throw BringUpRecovery.eligible(error) } @@ -677,13 +787,167 @@ public enum CloneCommand { runtime: runtime ) } catch { - try? runtime.deleteVolume(name: identity.workspaceVolumeName) + cleanupCreatedWorkspaceVolume() throw BringUpRecovery.eligible(error) } if let readyError { throw readyError } return result } + // MARK: - Workspace volume reuse / replace + + /// stderr prompt. Trailing space matches other `[Y/n]` prompts. Not QUIET-gated. + public static func workspaceVolumeReusePrompt(volumeName: String) -> String { + "Workspace volume \(volumeName) already exists. Reuse it? [Y/n] " + } + + /// argv token for the in-container empty-vs-populated probe. + static let workspaceVolumeEntriesToken = "adevcontainer-volume-entries" + + /// `volume list` does not report filesystem contents (`requireObjectEntries` is JSON + /// strictness). Probe the mounted workspace after start. `lost+found` alone is empty. + private static let workspaceVolumeEntriesScript = """ + set -eu + ws="$1" + [ -d "$ws" ] || exit 2 + for entry in "$ws"/* "$ws"/.[!.]* "$ws"/..?*; do + [ -e "$entry" ] || [ -L "$entry" ] || continue + base="${entry##*/}" + case "$base" in + '*'|'.[!.]*'|'..?*'|lost+found) continue ;; + esac + exit 0 + done + exit 1 + """ + + private enum WorkspaceVolumeDisposition { + case createFresh + case reuse + case replace + } + + private enum WorkspaceVolumeAnswer { + case reuse + case replace + case eof + case unrecognized(String) + } + + private static func classifyWorkspaceVolumeAnswer(_ line: String?) -> WorkspaceVolumeAnswer { + guard let line else { return .eof } + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return .reuse } + let token = trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init) ?? "" + switch token.lowercased() { + case "y", "yes": + return .reuse + case "n", "no": + return .replace + default: + return .unrecognized(token) + } + } + + /// Decide before any delete. Missing volume creates as today and does not prompt. + /// An existing volume is deleted only when the operator chooses replace. + private static func resolveWorkspaceVolumeDisposition( + name: String, + options: CloneOptions, + runtime: AppleContainerRuntime, + isTTY: Bool, + prompt: WorkspaceVolumePrompt + ) throws -> WorkspaceVolumeDisposition { + if options.reuseVolume && options.replaceVolume { + throw CommandSurface.conflictingWorkspaceVolumeFlagsError() + } + let exists: Bool + do { + exists = try runtime.volumePresence(name) + } catch { + let detail = (error as? CLIError)?.message ?? error.localizedDescription + throw CLIError( + code: CLIErrorCode.runtimeFailed, + message: "Could not tell whether workspace volume '\(name)' exists: \(detail)", + hint: "Refusing to treat a failed or unparsable volume list as a missing volume. The workspace volume was not deleted" + ) + } + guard exists else { + return .createFresh + } + if options.replaceVolume { return .replace } + if options.reuseVolume { return .reuse } + + guard isTTY, !options.jsonOutput else { + throw CLIError( + code: CLIErrorCode.usage, + message: "Workspace volume '\(name)' already exists", + hint: "Pass --reuse-volume to keep it, or --replace-volume to delete it and clone fresh" + ) + } + + prompt.writeError(workspaceVolumeReusePrompt(volumeName: name)) + switch classifyWorkspaceVolumeAnswer(prompt.readLine()) { + case .reuse: + return .reuse + case .replace: + return .replace + case .eof: + throw CLIError( + code: CLIErrorCode.usage, + message: "Clone cancelled; workspace volume '\(name)' was not deleted", + hint: "Re-run and answer Y to reuse or n to replace, or pass --reuse-volume or --replace-volume" + ) + case .unrecognized(let token): + throw CLIError( + code: CLIErrorCode.usage, + message: "Unrecognized answer '\(token)' for workspace volume '\(name)'", + hint: "Enter Y to reuse or n to replace, or pass --reuse-volume or --replace-volume. The volume was not deleted" + ) + } + } + + /// True when the mounted workspace has an entry other than `lost+found`. + /// Exit 1 (no entries) allows populate. Any other failure refuses to clobber or delete. + private static func workspaceHasObjectEntries( + containerId: String, + workspaceFolder: String, + volumeName: String, + runtime: AppleContainerRuntime + ) throws -> Bool { + let result: ProcessResult + do { + result = try runtime.exec( + nameOrId: containerId, + command: [ + "sh", "-c", + workspaceVolumeEntriesScript, + workspaceVolumeEntriesToken, + workspaceFolder + ], + workdir: workspaceFolder + ) + } catch { + throw CLIError( + code: CLIErrorCode.populateFailed, + message: "Could not inspect workspace volume '\(volumeName)'", + hint: "Refusing to git-clone over it or delete it. Re-run when the container can list \(workspaceFolder), or pass --replace-volume to clone fresh" + ) + } + switch result.exitCode { + case 0: + return true + case 1: + return false + default: + throw CLIError( + code: CLIErrorCode.populateFailed, + message: "Could not inspect workspace volume '\(volumeName)' (exit \(result.exitCode))", + hint: "Refusing to git-clone over it or delete it. Re-run when the container can list \(workspaceFolder), or pass --replace-volume to clone fresh" + ) + } + } + // MARK: - Recovery wiring /// Stable, non-temporary root for retained clone config checkouts. Defaults to diff --git a/Sources/ADevContainerLib/Commands/RebuildCommand.swift b/Sources/ADevContainerLib/Commands/RebuildCommand.swift index dd23674..2291573 100644 --- a/Sources/ADevContainerLib/Commands/RebuildCommand.swift +++ b/Sources/ADevContainerLib/Commands/RebuildCommand.swift @@ -114,6 +114,7 @@ public enum RebuildCommand { recoveryEditor: RecoveryEditor? = nil, openEditorPrompt: RecoveryOpenEditorPrompt = .default ) throws -> RebuildResult { + var volumeResume: VolumeRecoveryResume.State? let selected: ContainerInfo if let selectedOverride { selected = selectedOverride @@ -125,12 +126,17 @@ public enum RebuildCommand { picker: picker ) } catch let error as CLIError where error.code == CLIErrorCode.containerNotFound { - // Named bind recovery retry after non-TTY retention: container was removed; - // resume from retained stamps and the (already edited) host stamped path. + // Named recovery retry after the container was removed. + // Bind: host stamped path. Volume: retained session, no helper required. if let name = options.name, let resume = try BindRecoveryResume.load(name: name, fileManager: fileManager) { selected = BindRecoveryResume.containerInfo(from: resume) + } else if let name = options.name, + let resume = try VolumeRecoveryResume.load(name: name, fileManager: fileManager) + { + volumeResume = resume + selected = VolumeRecoveryResume.containerInfo(from: resume) } else { throw error } @@ -148,14 +154,27 @@ public enum RebuildCommand { let bindRecoveryEligible = RecoveryOrchestrator.isBindEligible(labels: labels) var recoveryContext = recovery var recoveryEndpointID = recoveryHelperID + var resumedSession: RecoveryConfigSession? var crossedDeleteBoundary = false defer { - if !crossedDeleteBoundary, let recoveryContext { - if recoveryEndpointID == nil { + // Pre-delete failures drop an unpublished session. A session already published + // under the container name (or any exit after the delete boundary) must stay: + // the container may already be gone and no helper was created. + if !crossedDeleteBoundary, let recoveryContext, recoveryEndpointID == nil { + let publishedID = try? VolumeRecoveryResume.load( + name: selected.name, + fileManager: fileManager + )?.sessionID + if publishedID != recoveryContext.session.sessionID { try? recoveryContext.session.cleanup() } } } + if volumeResume != nil { + // Container is already gone. A throw from here on must not drop the session, + // including a process exit that never reaches RecoveryOrchestrator.recover. + crossedDeleteBoundary = true + } func retryVolumeRecovery( helperID: String, @@ -182,11 +201,49 @@ public enum RebuildCommand { // ═══════════════════════════ PHASE A (non-destructive gate) ═══════════════════════════ + if let volumeResume { + do { + let directory = try RecoveryConfigSession.directoryURL( + forSessionID: volumeResume.sessionID, + fileManager: fileManager + ) + resumedSession = try RecoveryConfigSession.open( + directoryURL: directory, + fileManager: fileManager + ) + } catch { + throw CLIError( + code: CLIErrorCode.recoveryUnavailable, + message: "The retained volume recovery session could not be opened", + hint: "Resume '\(selected.name)' does not create a blank workspace volume. Session \(volumeResume.sessionID) was left in place" + ) + } + guard let opened = resumedSession else { + throw CLIError( + code: CLIErrorCode.recoveryUnavailable, + message: "The retained volume recovery session could not be opened", + hint: "Resume '\(selected.name)' does not create a blank workspace volume" + ) + } + // Fail closed before any create/ensure. A missing volume must not become a blank one. + try refuseMissingRecoveryWorkspaceVolume( + name: stampedWorkspaceVolume(labels), + runtime: runtime, + session: opened, + helperID: "not-created", + selectedName: selected.name, + environment: localEnv, + helperAvailable: false, + message: "The retained recovery workspace volume is no longer present" + ) + } + // Volume mode: the config lives in the volume; a stopped container cannot cat it. // Bare runtime start (no lifecycle hooks) is the only pre-delete runtime action // allowed on the old container. Named recovery retry also needs the helper up before // apply/write — do not run recovery apply above this gate. - if isVolumeMode, !selected.isRunning { + // A volume resume has no container to start. + if isVolumeMode, volumeResume == nil, !selected.isRunning { StatusPrinter.status("Starting container") try runtime.start(nameOrId: selected.id) } @@ -196,7 +253,8 @@ public enum RebuildCommand { // TTY (no --json): open the editor first so a retained broken config is fixed before rebuilding. // Non-TTY / --json: apply the current temp bytes (operator already edited offline). // openRetry/apply also bounce Apple zombie helpers (list=running, exec rejected). - if recoveryContext == nil, RecoveryHelper.isRecoveryHelper(selected) { + // A name-indexed volume resume has no helper; do not require one. + if recoveryContext == nil, volumeResume == nil, RecoveryHelper.isRecoveryHelper(selected) { let opened = try RecoveryOrchestrator.openRetry( helper: selected, runtime: runtime, @@ -222,7 +280,15 @@ public enum RebuildCommand { // the guard is defensive only). let volumeRead: ResolvedVolumeConfigRead? let resolvedConfig: ResolvedDevContainerConfig - if isVolumeMode { + if let resumedSession { + volumeRead = try resolvedVolumeRead( + from: resumedSession, + labels: labels, + localEnv: localEnv, + fileManager: fileManager + ) + resolvedConfig = volumeRead!.config + } else if isVolumeMode { volumeRead = try ConfigReader.readVolumeWithRaw( labels: labels, containerId: selected.id, @@ -256,7 +322,10 @@ public enum RebuildCommand { let localIdentityReader = localIdentityReaderOverride ?? DefaultRebuildLocalIdentityReader() let capturedLocalIdentity: GitAuthorIdentity - if isVolumeMode { + if let volumeResume { + // The old container is gone; do not exec into it. Missing identity is not invented. + capturedLocalIdentity = volumeResume.identity + } else if isVolumeMode { capturedLocalIdentity = localIdentityReader.readOldContainerWorkspace( containerId: selected.id, workspaceFolder: resolvedConfig.workspaceFolder, @@ -269,7 +338,7 @@ public enum RebuildCommand { ) } - if recoveryContext == nil, RecoveryHelper.isEligible(labels: labels), let volumeRead { + if recoveryContext == nil, resumedSession == nil, RecoveryHelper.isEligible(labels: labels), let volumeRead { // This is deliberately before host/Features work and before the old-container // delete. Preparation failures therefore leave the selected container untouched. recoveryContext = try RecoveryOrchestrator.prepare( @@ -281,7 +350,23 @@ public enum RebuildCommand { ) } - if recoveryContext == nil && isVolumeMode && RecoveryHelper.isEligible(labels: labels) { + if recoveryContext == nil, let resumedSession, RecoveryHelper.isEligible(labels: labels) { + // Helper-image preflight must not block resume and must not create a volume. + // A later hard failure can still enter recover when preparation succeeds. + if let preparation = try? RecoveryHelper.prepare( + for: selected, + sessionID: resumedSession.sessionID, + runtime: runtime, + pullIfMissing: !options.skipPull + ) { + recoveryContext = RecoveryOrchestrator.Prepared( + session: resumedSession, + preparation: preparation + ) + } + } + + if recoveryContext == nil && resumedSession == nil && isVolumeMode && RecoveryHelper.isEligible(labels: labels) { throw CLIError( code: CLIErrorCode.recoveryUnavailable, message: "Recovery config could not be retained", @@ -590,52 +675,44 @@ public enum RebuildCommand { newLabels[ContainerIdentity.labelConfigVolumes] = configVolumeNames.joined(separator: ",") } - // A retained helper is only a write endpoint for the exact pre-existing workspace - // volume. Recheck before deleting it; recovery must never manufacture a blank volume. - if isVolumeMode, let recovery = recoveryContext { - let present: Bool - do { - present = try runtime.volumeExists( - stampedWorkspaceVolume(labels), - requireObjectEntries: true - ) - } catch { - throw RecoveryOrchestrator.retainedUnavailableFailure( - session: recovery.session, - helperID: recoveryEndpointID ?? selected.id, - selectedName: selected.name, - failure: error, - environment: localEnv, - helperAvailable: recoveryEndpointID != nil - ) - } - guard present else { - let missing = CLIError( - code: CLIErrorCode.recoveryUnavailable, - message: "The retained recovery workspace volume is no longer present", - hint: "Recovery refuses to create a blank replacement volume" - ) - throw RecoveryOrchestrator.retainedUnavailableFailure( - session: recovery.session, - helperID: recoveryEndpointID ?? selected.id, - selectedName: selected.name, - failure: missing, - environment: localEnv, - helperAvailable: recoveryEndpointID != nil - ) - } + // A retained helper or name-indexed session is only a write endpoint for the exact + // pre-existing workspace volume. Recheck before deleting; never manufacture a blank volume. + if isVolumeMode, let session = recoveryContext?.session ?? resumedSession { + try refuseMissingRecoveryWorkspaceVolume( + name: stampedWorkspaceVolume(labels), + runtime: runtime, + session: session, + helperID: recoveryEndpointID ?? selected.id, + selectedName: selected.name, + environment: localEnv, + helperAvailable: recoveryEndpointID != nil, + message: "The retained recovery workspace volume is no longer present" + ) } // ═══════════════════════════ PHASE B (destructive create path) ═══════════════════════════ + // Publish the session under the container name BEFORE delete. A process exit after + // the container is gone must still be resumable by `rebuild --name` without a helper. + if isVolumeMode, let session = recoveryContext?.session ?? resumedSession { + try VolumeRecoveryResume.retain( + container: selected, + sessionID: session.sessionID, + authorName: capturedLocalIdentity.trimmedName, + authorEmail: capturedLocalIdentity.trimmedEmail, + fileManager: fileManager + ) + } + // Container-only delete of the OLD container (never volumes/images). - // Bind recovery resume / same-process retry may already have no live container. + // Bind recovery resume / volume session resume / same-process retry may already + // have no live container. if let existing = try runtime.findByName(selected.id) ?? (selected.name != selected.id ? try runtime.findByName(selected.name) : nil) { StatusPrinter.status("Deleting container", item: existing.id) try runtime.delete(nameOrId: existing.id, force: true) - } else if selectedOverride != nil || bindRecoveryEligible { + } else if selectedOverride != nil || bindRecoveryEligible || volumeResume != nil { StatusPrinter.status("Old container already removed; continuing rebuild") } else { StatusPrinter.status("Deleting container", item: selected.id) @@ -671,38 +748,18 @@ public enum RebuildCommand { // (never delete; missing config volumes created on demand). do { if isVolumeMode { - if let recovery = recoveryContext { - let present: Bool - do { - present = try runtime.volumeExists( - stampedWorkspaceVolume(labels), - requireObjectEntries: true - ) - } catch { - throw RecoveryOrchestrator.retainedUnavailableFailure( - session: recovery.session, - helperID: recoveryEndpointID ?? selected.id, - selectedName: selected.name, - failure: error, - environment: localEnv, - helperAvailable: false - ) - } - guard present else { - let missing = CLIError( - code: CLIErrorCode.recoveryUnavailable, - message: "The retained recovery workspace volume disappeared before replacement create", - hint: "Recovery refuses to create a blank replacement volume" - ) - throw RecoveryOrchestrator.retainedUnavailableFailure( - session: recovery.session, - helperID: recoveryEndpointID ?? selected.id, - selectedName: selected.name, - failure: missing, - environment: localEnv, - helperAvailable: false - ) - } + if let session = recoveryContext?.session ?? resumedSession { + // Never ensureVolume the workspace here: a miss must not create a blank volume. + try refuseMissingRecoveryWorkspaceVolume( + name: stampedWorkspaceVolume(labels), + runtime: runtime, + session: session, + helperID: recoveryEndpointID ?? "not-created", + selectedName: selected.name, + environment: localEnv, + helperAvailable: false, + message: "The retained recovery workspace volume disappeared before replacement create" + ) } else { try runtime.ensureVolume(name: stampedWorkspaceVolume(labels)) } @@ -713,13 +770,14 @@ public enum RebuildCommand { } catch let error as CLIError where error.recovery != nil { throw error } catch { + // Spec: volume ensure does not create a recovery helper. The session published + // before delete stays so `rebuild --name` can resume without one. StatusPrinter.warning("Old container '\(selected.name)' was already removed; rebuild failed while ensuring named volumes") - try? recoveryContext?.session.cleanup() throw CLIError( code: CLIErrorCode.runtimeFailed, property: "volumes", message: "Failed to ensure rebuild volumes: \(error.localizedDescription)", - hint: "Existing volumes were preserved" + hint: "Existing volumes were preserved. Run '\(CommandSurface.commandPrefix) rebuild --name \(selected.name)' to resume; recovery will not create a blank workspace volume" ) } StatusPrinter.status("Creating container", item: replacementName) @@ -1057,9 +1115,10 @@ public enum RebuildCommand { return try handleCreatePathFailure(error) } if let readyError { - // Open/postAttach failures are terminal non-recovery outcomes. Do not leak the - // prepared session after the delete boundary. + // Open/postAttach failures are terminal non-recovery outcomes. The replacement + // container exists, so the name-indexed session is no longer the resume path. if let recoveryContext { try? recoveryContext.session.cleanup() } + try? VolumeRecoveryResume.cleanup(name: selected.name, fileManager: fileManager) throw readyError } guard let result else { @@ -1121,8 +1180,12 @@ public enum RebuildCommand { ) } } - // Bind recovery resume is only needed while the managed container is missing. + // Bind / volume recovery resume is only needed while the managed container is missing. + if recoveryContext == nil { + try? resumedSession?.cleanup() + } try? BindRecoveryResume.cleanup(name: selected.name, fileManager: fileManager) + try? VolumeRecoveryResume.cleanup(name: selected.name, fileManager: fileManager) // Connection hints: entry point after human outcome digest / JSON. return result } @@ -1654,6 +1717,72 @@ public enum RebuildCommand { "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" } + /// Resolve a retained volume session with the same basename rules as the strict reader. + /// Does not exec into a container and does not create a volume. + private static func resolvedVolumeRead( + from session: RecoveryConfigSession, + labels: [String: String], + localEnv: [String: String], + fileManager: FileManager + ) throws -> ResolvedVolumeConfigRead { + let bytes = try session.readTempBytes() + let location = try ConfigReader.volumeConfigLocation(labels: labels) + let raw = RawVolumeConfig( + bytes: bytes, + pathInContainer: location.pathInContainer, + workspaceFolder: location.workspaceFolder, + workspaceFolderBasename: location.workspaceFolderBasename + ) + let resolved = try ConfigReader.resolveVolumeFile( + at: session.tempFileURL.path, + labels: labels, + localEnv: localEnv, + fileManager: fileManager + ) + return ResolvedVolumeConfigRead(config: resolved.config, raw: raw) + } + + /// Workspace volume must already exist. Never calls `ensureVolume` / `volume create`. + private static func refuseMissingRecoveryWorkspaceVolume( + name: String, + runtime: AppleContainerRuntime, + session: RecoveryConfigSession, + helperID: String, + selectedName: String, + environment: [String: String], + helperAvailable: Bool, + message: String + ) throws { + let present: Bool + do { + present = try runtime.volumeExists(name, requireObjectEntries: true) + } catch { + throw RecoveryOrchestrator.retainedUnavailableFailure( + session: session, + helperID: helperID, + selectedName: selectedName, + failure: error, + environment: environment, + helperAvailable: helperAvailable + ) + } + guard present else { + let missing = CLIError( + code: CLIErrorCode.recoveryUnavailable, + message: message, + hint: "Recovery refuses to create a blank replacement volume" + ) + throw RecoveryOrchestrator.retainedUnavailableFailure( + session: session, + helperID: helperID, + selectedName: selectedName, + failure: missing, + environment: environment, + helperAvailable: helperAvailable + ) + } + } + /// Persist a collision rename into the live config this rebuild is reading. private static func persistRebuildCreateName( _ name: String, diff --git a/Sources/ADevContainerLib/Commands/RecoveryOrchestrator.swift b/Sources/ADevContainerLib/Commands/RecoveryOrchestrator.swift index febdfc7..0a8c84f 100644 --- a/Sources/ADevContainerLib/Commands/RecoveryOrchestrator.swift +++ b/Sources/ADevContainerLib/Commands/RecoveryOrchestrator.swift @@ -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 { diff --git a/Sources/ADevContainerLib/Commands/VolumeRecoveryResume.swift b/Sources/ADevContainerLib/Commands/VolumeRecoveryResume.swift new file mode 100644 index 0000000..d12f892 --- /dev/null +++ b/Sources/ADevContainerLib/Commands/VolumeRecoveryResume.swift @@ -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)) + } +} diff --git a/Sources/ADevContainerLib/Runtime/AppleContainerRuntime.swift b/Sources/ADevContainerLib/Runtime/AppleContainerRuntime.swift index 3565996..e38d19b 100644 --- a/Sources/ADevContainerLib/Runtime/AppleContainerRuntime.swift +++ b/Sources/ADevContainerLib/Runtime/AppleContainerRuntime.swift @@ -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)") @@ -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] = [] diff --git a/Sources/ADevContainerLib/Support/CommandSurface.swift b/Sources/ADevContainerLib/Support/CommandSurface.swift index 0b41906..01e6440 100644 --- a/Sources/ADevContainerLib/Support/CommandSurface.swift +++ b/Sources/ADevContainerLib/Support/CommandSurface.swift @@ -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 @@ -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 ` invocation, or nil when `args` /// is not `help ` (including bare `help`, which the caller routes to /// main usage). `help ` MUST print the same command-specific help as @@ -261,6 +285,22 @@ public enum CommandSurface { hint: "Resume a retained checkout with: \(commandPrefix) clone --resume " ) } + 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 --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 --replace-volume" + ) + } } /// `plugin` requires exactly one of `--install` or `--uninstall`. @@ -332,6 +372,8 @@ public enum CommandSurface { -w, --workspace Workspace root for `up` only (default: cwd) --name Managed container name/id (exec/start/stop/delete/purge/rebuild/inspect) --resume 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 @@ -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 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 @@ -474,7 +521,7 @@ public enum CommandSurface { """ case "clone": return """ - \(cmd) clone [--json] [--skip-pull] [--vscode] [--resume ] + \(cmd) clone [--json] [--skip-pull] [--vscode] [--resume ] [--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. @@ -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 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 diff --git a/Sources/adevcontainer/AdevcontainerMain.swift b/Sources/adevcontainer/AdevcontainerMain.swift index 553c90a..b0e95ee 100644 --- a/Sources/adevcontainer/AdevcontainerMain.swift +++ b/Sources/adevcontainer/AdevcontainerMain.swift @@ -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 { diff --git a/Tests/adevcontainerTests/CloneInVolumeTests.swift b/Tests/adevcontainerTests/CloneInVolumeTests.swift index e60c2b0..b28630b 100644 --- a/Tests/adevcontainerTests/CloneInVolumeTests.swift +++ b/Tests/adevcontainerTests/CloneInVolumeTests.swift @@ -165,6 +165,100 @@ enum CloneRuntimeMock { } } +/// Non-interactive identity so volume-decision tests never read the identity prompt. +private func quietIdentityPrompt() -> IdentityPrompt { + IdentityPrompt(isInteractive: false, readLine: { nil }, writeError: { _ in }) +} + +/// Injected workspace-volume prompt. Exhausted answers are EOF. +private final class VolumePromptCapture: @unchecked Sendable { + var answers: [String?] + var written = "" + var reads = 0 + + init(answers: [String?]) { + self.answers = answers + } + + func read() -> String? { + reads += 1 + if answers.isEmpty { return nil } + return answers.removeFirst() + } + + func write(_ text: String) { + written += text + } + + var prompt: WorkspaceVolumePrompt { + WorkspaceVolumePrompt(readLine: { self.read() }, writeError: { self.write($0) }) + } +} + +private func volumeDecisionRuntime( + volumeName: String, + present: Bool, + entriesExit: Int32 = 0, + startFails: Bool = false, + configPathExists: Bool = true, + gitExists: Bool = true +) -> (AppleContainerRuntime, MockProcessRunner) { + let mock = MockProcessRunner() + let deleted = LockedFlag() + mock.handlers = [ + { args in + if args.starts(with: ["volume", "list"]) { + let show = present && !deleted.value + let items: [[String: Any]] = show ? [["id": volumeName]] : [] + let data = try! JSONSerialization.data(withJSONObject: items) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.starts(with: ["volume", "delete"]) { + deleted.value = true + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if startFails, args.first == "start" { + return ProcessResult(exitCode: 1, stdout: Data(), stderr: Data("start failed".utf8)) + } + if args.contains(CloneCommand.workspaceVolumeEntriesToken) { + return ProcessResult(exitCode: entriesExit, stdout: Data(), stderr: Data()) + } + if args.first == "exec", args.contains("test"), args.contains("-e") { + let path = args.last ?? "" + if path.hasSuffix("devcontainer.json") { + return ProcessResult( + exitCode: configPathExists ? 0 : 1, + stdout: Data(), + stderr: Data() + ) + } + if path.hasSuffix("/.git") { + return ProcessResult( + exitCode: gitExists ? 0 : 1, + stdout: Data(), + stderr: Data() + ) + } + } + return nil + } + ] + CloneRuntimeMock.handlers() + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + return (runtime, mock) +} + +private final class LockedFlag: @unchecked Sendable { + var value = false +} + +private func gitCloneCalls(_ mock: MockProcessRunner) -> [MockProcessRunner.MockProcessCall] { + mock.calls.filter { $0.arguments.contains { $0.contains("git clone") } } +} + +private func volumeDeleteCalls(_ mock: MockProcessRunner, name: String) -> [MockProcessRunner.MockProcessCall] { + mock.calls.filter { $0.arguments == ["volume", "delete", name] } +} + // MARK: - Identity / CreateRequest unit tests nonisolated(unsafe) let cloneIdentityTests: [(String, () throws -> Void)] = [ @@ -1061,6 +1155,41 @@ nonisolated(unsafe) let cloneCommandTests: [(String, () throws -> Void)] = [ try MiniTest.expect(help.contains("[--json]"), "clone help lists --json") try MiniTest.expect(help.contains("machine-readable"), "clone help describes JSON mode") try MiniTest.expect(help.contains("--resume"), "clone help describes resume") + try MiniTest.expect(help.contains("--reuse-volume"), "clone help documents --reuse-volume") + try MiniTest.expect(help.contains("--replace-volume"), "clone help documents --replace-volume") + try MiniTest.expect(help.contains("Reuse it? [Y/n]"), "clone help documents the reuse prompt") + try MiniTest.expect(usage.contains("--reuse-volume"), "usage documents --reuse-volume") + try MiniTest.expect(usage.contains("--replace-volume"), "usage documents --replace-volume") + }), + ("cloneVolumeFlagsParseAndAreCloneOnly", { + let reuse = try CommandSurface.parseArgs(["https://example.com/r.git", "--reuse-volume"]) + try MiniTest.expect(reuse.flags.contains("reuse-volume")) + try MiniTest.expect(!reuse.flags.contains("replace-volume")) + try CommandSurface.enforceWorkspaceGate(subcommand: "clone", parsed: reuse) + try MiniTest.expectThrows({ + try CommandSurface.enforceWorkspaceGate(subcommand: "up", parsed: reuse) + }) { error in + try MiniTest.expectEqual((error as? CLIError)?.code, CLIErrorCode.usage) + try MiniTest.expectEqual((error as? CLIError)?.property, "--reuse-volume") + } + + let replace = try CommandSurface.parseArgs(["--replace-volume", "https://example.com/r.git"]) + try MiniTest.expect(replace.flags.contains("replace-volume")) + try MiniTest.expectEqual(replace.passthrough, ["https://example.com/r.git"]) + try MiniTest.expectThrows({ + try CommandSurface.enforceWorkspaceGate(subcommand: "rebuild", parsed: replace) + }) { error in + try MiniTest.expectEqual((error as? CLIError)?.property, "--replace-volume") + } + + try MiniTest.expectThrows({ + _ = try CommandSurface.parseArgs(["--reuse-volume", "--replace-volume", "https://example.com/r.git"]) + }) { error in + let cli = error as? CLIError + try MiniTest.expectEqual(cli?.code, CLIErrorCode.usage) + try MiniTest.expect(cli?.message.contains("--reuse-volume") == true) + try MiniTest.expect(cli?.message.contains("--replace-volume") == true) + } }), ("cloneWithLocalFeaturesDefaultFetcher", { let restore = CloneGitFeatureTestSupport.installLiveOverrides() @@ -3484,13 +3613,22 @@ nonisolated(unsafe) let managedLifecycleTests: [(String, () throws -> Void)] = [ } ] let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + let prompt = VolumePromptCapture(answers: ["should-not-be-read"]) let result = try CloneCommand.run( - options: CloneOptions(gitURL: "https://github.com/org/reclone.git", skipPull: true), + options: CloneOptions( + gitURL: "https://github.com/org/reclone.git", + skipPull: true, + replaceVolume: true + ), runtime: runtime, git: git, credentials: MockGitCredential(), - localEnv: [:] + localEnv: [:], + identityPrompt: quietIdentityPrompt(), + isTTY: true, + volumePrompt: prompt.prompt ) + try MiniTest.expectEqual(prompt.reads, 0, "--replace-volume must not prompt") try MiniTest.expectEqual(result.outcome, "success") try MiniTest.expectEqual(result.workspaceVolume, wsVol) try MiniTest.expect(mock.calls.contains { $0.arguments == ["volume", "delete", wsVol] }) @@ -3499,6 +3637,630 @@ nonisolated(unsafe) let managedLifecycleTests: [(String, () throws -> Void)] = [ let createIdx = mock.calls.firstIndex { $0.arguments == ["volume", "create", wsVol] }! try MiniTest.expect(deleteIdx < createIdx) }), + ("cloneReuseVolumePromptYesKeepsTree", { + let restore = CloneGitFeatureTestSupport.installOverrides() + defer { restore() } + let gitURL = "https://github.com/org/reuse-yes.git" + let identity = ContainerIdentity.volumeModeIdentity( + gitURL: gitURL, + configRelativePath: ".devcontainer/devcontainer.json", + configName: nil + ) + let wsVol = identity.workspaceVolumeName + let (runtime, mock) = volumeDecisionRuntime(volumeName: wsVol, present: true, entriesExit: 0) + let prompt = VolumePromptCapture(answers: ["Y"]) + let git = MockGitClient() + git.configJSONToWrite = #"{ "image": "alpine:3.20" }"# + let result = try CloneCommand.run( + options: CloneOptions(gitURL: gitURL, skipPull: true), + runtime: runtime, + git: git, + credentials: MockGitCredential(), + localEnv: [:], + identityPrompt: quietIdentityPrompt(), + isTTY: true, + volumePrompt: prompt.prompt + ) + try MiniTest.expectEqual(result.outcome, "success") + try MiniTest.expectEqual(prompt.reads, 1) + try MiniTest.expect( + prompt.written.contains(CloneCommand.workspaceVolumeReusePrompt(volumeName: wsVol)) + ) + try MiniTest.expect(volumeDeleteCalls(mock, name: wsVol).isEmpty, "reuse must not delete the volume") + try MiniTest.expect(gitCloneCalls(mock).isEmpty, "reuse must not git-clone over an existing tree") + try MiniTest.expect(mock.calls.contains { + $0.arguments.contains("user.name") && $0.arguments.contains("Test User") + }, "reuse still applies author identity when .git exists") + }), + ("cloneReuseVolumeEmptyEnterAndFlagSkipPrompt", { + let restore = CloneGitFeatureTestSupport.installOverrides() + defer { restore() } + let gitURL = "https://github.com/org/reuse-empty.git" + let identity = ContainerIdentity.volumeModeIdentity( + gitURL: gitURL, + configRelativePath: ".devcontainer/devcontainer.json", + configName: nil + ) + let wsVol = identity.workspaceVolumeName + let git = MockGitClient() + git.configJSONToWrite = #"{ "image": "alpine:3.20" }"# + + let (runtime, mock) = volumeDecisionRuntime(volumeName: wsVol, present: true, entriesExit: 0) + let prompt = VolumePromptCapture(answers: [""]) + _ = try CloneCommand.run( + options: CloneOptions(gitURL: gitURL, skipPull: true), + runtime: runtime, + git: git, + credentials: MockGitCredential(), + localEnv: [:], + identityPrompt: quietIdentityPrompt(), + isTTY: true, + volumePrompt: prompt.prompt + ) + try MiniTest.expectEqual(prompt.reads, 1, "empty Enter is an answer") + try MiniTest.expect(volumeDeleteCalls(mock, name: wsVol).isEmpty) + try MiniTest.expect(gitCloneCalls(mock).isEmpty) + + let (flagRuntime, flagMock) = volumeDecisionRuntime(volumeName: wsVol, present: true, entriesExit: 0) + let flagPrompt = VolumePromptCapture(answers: ["n"]) + _ = try CloneCommand.run( + options: CloneOptions(gitURL: gitURL, skipPull: true, reuseVolume: true), + runtime: flagRuntime, + git: git, + credentials: MockGitCredential(), + localEnv: [:], + identityPrompt: quietIdentityPrompt(), + isTTY: false, + volumePrompt: flagPrompt.prompt + ) + try MiniTest.expectEqual(flagPrompt.reads, 0, "--reuse-volume must not prompt") + try MiniTest.expect(flagPrompt.written.isEmpty) + try MiniTest.expect(volumeDeleteCalls(flagMock, name: wsVol).isEmpty) + try MiniTest.expect(gitCloneCalls(flagMock).isEmpty) + }), + ("cloneReuseEmptyVolumeStillPopulates", { + let restore = CloneGitFeatureTestSupport.installOverrides() + defer { restore() } + let gitURL = "https://github.com/org/reuse-blank.git" + let identity = ContainerIdentity.volumeModeIdentity( + gitURL: gitURL, + configRelativePath: ".devcontainer/devcontainer.json", + configName: nil + ) + let wsVol = identity.workspaceVolumeName + let (runtime, mock) = volumeDecisionRuntime(volumeName: wsVol, present: true, entriesExit: 1) + let git = MockGitClient() + git.configJSONToWrite = #"{ "image": "alpine:3.20" }"# + _ = try CloneCommand.run( + options: CloneOptions(gitURL: gitURL, skipPull: true, reuseVolume: true), + runtime: runtime, + git: git, + credentials: MockGitCredential(), + localEnv: [:], + identityPrompt: quietIdentityPrompt(), + isTTY: false + ) + try MiniTest.expect(volumeDeleteCalls(mock, name: wsVol).isEmpty, "empty reuse must not delete") + try MiniTest.expect(!gitCloneCalls(mock).isEmpty, "empty reuse may populate") + }), + ("cloneReplaceVolumePromptNo", { + let restore = CloneGitFeatureTestSupport.installOverrides() + defer { restore() } + let gitURL = "https://github.com/org/replace-no.git" + let identity = ContainerIdentity.volumeModeIdentity( + gitURL: gitURL, + configRelativePath: ".devcontainer/devcontainer.json", + configName: nil + ) + let wsVol = identity.workspaceVolumeName + let (runtime, mock) = volumeDecisionRuntime(volumeName: wsVol, present: true) + let prompt = VolumePromptCapture(answers: ["no"]) + let git = MockGitClient() + git.configJSONToWrite = #"{ "image": "alpine:3.20" }"# + let result = try CloneCommand.run( + options: CloneOptions(gitURL: gitURL, skipPull: true), + runtime: runtime, + git: git, + credentials: MockGitCredential(), + localEnv: [:], + identityPrompt: quietIdentityPrompt(), + isTTY: true, + volumePrompt: prompt.prompt + ) + try MiniTest.expectEqual(result.outcome, "success") + try MiniTest.expectEqual(prompt.reads, 1) + try MiniTest.expect(!volumeDeleteCalls(mock, name: wsVol).isEmpty) + try MiniTest.expect(mock.calls.contains { $0.arguments == ["volume", "create", wsVol] }) + let deleteIdx = mock.calls.firstIndex { $0.arguments == ["volume", "delete", wsVol] }! + let createIdx = mock.calls.firstIndex { $0.arguments == ["volume", "create", wsVol] }! + try MiniTest.expect(deleteIdx < createIdx) + try MiniTest.expect(!gitCloneCalls(mock).isEmpty, "replace still populates") + try MiniTest.expect( + !mock.calls.contains { $0.arguments.contains(CloneCommand.workspaceVolumeEntriesToken) }, + "replace must not probe the old tree" + ) + }), + ("cloneExistingVolumeEOFAborts", { + let restore = CloneGitFeatureTestSupport.installOverrides() + defer { restore() } + let gitURL = "https://github.com/org/reuse-eof.git" + let identity = ContainerIdentity.volumeModeIdentity( + gitURL: gitURL, + configRelativePath: ".devcontainer/devcontainer.json", + configName: nil + ) + let wsVol = identity.workspaceVolumeName + let (runtime, mock) = volumeDecisionRuntime(volumeName: wsVol, present: true) + let prompt = VolumePromptCapture(answers: []) + let git = MockGitClient() + git.configJSONToWrite = #"{ "image": "alpine:3.20" }"# + try MiniTest.expectThrows({ + _ = try CloneCommand.run( + options: CloneOptions(gitURL: gitURL, skipPull: true), + runtime: runtime, + git: git, + credentials: MockGitCredential(), + localEnv: [:], + identityPrompt: quietIdentityPrompt(), + isTTY: true, + volumePrompt: prompt.prompt + ) + }) { error in + let cli = error as? CLIError + try MiniTest.expectEqual(cli?.code, CLIErrorCode.usage) + try MiniTest.expect(cli?.message.contains("not deleted") == true) + try MiniTest.expect(cli?.hint?.contains("--reuse-volume") == true) + try MiniTest.expect(cli?.hint?.contains("--replace-volume") == true) + } + try MiniTest.expectEqual(prompt.reads, 1) + try MiniTest.expect(prompt.written.contains("Reuse it? [Y/n]")) + try MiniTest.expect(volumeDeleteCalls(mock, name: wsVol).isEmpty, "EOF must not delete") + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "create" }, "EOF must not create a container") + }), + ("cloneExistingVolumeNonTTYFailsClosed", { + let restore = CloneGitFeatureTestSupport.installOverrides() + defer { restore() } + let gitURL = "https://github.com/org/reuse-nontty.git" + let identity = ContainerIdentity.volumeModeIdentity( + gitURL: gitURL, + configRelativePath: ".devcontainer/devcontainer.json", + configName: nil + ) + let wsVol = identity.workspaceVolumeName + let (runtime, mock) = volumeDecisionRuntime(volumeName: wsVol, present: true) + let prompt = VolumePromptCapture(answers: ["n"]) + let git = MockGitClient() + git.configJSONToWrite = #"{ "image": "alpine:3.20" }"# + try MiniTest.expectThrows({ + _ = try CloneCommand.run( + options: CloneOptions(gitURL: gitURL, skipPull: true), + runtime: runtime, + git: git, + credentials: MockGitCredential(), + localEnv: [:], + identityPrompt: quietIdentityPrompt(), + isTTY: false, + volumePrompt: prompt.prompt + ) + }) { error in + let cli = error as? CLIError + try MiniTest.expectEqual(cli?.code, CLIErrorCode.usage) + try MiniTest.expect(cli?.message.contains(wsVol) == true) + try MiniTest.expect(cli?.hint?.contains("--reuse-volume") == true) + try MiniTest.expect(cli?.hint?.contains("--replace-volume") == true) + } + try MiniTest.expectEqual(prompt.reads, 0, "non-TTY must not prompt") + try MiniTest.expect(prompt.written.isEmpty) + try MiniTest.expect(volumeDeleteCalls(mock, name: wsVol).isEmpty) + }), + ("cloneExistingVolumeJSONFailsClosed", { + let restore = CloneGitFeatureTestSupport.installOverrides() + defer { restore() } + let gitURL = "https://github.com/org/reuse-json.git" + let identity = ContainerIdentity.volumeModeIdentity( + gitURL: gitURL, + configRelativePath: ".devcontainer/devcontainer.json", + configName: nil + ) + let wsVol = identity.workspaceVolumeName + let (runtime, mock) = volumeDecisionRuntime(volumeName: wsVol, present: true) + let prompt = VolumePromptCapture(answers: ["y"]) + let git = MockGitClient() + git.configJSONToWrite = #"{ "image": "alpine:3.20" }"# + try MiniTest.expectThrows({ + _ = try CloneCommand.run( + options: CloneOptions(gitURL: gitURL, skipPull: true, jsonOutput: true), + runtime: runtime, + git: git, + credentials: MockGitCredential(), + localEnv: [:], + identityPrompt: quietIdentityPrompt(), + isTTY: true, + volumePrompt: prompt.prompt + ) + }) { error in + let cli = error as? CLIError + try MiniTest.expect(cli?.hint?.contains("--reuse-volume") == true) + try MiniTest.expect(cli?.hint?.contains("--replace-volume") == true) + } + try MiniTest.expectEqual(prompt.reads, 0, "--json must not prompt") + try MiniTest.expect(volumeDeleteCalls(mock, name: wsVol).isEmpty) + }), + ("cloneReuseVolumeFailureDoesNotDelete", { + let restore = CloneGitFeatureTestSupport.installOverrides() + defer { restore() } + let gitURL = "https://github.com/org/reuse-fail.git" + let identity = ContainerIdentity.volumeModeIdentity( + gitURL: gitURL, + configRelativePath: ".devcontainer/devcontainer.json", + configName: nil + ) + let wsVol = identity.workspaceVolumeName + let (runtime, mock) = volumeDecisionRuntime(volumeName: wsVol, present: true, startFails: true) + let git = MockGitClient() + git.configJSONToWrite = #"{ "image": "alpine:3.20" }"# + try MiniTest.expectThrows({ + _ = try CloneCommand.run( + options: CloneOptions(gitURL: gitURL, skipPull: true, reuseVolume: true), + runtime: runtime, + git: git, + credentials: MockGitCredential(), + localEnv: [:], + identityPrompt: quietIdentityPrompt(), + isTTY: false + ) + }) { _ in } + try MiniTest.expect(volumeDeleteCalls(mock, name: wsVol).isEmpty, "failure must not delete a reused volume") + try MiniTest.expect( + mock.calls.contains { $0.arguments.first == "delete" }, + "failure still deletes the container this invocation created" + ) + try MiniTest.expect(gitCloneCalls(mock).isEmpty, "start failed before populate") + }), + ("cloneMissingVolumeDoesNotPrompt", { + let restore = CloneGitFeatureTestSupport.installOverrides() + defer { restore() } + let gitURL = "https://github.com/org/fresh-vol.git" + let identity = ContainerIdentity.volumeModeIdentity( + gitURL: gitURL, + configRelativePath: ".devcontainer/devcontainer.json", + configName: nil + ) + let wsVol = identity.workspaceVolumeName + let (runtime, mock) = volumeDecisionRuntime(volumeName: wsVol, present: false) + let prompt = VolumePromptCapture(answers: ["n"]) + let git = MockGitClient() + git.configJSONToWrite = #"{ "image": "alpine:3.20" }"# + let result = try CloneCommand.run( + options: CloneOptions(gitURL: gitURL, skipPull: true), + runtime: runtime, + git: git, + credentials: MockGitCredential(), + localEnv: [:], + identityPrompt: quietIdentityPrompt(), + isTTY: true, + volumePrompt: prompt.prompt + ) + try MiniTest.expectEqual(result.outcome, "success") + try MiniTest.expectEqual(prompt.reads, 0, "missing volume must not prompt") + try MiniTest.expect(prompt.written.isEmpty) + try MiniTest.expect(volumeDeleteCalls(mock, name: wsVol).isEmpty) + try MiniTest.expect(mock.calls.contains { $0.arguments == ["volume", "create", wsVol] }) + try MiniTest.expect(!gitCloneCalls(mock).isEmpty) + }), + ("cloneBothVolumeFlagsIsStructuredError", { + let git = MockGitClient() + git.requireGitResult = .failure(CLIError(code: CLIErrorCode.gitMissing, message: "git missing")) + let mock = MockProcessRunner() + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + try MiniTest.expectThrows({ + _ = try CloneCommand.run( + options: CloneOptions( + gitURL: "https://github.com/org/both.git", + skipPull: true, + reuseVolume: true, + replaceVolume: true + ), + runtime: runtime, + git: git, + credentials: MockGitCredential(), + localEnv: [:], + identityPrompt: quietIdentityPrompt(), + isTTY: true + ) + }) { error in + let cli = error as? CLIError + try MiniTest.expectEqual(cli?.code, CLIErrorCode.usage) + try MiniTest.expect(cli?.message.contains("--reuse-volume") == true) + try MiniTest.expect(cli?.message.contains("--replace-volume") == true) + } + try MiniTest.expect(git.fetchConfigCalls.isEmpty, "conflicting flags fail before git fetch") + try MiniTest.expect(mock.calls.isEmpty, "conflicting flags must not touch the runtime") + }), + ("cloneVolumeListFailureFailsClosed", { + let restore = CloneGitFeatureTestSupport.installOverrides() + defer { restore() } + let gitURL = "https://github.com/org/list-fail.git" + let identity = ContainerIdentity.volumeModeIdentity( + gitURL: gitURL, + configRelativePath: ".devcontainer/devcontainer.json", + configName: nil + ) + let wsVol = identity.workspaceVolumeName + let git = MockGitClient() + git.configJSONToWrite = #"{ "image": "alpine:3.20" }"# + + func assertNoWorkspaceDelete(_ mock: MockProcessRunner) throws { + try MiniTest.expect(volumeDeleteCalls(mock, name: wsVol).isEmpty, "list failure must not delete") + try MiniTest.expect( + !mock.calls.contains { $0.arguments == ["volume", "create", wsVol] }, + "list failure must not create a workspace volume" + ) + try MiniTest.expect( + !mock.calls.contains { $0.arguments.first == "create" }, + "list failure must not create a container" + ) + } + + let failedList = MockProcessRunner() + failedList.handlers = [ + { args in + if args.starts(with: ["volume", "list"]) { + return ProcessResult(exitCode: 1, stdout: Data(), stderr: Data("volume list failed".utf8)) + } + return nil + } + ] + CloneRuntimeMock.handlers() + try MiniTest.expectThrows({ + _ = try CloneCommand.run( + options: CloneOptions(gitURL: gitURL, skipPull: true), + runtime: AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: failedList), + git: git, + credentials: MockGitCredential(), + localEnv: [:], + identityPrompt: quietIdentityPrompt(), + isTTY: true + ) + }) { error in + let cli = error as? CLIError + try MiniTest.expectEqual(cli?.code, CLIErrorCode.runtimeFailed) + try MiniTest.expect(cli?.message.contains(wsVol) == true) + try MiniTest.expect(cli?.hint?.contains("was not deleted") == true) + } + try assertNoWorkspaceDelete(failedList) + + let badJSON = MockProcessRunner() + badJSON.handlers = [ + { args in + if args.starts(with: ["volume", "list"]) { + return ProcessResult(exitCode: 0, stdout: Data("not-json".utf8), stderr: Data()) + } + return nil + } + ] + CloneRuntimeMock.handlers() + try MiniTest.expectThrows({ + _ = try CloneCommand.run( + options: CloneOptions(gitURL: gitURL, skipPull: true, replaceVolume: true), + runtime: AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: badJSON), + git: git, + credentials: MockGitCredential(), + localEnv: [:], + identityPrompt: quietIdentityPrompt(), + isTTY: false + ) + }) { error in + try MiniTest.expectEqual((error as? CLIError)?.code, CLIErrorCode.runtimeFailed) + } + try assertNoWorkspaceDelete(badJSON) + }), + ("cloneEnsureVolumeReuseFailureDoesNotDelete", { + let restore = CloneGitFeatureTestSupport.installOverrides() + defer { restore() } + let gitURL = "https://github.com/org/ensure-reused.git" + let identity = ContainerIdentity.volumeModeIdentity( + gitURL: gitURL, + configRelativePath: ".devcontainer/devcontainer.json", + configName: nil + ) + let wsVol = identity.workspaceVolumeName + let mock = MockProcessRunner() + let lists = LockedFlag() + mock.handlers = [ + { args in + if args.starts(with: ["volume", "list"]) { + // Disposition sees a confirmed miss. The later ensureVolume list sees + // the volume and reuses it (created == false). + let show = lists.value + lists.value = true + let items: [[String: Any]] = show ? [["id": wsVol]] : [] + let data = try! JSONSerialization.data(withJSONObject: items) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "start" { + return ProcessResult(exitCode: 1, stdout: Data(), stderr: Data("start failed".utf8)) + } + return nil + } + ] + CloneRuntimeMock.handlers() + let git = MockGitClient() + git.configJSONToWrite = #"{ "image": "alpine:3.20" }"# + try MiniTest.expectThrows({ + _ = try CloneCommand.run( + options: CloneOptions(gitURL: gitURL, skipPull: true), + runtime: AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock), + git: git, + credentials: MockGitCredential(), + localEnv: [:], + identityPrompt: quietIdentityPrompt(), + isTTY: false + ) + }) { _ in } + try MiniTest.expect( + mock.calls.contains { $0.arguments.first == "create" }, + "container create ran after ensureVolume reused the volume" + ) + try MiniTest.expect( + !mock.calls.contains { $0.arguments == ["volume", "create", wsVol] }, + "ensureVolume must reuse rather than create" + ) + try MiniTest.expect( + volumeDeleteCalls(mock, name: wsVol).isEmpty, + "failure must not delete a volume ensureVolume only reused" + ) + }), + ("cloneUnrecognizedVolumeAnswerDoesNotDelete", { + let restore = CloneGitFeatureTestSupport.installOverrides() + defer { restore() } + let gitURL = "https://github.com/org/reuse-maybe.git" + let identity = ContainerIdentity.volumeModeIdentity( + gitURL: gitURL, + configRelativePath: ".devcontainer/devcontainer.json", + configName: nil + ) + let wsVol = identity.workspaceVolumeName + let (runtime, mock) = volumeDecisionRuntime(volumeName: wsVol, present: true) + let prompt = VolumePromptCapture(answers: ["maybe"]) + let git = MockGitClient() + git.configJSONToWrite = #"{ "image": "alpine:3.20" }"# + try MiniTest.expectThrows({ + _ = try CloneCommand.run( + options: CloneOptions(gitURL: gitURL, skipPull: true), + runtime: runtime, + git: git, + credentials: MockGitCredential(), + localEnv: [:], + identityPrompt: quietIdentityPrompt(), + isTTY: true, + volumePrompt: prompt.prompt + ) + }) { error in + let cli = error as? CLIError + try MiniTest.expectEqual(cli?.code, CLIErrorCode.usage) + try MiniTest.expect(cli?.hint?.contains("--reuse-volume") == true) + try MiniTest.expect(cli?.hint?.contains("--replace-volume") == true) + } + try MiniTest.expect(volumeDeleteCalls(mock, name: wsVol).isEmpty, "unrecognized answer must not delete") + try MiniTest.expect(!mock.calls.contains { $0.arguments.first == "create" }) + }), + ("cloneReuseSkipsAuthorWhenGitMissing", { + let restore = CloneGitFeatureTestSupport.installOverrides() + defer { restore() } + let gitURL = "https://github.com/org/reuse-nogit.git" + let identity = ContainerIdentity.volumeModeIdentity( + gitURL: gitURL, + configRelativePath: ".devcontainer/devcontainer.json", + configName: nil + ) + let wsVol = identity.workspaceVolumeName + let (runtime, mock) = volumeDecisionRuntime( + volumeName: wsVol, + present: true, + entriesExit: 0, + gitExists: false + ) + let git = MockGitClient() + git.configJSONToWrite = #"{ "image": "alpine:3.20" }"# + _ = try CloneCommand.run( + options: CloneOptions(gitURL: gitURL, skipPull: true, reuseVolume: true), + runtime: runtime, + git: git, + credentials: MockGitCredential(), + localEnv: [:], + identityPrompt: quietIdentityPrompt(), + isTTY: false + ) + try MiniTest.expect(gitCloneCalls(mock).isEmpty) + try MiniTest.expect(volumeDeleteCalls(mock, name: wsVol).isEmpty) + try MiniTest.expect(!mock.calls.contains { + $0.arguments.contains("user.name") + }, "no .git means no author write") + }), + ("cloneReuseResumeOverlaysExistingConfigOnly", { + let restore = CloneGitFeatureTestSupport.installOverrides() + defer { restore() } + let gitURL = "https://github.com/org/reuse-resume.git" + let identity = ContainerIdentity.volumeModeIdentity( + gitURL: gitURL, + configRelativePath: ".devcontainer/devcontainer.json", + configName: nil + ) + let wsVol = identity.workspaceVolumeName + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("clone-reuse-resume-\(UUID().uuidString)", isDirectory: true).path + let child = (root as NSString).appendingPathComponent("kept") + try FileManager.default.createDirectory(atPath: child, withIntermediateDirectories: true) + try "adevcontainer-clone-recovery-v1\n".write( + toFile: (child as NSString).appendingPathComponent(".adevcontainer-retained-checkout"), + atomically: true, + encoding: .utf8 + ) + let dc = (child as NSString).appendingPathComponent(".devcontainer") + try FileManager.default.createDirectory(atPath: dc, withIntermediateDirectories: true) + try #"{ "image": "alpine:3.20", "name": "edited" }"#.write( + toFile: (dc as NSString).appendingPathComponent("devcontainer.json"), + atomically: true, + encoding: .utf8 + ) + CloneCommand.retainedCheckoutRootOverride = root + defer { + CloneCommand.retainedCheckoutRootOverride = nil + try? FileManager.default.removeItem(atPath: root) + } + + func runResume(configExists: Bool) throws -> MockProcessRunner { + let (runtime, mock) = volumeDecisionRuntime( + volumeName: wsVol, + present: true, + entriesExit: 0, + configPathExists: configExists + ) + // Recreate the checkout if a prior success removed it. + if !FileManager.default.fileExists(atPath: child) { + try FileManager.default.createDirectory(atPath: child, withIntermediateDirectories: true) + try "adevcontainer-clone-recovery-v1\n".write( + toFile: (child as NSString).appendingPathComponent(".adevcontainer-retained-checkout"), + atomically: true, + encoding: .utf8 + ) + try FileManager.default.createDirectory(atPath: dc, withIntermediateDirectories: true) + try #"{ "image": "alpine:3.20", "name": "edited" }"#.write( + toFile: (dc as NSString).appendingPathComponent("devcontainer.json"), + atomically: true, + encoding: .utf8 + ) + } + let git = MockGitClient() + _ = try CloneCommand.run( + options: CloneOptions( + gitURL: gitURL, + skipPull: true, + resumeConfigDir: child, + reuseVolume: true + ), + runtime: runtime, + git: git, + credentials: MockGitCredential(), + localEnv: [:], + identityPrompt: quietIdentityPrompt(), + isTTY: false + ) + try MiniTest.expect(git.fetchConfigCalls.isEmpty, "resume must not re-fetch") + try MiniTest.expect(gitCloneCalls(mock).isEmpty, "reuse must not re-clone") + try MiniTest.expect(volumeDeleteCalls(mock, name: wsVol).isEmpty) + return mock + } + + let overlaid = try runResume(configExists: true) + try MiniTest.expect( + overlaid.calls.contains { $0.arguments.contains("adevcontainer-clone-persist") }, + "resume overlays an edited devcontainer.json that already exists" + ) + let skipped = try runResume(configExists: false) + try MiniTest.expect( + !skipped.calls.contains { $0.arguments.contains("adevcontainer-clone-persist") }, + "resume does not create a missing config path in a reused tree" + ) + }), ("cloneSetsConfigVolumesLabel", { let restore = CloneGitFeatureTestSupport.installOverrides() defer { restore() } diff --git a/Tests/adevcontainerTests/RebuildCommandPhaseTests.swift b/Tests/adevcontainerTests/RebuildCommandPhaseTests.swift index c3e4207..1cdc9e8 100644 --- a/Tests/adevcontainerTests/RebuildCommandPhaseTests.swift +++ b/Tests/adevcontainerTests/RebuildCommandPhaseTests.swift @@ -42,6 +42,8 @@ final class RebuildScenario { var createFails = false var volumeCreateFails = false var buildFails = false + /// Invoked when the mock handles `delete`, before the result is returned. + var onDelete: (() -> Void)? var newContainerId = "new-id-created" /// exec script substrings that should exit non-zero. var failingExecSubstrings: [String] = [] @@ -153,6 +155,7 @@ final class RebuildScenario { return startFails ? fail("start failed") : ok(Data()) } if args.first == "delete" { + onDelete?() return deleteFails ? fail("delete failed") : ok(Data()) } if args.first == "build" { @@ -2089,6 +2092,14 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ s.volumeConfigText = #"{"image":"alpine:3.20","mounts":[{"type":"volume","source":"missing-config","target":"/data"}]}"# s.volumeCreateFails = true s.install() + defer { + if let resume = try? VolumeRecoveryResume.load(name: info.name) { + if let dir = try? RecoveryConfigSession.directoryURL(forSessionID: resume.sessionID) { + try? FileManager.default.removeItem(at: dir) + } + } + try? VolumeRecoveryResume.cleanup(name: info.name) + } try withRebuildVolumeOverrides { try MiniTest.expectThrows({ _ = try RebuildCommand.run(options: RebuildOptions(skipPull: true), runtime: s.runtime) @@ -2100,6 +2111,244 @@ nonisolated(unsafe) let rebuildPhaseTests: [(String, () throws -> Void)] = [ try MiniTest.expect(!s.mock.calls.contains { $0.arguments.first == "exec" && $0.arguments.contains("adevcontainer-recovery-write") }, "no recovery write after ensure failure") }), + ("rebuildVolumePostDeleteExitResumesWithoutHelper", { + // Incident: delete crossed the boundary, the process exited with the secure session + // still on disk, and no helper was created. openRetry cannot run. A later + // `rebuild --name` must resume that session and must not create a blank workspace volume. + let name = "plantsuite" + defer { + if let resume = try? VolumeRecoveryResume.load(name: name) { + if let dir = try? RecoveryConfigSession.directoryURL(forSessionID: resume.sessionID) { + try? FileManager.default.removeItem(at: dir) + } + } + try? VolumeRecoveryResume.cleanup(name: name) + } + + let pre = RebuildScenario() + let preInfo = RebuildScenario.container(id: name, labels: pre.volumeLabels()) + pre.containers = [preInfo] + pre.volumeConfigText = "{ not valid json" + pre.install() + try withRebuildVolumeOverrides { + try MiniTest.expectThrows({ + _ = try RebuildCommand.run( + options: RebuildOptions(name: name, skipPull: true, jsonOutput: true), + runtime: pre.runtime, + isTTY: false + ) + }) { error in + try MiniTest.expectEqual((error as? CLIError)?.code, CLIErrorCode.configParse) + } + } + try MiniTest.expect(!pre.mock.calls.contains { $0.arguments.first == "delete" }, "pre-delete failure leaves the old container") + try MiniTest.expect(try VolumeRecoveryResume.load(name: name) == nil, "pre-delete failure does not publish a resume") + + let failed = RebuildScenario() + let info = RebuildScenario.container(id: name, labels: failed.volumeLabels()) + failed.containers = [info] + failed.volumeConfigText = #"{"image":"alpine:3.20","mounts":[{"type":"volume","source":"missing-config","target":"/data"}]}"# + failed.volumeCreateFails = true + var resumePublishedBeforeDelete = false + failed.onDelete = { + resumePublishedBeforeDelete = (try? VolumeRecoveryResume.load(name: name)) != nil + } + failed.install() + try withRebuildVolumeOverrides { + try MiniTest.expectThrows({ + _ = try RebuildCommand.run( + options: RebuildOptions(name: name, skipPull: true, jsonOutput: true), + runtime: failed.runtime, + isTTY: false + ) + }) { error in + let cli = error as? CLIError + try MiniTest.expectEqual(cli?.property, "volumes") + try MiniTest.expect(cli?.hint?.contains("rebuild --name \(name)") == true) + try MiniTest.expect(cli?.recovery == nil, "volume ensure does not offer a helper recovery session") + } + } + try MiniTest.expect(resumePublishedBeforeDelete, "resume index is on disk before delete returns") + try MiniTest.expect(failed.mock.calls.contains { $0.arguments.first == "delete" }, "old container was deleted") + try MiniTest.expect(!failed.mock.calls.contains { $0.arguments.first == "create" }, "no replacement or helper after the post-delete exit") + let published = try VolumeRecoveryResume.load(name: name) + try MiniTest.expect(published != nil, "name-indexed session survives the process exit") + if let published { + let dir = try RecoveryConfigSession.directoryURL(forSessionID: published.sessionID) + try MiniTest.expect(FileManager.default.fileExists(atPath: dir.path), "secure session still on disk") + } + + // Container gone, workspace volume still present. Resume must not need a helper. + let resume = RebuildScenario() + resume.volumes = ["adev-repo-ws"] + resume.existingImages = [RecoveryHelper.helperImageReference] + resume.containers = [] + resume.install() + try withRebuildVolumeOverrides { + _ = try RebuildCommand.run( + options: RebuildOptions(name: name, skipPull: true, jsonOutput: true), + runtime: resume.runtime, + isTTY: false + ) + } + try MiniTest.expect(!resume.mock.calls.contains { + $0.arguments.starts(with: ["volume", "create"]) && $0.arguments.contains("adev-repo-ws") + }, "resume must not manufacture a blank workspace volume") + try MiniTest.expect(resume.mock.calls.contains { call in + call.arguments.first == "create" && !call.arguments.contains(RecoveryHelper.helperImageReference) + }, "resume creates the replacement without a live helper") + try MiniTest.expect(!resume.mock.calls.contains { + $0.arguments.first == "create" && $0.arguments.contains(RecoveryHelper.helperImageReference) + }, "resume does not require a recovery helper") + try MiniTest.expect(try VolumeRecoveryResume.load(name: name) == nil, "successful resume clears the name index") + }), + + ("rebuildVolumeDeleteThrowBeforeBoundaryKeepsResume", { + // retain() runs before delete. If delete throws the Apple stale-exec diagnostic, + // crossedDeleteBoundary is never set. The published session must stay, and a later + // rebuild --name must resume without a helper and refuse a blank workspace volume. + let name = "plantsuite-delete-throw" + let configToken = "adev-delete-throw-config-token" + let configText = #"{"image":"alpine:3.20","postCreateCommand":"echo \#(configToken)"}"# + defer { + if let resume = try? VolumeRecoveryResume.load(name: name) { + if let dir = try? RecoveryConfigSession.directoryURL(forSessionID: resume.sessionID) { + try? FileManager.default.removeItem(at: dir) + } + } + try? VolumeRecoveryResume.cleanup(name: name) + } + + let failed = RebuildScenario() + let info = RebuildScenario.container(id: name, labels: failed.volumeLabels()) + failed.containers = [info] + failed.volumeConfigText = configText + failed.install() + let staleExec = """ + Error: an unknown error occurred + caused by: internalError: failed to delete container + caused by: deleteProcess: exec 8F2A9C1B-4D3E-4A10-9B2C-1D2E3F4A5B6C does not exist in container \(name) + """ + failed.mock.handlers.insert({ args in + guard args.first == "delete" else { return nil } + return ProcessResult(exitCode: 1, stdout: Data(), stderr: Data(staleExec.utf8)) + }, at: 0) + try withRebuildVolumeOverrides { + try MiniTest.expectThrows({ + _ = try RebuildCommand.run( + options: RebuildOptions(name: name, skipPull: true, jsonOutput: true), + runtime: failed.runtime, + isTTY: false + ) + }) { error in + let cli = error as? CLIError + try MiniTest.expectEqual(cli?.code, CLIErrorCode.runtimeFailed) + try MiniTest.expect( + cli?.message.contains("deleteProcess") == true + && cli?.message.contains("does not exist in container") == true, + "delete throws the Apple diagnostic before crossedDeleteBoundary" + ) + } + } + try MiniTest.expect(failed.mock.calls.contains { $0.arguments.first == "delete" }, "delete was invoked") + try MiniTest.expect(!failed.mock.calls.contains { $0.arguments.first == "create" }, "throw is before replacement create") + + let published = try VolumeRecoveryResume.load(name: name) + try MiniTest.expect(published != nil, "published resume survives a delete throw before the boundary") + guard let published else { return } + let sessionDir = try RecoveryConfigSession.directoryURL(forSessionID: published.sessionID) + try MiniTest.expect( + FileManager.default.fileExists(atPath: sessionDir.path), + "session directory was not cleaned up" + ) + let stateURL = try VolumeRecoveryResume.directoryURL(forName: name) + .appendingPathComponent("state.json") + let stateText = try String(contentsOf: stateURL, encoding: .utf8) + try MiniTest.expect(!stateText.contains(configToken), "state.json must not contain devcontainer config bytes") + try MiniTest.expect(!stateText.contains(configText), "state.json must not contain the raw config document") + let stateObject = try JSONSerialization.jsonObject(with: Data(stateText.utf8)) as? [String: Any] + let forbidden = ["rawConfig", "raw", "config", "configBytes", "devcontainerJSON", "tempFile"] + for key in forbidden { + try MiniTest.expect(stateObject?[key] == nil, "state.json must not have a raw config field \(key)") + } + + let resume = RebuildScenario() + resume.containers = [] + resume.volumes = [] + resume.existingImages = [RecoveryHelper.helperImageReference] + resume.install() + try withRebuildVolumeOverrides { + try MiniTest.expectThrows({ + _ = try RebuildCommand.run( + options: RebuildOptions(name: name, skipPull: true, jsonOutput: true), + runtime: resume.runtime, + isTTY: false + ) + }) { error in + let cli = error as? CLIError + try MiniTest.expectEqual(cli?.code, CLIErrorCode.recoveryUnavailable) + try MiniTest.expectEqual(cli?.recovery?.sessionID, published.sessionID, "resume opened the published session") + try MiniTest.expectEqual(cli?.recovery?.helperAvailable, false) + try MiniTest.expect(cli?.message.contains("could not be opened") != true, "session was not cleaned up") + } + } + try MiniTest.expect(!resume.mock.calls.contains { + $0.arguments.starts(with: ["volume", "create"]) + }, "absent workspace volume is not created") + try MiniTest.expect(!resume.mock.calls.contains { + $0.arguments.first == "create" + }, "resume does not create a helper or a replacement when the workspace volume is absent") + try MiniTest.expect(try VolumeRecoveryResume.load(name: name) != nil, "refusing a blank volume keeps the resume") + }), + + ("rebuildVolumeResumeRefusesBlankWorkspaceVolume", { + let name = "plantsuite-missing-ws" + let labels = RebuildScenario().volumeLabels() + let info = RebuildScenario.container(id: name, labels: labels) + let raw = RawVolumeConfig( + bytes: Data(#"{"image":"alpine:3.20"}"#.utf8), + pathInContainer: "/workspaces/edge/.devcontainer/devcontainer.json", + workspaceFolder: "/workspaces/edge", + workspaceFolderBasename: "edge" + ) + let session = try RecoveryConfigSession.capture( + rawVolumeConfig: raw, + container: info, + sessionID: "missing-ws-\(String(UUID().uuidString.prefix(8)).lowercased())" + ) + defer { + try? session.cleanup() + try? VolumeRecoveryResume.cleanup(name: name) + } + try VolumeRecoveryResume.retain( + container: info, + sessionID: session.sessionID, + authorName: nil, + authorEmail: nil + ) + let s = RebuildScenario() + s.containers = [] + s.volumes = [] + s.existingImages = [RecoveryHelper.helperImageReference] + s.install() + try withRebuildVolumeOverrides { + try MiniTest.expectThrows({ + _ = try RebuildCommand.run( + options: RebuildOptions(name: name, skipPull: true, jsonOutput: true), + runtime: s.runtime, + isTTY: false + ) + }) { error in + let cli = error as? CLIError + try MiniTest.expectEqual(cli?.code, CLIErrorCode.recoveryUnavailable) + try MiniTest.expectEqual(cli?.recovery?.helperAvailable, false) + } + } + try MiniTest.expect(!s.mock.calls.contains { $0.arguments.starts(with: ["volume", "create"]) }, "missing workspace volume is not created") + try MiniTest.expect(!s.mock.calls.contains { $0.arguments.first == "create" }, "no replacement or helper when the workspace volume is gone") + try MiniTest.expect(try VolumeRecoveryResume.load(name: name) != nil, "refusing a blank volume keeps the resumable session") + }), + ("rebuildCreatesNewlyDeclaredConfigVolume", { let ws = try TestRepo.makeTempWorkspace(configJSON: """ { diff --git a/specs/clone.md b/specs/clone.md index e0c04f3..1100afb 100644 --- a/specs/clone.md +++ b/specs/clone.md @@ -159,7 +159,16 @@ See also: [core.md](core.md) **Deterministic identity and labels** for shared sa On `clone` create, the CLI MUST: -1. **Workspace volume freshness (re-clone) — `clone` only:** If the workspace named volume already exists, `clone` MUST **delete it and create it empty** before mount. MUST NOT reuse a dirty existing workspace volume tree. (Config `type=volume` mounts remain list-then-create/reuse per Named volume reuse policy — only the clone workspace `*-ws` volume is delete-and-create.) +1. **Workspace volume freshness (re-clone) — `clone` only:** If the workspace named volume does not exist, `clone` MUST create it and MUST NOT prompt. If it already exists, `clone` MUST NOT delete it unless the operator explicitly chooses replace. + + - **TTY, no `--json`, neither flag:** prompt on stderr (not QUIET-silenced): `Workspace volume already exists. Reuse it? [Y/n]`. Empty Enter or `Y`/`yes` reuses. `n`/`no` replaces (delete, then create empty). EOF aborts without deleting. An unrecognized answer aborts without deleting. + - **`--reuse-volume`:** reuse without prompting. + - **`--replace-volume`:** replace without prompting (delete, then create empty). + - **Both flags:** structured error; MUST NOT delete. + - **Non-TTY or `--json`, neither flag:** fail closed with a structured error. MUST NOT prompt. MUST NOT delete. The hint MUST name both `--reuse-volume` and `--replace-volume`. + - **Reuse:** MUST NOT delete that workspace volume, including failure cleanup after create, start, ownership, populate, or hooks. MUST NOT run in-container git clone populate over an existing tree. If the mounted volume has no object entries other than `lost+found`, populate is allowed. If it has entries, skip populate and keep the tree; still apply author identity when `.git` exists; still overlay an edited `devcontainer.json` on `--resume`/recovery when that path already exists. + - **Replace:** delete-then-create. Failure cleanup MAY delete only the volume this invocation created after the operator chose replace. + - Config `type=volume` mounts remain list-then-create/reuse per Named volume reuse policy — no prompt. `rebuild` MUST remain unable to delete `*-ws`. 2. **`rebuild` carve-out:** `rebuild` of a volume-mode managed container MUST **reuse** the existing `*-ws` volume tree with its data and MUST NOT delete, replace, or re-populate it; MUST NOT run git re-clone or `git pull` inside it. The freshness rule applies to `clone` only. 3. Mount that volume as the **container workspace folder** (the implicit workspace mount). MUST NOT bind-mount a durable host project directory as the workspace for clone-created containers. 4. **Existing occupant of the create name:** classify per **Create-name occupancy classification**. Same-workspace same-name MUST fail closed (MUST NOT silently reuse, replace, or attach; MUST NOT offer rename-to-duplicate). Foreign occupant MUST follow **Foreign create-name collision offer**. Same-workspace different-name MUST fail with a delete-hint. @@ -190,9 +199,45 @@ Additional existing labels MAY be set. Discovery of managed containers for `list - When labels are inspected - Then `devcontainer.managed` is `adevcontainer`, `devcontainer.workspace_mode` is `volume`, `devcontainer.workspace_volume` matches the volume name, and `devcontainer.git_url` is present (normalized) -#### Scenario: Re-clone deletes and creates a fresh workspace volume +#### Scenario: Missing workspace volume is created without a prompt +- Given the workspace volume name does not exist +- When the user runs `adevcontainer clone` for that identity +- Then the CLI creates the volume, does not prompt, and does not delete a pre-existing volume + +#### Scenario: TTY reuses an existing workspace volume by default +- Given a workspace volume that already exists with residual files, stdin is a TTY, `--json` is not set, and neither volume flag is set +- When the user runs `adevcontainer clone` and presses Enter or answers Y +- Then the CLI prompts on stderr `Workspace volume already exists. Reuse it? [Y/n]`, does not delete the volume, mounts it, and does not run in-container git clone over that tree + +#### Scenario: TTY replace deletes and creates a fresh workspace volume +- Given a workspace volume that already exists with residual files, stdin is a TTY, and `--json` is not set +- When the user runs `adevcontainer clone` and answers n or no +- Then the CLI deletes that volume, creates it empty, and mounts the fresh volume (MUST NOT mount the dirty pre-existing tree) + +#### Scenario: EOF aborts without deleting +- Given an existing workspace volume and the TTY reuse prompt +- When stdin hits EOF +- Then clone fails structured and does not delete the volume + +#### Scenario: Volume flags choose without prompting +- Given an existing workspace volume +- When the user passes `--reuse-volume` or `--replace-volume` +- Then clone does not prompt; reuse keeps the volume and replace deletes then creates an empty volume +- And passing both flags is a structured error and MUST NOT delete + +#### Scenario: Non-TTY and --json fail closed +- Given an existing workspace volume and neither volume flag +- When stdin is not a TTY, or `--json` is set +- Then clone fails structured, does not prompt, does not delete the volume, and the hint names `--reuse-volume` and `--replace-volume` + +#### Scenario: Reuse failure does not delete the workspace volume +- Given clone reused an existing workspace volume and a later create, start, ownership, populate, or hook step fails +- When clone returns failure +- Then that workspace volume is not deleted + +#### Scenario: Re-clone with --replace-volume deletes and creates a fresh workspace volume - Given a workspace volume name `adev-{base}-{hash12}-ws` that already exists (e.g. after a prior container-only delete) with residual files -- When the user runs `adevcontainer clone` for the same URL/config identity +- When the user runs `adevcontainer clone --replace-volume` for the same URL/config identity - Then the CLI deletes that volume, creates it empty, and mounts the fresh volume (MUST NOT mount the dirty pre-existing tree) #### Scenario: rebuild reuses the workspace volume instead of replacing it @@ -219,7 +264,7 @@ Additional existing labels MAY be set. Discovery of managed containers for `list ### Requirement: In-container full clone populate (auth by URL scheme) -After the container is created and started (and Features have ensured in-container git), `clone` MUST populate the workspace volume with a **full git clone inside the container** into `workspaceFolder` (volume mount), **before** create-path lifecycle hooks. +After the container is created and started (and Features have ensured in-container git), `clone` MUST populate the workspace volume with a **full git clone inside the container** into `workspaceFolder` (volume mount), **before** create-path lifecycle hooks — except when reusing a workspace volume that already has object entries (see **Workspace volume freshness**). That reuse path MUST skip in-container git clone and keep the tree. An empty reused volume (no object entries other than `lost+found`) MUST still be populated. **Populate steps — MUST** @@ -247,9 +292,9 @@ The host-side HTTPS acquisition (`git credential fill` with `GIT_TERMINAL_PROMPT If start, populate, or create-path lifecycle hooks fail after the workspace volume and/or container have been created, `clone` MUST: 1. Delete the managed dev container (force as needed), and -2. Delete the workspace volume (`*-ws`), +2. Delete the workspace volume (`*-ws`) **only when this invocation created it** (it did not exist, or the operator chose replace and this invocation created the replacement), -before returning the structured failure. (Create-path hook runners that already delete the container still require workspace volume deletion on this path.) Temp dirs remain subject to always-clean rules below. Tests MUST assert no successful outcome and no leftover clone container/workspace volume on these failures. +before returning the structured failure. A reused pre-existing workspace volume MUST NOT be deleted on these failures. (Create-path hook runners that already delete the container still require workspace volume deletion on this path when this invocation created that volume.) Temp dirs remain subject to always-clean rules below. Tests MUST assert no successful outcome and no leftover clone container on these failures, and no leftover workspace volume when this invocation created it. A reused volume MUST still exist. #### Scenario: Populate uses in-container git clone with verify - Given a resolved clone create with container started and in-container git available @@ -287,9 +332,10 @@ before returning the structured failure. (Create-path hook runners that already - Then clone fails structured (populate failed) and MUST NOT report success #### Scenario: Populate failure deletes container and workspace volume -- Given the container and workspace volume were created and populate then fails +- Given the container and workspace volume were created by this invocation (fresh or replace) and populate then fails - When clone returns failure - Then the managed container is deleted and the workspace `*-ws` volume is deleted +- And a reused pre-existing workspace volume is not deleted #### Scenario: No GCM-in-guest product integration - Given any clone URL @@ -319,7 +365,7 @@ At the start of `clone`, when a host checkout exists, the CLI MUST run host `ini `onCreateCommand` → `updateContentCommand` → `postCreateCommand` → `postStartCommand` - In-container hooks run via AppleContainerRuntime exec (not baked into the image). -- Non-zero exit of any create-path hook MUST fail `clone` and MUST delete the container **and** the workspace volume before returning failure. +- Non-zero exit of any create-path hook MUST fail `clone` and MUST delete the container before returning failure. It MUST also delete the workspace volume when this invocation created it. A reused pre-existing workspace volume MUST NOT be deleted. - `postAttachCommand` follows [vscode.md](vscode.md) **postAttachCommand policy (CLI-only)** (CLI attach at the end of successful `clone`; not `--vscode`-gated; failure fails `clone` but MUST NOT delete container/volume solely due to postAttach failure). - `waitFor` applies as on `up` fresh create. @@ -345,9 +391,10 @@ At the start of `clone`, when a host checkout exists, the CLI MUST run host `ini - Then config-fetch temp directories are removed (or a stderr warning is emitted if removal failed) #### Scenario: Hook failure deletes container and workspace volume -- Given populate succeeded and `postCreateCommand` exits non-zero +- Given this invocation created the workspace volume, populate succeeded, and `postCreateCommand` exits non-zero - When clone runs - Then clone fails structured, the managed dev container is deleted, the workspace `*-ws` volume is deleted, and temps are cleaned up +- And a reused pre-existing workspace volume is not deleted See also: [core.md](core.md) **Up lifecycle** and [lifecycle-hooks.md](lifecycle-hooks.md) for the shared create-path hook matrix; [vscode.md](vscode.md) for postAttach policy. diff --git a/wiki/architecture.md b/wiki/architecture.md index 1c2778d..abc01d5 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -68,8 +68,8 @@ Volume mode exists for better metadata I/O (git status, node_modules, many small | `doctor` | Host/runtime readiness; plugin layout. Checks-only (no restage). Missing plugin: PATH `adevcontainer plugin --install` (`sudo` when needed) — never `container dev plugin --install`. | | `plugin --install` / `plugin --uninstall` | Install or remove the Apple plugin layout. `--install` writes an absolute symlink at `bin/dev` (not a Mach-O copy). `--uninstall` removes only `bin/dev` and `config.toml`. | | `up` | **Bind-mode** only: resolve config from host workspace, create/start/reuse; ensure named volumes; workspace bind; Features; lifecycle hooks in scope. Optional `--vscode` after success (see [VS Code flow](#vs-code-flow)). Reuse only when stamped config hash matches; mismatch → fail `config_hash_mismatch` (remediate with `rebuild`). Eligible bring-up failure with an editable host `devcontainer.json` → [bring-up recovery](#bring-up-recovery). Author synchronization is not part of `up` fresh-create. | -| `clone [--resume]` | **Volume-mode** workspace (VS Code clone-in-volume analogue): host sparse/shallow **config-only** fetch → resolve (workspaceFolder default + `${localWorkspaceFolderBasename}` = **git URL repo basename**, not temp dir name) → **author identity before Features/create:** host `git -C config --get user.name/email` (includeIf-aware; env `ADEVCONTAINER_GIT_AUTHOR_*`); both env → skip prompt; TTY confirm/override or collect; non-TTY silent + warn if incomplete → **ensure Features `ghcr.io/devcontainers/features/git:1` when no `git`/`common-utils`** (Features path, not apt; `up` unchanged) → ensure workspace volume → create + start (**SSH:** inject `create --ssh` when `SSH_AUTH_SOCK` set) → **in-container full `git clone`** + verify `.git` (**HTTPS:** host `git credential fill` one-shot → guest `credential.helper store`; no GCM-in-guest; no host full+tar happy path) → **author both:** a complete chosen pair writes `/.git/config` at local scope (persistence/source of truth) and the resolved connection user’s global Git config so sibling repositories/hooks inherit it; local config remains authoritative for the main repo; incomplete → warn, no partial → create-path hooks. Global HOME is container-scoped, not the persistence mechanism; workspace-local config survives via the bind path or workspace volume. Eligible failure retains the config checkout (not always-clean). Optional `--vscode` after success. Resume: `clone --resume ` — [bring-up recovery](#bring-up-recovery) | -| `rebuild [--name]` | **Forced-rebuild** path (realized in domain specs; archive [`20260810-rebuild`](../specs/changes/archive/20260810-rebuild/)): managed selection (`--name` / auto-single / picker); read **current** stamped `devcontainer.json` before any delete and capture the existing workspace-local Git author identity as the sole source of truth (bind: host workspace; volume: old container workspace); after config/host/Features succeed → container-only delete old → create under the **live computed** name (user `name` change and old `adev-*` migrate). Reuse stem-keyed workspace `*-ws` and `${devcontainerId}` volumes; after replacement preparation, synchronize a complete pair to the replacement connection user’s global Git config before create-path hooks; no host config or other container users changed; missing/incomplete → no invented value. User-literal sources stay as written (data preserved, never re-clone). Foreign occupant of the new name: do not delete selected; TTY Y/n + new name. Volume/clone-origin with no host workspace still runs host `initializeCommand` from a temp guest-config root (not skip). Optional `--skip-pull` / `--vscode` / `--json`. Recovery mode-split (TTY prompt Y/n, retain): [gaps](domain/devcontainer-apple-gaps.md#failed-rebuild-recovery-mode-split) | +| `clone [--resume]` | **Volume-mode** workspace (VS Code clone-in-volume analogue): host sparse/shallow **config-only** fetch → resolve (workspaceFolder default + `${localWorkspaceFolderBasename}` = **git URL repo basename**, not temp dir name) → **author identity before Features/create:** host `git -C config --get user.name/email` (includeIf-aware; env `ADEVCONTAINER_GIT_AUTHOR_*`); both env → skip prompt; TTY confirm/override or collect; non-TTY silent + warn if incomplete → workspace volume `adev-*-ws` (choice before Features; replace deletes only after Features succeed): missing creates; an existing volume prompts `Reuse it? [Y/n]` on a TTY (empty/Y keeps it, n/no replaces, EOF aborts without delete); non-TTY/`--json` fails closed unless `--reuse-volume` or `--replace-volume`; reuse never deletes that volume and does not git-clone over a tree that already has entries (`lost+found` alone is empty); replace is delete-then-create; config volumes stay list-then-reuse → **ensure Features `ghcr.io/devcontainers/features/git:1` when no `git`/`common-utils`** (Features path, not apt; `up` unchanged) → create + start (**SSH:** inject `create --ssh` when `SSH_AUTH_SOCK` set) → **in-container full `git clone`** unless reuse is keeping that tree + verify `.git` (**HTTPS:** host `git credential fill` one-shot → guest `credential.helper store`; no GCM-in-guest; no host full+tar happy path) → **author both:** a complete chosen pair writes `/.git/config` at local scope (persistence/source of truth) and the resolved connection user’s global Git config so sibling repositories/hooks inherit it; local config remains authoritative for the main repo; incomplete → warn, no partial → create-path hooks. Global HOME is container-scoped, not the persistence mechanism; workspace-local config survives via the bind path or workspace volume. Eligible failure retains the config checkout (not always-clean). Optional `--vscode` after success. Resume: `clone --resume ` — [bring-up recovery](#bring-up-recovery) | +| `rebuild [--name]` | **Forced-rebuild** path (realized in domain specs; archive [`20260810-rebuild`](../specs/changes/archive/20260810-rebuild/)): managed selection (`--name` / auto-single / picker); read **current** stamped `devcontainer.json` before any delete and capture the existing workspace-local Git author identity as the sole source of truth (bind: host workspace; volume: old container workspace); after config/host/Features succeed → container-only delete old → create under the **live computed** name (user `name` change and old `adev-*` migrate). Reuse stem-keyed workspace `*-ws` (rebuild never deletes `*-ws`) and `${devcontainerId}` volumes; after replacement preparation, synchronize a complete pair to the replacement connection user’s global Git config before create-path hooks; no host config or other container users changed; missing/incomplete → no invented value. User-literal sources stay as written (data preserved, never re-clone). Foreign occupant of the new name: do not delete selected; TTY Y/n + new name. Volume/clone-origin with no host workspace still runs host `initializeCommand` from a temp guest-config root (not skip). Optional `--skip-pull` / `--vscode` / `--json`. Recovery mode-split (TTY prompt Y/n, retain): [gaps](domain/devcontainer-apple-gaps.md#failed-rebuild-recovery-mode-split). Volume resume is not helper-only: a name-indexed resume is published before delete, and `rebuild --name` opens that session when the container is gone and no helper exists, reusing the stamped `*-ws` without creating a blank workspace volume. A listed marked helper still wins. | | `list [--json]` | Managed containers only (`devcontainer.managed=adevcontainer`) | | `start [--name]` | Start a managed stopped container via `--name` or interactive picker. **Real start** (stopped→running, bind+volume): host `initializeCommand` when a host workspace exists (volume start without host path skips+warns) → `postStartCommand` + feature remelt → CLI-attach `postAttachCommand`. **Already-running start:** no initialize/postStart; postAttach only after successful `--vscode` open. **Never applies** settings/extensions. Optional `--vscode` is **open only**. postAttach loads config from labels (bind: host `local_folder`+`config_file`; volume: in-container config path) and merges feature postAttach from image `devcontainer.metadata` (load errors → treat absent, do not fail start). Start failure recovery delegates to `rebuild --name` — does **not** re-run start or open an editor | | `exec [--name]` | Run command/shell in running managed container (`-it` / empty cmd → interactive TTY, default `bash`). Selection: `--name` or picker (no `-w`). User/workdir from labels `devcontainer.remote_user` / `devcontainer.workspace_folder` when stamped (new creates always stamp non-empty `remote_user` incl. `root`; empty = legacy — see [Connection user](#connection-user)) | diff --git a/wiki/conventions/cli-runtime-boundary.md b/wiki/conventions/cli-runtime-boundary.md index 018f83e..01266ee 100644 --- a/wiki/conventions/cli-runtime-boundary.md +++ b/wiki/conventions/cli-runtime-boundary.md @@ -188,14 +188,15 @@ Do not depend on Docker-style `ps --filter label=` as the primary discovery mech - Before create, **ensureVolume** for each config named volume: **list first**. Sources must already have `${devcontainerId}` expanded to the stem (see above) — e.g. folder `foo` → stem `adev-foo-abc123def456` → `adev-foo-abc123def456-shellhistory`, not a literal `${…}` token and not the create name (`my-app`). User-literal sources (no token) stay as written. - If the volume already exists → status `Reusing existing volume` and mount it; **never fail `up`/`clone` only because a config volume exists**. -- If missing → create, then mount. -- **Clone workspace volume:** if `adev-*-ws` already exists → **delete + create** (fresh tree), then mount as the workspace root (not a host bind). +- If missing → create, then mount. Exception: volume rebuild resume refuses a missing `*-ws` (no blank workspace volume). +- **Clone workspace volume** (`adev-*-ws`, workspace root, not a host bind): missing → create. An existing volume prompts `Reuse it? [Y/n]` on a TTY (empty/Y keeps it, n/no replaces, EOF aborts without delete). Non-TTY/`--json` fails closed unless `--reuse-volume` or `--replace-volume`. Reuse never deletes that volume and does not git-clone over a tree that already has entries (emptiness is an in-container listing after start; `lost+found` alone counts as empty). Replace is the old delete-then-create. A failed or unparsable volume list fails closed before any workspace delete. Failure cleanup deletes the workspace volume only when this invocation's `ensureVolume` created it. Config volumes stay list-then-reuse. Rebuild still never deletes `*-ws`. Contract: [`specs/clone.md`](../../specs/clone.md). +- **Volume rebuild resume:** not helper-only. `VolumeRecoveryResume` publishes identity-only state at `FileManager.temporaryDirectory/adevcontainer-volume-recovery/adev-volume-recovery-/state.json` before delete; config bytes stay in the secure session directory. `rebuild --name` loads that index on `containerNotFound` when the container is gone and no helper exists, reuses the stamped `*-ws`, and refuses to create one if missing (no blank workspace volume; not ensureVolume create). A listed marked helper still wins. If `runtime.delete` throws before `crossedDeleteBoundary` — including Apple `deleteProcess: exec … does not exist` — do not delete a session whose id is already published. Detail: [gaps — clone-origin](../domain/devcontainer-apple-gaps.md#clone-origin-volume-path). ### Image copy-up for new config volumes Docker/Podman automatically seed a new empty named volume from content already present in the image at its mount target; Apple container currently hides that content behind an empty volume without copying it. -**Product (`up` fresh create, bind mode):** only config volumes that `ensureVolume` created in that attempt enter initialization. Before the main container is created—and therefore before its in-container create-path hooks, including `postCreateCommand`—a root helper from the same image mounts each new volume at a private disjoint staging path and runs `sh -c` with `cp -a /. /` when the image target is a directory. The image must provide `sh` and `cp -a`. Existing volumes skip the helper and are never overwritten. This opt-in is not used by `clone` or `rebuild`, whose existing ensure/reuse semantics remain unchanged. +**Product (`up` fresh create, bind mode):** only config volumes that `ensureVolume` created in that attempt enter initialization. Before the main container is created—and therefore before its in-container create-path hooks, including `postCreateCommand`—a root helper from the same image mounts each new volume at a private disjoint staging path and runs `sh -c` with `cp -a /. /` when the image target is a directory. The image must provide `sh` and `cp -a`. Existing volumes skip the helper and are never overwritten. This opt-in is not used by `clone` or `rebuild`. Config named volumes stay list-then-reuse; rebuild still never deletes `*-ws`. A newly created config volume targeting container root `/` is rejected as `populate_failed` because no disjoint helper staging path is safe; a preexisting root-target volume is reused without initialization. Initialization failure deletes the helper first, then only volumes created by that attempt. Cleanup failures retain the primary diagnostic and name the surviving helper/volumes plus exact `container delete --force` / `container volume delete` remediation; an attached volume is preserved when helper deletion fails. @@ -236,17 +237,17 @@ After ownership, before create-path hooks, `up` fresh create (bind) and `rebuild 3. Resolve `devcontainer.json` from that temp tree. **workspaceFolder** default and `${localWorkspaceFolderBasename}` use the **git URL repo basename**, not the host temp checkout directory name. 4. **Author identity (before Features/create):** host `git -C config --get user.name` / `user.email` (includeIf-aware). Env overrides: `ADEVCONTAINER_GIT_AUTHOR_NAME` / `ADEVCONTAINER_GIT_AUTHOR_EMAIL`. **Both env set** → use env, skip prompt (even on TTY). **TTY** and env incomplete: if both resolved → confirm `Use this identity? [Y/n]` (decline → collect name+email); if either missing → prompt for both (empty → fail structured, no Features/create). **Non-TTY:** no prompt; resolved/env silently when complete; incomplete → continue (warn at apply, no hang). After populate (step 8), a complete chosen pair writes both `/.git/config` at local scope (persistence/source of truth) and the resolved connection user’s global Git config so sibling repositories/hooks inherit it; local config remains authoritative in the main repository. 5. **Ensure in-container git (Features path, not apt):** after resolve + identity, before the Features gate, if no admitted feature id is `git` or `common-utils` (any registry/tag or local path), append `ghcr.io/devcontainers/features/git:1` (empty options). Status: `==> Ensuring git feature for volume-mode dev container`. Empty features → inject then enter FeaturesRunner. Already covered → no double-add. Config hash / effective features include the inject when added. **`up` does not inject.** -6. Ensure workspace volume (`adev-{base}-{hash12}-ws`); delete+create if present. Occupancy of the create name: same-workspace same-name fail-closed (no silent reuse); leftover different name → delete-hint; foreign → TTY ask-to-rename then prompt for new name (persist into config `name`). See [occupancy](#deterministic-names-and-labels). +6. Workspace volume (`adev-{base}-{hash12}-ws`) per [Named volumes](#named-volumes-ensure--reuse--ownership): missing creates; an existing volume prompts `Reuse it? [Y/n]` on a TTY (empty/Y keeps it, n/no replaces, EOF aborts without delete); non-TTY/`--json` fails closed unless `--reuse-volume` or `--replace-volume`; reuse never deletes that volume; replace is delete-then-create. The choice is before Features (cancel or fail-closed does not pull images); replace deletes only after Features succeed, immediately before create. A failed or unparsable volume list fails closed before any workspace delete. Config volumes stay list-then-reuse. Occupancy of the create name: same-workspace same-name fail-closed (no silent reuse); leftover different name → delete-hint; foreign → TTY ask-to-rename then prompt for new name (persist into config `name`). See [occupancy](#deterministic-names-and-labels). 7. Create the volume-mode container (workspace = named volume; labels as above). Features runner runs when features are non-empty after step 5. - **SSH URL:** require host `SSH_AUTH_SOCK` non-empty; inject `AllowlistedRunArg.ssh` (`container create --ssh`) if not already in runArgs. Missing agent → fail structured (hint ssh-agent / HTTPS). Later push uses the same forward. - **HTTPS:** no create-time auth flag; credentials applied at populate (step 8). 8. **Ownership + start:** scoped helper (when `--read-only` or dropped `CAP_CHOWN` selects it) chowns writable named volumes then the main container starts; otherwise start first and use the normal ownership path — see [Named volumes / ownership](#named-volumes-ensure--reuse--ownership). -9. **Populate (in-container full clone)** — happy path is **not** host full clone + tar-pipe (`copyTreeIntoContainer` may remain as unused utility). After Features ensure git: +9. **Populate (in-container full clone)** — happy path is **not** host full clone + tar-pipe (`copyTreeIntoContainer` may remain as unused utility). After Features ensure git. Reuse does not git-clone over a tree that already has entries; emptiness is an in-container listing after start (`lost+found` alone counts as empty): - Exec in-container `git clone` of the URL into `workspaceFolder` (as `remoteUser` when set); verify `workspaceFolder/.git`. - **HTTPS auth:** host `git credential fill` (protocol/host/path; GCM/osxkeychain transparent — **no product GCM-in-guest**, no mount of host `~/.git-credentials`). Optional fallbacks: `ADEVCONTAINER_GIT_TOKEN`; `gh auth token` for github.com when `gh` available. When creds exist → one-shot into guest clone via GIT_ASKPASS/env (never log secrets; redact errors) → guest `credential.helper store` + `git credential approve` once. When fill empty → anonymous in-container clone (public); auth failure → structured hint to configure host credentials or use SSH. - **SSH auth:** agent already forwarded via `--ssh` from create. - - **Author apply:** when **both** name+email from step 4 → guest `git config --local` both; if either missing → warn once, no partial write; no synthetic defaults. - 10. Create-path lifecycle hooks (same order as fresh `up`). On start/populate/hook/ownership failure after create: delete container **and** workspace `*-ws` volume. + - **Author apply:** when **both** name+email from step 4 → guest `git config --local` both; if either missing → warn once, no partial write; no synthetic defaults. On a kept tree, apply only when `.git` already exists. + 10. Create-path lifecycle hooks (same order as fresh `up`). On start/populate/hook/ownership failure after create: delete the container; delete the workspace `*-ws` volume only when this invocation's `ensureVolume` created it. Reuse never deletes that volume. 11. **Temps:** success or ineligible failure → clean config-fetch temps. Eligible bring-up recovery → retain the checkout (do not delete). Successful recovery/`--resume` overlays the edited `devcontainer.json` into the guest workspace **after populate** (replaces the git-populated original), then removes only a validated managed checkout. No overlay without an editable config. No host full-clone staging temp on the happy path. `up` remains bind-mode host workspace only (no auto git Feature). The global HOME is container-scoped, not the persistence mechanism; workspace-local `.git/config` survives through the bind path or workspace volume. Detail: [architecture.md](../architecture.md); contract [`specs/clone.md`](../../specs/clone.md). Eligible clone recovery: [Bring-up recovery](#bring-up-recovery-bringuprecovery). @@ -262,7 +263,7 @@ After ownership, before create-path hooks, `up` fresh create (bind) and `rebuild ### InteractivePicker (multi-container) -`ManagedContainers.resolveSelection` → **`InteractivePicker`** when `--name` omitted and more than one managed container. Code: `ManagedContainers.swift`, `TerminalRawInput.swift`. Rows/header via shared **`ManagedContainerTable`** (NAME STATE MODE SOURCE; lead `>` / `N)` — [terminal-output](terminal-output.md#managed-container-table-list--interactivepicker)). Foreign create-name collision does not use this picker. Rebuild recovery retry must **not** re-open the picker once a container is selected — pin the same id via `--name` / `selectedOverride` (bind resume already; volume named/retry must too). [gaps — clone-origin](../domain/devcontainer-apple-gaps.md#clone-origin-volume-path). +`ManagedContainers.resolveSelection` → **`InteractivePicker`** when `--name` omitted and more than one managed container. Code: `ManagedContainers.swift`, `TerminalRawInput.swift`. Rows/header via shared **`ManagedContainerTable`** (NAME STATE MODE SOURCE; lead `>` / `N)` — [terminal-output](terminal-output.md#managed-container-table-list--interactivepicker)). Foreign create-name collision does not use this picker. Rebuild recovery retry must **not** re-open the picker once a container is selected — pin the same id via `--name` / `selectedOverride` (bind resume already; volume named/retry must too). Volume `containerNotFound` with no listed helper loads the name-indexed resume instead of requiring a helper; a listed marked helper still wins. [gaps — clone-origin](../domain/devcontainer-apple-gaps.md#clone-origin-volume-path). | Mode | When | Behavior | |------|------|----------| @@ -283,7 +284,7 @@ Presentation / QUIET: [terminal-output.md](terminal-output.md) (picker stays raw | `up` create | Fresh bind-mode create when occupancy is none (desired name free, no same-workspace leftover) | | `up` reuse / start-stopped | Only when existing container's stamped config hash **equals** current resolve. Running → reuse (no Features re-run; host `initializeCommand` still runs); stopped → start + `postStartCommand` + feature remelt | | `up` config hash mismatch | Fail closed: `config_hash_mismatch`; **does not** delete or replace. Hint: `adevcontainer rebuild` (managed selection: `--name` or auto) | -| `rebuild` | **Forced rebuild** (landed; archive [`20260810-rebuild`](../../specs/changes/archive/20260810-rebuild/)): read current stamped config **before** any delete and capture the existing workspace repository’s local author identity (bind: host workspace; volume: old container workspace); hostRequirements + Features; then container-only delete old → create under the **live computed** name (user `name` change and old `adev-*` migrate). Reuse stem-keyed workspace `adev-*-ws` and `${devcontainerId}` volumes (ensureVolume, never delete/replace workspace volume; no re-clone). After ownership (pre-start helper or post-start normal path) and credential-seeding work, synchronize a complete pair to the replacement connection user’s global Git config before create-path hooks; missing/incomplete → no invented value; host Git config and other container users remain unchanged. Foreign occupant of the new name: do not delete selected; TTY Y/n + new name (persist into live config). `${devcontainerId}` expands to the resource stem (stable across create-name change). Volume/clone-origin with no host workspace still runs host `initializeCommand` from a temp guest-config root (not skip; see [Lifecycle](#lifecycle-execution-hook-matrix)). Pre-delete failure leaves old container untouched. Optional `--skip-pull` / `--vscode` / `--json`. Volume `rebuild`: if any admitted ref is local-path, stage guest `.devcontainer` (and root `.devcontainer.json` when that is the config) via `exec tar` (not `container cp`) onto a host temp **before** Features/delete, then `DefaultFeatureFetcher`; fail structured pre-delete (old container kept); clean temp. Mixed local+OCI uses `DefaultFeatureFetcher`. OCI-only volume may still use `OCIFeatureClient`. Ownership: [Named volumes / ownership](#named-volumes-ensure--reuse--ownership) (volume-mode workspace chown only if connection user ≠ stamped `remote_user`). Hard post-delete recovery mode-split (TTY `Open the recovery editor now? [Y/n]` default Y; decline/EOF retain; named retry skips prompt; README + CLI help document UX): [gaps — Failed rebuild recovery](../domain/devcontainer-apple-gaps.md#failed-rebuild-recovery-mode-split). Bring-up (`up`/`clone`/`start`) is a separate primitive — [below](#bring-up-recovery-bringuprecovery) | +| `rebuild` | **Forced rebuild** (landed; archive [`20260810-rebuild`](../../specs/changes/archive/20260810-rebuild/)): read current stamped config **before** any delete and capture the existing workspace repository’s local author identity (bind: host workspace; volume: old container workspace); hostRequirements + Features; then container-only delete old → create under the **live computed** name (user `name` change and old `adev-*` migrate). Reuse stem-keyed workspace `adev-*-ws` and `${devcontainerId}` volumes (ensureVolume, never delete/replace workspace volume; no re-clone; name-indexed resume refuses a missing `*-ws`). After ownership (pre-start helper or post-start normal path) and credential-seeding work, synchronize a complete pair to the replacement connection user’s global Git config before create-path hooks; missing/incomplete → no invented value; host Git config and other container users remain unchanged. Foreign occupant of the new name: do not delete selected; TTY Y/n + new name (persist into live config). `${devcontainerId}` expands to the resource stem (stable across create-name change). Volume/clone-origin with no host workspace still runs host `initializeCommand` from a temp guest-config root (not skip; see [Lifecycle](#lifecycle-execution-hook-matrix)). Pre-delete failure leaves old container untouched. Optional `--skip-pull` / `--vscode` / `--json`. Volume `rebuild`: if any admitted ref is local-path, stage guest `.devcontainer` (and root `.devcontainer.json` when that is the config) via `exec tar` (not `container cp`) onto a host temp **before** Features/delete, then `DefaultFeatureFetcher`; fail structured pre-delete (old container kept); clean temp. Mixed local+OCI uses `DefaultFeatureFetcher`. OCI-only volume may still use `OCIFeatureClient`. Ownership: [Named volumes / ownership](#named-volumes-ensure--reuse--ownership) (volume-mode workspace chown only if connection user ≠ stamped `remote_user`). Hard post-delete recovery mode-split (TTY `Open the recovery editor now? [Y/n]` default Y; decline/EOF retain; named retry skips prompt; README + CLI help document UX): [gaps — Failed rebuild recovery](../domain/devcontainer-apple-gaps.md#failed-rebuild-recovery-mode-split). Volume resume is not helper-only: name-indexed state is published before delete; `rebuild --name` opens it on `containerNotFound` with no helper, reuses stamped `*-ws`, and does not create a blank workspace volume. A listed marked helper still wins. Bring-up (`up`/`clone`/`start`) is a separate primitive — [below](#bring-up-recovery-bringuprecovery) | Nested `build` on `rebuild` (Dockerfile + Features rebuild, config-dir staging): [Dockerfile image](#dockerfile-image-nested-build). diff --git a/wiki/domain/devcontainer-apple-gaps.md b/wiki/domain/devcontainer-apple-gaps.md index ef7b600..42bea85 100644 --- a/wiki/domain/devcontainer-apple-gaps.md +++ b/wiki/domain/devcontainer-apple-gaps.md @@ -62,7 +62,7 @@ The mounts-ports fixture uses a `~/.kube` **directory** bind; `reference/devcont `container cp` host→guest targeting a path on a **named volume mount** can exit **0** and still write nothing (silent no-op). Rootfs destinations may still work with `cp`. -**Product implication:** volume-mode clone populate does **not** host→guest copy into the workspace volume. Happy path is **in-container full `git clone`** (after Features ensure git) + verify `.git`. Runtime may still expose tar-pipe `copyTreeIntoContainer` as a utility (not the clone happy path). Do not use `container cp` into named-volume mounts. Detail: [cli-runtime-boundary.md](../conventions/cli-runtime-boundary.md). +**Product implication:** volume-mode clone populate does **not** host→guest copy into the workspace volume. Populate, when it runs, is **in-container full `git clone`** (after Features ensure git) + verify `.git`. Reuse does not git-clone over a tree that already has entries. Runtime may still expose tar-pipe `copyTreeIntoContainer` as a utility (not the clone happy path). Do not use `container cp` into named-volume mounts. Detail: [cli-runtime-boundary.md](../conventions/cli-runtime-boundary.md). ### Failed rebuild recovery (mode-split) @@ -70,21 +70,25 @@ Rebuild recovery is **mode-split**. Shared rules: same hard post-delete trigger **Shared hard post-delete matrix:** replacement `create` failure, replacement `start` failure, or create-path hook failure. Excluded (terminal, no recovery): volume ensure, pre-delete config/host/Features, `postAttachCommand`, customizations apply, final verification, and other non-retryable errors. Retryable hard failures are runtime, lifecycle, and post-create only. -**TTY / non-TTY (both modes):** After hard post-delete on TTY: structured error, then `Open the recovery editor now? [Y/n]` (default **Y**). Decline/EOF retains helper/session or BindRecoveryResume and prints retry guidance (no editor). Named `rebuild` retry skips the prompt and opens the editor before apply/write. Non-TTY/JSON unchanged: structured retained details, no editor/prompt; named retry applies retained temp. TTY loops on invalid config once the editor is open. Progress must show "entering recovery" so create-path/postCreate failure is not mistaken for a hang. TTY editor launch uses **InteractiveProcessRunner** `tcsetpgrp` + `SIGCONT` (inherit-stdio alone → `nano`/`vi` `STAT=T` hang). Detail: [cli-runtime-boundary](../conventions/cli-runtime-boundary.md). +**TTY / non-TTY (both modes):** After hard post-delete on TTY: structured error, then `Open the recovery editor now? [Y/n]` (default **Y**). Decline/EOF retains the marked helper/session when one is listed, the published volume name index, or BindRecoveryResume, and prints retry guidance (no editor). A live helper is not required to retain volume resume. Named `rebuild` retry skips the prompt and opens the editor before apply/write. Non-TTY/JSON unchanged: structured retained details, no editor/prompt; named retry applies retained temp. TTY loops on invalid config once the editor is open. Progress must show "entering recovery" so create-path/postCreate failure is not mistaken for a hang. TTY editor launch uses **InteractiveProcessRunner** `tcsetpgrp` + `SIGCONT` (inherit-stdio alone → `nano`/`vi` `STAT=T` hang). Detail: [cli-runtime-boundary](../conventions/cli-runtime-boundary.md). #### Clone-origin volume path -Eligible only for a managed clone-origin container with complete volume-mode stamps: non-empty normalized git URL, existing workspace volume, config path contained by stamped workspace folder, managed identity. Incomplete/malformed/unknown identity fail closed. +Volume rebuild resume does not require a live helper. Before the old container is deleted, `VolumeRecoveryResume` publishes identity-only state at `FileManager.temporaryDirectory/adevcontainer-volume-recovery/adev-volume-recovery-/state.json`. Config bytes stay in the existing secure session directory. `rebuild --name` loads that index on `containerNotFound` when the container is gone and no helper exists, reuses the stamped `*-ws` volume, and refuses to create one if it is missing (no blank workspace volume). If `runtime.delete` throws — including Apple `deleteProcess: exec … does not exist` — before `crossedDeleteBoundary` is set, do not delete a session whose id is already published. An existing `adev-*-ws` on clone prompts `Reuse it? [Y/n]` on a TTY (empty/Y keeps it, n/no replaces, EOF aborts without delete); non-TTY/`--json` fails closed unless `--reuse-volume` or `--replace-volume`; reuse never deletes that volume and does not git-clone over a tree that already has entries (`lost+found` alone is empty); replace is the old delete-then-create. Config volumes stay list-then-reuse. Rebuild still never deletes `*-ws`. -- Helper: immutable digest-pinned Alpine `linux/arm64`; digest/platform inspection + exact existing workspace-volume presence preflighted before the old-container delete gate. Never deletes/replaces/repopulates/rolls back an image. +When a marked helper is listed, helper selection wins. That path is below; it is not the only resume path. + +Entering recovery from a live container still requires a managed clone-origin container with complete volume-mode stamps: non-empty normalized git URL, existing workspace volume, config path contained by stamped workspace folder, managed identity. Incomplete/malformed/unknown identity fail closed. + +- Helper (when a marked helper is listed): immutable digest-pinned Alpine `linux/arm64`; digest/platform inspection + exact existing workspace-volume presence preflighted before the old-container delete gate. Never deletes/replaces/repopulates/rolls back an image. - Apple mount identity is nested: `configuration.mounts[].type.volume.name` (logical name); do not use `source` (may be `volume.img`). Require stamped volume at stamped folder, read-write. Malformed/unknown/read-only/wrong-target/bind/virtiofs fail closed. -- After failed replacement: detach failed container and verify absence from all attachments before helper create; failed detach blocks recovery. +- After failed replacement, on the helper path: detach the failed container and verify absence from all attachments before helper create; failed detach blocks helper create. - Host session: raw config private (`0700`/`0600`); edits via helper stdin → atomic same-directory write guarded by baseline hash → readback byte/hash verify. Conflicts/failed verify retain state. Raw config never in labels, JSON errors, or logs. Spec private-file = host-session only. - After atomic `mv`, `chown` the in-volume stamped `devcontainer.json` to the parent-directory owner, then `chmod 644` — connection-user **writable**, not merely readable, not world-writable. Host session stays `0700`/`0600`. - `RecoveryConfigSession.cleanup` fail-closed bar: path/ownership/session-id only — not on-disk metadata equality after `applyValidatedEdit` advances `lastAppliedHash`. -- Helper/session retained for retry; crossing helper delete gate detaches/replaces helper before another edit. Cleanup only after successful final verification. +- When a marked helper is listed, that helper/session is retained for retry; crossing the helper delete gate detaches/replaces the helper before another edit. Cleanup only after successful final verification. No listed helper does not block resume: the published name index remains the path. - Volume named/retry rebuild must **not** re-open the interactive picker once a container is selected; same id via pinned `--name` / `selectedOverride` (bind resume already did this). -- **Named apply after volume auto-start** (order: volume auto-start → apply/write). Helper must be **exec-ready** before exec (probe → `start` → stop+start bounce); status alone insufficient (`cannot exec: container is not running` while listed running). +- **Named apply on the marked-helper path** (order: volume auto-start → apply/write). That helper must be **exec-ready** before exec (probe → `start` → stop+start bounce); status alone insufficient (`cannot exec: container is not running` while listed running). Not required when resume is the name index and no helper is listed. - Sibling Apple process inconsistency: `container delete --force` can fail after successful product execs with nested `deleteProcess: exec … does not exist in container …`. Product retries that Apple error on `AppleContainerRuntime.delete`/`stop` (3 attempts; delete then one stop+start bounce; gone after a failed-looking delete is success even if stderr is `not found`; still listed after budget fail-closed). Retryable Apple `deleteProcess: exec … does not exist` stderr is captured for retry/classification, not live-teed under `==> Deleting container` (do not restore `streamStderr: true` on the retry loop; framed error is not required); the operator sees that diagnostic only on fail-closed. Named rebuild still force-deletes through that runtime path. Do not re-add the wrap or treat leftover `[RECOVERY]` as a rebuild-logic bug; do not purge volumes (container-only). Live leftover-helper validation of the retry path is still pending. - No `container cp`, volume delete/replace/repopulate, or image rollback (named-volume `cp` limitation still applies independently). @@ -92,7 +96,7 @@ Eligible only for a managed clone-origin container with complete volume-mode sta Host stamped `devcontainer.json` editor UX only — **no** Alpine helper, **no** helper volume attach, **no** atomic in-volume write. Operator edits the host-side stamped config; recovery does not route through the volume helper pipeline. -- Bind named retry after non-TTY may use host-side **BindRecoveryResume** stamps (not container labels) when the container is already gone — labels are unavailable once the failed container is deleted. +- Bind named retry after non-TTY may use host-side **BindRecoveryResume** stamps (not container labels) when the container is already gone — labels are unavailable once the failed container is deleted. Volume gone-container retry uses the name-indexed resume above, not these stamps and not a required live helper. Contract + README/CLI help landed (archive `20260810-rebuild`). Rebuild remaining gaps only: automated TTY E2E absent; non-TTY live rebuild recovery E2E gated `ADEVCONTAINER_RECOVERY_E2E=1`. Bring-up recovery is a separate path — see [below](#bring-up-recovery-up--clone--start). From fb70148512d6dd54f40a32eb4fafc7779f62b91f Mon Sep 17 00:00:00 2001 From: Wyller Gomes Date: Thu, 1 Oct 2026 21:25:31 -0300 Subject: [PATCH 2/3] docs: record Apple volume.img as raw ext4 Named-volume volume.img is raw ext4 and can be e2fsck'd in place only while unmounted. Structure needs cleaning on chown is EUCLEAN, not a chown-logic bug. --- wiki/architecture.md | 4 ++-- wiki/conventions/cli-runtime-boundary.md | 2 ++ wiki/domain/devcontainer-apple-gaps.md | 6 ++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/wiki/architecture.md b/wiki/architecture.md index abc01d5..8c118d6 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -57,9 +57,9 @@ devcontainer.json → Config resolver → [Dockerfile image] → [Features runne | Mode | Entry | Workspace storage | Host FS path | |------|-------|-------------------|--------------| | **Bind** | `up` from host workspace | Host dir via virtiofs (APFS) | Real path on Mac | -| **Volume** | `clone ` | Named volume via virtio-blk (`volume.img` ext4) | No durable host checkout; label `local_folder=volume://…` | +| **Volume** | `clone ` | Named volume via virtio-blk (`volume.img` raw ext4) | No durable host checkout; label `local_folder=volume://…` | -Volume mode exists for better metadata I/O (git status, node_modules, many small files) vs virtiofs binds. Contract: [`specs/clone.md`](../specs/clone.md) (archived change `20260808-clone-in-volume`). +Volume mode exists for better metadata I/O (git status, node_modules, many small files) vs virtiofs binds. Apple named-volume `volume.img` is raw ext4 (magic `0xEF53` at offset 1080) and can be `e2fsck`'d in place only while unmounted; do not copy the sparse image. `Structure needs cleaning` on chown is ext4 `EUCLEAN`, not a chown-logic bug. Detail: [gaps — named volume ownership](domain/devcontainer-apple-gaps.md#named-volume-ownership-rootroot). Contract: [`specs/clone.md`](../specs/clone.md) (archived change `20260808-clone-in-volume`). ## Commands (product surface) diff --git a/wiki/conventions/cli-runtime-boundary.md b/wiki/conventions/cli-runtime-boundary.md index 01266ee..15d5636 100644 --- a/wiki/conventions/cli-runtime-boundary.md +++ b/wiki/conventions/cli-runtime-boundary.md @@ -216,6 +216,8 @@ Helper shape: same image/platform; eligible RW named volumes at private targets; **Parents:** some Apple rootfs inodes (`/workspaces`) reject `chown` with EPERM even as root. Unmodifiable but traversable ancestors do not fail `up`. Recursive target `chown -R` stays strict. The helper cannot repair the main private rootfs; on the helper/`--read-only` path, parents are probed after start as the connection user (`test -x`). +**Filesystem:** Apple named-volume `volume.img` is raw ext4 (magic `0xEF53` at offset 1080) and can be `e2fsck`'d in place only while unmounted; do not copy the sparse image. `Structure needs cleaning` on chown is ext4 `EUCLEAN`, not a chown-logic bug. + Create `-u` for a non-root connection user (typical official image: OCI USER root + metadata `remoteUser` vscode) stays; ownership exec stays `-u root`. Contract: [`specs/core.md`](../../specs/core.md) (workspace parents + read-only helper). diff --git a/wiki/domain/devcontainer-apple-gaps.md b/wiki/domain/devcontainer-apple-gaps.md index 42bea85..16698ed 100644 --- a/wiki/domain/devcontainer-apple-gaps.md +++ b/wiki/domain/devcontainer-apple-gaps.md @@ -19,7 +19,7 @@ Facts that constrain the CLI. Not a full Apple container manual — only gaps th | Default process | Official CLI: Feature `entrypoint` wrap then keep-alive sleep | Main create: sleep-only when Feature `entrypoint` list is empty; else wrap then `exec /bin/sleep infinity`. Image `ENTRYPOINT`/`CMD` discarded; helpers sleep-only. Detail: [cli-runtime-boundary](../conventions/cli-runtime-boundary.md) | | Create identity | Name vs id often distinct; official CLI uses labels + a random Docker name | `create --name` **is** the id (`configuration.id`) and a DNS hostname — sanitized `name` ≤63, no `adev-`/hash. `${devcontainerId}` / `*-ws` / Features tags use workspace/repo basename + hash, never config `name`. Occupancy on collision. See [Identity workaround](#identity-workaround) | | Bind mounts | File or directory host source OK | **Directory sources only** — file binds rejected by runtime | -| Workspace I/O | Often bind or named volume | **Bind:** host APFS via virtiofs. **Named volume:** `volume.img` ext4 via virtio-blk — better metadata I/O; rationale for `clone` volume-mode | +| Workspace I/O | Often bind or named volume | **Bind:** host APFS via virtiofs. **Named volume:** `volume.img` raw ext4 via virtio-blk — better metadata I/O; rationale for `clone` volume-mode. Repair and `EUCLEAN`: [below](#named-volume-ownership-rootroot) | | Named volume copy-up | Docker/Podman copy preexisting image content at the mount target into a new empty named volume | Apple mounts the new volume empty. Fresh bind-mode `up` emulates copy-up only for config volumes created by that attempt, through a same-image helper before the main container and its in-container lifecycle hooks. Existing volumes are never overwritten; a newly created volume targeting `/` is rejected. Detail: [cli-runtime-boundary](../conventions/cli-runtime-boundary.md#image-copy-up-for-new-config-volumes) | | Named volume ownership | Engine/user mapping varies; often usable by non-root | Apple named volumes mount **root:root**. `WorkspaceOwnership` chowns eligible volumes in the main container, or through a scoped helper when `--read-only` (rootfs, not volume `:ro`) or dropped `CAP_CHOWN` selects it. Unmodifiable but traversable ancestors (Apple EPERM on `/workspaces` even as root) do not fail `up`. Bind targets remain untouched — see [Named volume ownership](#named-volume-ownership-rootroot) | | Host→guest copy | `docker cp` into bind or volume | **`container cp` into a named-volume mount can exit 0 without writing files** (silent no-op). Rootfs paths may still accept `cp`. Product clone populate avoids host→guest copy entirely (in-container `git clone`); tar-pipe utility remains if ever needed — see below | @@ -30,7 +30,7 @@ Facts that constrain the CLI. Not a full Apple container manual — only gaps th | | Bind (`up`) | Volume (`clone`) | |--|-------------|------------------| -| Storage | Host directory (virtiofs → APFS) | Named volume `adev-*-ws` (virtio-blk → ext4 in `volume.img`) | +| Storage | Host directory (virtiofs → APFS) | Named volume `adev-*-ws` (virtio-blk → raw ext4 `volume.img`) | | Identity hash | workspace path + config path | normalized git URL + config relpath | | `local_folder` label | real host path | `volume://…` | | Start hooks | real start: host `initializeCommand` (if host path) + `postStart` + feature remelt + CLI-attach postAttach | same; volume start without host path skips initialize + warns | @@ -52,6 +52,8 @@ Apple named volumes present as **root:root** at the mount target. Without a prod **Product:** `WorkspaceOwnership` chowns in the main container after start when `CAP_CHOWN` is available and `runArgs` has no `--read-only`. `--read-only` is a rootfs flag (not volume `:ro`) and selects the same pre-start scoped helper as dropped `CAP_CHOWN`; the helper does not inherit `--read-only` and still chowns writable named volumes. Some Apple rootfs inodes (`/workspaces`) reject `chown` with EPERM even as root; unmodifiable but traversable ancestors do not fail `up`. Recursive target `chown -R` stays strict. The helper cannot repair the main private rootfs; on the helper path, parents are probed as the connection user. Create `-u` for metadata `remoteUser` (typical OCI USER root + metadata vscode) stays; ownership exec stays `-u root`. Real Apple-runtime validation of the ownership helper path remains pending. Detail: [cli-runtime-boundary — Named volumes / ownership](../conventions/cli-runtime-boundary.md#named-volumes-ensure--reuse--ownership). Contract: [`specs/core.md`](../../specs/core.md). +Apple named-volume `volume.img` is raw ext4 (magic `0xEF53` at offset 1080) and can be `e2fsck`'d in place only while unmounted; do not copy the sparse image. `Structure needs cleaning` on chown is ext4 `EUCLEAN`, not a chown-logic bug. + ### File bind mounts Apple `container` rejects bind mounts whose host source is a **file** (source must be a directory). File-path binds in `devcontainer.json` (e.g. `~/.kube/config`) must be promoted to the parent directory on both host and container sides before create. Product behavior: [cli-runtime-boundary.md](../conventions/cli-runtime-boundary.md) (`MountNormalizer`). From 07d2478dffcc8a201d8184f1a3accee75d2df358 Mon Sep 17 00:00:00 2001 From: Wyller Gomes Date: Thu, 1 Oct 2026 21:34:53 -0300 Subject: [PATCH 3/3] docs: split start, stop, and inspect in the Commands table Those three verbs shared one cell, so their flags were not readable. Each command now has its own row. --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bee424e..643d8be 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,9 @@ The `start`, `exec`, `stop`, `delete`, `purge`, `rebuild`, and `inspect` command | `adevcontainer clone [--vscode] [--resume ]` | Clone a repository into a named volume and start its dev container | | `adevcontainer exec [-it] [--name ] [--] [cmd…]` | Open a shell or run a command in a running managed container | | `adevcontainer list [--json]` | List managed dev containers | -| `adevcontainer start [--name ] [--vscode] [--json] \| stop \| inspect [--name ]` | Manage a container by name or with the interactive picker | +| `adevcontainer start [--name ] [--vscode] [--json]` | Start a container by name or with the interactive picker | +| `adevcontainer stop [--name ]` | Stop a container by name or with the interactive picker | +| `adevcontainer inspect [--name ]` | Inspect a container by name or with the interactive picker | | `adevcontainer delete [--name ]` | Remove a container, keeping its managed volumes | | `adevcontainer purge [--name ]` | Remove a container, its managed volumes, and the image | | `adevcontainer rebuild [--name ] [--vscode]` | Rebuild a managed container from its current configuration |