diff --git a/Package.resolved b/Package.resolved index d1aabdfd4..18b1a5443 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "040dd2e2c8649defb737f900e4032d270935f0af91ebf4a87d64391ecd4ea40b", + "originHash" : "ff3186365938b6d99b7cae1f7e22da26aea8b8e1a4b2356d650eeb3cb2b4a6ad", "pins" : [ { "identity" : "async-http-client", @@ -22,7 +22,7 @@ { "identity" : "dns", "kind" : "remoteSourceControl", - "location" : "https://github.com/Bouke/DNS.git", + "location" : "https://github.com/Bouke/DNS", "state" : { "revision" : "78bbd1589890a90b202d11d5f9e1297050cf0eb2", "version" : "1.2.0" @@ -31,7 +31,7 @@ { "identity" : "dnsclient", "kind" : "remoteSourceControl", - "location" : "https://github.com/orlandos-nl/DNSClient.git", + "location" : "https://github.com/orlandos-nl/DNSClient", "state" : { "revision" : "551fbddbf4fa728d4cd86f6a5208fe4f925f0549", "version" : "2.4.4" @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { - "revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3", - "version" : "1.6.1" + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" } }, { @@ -159,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", - "version" : "1.6.4" + "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523", + "version" : "1.10.1" } }, { @@ -222,8 +222,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "102a647b573f60f73afdce5613a51d71349fe507", - "version" : "1.30.0" + "revision" : "a008af1a102ff3dd6cc3764bb69bf63226d0f5f6", + "version" : "1.36.1" } }, { @@ -240,8 +240,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system.git", "state" : { - "revision" : "890830fff1a577dc83134890c7984020c5f6b43b", - "version" : "1.6.2" + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" } }, { diff --git a/Sources/ContainerCommands/Application.swift b/Sources/ContainerCommands/Application.swift index acf311c39..69d8fc0c0 100644 --- a/Sources/ContainerCommands/Application.swift +++ b/Sources/ContainerCommands/Application.swift @@ -51,6 +51,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..9f38d0dd9 --- /dev/null +++ b/Sources/ContainerCommands/Container/ContainerCopy.swift @@ -0,0 +1,122 @@ +//===----------------------------------------------------------------------===// +// 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) + let dstWithBasename = destURL.appendingPathComponent(srcURL.lastPathComponent) + do { + try await client.copyIn(id: id, source: srcURL, destination: dstWithBasename, createParents: false) + } catch { + if isDirectory.boolValue { + try await client.copyIn(id: id, source: srcURL, destination: destURL) + } else if path.hasSuffix("/") { + throw ContainerizationError( + .invalidArgument, + message: "destination is not a directory: \(path)") + } else { + try await client.copyIn(id: id, source: srcURL, destination: destURL) + } + } + 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/Helpers/APIServer/APIServer+Start.swift b/Sources/Helpers/APIServer/APIServer+Start.swift index b4df3b09f..6272c27ff 100644 --- a/Sources/Helpers/APIServer/APIServer+Start.swift +++ b/Sources/Helpers/APIServer/APIServer+Start.swift @@ -286,6 +286,8 @@ extension APIServer { routes[XPCRoute.containerKill] = harness.kill routes[XPCRoute.containerStats] = harness.stats routes[XPCRoute.containerDiskUsage] = harness.diskUsage + routes[XPCRoute.containerCopyIn] = harness.copyIn + routes[XPCRoute.containerCopyOut] = harness.copyOut routes[XPCRoute.containerExport] = harness.export return service diff --git a/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper+Start.swift b/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper+Start.swift index 9031359a7..1677dfda8 100644 --- a/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper+Start.swift +++ b/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper+Start.swift @@ -104,6 +104,8 @@ extension RuntimeLinuxHelper { SandboxRoutes.dial.rawValue: server.dial, SandboxRoutes.shutdown.rawValue: server.shutdown, SandboxRoutes.statistics.rawValue: server.statistics, + SandboxRoutes.copyIn.rawValue: server.copyIn, + SandboxRoutes.copyOut.rawValue: server.copyOut, ], log: log ) diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift index ec50d91c0..bd52bcae1 100644 --- a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -299,6 +299,45 @@ 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) + request.set(key: .id, value: id) + request.set(key: .sourcePath, value: source.path) + request.set(key: .destinationPath, value: destination.path) + 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 495a227fd..24eee2085 100644 --- a/Sources/Services/ContainerAPIService/Client/XPC+.swift +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -132,6 +132,12 @@ public enum XPCKeys: String { /// Disk usage case diskUsageStats + + /// Copy parameters + case sourcePath + case destinationPath + case fileMode + case createParents } public enum XPCRoute: String { @@ -151,6 +157,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 c315c3e08..be8471549 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift @@ -290,6 +290,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 3c8247770..34f5effd0 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -776,6 +776,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 6f1cdd2d8..42aada3df 100644 --- a/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift +++ b/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift @@ -277,6 +277,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 e207cb049..fad86eab8 100644 --- a/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift +++ b/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift @@ -43,6 +43,11 @@ public enum SandboxKeys: String { /// Container statistics case statistics + /// Copy parameters + case sourcePath + case destinationPath + case fileMode + case createParents /// Network resource keys. case allocatedAttachments case networkAdditionalData 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 db8acd03c..47038e689 100644 --- a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift @@ -632,6 +632,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..2d56b7bf2 --- /dev/null +++ b/Tests/CLITests/Subcommands/Containers/TestCLICopy.swift @@ -0,0 +1,338 @@ +//===----------------------------------------------------------------------===// +// 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 3f9b6fcff..f41e0ed77 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -431,6 +431,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.