diff --git a/Package.resolved b/Package.resolved index 57654d501..faba9d8b6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "22a1e1b6903f45f7d9b7dc7940fd328f427c4123c73d6985eab99ae6c21523c4", + "originHash" : "33002bd04671ab82772f00d444616e9876ce990001e53950f58ea42fa332e493", "pins" : [ { "identity" : "async-http-client", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/containerization.git", "state" : { - "revision" : "f8a18e8dcd965eefcfe65ead986405533c7e3872", - "version" : "0.32.1" + "revision" : "db5b5b98405d53543f69105087130ffd623a5b9a", + "version" : "0.32.2" } }, { diff --git a/Package.swift b/Package.swift index 4323fcfef..5135fc7e5 100644 --- a/Package.swift +++ b/Package.swift @@ -23,7 +23,7 @@ import PackageDescription let releaseVersion = ProcessInfo.processInfo.environment["RELEASE_VERSION"] ?? "0.0.0" let gitCommit = ProcessInfo.processInfo.environment["GIT_COMMIT"] ?? "unspecified" let builderShimVersion = "0.12.0" -let scVersion = "0.32.1" +let scVersion = "0.32.2" let package = Package( name: "container", diff --git a/Sources/APIServer/APIServer+Start.swift b/Sources/APIServer/APIServer+Start.swift index 3bdb7c8d1..527839153 100644 --- a/Sources/APIServer/APIServer+Start.swift +++ b/Sources/APIServer/APIServer+Start.swift @@ -294,6 +294,8 @@ extension APIServer { routes[XPCRoute.containerKill] = XPCServer.route(harness.kill) routes[XPCRoute.containerStats] = XPCServer.route(harness.stats) routes[XPCRoute.containerDiskUsage] = XPCServer.route(harness.diskUsage) + routes[XPCRoute.containerCopyIn] = XPCServer.route(harness.copyIn) + routes[XPCRoute.containerCopyOut] = XPCServer.route(harness.copyOut) routes[XPCRoute.containerExport] = XPCServer.route(harness.export) return service diff --git a/Sources/ContainerCommands/Application.swift b/Sources/ContainerCommands/Application.swift index 3e43c05bc..55e40ae58 100644 --- a/Sources/ContainerCommands/Application.swift +++ b/Sources/ContainerCommands/Application.swift @@ -52,6 +52,7 @@ public struct Application: AsyncLoggableCommand { CommandGroup( name: "Container", subcommands: [ + ContainerCopy.self, ContainerCreate.self, ContainerDelete.self, ContainerExec.self, diff --git a/Sources/ContainerCommands/Container/ContainerCopy.swift b/Sources/ContainerCommands/Container/ContainerCopy.swift new file mode 100644 index 000000000..8778b74d9 --- /dev/null +++ b/Sources/ContainerCommands/Container/ContainerCopy.swift @@ -0,0 +1,109 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ArgumentParser +import ContainerAPIClient +import ContainerResource +import Containerization +import ContainerizationError +import Foundation + +extension Application { + public struct ContainerCopy: AsyncLoggableCommand { + enum PathRef { + case local(String) + case container(id: String, path: String) + } + + static func parsePathRef(_ ref: String) throws -> PathRef { + let parts = ref.components(separatedBy: ":") + switch parts.count { + case 1: + return .local(ref) + case 2 where !parts[0].isEmpty && parts[1].starts(with: "/"): + return .container(id: parts[0], path: parts[1]) + default: + throw ContainerizationError(.invalidArgument, message: "invalid path given: \(ref)") + } + } + + public init() {} + + public static let configuration = CommandConfiguration( + commandName: "copy", + abstract: "Copy files/folders between a container and the local filesystem", + aliases: ["cp"]) + + @OptionGroup() + public var logOptions: Flags.Logging + + @Argument(help: "Source path (container:path or local path)") + var source: String + + @Argument(help: "Destination path (container:path or local path)") + var destination: String + + public func run() async throws { + let client = ContainerClient() + let srcRef = try Self.parsePathRef(source) + let dstRef = try Self.parsePathRef(destination) + + switch (srcRef, dstRef) { + case (.container(let id, let path), .local(let localPath)): + let srcURL = URL(fileURLWithPath: path) + let destURL = URL(fileURLWithPath: localPath).standardizedFileURL + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: destURL.path, isDirectory: &isDirectory) + + if exists && isDirectory.boolValue { + let finalDest = destURL.appendingPathComponent(srcURL.lastPathComponent) + try await client.copyOut(id: id, source: srcURL, destination: finalDest) + } else if localPath.hasSuffix("/") { + try await client.copyOut(id: id, source: srcURL, destination: destURL) + var resultIsDir: ObjCBool = false + if FileManager.default.fileExists(atPath: destURL.path, isDirectory: &resultIsDir), + !resultIsDir.boolValue + { + try? FileManager.default.removeItem(at: destURL) + throw ContainerizationError( + .invalidArgument, + message: "destination is not a directory: \(localPath)") + } + } else { + try await client.copyOut(id: id, source: srcURL, destination: destURL) + } + case (.local(let localPath), .container(let id, let path)): + let srcURL = URL(fileURLWithPath: localPath).standardizedFileURL + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: srcURL.path, isDirectory: &isDirectory) else { + throw ContainerizationError(.notFound, message: "source path does not exist: \(localPath)") + } + if localPath.hasSuffix("/") && !isDirectory.boolValue { + throw ContainerizationError(.invalidArgument, message: "source path is not a directory: \(localPath)") + } + + let destURL = URL(fileURLWithPath: path) + try await client.copyIn(id: id, source: srcURL, destination: destURL, createParents: true) + case (.container, .container): + throw ContainerizationError(.invalidArgument, message: "copying between containers is not supported") + case (.local, .local): + throw ContainerizationError( + .invalidArgument, + message: "one of source or destination must be a container reference (container_id:path)") + } + } + } +} diff --git a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift index f946ab0e1..c79808c0c 100644 --- a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift +++ b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift @@ -104,6 +104,8 @@ extension RuntimeLinuxHelper { SandboxRoutes.dial.rawValue: XPCServer.route(server.dial), SandboxRoutes.shutdown.rawValue: XPCServer.route(server.shutdown), SandboxRoutes.statistics.rawValue: XPCServer.route(server.statistics), + SandboxRoutes.copyIn.rawValue: XPCServer.route(server.copyIn), + SandboxRoutes.copyOut.rawValue: XPCServer.route(server.copyOut), ], log: log ) diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift index c108ef3d2..838d86bd5 100644 --- a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -311,6 +311,49 @@ public struct ContainerClient: Sendable { return fh } + /// Copy a file or directory from the host into the container. + public func copyIn(id: String, source: URL, destination: URL, mode: UInt32 = 0o644, createParents: Bool = true) async throws { + let request = XPCMessage(route: .containerCopyIn) + let destinationPath = + destination.hasDirectoryPath && !destination.path.hasSuffix("/") + ? "\(destination.path)/" + : destination.path + request.set(key: .id, value: id) + request.set(key: .sourcePath, value: source.path) + request.set(key: .destinationPath, value: destinationPath) + request.set(key: .fileMode, value: UInt64(mode)) + request.set(key: .createParents, value: createParents) + + do { + try await xpcSend(message: request, timeout: .seconds(300)) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to copy into container \(id)", + cause: error + ) + } + } + + /// Copy a file or directory from the container to the host. + public func copyOut(id: String, source: URL, destination: URL, createParents: Bool = true) async throws { + let request = XPCMessage(route: .containerCopyOut) + request.set(key: .id, value: id) + request.set(key: .sourcePath, value: source.path) + request.set(key: .destinationPath, value: destination.path) + request.set(key: .createParents, value: createParents) + + do { + try await xpcSend(message: request, timeout: .seconds(300)) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to copy from container \(id)", + cause: error + ) + } + } + /// Get resource usage statistics for a container. public func stats(id: String) async throws -> ContainerStats { let request = XPCMessage(route: .containerStats) diff --git a/Sources/Services/ContainerAPIService/Client/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift index 952e1a4c7..4a6f6f42c 100644 --- a/Sources/Services/ContainerAPIService/Client/XPC+.swift +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -141,6 +141,12 @@ public enum XPCKeys: String { /// Disk usage case diskUsageStats + + /// Copy parameters + case sourcePath + case destinationPath + case fileMode + case createParents } public enum XPCRoute: String { @@ -160,6 +166,8 @@ public enum XPCRoute: String { case containerEvent case containerStats case containerDiskUsage + case containerCopyIn + case containerCopyOut case containerExport case pluginLoad diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift index ad76412f2..d7da46e3d 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift @@ -295,6 +295,60 @@ public struct ContainersHarness: Sendable { return reply } + @Sendable + public func copyIn(_ message: XPCMessage) async throws -> XPCMessage { + guard let id = message.string(key: .id) else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + guard let sourcePath = message.string(key: .sourcePath) else { + throw ContainerizationError( + .invalidArgument, + message: "source path cannot be empty" + ) + } + guard let destinationPath = message.string(key: .destinationPath) else { + throw ContainerizationError( + .invalidArgument, + message: "destination path cannot be empty" + ) + } + let mode = UInt32(message.uint64(key: .fileMode)) + let createParents = message.bool(key: .createParents) + + try await service.copyIn(id: id, source: sourcePath, destination: destinationPath, mode: mode, createParents: createParents) + return message.reply() + } + + @Sendable + public func copyOut(_ message: XPCMessage) async throws -> XPCMessage { + guard let id = message.string(key: .id) else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + guard let sourcePath = message.string(key: .sourcePath) else { + throw ContainerizationError( + .invalidArgument, + message: "source path cannot be empty" + ) + } + guard let destinationPath = message.string(key: .destinationPath) else { + throw ContainerizationError( + .invalidArgument, + message: "destination path cannot be empty" + ) + } + + let createParents = message.bool(key: .createParents) + + try await service.copyOut(id: id, source: sourcePath, destination: destinationPath, createParents: createParents) + return message.reply() + } + @Sendable public func stats(_ message: XPCMessage) async throws -> XPCMessage { let id = message.string(key: .id) diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index 8bcf8095f..12f45c4dc 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -763,6 +763,30 @@ public actor ContainersService { } } + /// Copy a file or directory from the host into the container. + public func copyIn(id: String, source: String, destination: String, mode: UInt32, createParents: Bool = true) async throws { + self.log.debug("\(#function)") + + let state = try self._getContainerState(id: id) + guard state.snapshot.status == .running else { + throw ContainerizationError(.invalidState, message: "container \(id) is not running") + } + let client = try state.getClient() + try await client.copyIn(source: source, destination: destination, mode: mode, createParents: createParents) + } + + /// Copy a file or directory from the container to the host. + public func copyOut(id: String, source: String, destination: String, createParents: Bool = true) async throws { + self.log.debug("\(#function)") + + let state = try self._getContainerState(id: id) + guard state.snapshot.status == .running else { + throw ContainerizationError(.invalidState, message: "container \(id) is not running") + } + let client = try state.getClient() + try await client.copyOut(source: source, destination: destination, createParents: createParents) + } + /// Get statistics for the container. public func stats(id: String) async throws -> ContainerStats { log.debug( diff --git a/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift b/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift index 23cbc946c..d3b80fa22 100644 --- a/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift +++ b/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift @@ -284,6 +284,41 @@ extension SandboxClient { } } + public func copyIn(source: String, destination: String, mode: UInt32, createParents: Bool = true) async throws { + let request = XPCMessage(route: SandboxRoutes.copyIn.rawValue) + request.set(key: SandboxKeys.sourcePath.rawValue, value: source) + request.set(key: SandboxKeys.destinationPath.rawValue, value: destination) + request.set(key: SandboxKeys.fileMode.rawValue, value: UInt64(mode)) + request.set(key: SandboxKeys.createParents.rawValue, value: createParents) + + do { + try await self.client.send(request, responseTimeout: .seconds(300)) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to copy into container \(self.id)", + cause: error + ) + } + } + + public func copyOut(source: String, destination: String, createParents: Bool = true) async throws { + let request = XPCMessage(route: SandboxRoutes.copyOut.rawValue) + request.set(key: SandboxKeys.sourcePath.rawValue, value: source) + request.set(key: SandboxKeys.destinationPath.rawValue, value: destination) + request.set(key: SandboxKeys.createParents.rawValue, value: createParents) + + do { + try await self.client.send(request, responseTimeout: .seconds(300)) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to copy from container \(self.id)", + cause: error + ) + } + } + public func statistics() async throws -> ContainerStats { let request = XPCMessage(route: SandboxRoutes.statistics.rawValue) diff --git a/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift b/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift index ef9719af7..07eb04f60 100644 --- a/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift +++ b/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift @@ -43,6 +43,12 @@ public enum SandboxKeys: String { /// Container statistics case statistics + /// Copy parameters + case sourcePath + case destinationPath + case fileMode + case createParents + /// Special-case environment variables recomputed on each container start case dynamicEnv diff --git a/Sources/Services/ContainerSandboxService/Client/SandboxRoutes.swift b/Sources/Services/ContainerSandboxService/Client/SandboxRoutes.swift index 79af080f9..b0da30f54 100644 --- a/Sources/Services/ContainerSandboxService/Client/SandboxRoutes.swift +++ b/Sources/Services/ContainerSandboxService/Client/SandboxRoutes.swift @@ -41,4 +41,8 @@ public enum SandboxRoutes: String { case shutdown = "com.apple.container.sandbox/shutdown" /// Get statistics for the sandbox. case statistics = "com.apple.container.sandbox/statistics" + /// Copy a file or directory into the container. + case copyIn = "com.apple.container.sandbox/copyIn" + /// Copy a file or directory out of the container. + case copyOut = "com.apple.container.sandbox/copyOut" } diff --git a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift index e65b09a45..40826b92c 100644 --- a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift @@ -668,6 +668,96 @@ public actor SandboxService { return reply } + /// Copy a file or directory from the host into the container. + /// + /// - Parameters: + /// - message: An XPC message with the following parameters: + /// - sourcePath: The host path to copy from. + /// - destinationPath: The container path to copy to. + /// - fileMode: The file permissions mode (UInt64). + /// + /// - Returns: An XPC message with no parameters. + @Sendable + public func copyIn(_ message: XPCMessage) async throws -> XPCMessage { + self.log.info("`copyIn` xpc handler") + switch self.state { + case .running, .booted: + guard let source = message.string(key: SandboxKeys.sourcePath.rawValue) else { + throw ContainerizationError( + .invalidArgument, + message: "no source path supplied for copyIn" + ) + } + guard let destination = message.string(key: SandboxKeys.destinationPath.rawValue) else { + throw ContainerizationError( + .invalidArgument, + message: "no destination path supplied for copyIn" + ) + } + let mode = UInt32(message.uint64(key: SandboxKeys.fileMode.rawValue)) + let createParents = message.bool(key: SandboxKeys.createParents.rawValue) + + let ctr = try getContainer() + try await ctr.container.copyIn( + from: URL(fileURLWithPath: source), + to: URL(fileURLWithPath: destination), + mode: mode, + createParents: createParents + ) + + return message.reply() + default: + throw ContainerizationError( + .invalidState, + message: "cannot copyIn: container is not running" + ) + } + } + + /// Copy a file or directory from the container to the host. + /// + /// - Parameters: + /// - message: An XPC message with the following parameters: + /// - sourcePath: The container path to copy from. + /// - destinationPath: The host path to copy to. + /// + /// - Returns: An XPC message with no parameters. + @Sendable + public func copyOut(_ message: XPCMessage) async throws -> XPCMessage { + self.log.info("`copyOut` xpc handler") + switch self.state { + case .running, .booted: + guard let source = message.string(key: SandboxKeys.sourcePath.rawValue) else { + throw ContainerizationError( + .invalidArgument, + message: "no source path supplied for copyOut" + ) + } + guard let destination = message.string(key: SandboxKeys.destinationPath.rawValue) else { + throw ContainerizationError( + .invalidArgument, + message: "no destination path supplied for copyOut" + ) + } + + let createParents = message.bool(key: SandboxKeys.createParents.rawValue) + + let ctr = try getContainer() + try await ctr.container.copyOut( + from: URL(fileURLWithPath: source), + to: URL(fileURLWithPath: destination), + createParents: createParents + ) + + return message.reply() + default: + throw ContainerizationError( + .invalidState, + message: "cannot copyOut: container is not running" + ) + } + } + /// Dial a vsock port on the virtual machine. /// /// - Parameters: diff --git a/Tests/CLITests/Subcommands/Containers/TestCLICopy.swift b/Tests/CLITests/Subcommands/Containers/TestCLICopy.swift new file mode 100644 index 000000000..0deab7b9f --- /dev/null +++ b/Tests/CLITests/Subcommands/Containers/TestCLICopy.swift @@ -0,0 +1,339 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerizationExtras +import Foundation +import Testing + +class TestCLICopyCommand: CLITest { + private func getTestName() -> String { + Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() + } + + @Test func testCopyHostToContainer() throws { + do { + let name = getTestName() + try doCreate(name: name) + defer { + try? doStop(name: name) + } + try doStart(name: name) + try waitForContainerRunning(name) + + let tempFile = testDir.appendingPathComponent("testfile.txt") + let content = "hello from host" + try content.write(to: tempFile, atomically: true, encoding: .utf8) + + let (_, _, error, status) = try run(arguments: [ + "copy", + tempFile.path, + "\(name):/tmp/", + ]) + if status != 0 { + throw CLIError.executionFailed("copy failed: \(error)") + } + + let catOutput = try doExec(name: name, cmd: ["cat", "/tmp/testfile.txt"]) + #expect( + catOutput.trimmingCharacters(in: .whitespacesAndNewlines) == content, + "expected file content to be '\(content)', got '\(catOutput.trimmingCharacters(in: .whitespacesAndNewlines))'" + ) + + try doStop(name: name) + } catch { + Issue.record("failed to copy file from host to container: \(error)") + return + } + } + + @Test func testCopyContainerToHost() throws { + do { + let name = getTestName() + try doCreate(name: name) + defer { + try? doStop(name: name) + } + try doStart(name: name) + try waitForContainerRunning(name) + + let content = "hello from container" + _ = try doExec(name: name, cmd: ["sh", "-c", "echo -n '\(content)' > /tmp/containerfile.txt"]) + + let destPath = testDir.appendingPathComponent("containerfile.txt") + let (_, _, error, status) = try run(arguments: [ + "copy", + "\(name):/tmp/containerfile.txt", + destPath.path, + ]) + if status != 0 { + throw CLIError.executionFailed("copy failed: \(error)") + } + + let hostContent = try String(contentsOfFile: destPath.path, encoding: .utf8) + #expect( + hostContent == content, + "expected file content to be '\(content)', got '\(hostContent)'" + ) + + try doStop(name: name) + } catch { + Issue.record("failed to copy file from container to host: \(error)") + return + } + } + + @Test func testCopyUsingCpAlias() throws { + do { + let name = getTestName() + try doCreate(name: name) + defer { + try? doStop(name: name) + } + try doStart(name: name) + try waitForContainerRunning(name) + + let tempFile = testDir.appendingPathComponent("aliasfile.txt") + let content = "testing cp alias" + try content.write(to: tempFile, atomically: true, encoding: .utf8) + + let (_, _, error, status) = try run(arguments: [ + "cp", + tempFile.path, + "\(name):/tmp/", + ]) + if status != 0 { + throw CLIError.executionFailed("cp alias failed: \(error)") + } + + let catOutput = try doExec(name: name, cmd: ["cat", "/tmp/aliasfile.txt"]) + #expect( + catOutput.trimmingCharacters(in: .whitespacesAndNewlines) == content, + "expected file content to be '\(content)', got '\(catOutput.trimmingCharacters(in: .whitespacesAndNewlines))'" + ) + + try doStop(name: name) + } catch { + Issue.record("failed to copy file using cp alias: \(error)") + return + } + } + + @Test func testCopyLocalToLocalFails() throws { + let (_, _, _, status) = try run(arguments: [ + "copy", + "/tmp/source.txt", + "/tmp/dest.txt", + ]) + #expect(status != 0, "expected local-to-local copy to fail") + } + + @Test func testCopyContainerToContainerFails() throws { + do { + let name = getTestName() + try doCreate(name: name) + defer { + try? doStop(name: name) + } + + let (_, _, _, status) = try run(arguments: [ + "copy", + "\(name):/tmp/file.txt", + "\(name):/tmp/file2.txt", + ]) + #expect(status != 0, "expected container-to-container copy to fail") + } catch { + Issue.record("failed test for container-to-container copy: \(error)") + return + } + } + + @Test func testCopyToNonRunningContainerFails() throws { + do { + let name = getTestName() + try doCreate(name: name) + defer { + try? doStop(name: name) + } + + let tempFile = testDir.appendingPathComponent("norun.txt") + try "test".write(to: tempFile, atomically: true, encoding: .utf8) + + let (_, _, _, status) = try run(arguments: [ + "copy", + tempFile.path, + "\(name):/tmp/", + ]) + #expect(status != 0, "expected copy to non-running container to fail") + } catch { + Issue.record("failed test for copy to non-running container: \(error)") + return + } + } + + @Test func testCopyDirectoryHostToContainer() throws { + do { + let name = getTestName() + try doCreate(name: name) + defer { + try? doStop(name: name) + } + try doStart(name: name) + try waitForContainerRunning(name) + + let srcDir = testDir.appendingPathComponent("hostdir") + try FileManager.default.createDirectory(at: srcDir, withIntermediateDirectories: true) + try "file1 content".write(to: srcDir.appendingPathComponent("file1.txt"), atomically: true, encoding: .utf8) + try "file2 content".write(to: srcDir.appendingPathComponent("file2.txt"), atomically: true, encoding: .utf8) + + let (_, _, error, status) = try run(arguments: [ + "copy", + srcDir.path, + "\(name):/tmp/", + ]) + if status != 0 { + throw CLIError.executionFailed("copy directory failed: \(error)") + } + + let cat1 = try doExec(name: name, cmd: ["cat", "/tmp/hostdir/file1.txt"]) + #expect( + cat1.trimmingCharacters(in: .whitespacesAndNewlines) == "file1 content", + "expected file1 content, got '\(cat1.trimmingCharacters(in: .whitespacesAndNewlines))'" + ) + let cat2 = try doExec(name: name, cmd: ["cat", "/tmp/hostdir/file2.txt"]) + #expect( + cat2.trimmingCharacters(in: .whitespacesAndNewlines) == "file2 content", + "expected file2 content, got '\(cat2.trimmingCharacters(in: .whitespacesAndNewlines))'" + ) + + try doStop(name: name) + } catch { + Issue.record("failed to copy directory from host to container: \(error)") + return + } + } + + @Test func testCopyDirectoryContainerToHost() throws { + do { + let name = getTestName() + try doCreate(name: name) + defer { + try? doStop(name: name) + } + try doStart(name: name) + try waitForContainerRunning(name) + + _ = try doExec(name: name, cmd: ["sh", "-c", "mkdir -p /tmp/guestdir && echo -n 'aaa' > /tmp/guestdir/a.txt && echo -n 'bbb' > /tmp/guestdir/b.txt"]) + + let destPath = testDir.appendingPathComponent("guestdir") + let (_, _, error, status) = try run(arguments: [ + "copy", + "\(name):/tmp/guestdir", + destPath.path, + ]) + if status != 0 { + throw CLIError.executionFailed("copy directory failed: \(error)") + } + + let contentA = try String(contentsOfFile: destPath.appendingPathComponent("a.txt").path, encoding: .utf8) + #expect(contentA == "aaa", "expected 'aaa', got '\(contentA)'") + let contentB = try String(contentsOfFile: destPath.appendingPathComponent("b.txt").path, encoding: .utf8) + #expect(contentB == "bbb", "expected 'bbb', got '\(contentB)'") + + try doStop(name: name) + } catch { + Issue.record("failed to copy directory from container to host: \(error)") + return + } + } + + @Test func testCopyNestedDirectoryHostToContainer() throws { + do { + let name = getTestName() + try doCreate(name: name) + defer { + try? doStop(name: name) + } + try doStart(name: name) + try waitForContainerRunning(name) + + let srcDir = testDir.appendingPathComponent("nested") + let subDir = srcDir.appendingPathComponent("sub") + try FileManager.default.createDirectory(at: subDir, withIntermediateDirectories: true) + try "root file".write(to: srcDir.appendingPathComponent("root.txt"), atomically: true, encoding: .utf8) + try "nested file".write(to: subDir.appendingPathComponent("deep.txt"), atomically: true, encoding: .utf8) + + let (_, _, error, status) = try run(arguments: [ + "copy", + srcDir.path, + "\(name):/tmp/", + ]) + if status != 0 { + throw CLIError.executionFailed("copy nested directory failed: \(error)") + } + + let catRoot = try doExec(name: name, cmd: ["cat", "/tmp/nested/root.txt"]) + #expect( + catRoot.trimmingCharacters(in: .whitespacesAndNewlines) == "root file", + "expected 'root file', got '\(catRoot.trimmingCharacters(in: .whitespacesAndNewlines))'" + ) + let catDeep = try doExec(name: name, cmd: ["cat", "/tmp/nested/sub/deep.txt"]) + #expect( + catDeep.trimmingCharacters(in: .whitespacesAndNewlines) == "nested file", + "expected 'nested file', got '\(catDeep.trimmingCharacters(in: .whitespacesAndNewlines))'" + ) + + try doStop(name: name) + } catch { + Issue.record("failed to copy nested directory from host to container: \(error)") + return + } + } + + @Test func testCopyNestedDirectoryContainerToHost() throws { + do { + let name = getTestName() + try doCreate(name: name) + defer { + try? doStop(name: name) + } + try doStart(name: name) + try waitForContainerRunning(name) + + _ = try doExec( + name: name, cmd: ["sh", "-c", "mkdir -p /tmp/nested/sub && echo -n 'root file' > /tmp/nested/root.txt && echo -n 'nested file' > /tmp/nested/sub/deep.txt"]) + + let destPath = testDir.appendingPathComponent("nested") + let (_, _, error, status) = try run(arguments: [ + "copy", + "\(name):/tmp/nested", + destPath.path, + ]) + if status != 0 { + throw CLIError.executionFailed("copy nested directory failed: \(error)") + } + + let contentRoot = try String(contentsOfFile: destPath.appendingPathComponent("root.txt").path, encoding: .utf8) + #expect(contentRoot == "root file", "expected 'root file', got '\(contentRoot)'") + let contentDeep = try String(contentsOfFile: destPath.appendingPathComponent("sub").appendingPathComponent("deep.txt").path, encoding: .utf8) + #expect(contentDeep == "nested file", "expected 'nested file', got '\(contentDeep)'") + + try doStop(name: name) + } catch { + Issue.record("failed to copy nested directory from container to host: \(error)") + return + } + } +} diff --git a/docs/command-reference.md b/docs/command-reference.md index de231de9b..c4efb0812 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -465,6 +465,39 @@ container stats --no-stream web container stats --format json --no-stream web ``` +### `container copy (cp)` + +Copies files between a container and the local filesystem. The container must be running. One of the source or destination must be a container reference in the form `container_id:path`. + +**Usage** + +```bash +container copy [--debug] +``` + +**Arguments** + +* ``: Source path (local path or `container_id:path`) +* ``: Destination path (local path or `container_id:path`) + +**Path Format** + +* Local path: `/path/to/file` or `relative/path` +* Container path: `container_id:/path/in/container` + +**Examples** + +```bash +# copy a file from host to container +container cp ./config.json mycontainer:/etc/app/ + +# copy a file from container to host +container cp mycontainer:/var/log/app.log ./logs/ + +# copy using the full command name +container copy ./data.txt mycontainer:/tmp/ +``` + ### `container prune` Removes stopped containers to reclaim disk space. The command outputs the amount of space freed after deletion.