diff --git a/Package.resolved b/Package.resolved index e934a06ea..c271caecf 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "12dd01b636ad85e70d70f21bafeba9939a6bcae045ec5a851d5e99729cfbe7eb", + "originHash" : "a823585b0904c85df871f25c3eddb487549c75a613b974e763013761fe5c95dd", "pins" : [ { "identity" : "async-http-client", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/containerization.git", "state" : { - "revision" : "d9868bb657fac3b55ed5dcec97c8eb8a08e78bf5", - "version" : "0.38.0" + "revision" : "7800b4642171561c95b5f55500b19e5dce5acd45", + "version" : "0.40.1" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", - "version" : "1.6.0" + "revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a", + "version" : "1.5.1" } }, { diff --git a/Package.swift b/Package.swift index c77d5e5a1..d46548cae 100644 --- a/Package.swift +++ b/Package.swift @@ -22,8 +22,8 @@ import PackageDescription let releaseVersion = ProcessInfo.processInfo.environment["RELEASE_VERSION"] ?? "0.0.0" let gitCommit = ProcessInfo.processInfo.environment["GIT_COMMIT"] ?? "unspecified" -let builderShimVersion = "0.13.0" -let scVersion = "0.38.0" +let builderShimVersion = "0.13.1" +let scVersion = "0.40.1" let package = Package( name: "container", @@ -143,6 +143,7 @@ let package = Package( .product(name: "Containerization", package: "containerization"), .product(name: "ContainerizationArchive", package: "containerization"), .product(name: "ContainerizationOCI", package: "containerization"), + .product(name: "ContainerizationOS", package: "containerization"), .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "GRPCCore", package: "grpc-swift-2"), .product(name: "GRPCNIOTransportHTTP2", package: "grpc-swift-nio-transport"), @@ -574,12 +575,18 @@ let package = Package( name: "ContainerTestSupport", dependencies: [ .product(name: "AsyncHTTPClient", package: "async-http-client"), + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationArchive", package: "containerization"), .product(name: "ContainerizationExtras", package: "containerization"), .product(name: "Logging", package: "swift-log"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOHTTP1", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), .product(name: "SystemPackage", package: "swift-system"), .product(name: "TOML", package: "swift-toml"), "ContainerLog", "ContainerPersistence", + "ContainerPlugin", "ContainerResource", ] ), diff --git a/Sources/ContainerBuild/BuildFSSync.swift b/Sources/ContainerBuild/BuildFSSync.swift index 7b4fc4400..c5a5288f2 100644 --- a/Sources/ContainerBuild/BuildFSSync.swift +++ b/Sources/ContainerBuild/BuildFSSync.swift @@ -22,18 +22,49 @@ import CryptoKit import Foundation import GRPCCore +/// Handles the `fssync` stage of the build protocol. +/// +/// When BuildKit needs build-context files it sends `Walk`, `Read`, and `Info` +/// requests to the shim, which proxies them over the gRPC stream to this actor. +/// +/// ## Primary path: Walk (tar mode) +/// +/// `Walk` is the primary data path. The host packs all requested context paths +/// into a tar archive and streams it to the shim. The shim unpacks the tar to a +/// local cache and presents the files to BuildKit via `DiffCopy`. BuildKit then +/// issues `PACKET_REQ` for regular files it needs; the shim serves those from +/// the local cache without any further calls to the host. +/// +/// When a context path is a symlink whose target lies within the context root, +/// ``walk(_:_:_:)`` adds the target to the archive alongside the symlink so +/// BuildKit can dereference it during `COPY`/`ADD` processing. +/// +/// ## Fallback path: Info + Read +/// +/// `FS.Open()` in the shim falls back to `Info` followed by `Read` calls when +/// its local checksum cache is unpopulated (a narrow race window at the start of +/// a build). These paths are not exercised during a normal build. +/// +/// ## Symlink safety +/// +/// The host enforces that no file served to the builder resolves to a path +/// outside the context root. If any component of a requested path is a symlink +/// whose target lies outside the context root the request is rejected. +/// Dockerignore filtering is **not** applied here; the shim applies it after +/// unpacking the tar. actor BuildFSSync: BuildPipelineHandler { let contextDir: URL init(_ contextDir: URL) throws { + let resolved = contextDir.resolvingSymlinksInPath() guard FileManager.default.fileExists(atPath: contextDir.cleanPath) else { throw Error.contextNotFound(contextDir.cleanPath) } - guard try contextDir.isDir() else { + guard resolved.isDirectory else { throw Error.contextIsNotDirectory(contextDir.cleanPath) } - self.contextDir = contextDir + self.contextDir = resolved } nonisolated func accept(_ packet: ServerStream) throws -> Bool { @@ -63,6 +94,11 @@ actor BuildFSSync: BuildPipelineHandler { } } + /// Serves the content of a single context file to the shim. + /// + /// Called only via the shim's `FS.Open()` fallback path, not during a + /// normal `Walk`-based build. Rejects any path whose symlink chain resolves + /// outside the context root. func read(_ sender: AsyncStream.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws { let offset: UInt64 = packet.offset() ?? 0 let size: Int = packet.len() ?? 0 @@ -79,6 +115,10 @@ actor BuildFSSync: BuildPipelineHandler { path = URL(filePath: self.contextDir.cleanPath) path.append(components: packet.source.cleanPathComponent) } + let resolved = path.resolvingSymlinksInPath() + guard self.contextDir.parentOf(resolved) else { + throw Error.pathIsNotChild(resolved.cleanPath, self.contextDir.cleanPath) + } let data = try { if try path.isDir() { return Data() @@ -95,6 +135,12 @@ actor BuildFSSync: BuildPipelineHandler { sender.yield(response) } + /// Returns metadata (mode, size, modification time, uid/gid) for a single + /// context path. + /// + /// Called only via the shim's `FS.Open()` fallback path, not during a + /// normal `Walk`-based build. Must reject paths that escape the context root + /// via symlinks for the same reasons as ``read(_:_:_:)``. func info(_ sender: AsyncStream.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws { let path: URL if packet.source.hasPrefix("/") { @@ -105,6 +151,10 @@ actor BuildFSSync: BuildPipelineHandler { .appendingPathComponent(packet.source) .standardizedFileURL } + let resolved = path.resolvingSymlinksInPath() + guard self.contextDir.parentOf(resolved) else { + throw Error.pathIsNotChild(resolved.cleanPath, self.contextDir.cleanPath) + } let transfer = try path.buildTransfer(id: packet.id, contextDir: self.contextDir, complete: true) var response = ClientStream() response.buildID = buildID @@ -127,6 +177,23 @@ actor BuildFSSync: BuildPipelineHandler { } } + /// Packs requested context paths into a tar archive and streams it to the shim. + /// + /// This is the primary data path for build-context transfer. BuildKit sends + /// a `Walk` request whose `followpaths` field names the context paths needed + /// for the current build step (e.g. the source of a `COPY` instruction). + /// The host resolves those globs, builds an entry set, and passes it to + /// `Archiver.compress` to produce the tar. + /// + /// For any symlink in the entry set whose target lies within the context + /// root, the target is added to the entry set so BuildKit can dereference + /// the symlink during `COPY`/`ADD` processing without a separate request. + /// Symlinks whose targets lie outside the context root are included as + /// symlink entries but their targets are not; BuildKit will resolve them + /// against the shim's local filesystem on Linux, not the macOS host. + /// + /// Dockerignore filtering is the shim's responsibility and is applied after + /// the tar is unpacked; this method has no knowledge of `.dockerignore`. func walk( _ sender: AsyncStream.Continuation, _ packet: BuildTransfer, @@ -334,16 +401,10 @@ actor BuildFSSync: BuildPipelineHandler { let target: String init(path: URL, contextDir: URL) throws { - if path.isSymlink { - let target: URL = path.resolvingSymlinksInPath() - if contextDir.parentOf(target) { - self.target = target.relativePathFrom(from: path) - } else { - self.target = target.cleanPath - } - } else { - self.target = "" - } + // Always report the literal, unresolved on-disk symlink target — + // the same value tar mode provides via Archiver's use of + // destinationOfSymbolicLink — rather than a host-resolved path. + self.target = path.isSymlink ? try FileManager.default.destinationOfSymbolicLink(atPath: path.cleanPath) : "" self.name = try path.relativeChildPath(to: contextDir) self.modTime = try path.modTime() diff --git a/Sources/ContainerBuild/BuildImageResolver.swift b/Sources/ContainerBuild/BuildImageResolver.swift index 77f5b49e9..5b15352d8 100644 --- a/Sources/ContainerBuild/BuildImageResolver.swift +++ b/Sources/ContainerBuild/BuildImageResolver.swift @@ -23,6 +23,13 @@ import GRPCCore import Logging import TerminalProgress +/// Handles the `resolver` stage of the build protocol. +/// +/// Resolves image references on behalf of BuildKit: authenticates with +/// registries, pulls missing base-image manifests and layers, and stores +/// them in the local content store. BuildKit delegates these operations to +/// the host because registry credentials and network access live on the +/// macOS side, not inside the builder VM. struct BuildImageResolver: BuildPipelineHandler { let contentStore: ContentStore let quiet: Bool diff --git a/Sources/ContainerBuild/BuildPipelineHandler.swift b/Sources/ContainerBuild/BuildPipelineHandler.swift index 6ee36e8f3..da0b0081e 100644 --- a/Sources/ContainerBuild/BuildPipelineHandler.swift +++ b/Sources/ContainerBuild/BuildPipelineHandler.swift @@ -18,11 +18,61 @@ import Foundation import GRPCCore import NIO +/// A handler for one stage of the build protocol. +/// +/// The build pipeline multiplexes a single bidirectional gRPC stream between +/// the macOS host and the builder shim. Each packet carries a stage tag; +/// a handler claims packets for its stage via ``accept(_:)`` and processes +/// them via ``handle(_:_:)``. protocol BuildPipelineHandler: Sendable { func accept(_ packet: ServerStream) throws -> Bool func handle(_ sender: AsyncStream.Continuation, _ packet: ServerStream) async throws } +/// Drives a build session by routing packets from the builder shim to the +/// appropriate handler. +/// +/// ## Three-tier architecture +/// +/// Builds involve three components with distinct responsibilities: +/// +/// **macOS host (`BuildPipeline` / its handlers)** +/// Serves resources to the builder shim over a bidirectional gRPC stream. +/// Responsibilities include: +/// - Packing requested build-context files into a tar archive (``BuildFSSync``). +/// - Proxying image-layer blobs from the local content store (``BuildRemoteContentProxy``). +/// - Resolving and pulling base images (``BuildImageResolver``). +/// - Relaying builder stdout/stderr to the terminal (``BuildStdio``). +/// - Enforcing the context root boundary: directory traversal uses `openat(O_NOFOLLOW)` +/// at every descent step, and every individual file request resolves symlinks to their +/// canonical path before verifying containment within the context root. +/// +/// **Builder shim (`container-builder-shim`)** +/// A Go process running inside a Linux VM that bridges the host gRPC stream +/// and BuildKit's `filesync` gRPC interface. Responsibilities include: +/// - Receiving the context tar from the host, unpacking it to a local cache, +/// and presenting the result to BuildKit via `DiffCopy`. +/// - Applying dockerignore exclusions (received from BuildKit as +/// `exclude-patterns` metadata) when walking the unpacked cache. +/// - Passing `followpaths` from BuildKit to the host so the host knows which +/// context paths to include in the tar. +/// +/// **BuildKit** +/// Parses and executes the Dockerfile. Responsibilities include: +/// - Sending `Walk` requests with `followpaths` derived from each `COPY`/`ADD` +/// source and `exclude-patterns` derived from `.dockerignore`. +/// - Dereferencing symlinks, recursing into directories, and applying all +/// other COPY/ADD transfer semantics on the unpacked context the shim provides. +/// +/// ## Packet flow +/// +/// ``` +/// BuildKit ──► shim DiffCopy ──► host Walk (tar of context files) +/// ◄── tar archive +/// ◄── PACKET_STAT per file (after shim unpacks + filters) +/// ──► PACKET_REQ for each regular file +/// ◄── PACKET_DATA (shim reads from local unpacked cache) +/// ``` public actor BuildPipeline { let handlers: [BuildPipelineHandler] public init(_ config: Builder.BuildConfig) async throws { diff --git a/Sources/ContainerBuild/BuildRemoteContentProxy.swift b/Sources/ContainerBuild/BuildRemoteContentProxy.swift index e6cb1cbce..afc1e2707 100644 --- a/Sources/ContainerBuild/BuildRemoteContentProxy.swift +++ b/Sources/ContainerBuild/BuildRemoteContentProxy.swift @@ -21,6 +21,11 @@ import ContainerizationOCI import Foundation import GRPCCore +/// Handles the `content-store` stage of the build protocol. +/// +/// Proxies image-layer blob requests from BuildKit to the host's local +/// containerd content store. BuildKit issues these requests when it needs +/// base-image layers that are not already present in the builder VM. struct BuildRemoteContentProxy: BuildPipelineHandler { let local: ContentStore diff --git a/Sources/ContainerBuild/BuildStdio.swift b/Sources/ContainerBuild/BuildStdio.swift index 324294802..47b2201ba 100644 --- a/Sources/ContainerBuild/BuildStdio.swift +++ b/Sources/ContainerBuild/BuildStdio.swift @@ -19,6 +19,11 @@ import Foundation import GRPCCore import NIO +/// Handles the stdio stage of the build protocol. +/// +/// Relays builder stdout/stderr from the shim to the client terminal. +/// Build output (layer download progress, `RUN` command output, etc.) flows +/// through this handler and is written directly to the configured file handle. actor BuildStdio: BuildPipelineHandler { public let quiet: Bool public let handle: FileHandle diff --git a/Sources/ContainerBuild/Builder.swift b/Sources/ContainerBuild/Builder.swift index c43b5ea63..151ee033e 100644 --- a/Sources/ContainerBuild/Builder.swift +++ b/Sources/ContainerBuild/Builder.swift @@ -268,6 +268,7 @@ public struct Builder: Sendable { public let contentStore: ContentStore public let buildArgs: [String] public let secrets: [String: Data] + public let ssh: String public let contextDir: String public let dockerfile: Data public let dockerignore: Data? @@ -289,6 +290,7 @@ public struct Builder: Sendable { contentStore: ContentStore, buildArgs: [String], secrets: [String: Data], + ssh: String, contextDir: String, dockerfile: Data, dockerignore: Data?, @@ -309,6 +311,7 @@ public struct Builder: Sendable { self.contentStore = contentStore self.buildArgs = buildArgs self.secrets = secrets + self.ssh = ssh self.contextDir = contextDir self.dockerfile = dockerfile self.dockerignore = dockerignore @@ -356,6 +359,9 @@ public struct Builder: Sendable { for (id, data) in config.secrets { metadata.addString(id + "=" + data.base64EncodedString(), forKey: "secrets") } + if config.ssh == "default" { + metadata.addString("default", forKey: "ssh") + } for output in config.exports { metadata.addString(try output.stringValue, forKey: "outputs") } diff --git a/Sources/ContainerBuild/Globber.swift b/Sources/ContainerBuild/Globber.swift index baecf60d0..9b3011b3f 100644 --- a/Sources/ContainerBuild/Globber.swift +++ b/Sources/ContainerBuild/Globber.swift @@ -14,7 +14,9 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerizationOS import Foundation +import SystemPackage public class Globber { let input: URL @@ -33,7 +35,7 @@ public class Globber { .replacingOccurrences(of: "[/]\\*{2,}([^/])", with: "/**/*$1", options: .regularExpression) .replacingOccurrences(of: "^\\*{2,}([^/])", with: "**/*$1", options: .regularExpression) - for child in input.children { + for child in self.children(of: input) { try self.match(input: child, components: adjustedPattern.split(separator: "/").map(String.init)) } } @@ -47,7 +49,7 @@ public class Globber { guard dir.pathComponents.count > 1 else { break } dir.deleteLastPathComponent() } - return input.childrenRecursive.forEach { results.insert($0) } + return self.childrenRecursive(of: input).forEach { results.insert($0) } } let head = components.first ?? "" @@ -59,7 +61,7 @@ public class Globber { tail = tail.tail } try self.match(input: input, components: tail) - for child in input.children { + for child in self.children(of: input) { try self.match(input: child, components: components) } return @@ -68,13 +70,66 @@ public class Globber { if try glob(input.lastPathComponent, head) { try self.match(input: input, components: tail) - for child in input.children where try glob(child.lastPathComponent, tail.first ?? "") { + for child in self.children(of: input) where try glob(child.lastPathComponent, tail.first ?? "") { try self.match(input: child, components: tail) } return } } + /// Returns the direct children of `url`, following `url` itself when it is + /// a directory symlink whose fully-resolved target stays within the match + /// root. A symlink that escapes the root is treated as having no children + /// (same as a regular file) so pattern components after it never match — + /// mirrors the containment check `BuildFSSync` applies before reading. + /// + /// Children are named by their resolved (physical) path, not by `url`, so + /// that `walk(root:includePatterns:)`'s later filter — which is driven by + /// `Archiver.compress`'s own physical directory walk — reliably finds a + /// matching entry regardless of whether that walk itself follows `url`'s + /// symlink. `url` is separately inserted into `results` so the symlink + /// entry is still present in the tar for the builder to resolve the + /// original path against. + private func children(of url: URL) -> [URL] { + // TODO: modifying object state and returning results is odd, rework + guard let dir = self.resolvedDirectory(of: url) else { return [] } + if url.isSymlink { self.results.insert(url) } + return (try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) + ?? [] + } + + /// Recursive form of ``children(of:)``, used once a full pattern (or `**`) + /// has matched `url` and every descendant needs to be collected. Nested + /// directory symlinks are resolved and boundary-checked the same way, one + /// level at a time, via ``FileDescriptorOps/enumerate`` which never follows + /// symlinks it encounters mid-traversal — only the top-level `url` passed + /// in here gets the resolve-and-check treatment. + private func childrenRecursive(of url: URL) -> [URL] { + guard let dir = self.resolvedDirectory(of: url) else { return [url] } + if url.isSymlink { self.results.insert(url) } + guard let fd = try? FileDescriptor.open(FilePath(dir.path), .readOnly, options: .directory) else { + return [dir] + } + defer { try? fd.close() } + var found: [URL] = [dir] + try? FileDescriptorOps.enumerate(fd) { relPath, _, _ in + found.append(dir.appendingPathComponent(relPath.string)) + } + return found + } + + /// Resolves `url` to the real directory whose contents should be listed in + /// its place. Non-symlinks resolve to themselves. A directory symlink + /// resolves to its target only if the fully-resolved target is still + /// within `self.input` (the match root); otherwise `nil`, so callers treat + /// it as a leaf rather than descending outside the context. + private func resolvedDirectory(of url: URL) -> URL? { + guard url.isSymlink else { return url } + let resolved = url.resolvingSymlinksInPath() + guard resolved.isDirectory, self.input.parentOf(resolved) else { return nil } + return resolved + } + func glob(_ input: String, _ pattern: String) throws -> Bool { let regexPattern = "^" @@ -91,26 +146,6 @@ public class Globber { } } -extension URL { - var children: [URL] { - - (try? FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil)) - ?? [] - } - - var childrenRecursive: [URL] { - var results: [URL] = [] - if let enumerator = FileManager.default.enumerator( - at: self, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey]) - { - while let child = enumerator.nextObject() as? URL { - results.append(child) - } - } - return [self] + results - } -} - extension [String] { var tail: [String] { if self.count <= 1 { diff --git a/Sources/ContainerBuild/URL+Extensions.swift b/Sources/ContainerBuild/URL+Extensions.swift index 4f0bd7ec1..81818b203 100644 --- a/Sources/ContainerBuild/URL+Extensions.swift +++ b/Sources/ContainerBuild/URL+Extensions.swift @@ -54,6 +54,13 @@ extension URL { self.path.fs_cleaned } + /// Returns true if `url` is lexically a descendant of `self`. + /// + /// This is a **string-prefix check** on the normalised path components; it + /// does not call `realpath` or resolve symlinks. A URL that is lexically + /// inside the context root but reachable through an intermediate symlink + /// that points outside it will still pass this check. Callers that need + /// physical containment must resolve symlinks before calling this method. func parentOf(_ url: URL) -> Bool { let parentPath = self.absoluteURL.cleanPath let childPath = url.absoluteURL.cleanPath @@ -80,18 +87,6 @@ extension URL { return selfParts.dropFirst(ctxParts.count).joined(separator: "/") } - func relativePathFrom(from base: URL) -> String { - let destParts = cleanPath.fs_components - let baseParts = base.cleanPath.fs_components - - let common = zip(destParts, baseParts).prefix { $0 == $1 }.count - guard common > 0 else { return cleanPath } - - let ups = Array(repeating: "..", count: baseParts.count - common) - let remainder = destParts.dropFirst(common) - return (ups + remainder).joined(separator: "/") - } - func zeroCopyReader( chunk: Int = 1024 * 1024, buffer: AsyncStream.Continuation.BufferingPolicy = .unbounded diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift index 34ec4356f..c216bc26b 100644 --- a/Sources/ContainerCommands/BuildCommand.swift +++ b/Sources/ContainerCommands/BuildCommand.swift @@ -125,6 +125,12 @@ extension Application { var secrets: [String: SecretType] = [:] + @Option( + name: .long, + help: ArgumentHelp("Forward SSH agent authentication to the build (format: default)", valueName: "default") + ) + var ssh: String = "" + @Option(name: [.short, .customLong("tag")], help: ArgumentHelp("Name for the built image", valueName: "name")) var targetImageNames: [String] = { [UUID().uuidString.lowercased()] @@ -165,12 +171,26 @@ extension Application { progress.set(description: "Dialing builder") let dnsNameservers = self.dns.nameservers - let builder: Builder? = try await withThrowingTaskGroup(of: Builder.self) { [vsockPort, cpus, memory, dnsNameservers] group in + + // Ensure the builder is started (or restarted) with the correct SSH configuration + // before attempting to dial. This handles the case where the builder is already + // running but was not started with SSH forwarding enabled. + try await BuilderStart.start( + cpus: cpus, + memory: memory, + log: log, + ssh: ssh == "default", + dnsNameservers: dnsNameservers, + progressUpdate: progress.handler, + containerSystemConfig: containerSystemConfig, + ) + + let builder: Builder? = try await withThrowingTaskGroup(of: Builder.self) { [vsockPort, cpus, memory, dnsNameservers, ssh] group in defer { group.cancelAll() } - group.addTask { [vsockPort, cpus, memory, log, dnsNameservers] in + group.addTask { [vsockPort, cpus, memory, log, dnsNameservers, ssh] in let client = ContainerClient() while true { do { @@ -192,6 +212,7 @@ extension Application { cpus: cpus, memory: memory, log: log, + ssh: ssh == "default", dnsNameservers: dnsNameservers, progressUpdate: progress.handler, containerSystemConfig: containerSystemConfig, @@ -349,13 +370,15 @@ extension Application { }() group.addTask { [ - terminal, buildArg, secretsData, contextDir, ignoreFileData, label, noCache, target, quiet, cacheIn, cacheOut, pull, exports, imageNames, tempURL, log, + terminal, buildArg, secretsData, ssh, contextDir, ignoreFileData, label, noCache, target, quiet, cacheIn, cacheOut, pull, exports, imageNames, tempURL, + log ] in let config = Builder.BuildConfig( buildID: buildID, contentStore: RemoteContentStoreClient(), buildArgs: buildArg, secrets: secretsData, + ssh: ssh, contextDir: contextDir, dockerfile: buildFileData, dockerignore: ignoreFileData, @@ -510,6 +533,17 @@ extension Application { throw ValidationError("secret bad value \(parts[1])") } } + + switch ssh { + case "": + break + case "default" where ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] != nil: + break + case "default": + throw ValidationError("--ssh default requires SSH_AUTH_SOCK to be set") + default: + throw ValidationError("only --ssh default is currently supported") + } } } } diff --git a/Sources/ContainerCommands/Builder/BuilderStart.swift b/Sources/ContainerCommands/Builder/BuilderStart.swift index a7f6ff377..f8bf20972 100644 --- a/Sources/ContainerCommands/Builder/BuilderStart.swift +++ b/Sources/ContainerCommands/Builder/BuilderStart.swift @@ -84,6 +84,7 @@ extension Application { cpus: Int64?, memory: String?, log: Logger, + ssh: Bool = false, dnsNameservers: [String] = [], dnsDomain: String? = nil, dnsSearchDomains: [String] = [], @@ -150,6 +151,9 @@ extension Application { let imageChanged = existingImage != builderImage let cpuChanged = existingResources.cpus != resources.cpus let memChanged = existingResources.memoryInBytes != resources.memoryInBytes + let sshForwarded = existingContainer.configuration.ssh + let sshWanted = ssh && ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] != nil + let sshChanged = sshForwarded != sshWanted let dnsChanged = { if !dnsNameservers.isEmpty { return existingDNS?.nameservers != dnsNameservers @@ -168,7 +172,7 @@ extension Application { switch existingContainer.status { case .running: - guard imageChanged || cpuChanged || memChanged || envChanged || dnsChanged else { + guard imageChanged || cpuChanged || memChanged || envChanged || dnsChanged || sshChanged else { // If image, mem, cpu, env, and DNS are the same, continue using the existing builder return } @@ -178,11 +182,22 @@ extension Application { case .stopped: // If the builder is stopped and matches our requirements, start it // Otherwise, delete it and create a new one - guard imageChanged || cpuChanged || memChanged || envChanged || dnsChanged else { - try await startBuildKit(client: client, id: existingContainer.id, progressUpdate, nil) - return + if imageChanged || cpuChanged || memChanged || envChanged || dnsChanged || sshChanged { + try? await client.delete(id: existingContainer.id) + } else { + do { + try await startBuildKit(client: client, id: existingContainer.id, progressUpdate, nil) + return + } catch { + log.warning( + "failed to restart existing stopped BuildKit container, recreating it", + metadata: [ + "id": "\(existingContainer.id)", + "error": "\(error)", + ]) + } + try? await client.delete(id: existingContainer.id) } - try await client.delete(id: existingContainer.id) case .stopping: throw ContainerizationError( .invalidState, @@ -242,6 +257,7 @@ extension Application { var config = ContainerConfiguration(id: Builder.builderContainerId, image: imageDesc, process: processConfig) config.resources = resources + config.ssh = ssh && ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] != nil config.labels = [ ResourceLabelKeys.plugin: "builder", ResourceLabelKeys.role: ResourceRoleValues.builder, @@ -292,11 +308,17 @@ extension Application { .setDescription("Starting BuildKit container") ]) - try await client.create( - configuration: config, - options: .default, - kernel: kernel - ) + do { + try await client.create( + configuration: config, + options: .default, + kernel: kernel + ) + } catch let error as ContainerizationError where error.code == .exists { + // A concurrent `container build` invocation already created the builder + // while we were fetching the image/kernel above. `bootstrap` below is + // idempotent, so just proceed against the container the winner created. + } try await startBuildKit(client: client, id: Builder.builderContainerId, progressUpdate, taskManager) log.debug("starting BuildKit and BuildKit-shim") diff --git a/Sources/ContainerCommands/Container/ContainerExport.swift b/Sources/ContainerCommands/Container/ContainerExport.swift index a7394f268..c0c21aae5 100644 --- a/Sources/ContainerCommands/Container/ContainerExport.swift +++ b/Sources/ContainerCommands/Container/ContainerExport.swift @@ -67,7 +67,11 @@ extension Application { } try fileHandle.close() } else { - try FileManager.default.moveItem(at: archive, to: URL(fileURLWithPath: output!)) + let outputURL = URL(fileURLWithPath: output!) + if FileManager.default.fileExists(atPath: outputURL.path(percentEncoded: false)) { + try FileManager.default.removeItem(at: outputURL) + } + try FileManager.default.moveItem(at: archive, to: outputURL) } } } diff --git a/Sources/ContainerCommands/System/SystemStart.swift b/Sources/ContainerCommands/System/SystemStart.swift index b91eb7964..16dadb555 100644 --- a/Sources/ContainerCommands/System/SystemStart.swift +++ b/Sources/ContainerCommands/System/SystemStart.swift @@ -141,7 +141,7 @@ extension Application { } do { - print("Verifying machine API server is running...") + log.info("Verifying machine API server is running...") _ = try await MachineClient().list() } catch { throw ContainerizationError( @@ -177,7 +177,7 @@ extension Application { private func installDefaultKernel(kernelURL: URL, kernelBinaryPath: String, kernelDigest: String) async throws { var shouldInstallKernel = false if kernelInstall == nil { - print("No default kernel configured.") + log.warning("No default kernel configured.") print("Install the recommended default kernel from [\(kernelURL)]? [Y/n]: ", terminator: "") guard let read = readLine(strippingNewline: true) else { throw ContainerizationError(.internalError, message: "failed to read user input") diff --git a/Sources/ContainerPlugin/PluginFactory.swift b/Sources/ContainerPlugin/PluginFactory.swift index 2a9998b6f..04d756f09 100644 --- a/Sources/ContainerPlugin/PluginFactory.swift +++ b/Sources/ContainerPlugin/PluginFactory.swift @@ -14,8 +14,10 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerizationError import Foundation import Logging +import SystemPackage /// Describes the configuration and binary file locations for a plugin. public protocol PluginFactory: Sendable { @@ -25,6 +27,15 @@ public protocol PluginFactory: Sendable { func create(parentURL: URL, name: String) throws -> Plugin? } +/// Requires `name` to be a single regular path component that round-trips +/// exactly, so it can't escape the plugin directory via `/`, `..`, or a NUL +/// that `FilePath.Component` would silently truncate. +private func validatePluginName(_ name: String) throws { + guard let component = FilePath.Component(name), component.kind == .regular, component.string == name else { + throw ContainerizationError(.invalidArgument, message: "invalid plugin name \(name)") + } +} + /// Default layout which uses a Unix-like structure. public struct DefaultPluginFactory: PluginFactory { // Order matters: earlier entries take priority during config file discovery. @@ -78,7 +89,8 @@ public struct DefaultPluginFactory: PluginFactory { } public func create(parentURL: URL, name: String) throws -> Plugin? { - try create(installURL: parentURL.appendingPathComponent(name)) + try validatePluginName(name) + return try create(installURL: parentURL.appendingPathComponent(name)) } } @@ -130,6 +142,7 @@ public struct AppBundlePluginFactory: PluginFactory { } public func create(parentURL: URL, name: String) throws -> Plugin? { - try create(installURL: parentURL.appendingPathComponent("\(name)\(Self.appSuffix)")) + try validatePluginName(name) + return try create(installURL: parentURL.appendingPathComponent("\(name)\(Self.appSuffix)")) } } diff --git a/Sources/ContainerResource/Container/ContainerConfiguration.swift b/Sources/ContainerResource/Container/ContainerConfiguration.swift index b1f234915..87e0f9049 100644 --- a/Sources/ContainerResource/Container/ContainerConfiguration.swift +++ b/Sources/ContainerResource/Container/ContainerConfiguration.swift @@ -62,6 +62,14 @@ public struct ContainerConfiguration: Sendable, Codable { public var shmSize: UInt64? /// Signal to send to the container process on stop (from image config). public var stopSignal: String? + /// Paths inside the container to hide from the workload. When nil, the + /// runtime's default set is used. Set to `[]` to opt out, or provide a + /// custom list to override the default entirely. + public var maskedPaths: [String]? + /// Paths inside the container to mark read-only. When nil, the runtime's + /// default set is used. Set to `[]` to opt out, or provide a custom list + /// to override the default entirely. + public var readonlyPaths: [String]? /// The time at which the container was created. public var creationDate: Date = Date() @@ -88,6 +96,8 @@ public struct ContainerConfiguration: Sendable, Codable { case capDrop case shmSize case stopSignal + case maskedPaths + case readonlyPaths case creationDate } @@ -124,6 +134,8 @@ public struct ContainerConfiguration: Sendable, Codable { capDrop = try container.decodeIfPresent([String].self, forKey: .capDrop) ?? [] shmSize = try container.decodeIfPresent(UInt64.self, forKey: .shmSize) stopSignal = try container.decodeIfPresent(String.self, forKey: .stopSignal) + maskedPaths = try container.decodeIfPresent([String].self, forKey: .maskedPaths) + readonlyPaths = try container.decodeIfPresent([String].self, forKey: .readonlyPaths) creationDate = try container.decodeIfPresent(Date.self, forKey: .creationDate) ?? Date(timeIntervalSince1970: 0) } diff --git a/Sources/ContainerTestSupport/BuildFixture.swift b/Sources/ContainerTestSupport/BuildFixture.swift index 9beca8008..f317c2fb9 100644 --- a/Sources/ContainerTestSupport/BuildFixture.swift +++ b/Sources/ContainerTestSupport/BuildFixture.swift @@ -94,42 +94,6 @@ extension ContainerFixture { } throw CommandError.executionFailed("timed out waiting for container-builder-shim on buildkit") } - - /// Deletes any existing builder, starts a fresh one, runs `body`, then deletes the builder. - /// - /// Each build test gets an isolated builder to avoid inter-test contamination. - /// Acquires a process-wide lock so only one test holds the buildkit singleton at a time, - /// regardless of how many suites run concurrently in the global pass. - public func withBuilder( - cpus: Int64 = 2, - memoryInGBs: Int64 = 2, - _ body: @Sendable (ContainerFixture) async throws -> Void - ) async throws { - try await withoutActuallyEscaping(body) { escapingBody in - try await Self.builderLock.withLock { _ in - _ = try? self.run(["builder", "delete", "--force"]) - try self.builderStart(cpus: cpus, memoryInGBs: memoryInGBs) - defer { _ = try? self.run(["builder", "delete", "--force"]) } - try await self.waitForBuilderRunning() - try await escapingBody(self) - } - } - } - - /// Acquires the process-wide builder lock without starting a builder. - /// - /// Use this in tests that manually manage the builder lifecycle (e.g. lifecycle - /// tests that call ``builderStart()``/``builderStop()`` directly) so they - /// serialise correctly with tests that use ``withBuilder(_:)``. - public func withBuilderLock(_ body: @Sendable () async throws -> T) async throws -> T { - try await withoutActuallyEscaping(body) { escapingBody in - try await Self.builderLock.withLock { _ in - try await escapingBody() - } - } - } - - private static let builderLock = AsyncLock() } // MARK: - Build context helpers diff --git a/Sources/ContainerTestSupport/ContainerFixture+ContainerHelpers.swift b/Sources/ContainerTestSupport/ContainerFixture+ContainerHelpers.swift index 9aadaf9a1..73c6c45b9 100644 --- a/Sources/ContainerTestSupport/ContainerFixture+ContainerHelpers.swift +++ b/Sources/ContainerTestSupport/ContainerFixture+ContainerHelpers.swift @@ -49,15 +49,19 @@ extension ContainerFixture { /// /// `containerEnv` injects environment variables into the container via `-e` flags. /// To set the CLI subprocess environment (e.g. for `--ssh`), use ``run(_:env:)`` directly. + /// + /// Pass `waitUntilRunning: true` to poll for `running` state before returning, + /// eliminating a separate ``ContainerFixture/waitForContainerRunning(_:attempts:)`` call. public func doLongRun( name: String, image: String? = nil, args: [String] = [], containerArgs: [String] = ["sleep", "infinity"], autoRemove: Bool = true, - containerEnv: [String: String] = [:] - ) throws { - let imageRef = image ?? ContainerFixture.warmupImages[0] + containerEnv: [String: String] = [:], + waitUntilRunning: Bool = false + ) async throws { + let imageRef = image ?? WarmupImage.alpine320.rawValue var runArgs = ["run"] if autoRemove { runArgs.append("--rm") } runArgs += ["--name", name, "-d"] @@ -67,6 +71,9 @@ extension ContainerFixture { runArgs.append(imageRef) runArgs += containerArgs try run(runArgs).check() + if waitUntilRunning { + try await waitForContainerRunning(name) + } } /// Creates a stopped container (`container create`). @@ -78,7 +85,7 @@ extension ContainerFixture { networks: [String] = [], ports: [String] = [] ) throws { - let imageRef = image ?? ContainerFixture.warmupImages[0] + let imageRef = image ?? WarmupImage.alpine320.rawValue var createArgs = ["create", "--rm", "--name", name] createArgs += proxyEnvironmentArgs for v in volumes { createArgs += ["-v", v] } @@ -147,7 +154,7 @@ extension ContainerFixture { } /// Cleans a running container. - func doClean(name: String) throws { + public func doClean(_ name: String) throws { try run(["clean", name]).check() } } diff --git a/Sources/ContainerTestSupport/ContainerFixture+NetworkHelpers.swift b/Sources/ContainerTestSupport/ContainerFixture+NetworkHelpers.swift index 49d3bffca..1e3eb95ae 100644 --- a/Sources/ContainerTestSupport/ContainerFixture+NetworkHelpers.swift +++ b/Sources/ContainerTestSupport/ContainerFixture+NetworkHelpers.swift @@ -69,4 +69,23 @@ extension ContainerFixture { public func makeHTTPClient() -> HTTPClient { HTTPClient(eventLoopGroupProvider: .singleton) } + + /// Polls `url` via HTTP GET until it returns a 2xx status. + /// + /// Useful after a container reaches `running` state, since that only proves + /// the container's init process is up — a server inside may still be starting. + public func waitForHTTPOk( + _ url: String, using client: HTTPClient, attempts: Int = 10, delay: Duration = .seconds(1) + ) async throws { + try await retry(attempts: attempts, delay: delay) { + do { + var req = HTTPClientRequest(url: url) + req.method = .GET + let resp = try await client.execute(req, timeout: .seconds(3)) + return resp.status.code >= 200 && resp.status.code < 300 + } catch { + return false + } + } + } } diff --git a/Sources/ContainerTestSupport/ContainerFixture+SSHTestHelpers.swift b/Sources/ContainerTestSupport/ContainerFixture+SSHTestHelpers.swift index 477ea60cf..96beaafa0 100644 --- a/Sources/ContainerTestSupport/ContainerFixture+SSHTestHelpers.swift +++ b/Sources/ContainerTestSupport/ContainerFixture+SSHTestHelpers.swift @@ -24,21 +24,16 @@ extension ContainerFixture { /// Creates a Unix-domain listening socket at a short path under `/tmp`, /// suitable for use as `SSH_AUTH_SOCK` in tests that exercise `--ssh` forwarding. /// - /// The path is `/tmp/{testID}-ssh/ssh-auth.sock`, which fits comfortably within - /// `sockaddr_un.sun_path`'s 104-byte limit on macOS regardless of project depth. - /// /// An accept loop runs on a background thread, closing each incoming connection. /// `accept()` is a blocking syscall, so `Thread` is appropriate here — using /// `Task.detached` would block a cooperative thread without yielding. /// - /// The socket fd and its parent directory are auto-cleaned on fixture scope exit; - /// closing the listening fd is what causes the accept loop to exit. + /// The socket fd is closed on fixture scope exit, which is what causes the + /// accept loop to exit; its parent directory is removed by ``makeShortSocketDir(_:)``. /// /// Returns the socket path. Pass it as `SSH_AUTH_SOCK` in the CLI process env. public func makeFakeSSHAgentSocket() throws -> String { - let socketDir = "/tmp/\(testID)-ssh" - try FileManager.default.createDirectory( - atPath: socketDir, withIntermediateDirectories: true, attributes: nil) + let socketDir = try makeShortSocketDir("ssh") let socketPath = socketDir + "/ssh-auth.sock" let serverFd = socket(AF_UNIX, SOCK_STREAM, 0) @@ -81,7 +76,6 @@ extension ContainerFixture { addCleanup { Darwin.close(serverFd) - try? FileManager.default.removeItem(atPath: socketDir) } return socketPath diff --git a/Sources/ContainerTestSupport/ContainerFixture.swift b/Sources/ContainerTestSupport/ContainerFixture.swift index f00d05d97..d80a84e4d 100644 --- a/Sources/ContainerTestSupport/ContainerFixture.swift +++ b/Sources/ContainerTestSupport/ContainerFixture.swift @@ -59,16 +59,6 @@ import Testing /// pattern the structured helpers don't cover. public final class ContainerFixture: Sendable { - // MARK: - Configuration - - /// Images preloaded by the ``ImageWarmup`` suite before concurrent tests run. - /// Add new commonly-used images here; the warmup pass pulls them in parallel. - public static let warmupImages: [String] = [ - "ghcr.io/linuxcontainers/alpine:3.20", - "ghcr.io/linuxcontainers/alpine:3.18", - "ghcr.io/containerd/busybox:1.36", - ] - // MARK: - State /// Short random identifier prefixed to every resource this test creates. @@ -244,12 +234,29 @@ public final class ContainerFixture: Sendable { status: process.terminationStatus) } + /// Creates a directory at a short, fixed-depth path under `/tmp`, suitable for + /// Unix-domain socket files that must fit within `sockaddr_un.sun_path`'s 104-byte + /// limit on macOS regardless of the project checkout's directory depth. + /// + /// Returns the directory path; the caller creates the socket file inside it. + /// The directory is removed on fixture cleanup. + public func makeShortSocketDir(_ suffix: String) throws -> String { + let dir = "/tmp/\(testID)-\(suffix)" + try FileManager.default.createDirectory( + atPath: dir, withIntermediateDirectories: true, attributes: nil) + addCleanup { + try? FileManager.default.removeItem(atPath: dir) + } + return dir + } + /// Tags a warmup image to a test-local reference and registers its removal. /// /// The returned name is `{testID}-{imageName}:{tag}`, e.g. /// `a3f7c2b1-alpine:3.20`. Tests operate freely on this reference; /// the canonical warmup image is never touched. - public func copyWarmupImage(_ canonical: String) throws -> String { + public func copyWarmupImage(_ image: WarmupImage) throws -> String { + let canonical = image.rawValue let lastComponent = canonical.split(separator: "/").last.map(String.init) ?? canonical let parts = lastComponent.split(separator: ":", maxSplits: 1) let name = String(parts[0]) diff --git a/Sources/ContainerTestSupport/WarmupImage.swift b/Sources/ContainerTestSupport/WarmupImage.swift new file mode 100644 index 000000000..307f9aa4a --- /dev/null +++ b/Sources/ContainerTestSupport/WarmupImage.swift @@ -0,0 +1,23 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +/// Images preloaded by the ``ImageWarmup`` suite before concurrent tests run. +/// Add new commonly-used images here; the warmup pass pulls them in parallel. +public enum WarmupImage: String, CaseIterable, Sendable { + case alpine320 = "ghcr.io/linuxcontainers/alpine:3.20" + case alpine318 = "ghcr.io/linuxcontainers/alpine:3.18" + case busybox136 = "ghcr.io/containerd/busybox:1.36" +} diff --git a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift index 0eb336713..59d032657 100644 --- a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift +++ b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift @@ -107,6 +107,7 @@ extension RuntimeLinuxHelper { RuntimeRoutes.copyIn.rawValue: XPCServer.route(server.copyIn), RuntimeRoutes.copyOut.rawValue: XPCServer.route(server.copyOut), RuntimeRoutes.clean.rawValue: XPCServer.route(server.clean), + RuntimeRoutes.snapshotDisk.rawValue: XPCServer.route(server.snapshotDisk), ], log: log ) diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift index 8469a2d07..b52538d94 100644 --- a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -71,6 +71,8 @@ public struct ContainerClient: Sendable { } try await xpcSend(message: request) + } catch let error as ContainerizationError { + throw error } catch { throw ContainerizationError( .internalError, diff --git a/Sources/Services/ContainerAPIService/Client/Flags.swift b/Sources/Services/ContainerAPIService/Client/Flags.swift index d26eddd75..980c7c1b2 100644 --- a/Sources/Services/ContainerAPIService/Client/Flags.swift +++ b/Sources/Services/ContainerAPIService/Client/Flags.swift @@ -176,6 +176,7 @@ public struct Flags { entrypoint: String?, initImage: String?, kernel: String?, + kernelArgs: [String], labels: [String], mounts: [String], name: String?, @@ -205,6 +206,7 @@ public struct Flags { self.entrypoint = entrypoint self.initImage = initImage self.kernel = kernel + self.kernelArgs = kernelArgs self.labels = labels self.mounts = mounts self.name = name @@ -277,6 +279,15 @@ public struct Flags { ) public var kernel: String? + @Option( + name: .customLong("kernel-arg"), + help: .init( + "Append a raw boot argument to the kernel command line (repeatable).", + valueName: "arg" + ) + ) + public var kernelArgs: [String] = [] + @Option(name: [.short, .customLong("label")], help: "Add a key=value label to the container") public var labels: [String] = [] diff --git a/Sources/Services/ContainerAPIService/Client/Parser.swift b/Sources/Services/ContainerAPIService/Client/Parser.swift index ef209df5c..e4516d7fd 100644 --- a/Sources/Services/ContainerAPIService/Client/Parser.swift +++ b/Sources/Services/ContainerAPIService/Client/Parser.swift @@ -125,7 +125,9 @@ public struct Parser { public static func allEnv(imageEnvs: [String], envFiles: [String], envs: [String]) throws -> [String] { var combined: [String] = [] - combined.append(contentsOf: Parser.env(envList: imageEnvs)) + // Image config is untrusted. Bare env var names here must not be expanded from the host + // process's environment. + combined.append(contentsOf: imageEnvs.filter { $0.contains("=") }) for envFile in envFiles { let content = try Parser.envFile(path: envFile) combined.append(contentsOf: content) diff --git a/Sources/Services/ContainerAPIService/Client/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift index 4d0ba700a..163a1db14 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -330,14 +330,20 @@ public struct Utility { // For the image itself we'll take the user input and try with it as we can do userspace // emulation for x86, but for the kernel we need it to match the hosts architecture. let s: SystemPlatform = .current + var kernel: Kernel if let userKernel = management.kernel { guard FileManager.default.fileExists(atPath: userKernel) else { throw ContainerizationError(.notFound, message: "kernel file not found at path \(userKernel)") } let p = URL(filePath: userKernel) - return .init(path: p, platform: s) + kernel = .init(path: p, platform: s) + } else { + kernel = try await ClientKernel.getDefaultKernel(for: s) } - return try await ClientKernel.getDefaultKernel(for: s) + // Persist any user-supplied boot args onto the kernel command line. A key supplied + // here overrides the runtime's matching built-in default (see RuntimeService.bootstrap). + kernel.commandLine.kernelArgs.append(contentsOf: management.kernelArgs) + return kernel } /// Parses key-value pairs from command line arguments. diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index 8ae4b855a..dbc1bbb55 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -901,14 +901,22 @@ public actor ContainersService { self.log.debug("\(#function)") let state = try self._getContainerState(id: id) - guard state.snapshot.status == .stopped else { - throw ContainerizationError(.invalidState, message: "container is not stopped") - } - let path = self.containerRoot.appendingPathComponent(id) let bundle = ContainerResource.Bundle(path: path) let rootfs = bundle.containerRootfsBlock - try EXT4.EXT4Reader(blockDevice: FilePath(rootfs)).export(archive: FilePath(archive)) + + switch state.snapshot.status { + case .running: + let client = try state.getClient() + let snapshot = rootfs.appendingPathExtension("snapshot") + defer { try? FileManager.default.removeItem(at: snapshot) } + try await client.snapshotDisk(imagePath: rootfs.path, destinationPath: snapshot.path) + try EXT4.EXT4Reader(blockDevice: FilePath(snapshot)).export(archive: FilePath(archive)) + case .stopped: + try EXT4.EXT4Reader(blockDevice: FilePath(rootfs)).export(archive: FilePath(archive)) + default: + throw ContainerizationError(.invalidState, message: "container must be running or stopped") + } } public func clean(id: String) async throws { @@ -920,7 +928,7 @@ public actor ContainersService { } let client = try state.getClient() - try await client.clean(id: id) + try await client.clean() } private func handleContainerExit(id: String, code: ExitStatus? = nil) async throws { diff --git a/Sources/Services/MachineAPIService/Client/MachineClient.swift b/Sources/Services/MachineAPIService/Client/MachineClient.swift index 7f3167ff4..4fa5d0902 100644 --- a/Sources/Services/MachineAPIService/Client/MachineClient.swift +++ b/Sources/Services/MachineAPIService/Client/MachineClient.swift @@ -106,7 +106,7 @@ public struct MachineClient: Sendable { let response = try await xpcSend( message: request, - timeout: .seconds(10) + timeout: nil ) let data = response.dataNoCopy(key: MachineKeys.machines.rawValue) guard let data else { @@ -142,7 +142,7 @@ public struct MachineClient: Sendable { let bootData = try JSONEncoder().encode(bootConfig) request.set(key: MachineKeys.bootConfig.rawValue, value: bootData) - let _ = try await xpcSend(message: request) + let _ = try await xpcSend(message: request, timeout: nil) } catch { throw ContainerizationError( .internalError, @@ -158,7 +158,7 @@ public struct MachineClient: Sendable { let request = XPCMessage(route: MachineRoutes.deleteMachine.rawValue) request.set(key: MachineKeys.id.rawValue, value: id) - let _ = try await xpcSend(message: request, timeout: .seconds(15)) + let _ = try await xpcSend(message: request, timeout: .seconds(60)) } catch { throw ContainerizationError( .internalError, @@ -216,7 +216,7 @@ public struct MachineClient: Sendable { let dynamicEnvData = try JSONEncoder().encode(dynamicEnv) request.set(key: MachineKeys.dynamicEnv.rawValue, value: dynamicEnvData) - let response = try await xpcSend(message: request) + let response = try await xpcSend(message: request, timeout: nil) guard let data = response.dataNoCopy(key: MachineKeys.snapshot.rawValue) else { throw ContainerizationError( .internalError, @@ -239,7 +239,7 @@ public struct MachineClient: Sendable { let request = XPCMessage(route: MachineRoutes.stopMachine.rawValue) request.set(key: MachineKeys.id.rawValue, value: id) - let _ = try await xpcSend(message: request, timeout: .seconds(30)) + let _ = try await xpcSend(message: request, timeout: nil) } catch { throw ContainerizationError( .internalError, @@ -272,7 +272,7 @@ public struct MachineClient: Sendable { let request = XPCMessage(route: MachineRoutes.inspectMachine.rawValue) request.set(key: MachineKeys.id.rawValue, value: id) - let response = try await xpcSend(message: request) + let response = try await xpcSend(message: request, timeout: nil) guard let data = response.dataNoCopy(key: MachineKeys.snapshot.rawValue) else { throw ContainerizationError( .internalError, diff --git a/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift b/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift index e1306a6e4..17b72cecc 100644 --- a/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift @@ -319,6 +319,22 @@ extension RuntimeClient { } } + public func snapshotDisk(imagePath: String, destinationPath: String) async throws { + let request = XPCMessage(route: RuntimeRoutes.snapshotDisk.rawValue) + request.set(key: RuntimeKeys.imagePath.rawValue, value: imagePath) + request.set(key: RuntimeKeys.destinationPath.rawValue, value: destinationPath) + + do { + try await self.client.send(request, responseTimeout: .seconds(300)) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to snapshot disk in container \(self.id)", + cause: error + ) + } + } + public func statistics() async throws -> ContainerStats { let request = XPCMessage(route: RuntimeRoutes.statistics.rawValue) @@ -343,9 +359,9 @@ extension RuntimeClient { return try JSONDecoder().decode(ContainerStats.self, from: data) } - public func clean(id: String) async throws { + public func clean() async throws { let request = XPCMessage(route: RuntimeRoutes.clean.rawValue) - request.set(key: RuntimeKeys.id.rawValue, value: id) + request.set(key: RuntimeKeys.id.rawValue, value: self.id) do { try await self.client.send(request) diff --git a/Sources/Services/Runtime/RuntimeClient/RuntimeKeys.swift b/Sources/Services/Runtime/RuntimeClient/RuntimeKeys.swift index b472d9dd1..1d3548cfe 100644 --- a/Sources/Services/Runtime/RuntimeClient/RuntimeKeys.swift +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeKeys.swift @@ -48,6 +48,8 @@ public enum RuntimeKeys: String { case destinationPath case fileMode case createParents + /// Image path for snapshot operations + case imagePath /// Special-case environment variables recomputed on each container start case dynamicEnv diff --git a/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift b/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift index 1c8aae6d9..addf46ff9 100644 --- a/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift @@ -58,4 +58,6 @@ public enum RuntimeRoutes: String { case copyOut = "com.apple.container.runtime/copyOut" /// Clean up unused space in the container filesystem. case clean = "com.apple.container.runtime/clean" + /// Snapshot the container's root filesystem to an image file. + case snapshotDisk = "com.apple.container.runtime/snapshotDisk" } diff --git a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift index b55608daa..774d6807d 100644 --- a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift +++ b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift @@ -160,8 +160,18 @@ public actor RuntimeService { var config = try bundle.configuration var kernel = try bundle.kernel - kernel.commandLine.kernelArgs.append("oops=panic") - kernel.commandLine.kernelArgs.append("lsm=lockdown,capability,landlock,yama,apparmor") + // Built-in defaults keyed by arg name. Each is applied only if the user did not already + // supply the same key via --kernel-arg, letting custom kernels override them (e.g. lsm=...,bpf). + let defaultKernelArgs: KeyValuePairs = [ + "oops": "panic", + "lsm": "lockdown,capability,landlock,yama,apparmor", + ] + for (key, value) in defaultKernelArgs { + guard !kernel.commandLine.kernelArgs.contains(where: { $0.hasPrefix("\(key)=") }) else { + continue + } + kernel.commandLine.kernelArgs.append("\(key)=\(value)") + } let vmm = VZVirtualMachineManager( kernel: kernel, initialFilesystem: bundle.initialFilesystem.asMount, @@ -784,7 +794,7 @@ public actor RuntimeService { self.log.info("`clean` xpc handler") switch self.state { case .running: - guard message.string(key: RuntimeKeys.id.rawValue) != nil else { + guard let id = message.string(key: RuntimeKeys.id.rawValue) else { throw ContainerizationError( .invalidArgument, message: "no id supplied for clean" @@ -792,6 +802,12 @@ public actor RuntimeService { } let ctr = try getContainer() + guard id == ctr.config.id else { + throw ContainerizationError( + .invalidArgument, + message: "clean id does not match runtime container" + ) + } // Perform filesystem trim on the root filesystem try await ctr.container.filesystemOperation(operation: .trim, path: "/") @@ -814,6 +830,72 @@ public actor RuntimeService { } } + /// Snapshot the container's root filesystem. + /// + /// When the container is running, freeze/thaw around the copy for consistency. + /// When it is not running, copy directly without freeze/thaw. + /// + /// - Parameters: + /// - message: An XPC message with the following parameters: + /// - imagePath: The path to the source filesystem image. + /// - destinationPath: The path where the snapshot will be written. + /// + /// - Returns: An XPC message with no parameters. + @Sendable + public func snapshotDisk(_ message: XPCMessage) async throws -> XPCMessage { + self.log.info("`snapshotDisk` xpc handler") + switch self.state { + case .running, .booted: + guard let imagePath = message.string(key: RuntimeKeys.imagePath.rawValue) else { + throw ContainerizationError( + .invalidArgument, + message: "no image path supplied for snapshotDisk" + ) + } + guard let destinationPath = message.string(key: RuntimeKeys.destinationPath.rawValue) else { + throw ContainerizationError( + .invalidArgument, + message: "no destination path supplied for snapshotDisk" + ) + } + + let ctr = try getContainer() + let shouldFreeze = self.state == .running + + if shouldFreeze { + try await ctr.container.filesystemOperation(operation: .freeze, path: "/") + } + + do { + try FileManager.default.copyItem(atPath: imagePath, toPath: destinationPath) + } catch { + if shouldFreeze { + do { + try await ctr.container.filesystemOperation(operation: .thaw, path: "/") + } catch { + self.log.error( + "failed to thaw filesystem after snapshotDisk error", + metadata: [ + "error": "\(error)" + ]) + } + } + throw error + } + + if shouldFreeze { + try await ctr.container.filesystemOperation(operation: .thaw, path: "/") + } + + return message.reply() + default: + throw ContainerizationError( + .invalidState, + message: "cannot snapshot disk: container is not running" + ) + } + } + /// Dial a vsock port on the virtual machine. /// /// - Parameters: @@ -1029,13 +1111,24 @@ public actor RuntimeService { czConfig.cpus = config.resources.cpus czConfig.cpuOverhead = config.resources.cpuOverhead czConfig.memoryInBytes = config.resources.memoryInBytes - czConfig.sysctl = config.sysctls.reduce(into: [String: String]()) { - $0[$1.key] = $1.value - } + // Overcommit memory and allow more memory mappings than the kernel default + // so workloads inside swap-less guest VMs hit limits less easily. + var sysctls = config.sysctls + sysctls["vm.overcommit_memory"] = "1" + sysctls["vm.max_map_count"] = "262144" + czConfig.sysctl = sysctls // If the host doesn't support this, we'll throw on container creation. czConfig.virtualization = config.virtualization czConfig.useInit = config.useInit + // nil leaves LinuxContainer's own default set in place. + if let maskedPaths = config.maskedPaths { + czConfig.maskedPaths = maskedPaths + } + if let readonlyPaths = config.readonlyPaths { + czConfig.readonlyPaths = readonlyPaths + } + if let shmSize = config.shmSize { for i in czConfig.mounts.indices { if czConfig.mounts[i].destination == "/dev/shm" { diff --git a/Sources/SocketForwarder/ConnectHandler.swift b/Sources/SocketForwarder/ConnectHandler.swift index 5f98e805b..c55e3a538 100644 --- a/Sources/SocketForwarder/ConnectHandler.swift +++ b/Sources/SocketForwarder/ConnectHandler.swift @@ -19,13 +19,13 @@ import NIOCore import NIOPosix final class ConnectHandler { - private var pendingBytes: [NIOAny] private let serverAddress: SocketAddress + private let connectTimeout: TimeAmount private var log: Logger? = nil - init(serverAddress: SocketAddress, log: Logger?) { - self.pendingBytes = [] + init(serverAddress: SocketAddress, connectTimeout: TimeAmount, log: Logger?) { self.serverAddress = serverAddress + self.connectTimeout = connectTimeout self.log = log } } @@ -34,10 +34,6 @@ extension ConnectHandler: ChannelInboundHandler { typealias InboundIn = ByteBuffer typealias OutboundOut = ByteBuffer - func channelRead(context: ChannelHandlerContext, data: NIOAny) { - self.pendingBytes.append(data) - } - func handlerAdded(context: ChannelHandlerContext) { // Add logger metadata. self.log?[metadataKey: "proxy"] = "\(context.channel.localAddress?.description ?? "none")" @@ -51,31 +47,14 @@ extension ConnectHandler: ChannelInboundHandler { } } -extension ConnectHandler: RemovableChannelHandler { - func removeHandler(context: ChannelHandlerContext, removalToken: ChannelHandlerContext.RemovalToken) { - var didRead = false - - // We are being removed, and need to deliver any pending bytes we may have if we're upgrading. - while self.pendingBytes.count > 0 { - let data = self.pendingBytes.removeFirst() - context.fireChannelRead(data) - didRead = true - } - - if didRead { - context.fireChannelReadComplete() - } - - self.log?.trace("backend - removing connect handler from pipeline") - context.leavePipeline(removalToken: removalToken) - } -} +extension ConnectHandler: RemovableChannelHandler {} extension ConnectHandler { private func connectToServer(context: ChannelHandlerContext) { self.log?.trace("backend - connecting") ClientBootstrap(group: context.eventLoop) + .connectTimeout(self.connectTimeout) .connect(to: serverAddress) .assumeIsolatedUnsafeUnchecked() .whenComplete { result in @@ -105,6 +84,11 @@ extension ConnectHandler { try context.channel.pipeline.syncOperations.addHandler(localGlue) try peerChannel.pipeline.syncOperations.addHandler(peerGlue) context.pipeline.syncOperations.removeHandler(self, promise: nil) + + // Reads were paused on the frontend channel while we waited for the backend to + // connect. Resume both sides now that GlueHandler owns steady-state flow control. + try context.channel.syncOptions?.setOption(ChannelOptions.autoRead, value: true) + try peerChannel.syncOptions?.setOption(ChannelOptions.autoRead, value: true) } catch { // Close connected peer channel before closing our channel. peerChannel.close(mode: .all, promise: nil) diff --git a/Sources/SocketForwarder/TCPForwarder.swift b/Sources/SocketForwarder/TCPForwarder.swift index e5103360b..0f616419c 100644 --- a/Sources/SocketForwarder/TCPForwarder.swift +++ b/Sources/SocketForwarder/TCPForwarder.swift @@ -26,17 +26,21 @@ public struct TCPForwarder: SocketForwarder { private let eventLoopGroup: any EventLoopGroup + private let connectTimeout: TimeAmount + private let log: Logger? public init( proxyAddress: SocketAddress, serverAddress: SocketAddress, eventLoopGroup: any EventLoopGroup, + connectTimeout: TimeAmount = .seconds(10), log: Logger? = nil ) throws { self.proxyAddress = proxyAddress self.serverAddress = serverAddress self.eventLoopGroup = eventLoopGroup + self.connectTimeout = connectTimeout self.log = log } @@ -46,10 +50,13 @@ public struct TCPForwarder: SocketForwarder { let bootstrap = ServerBootstrap(group: self.eventLoopGroup) .serverChannelOption(ChannelOptions.socket(.init(SOL_SOCKET), .init(SO_REUSEADDR)), value: 1) .childChannelOption(ChannelOptions.socket(.init(SOL_SOCKET), .init(SO_REUSEADDR)), value: 1) + // Reads are paused until the backend connects; the client's bytes are held in the + // kernel receive buffer instead of an app-level buffer while we wait. + .childChannelOption(ChannelOptions.autoRead, value: false) .childChannelInitializer { channel in channel.eventLoop.makeCompletedFuture { try channel.pipeline.syncOperations.addHandler( - ConnectHandler(serverAddress: self.serverAddress, log: log) + ConnectHandler(serverAddress: self.serverAddress, connectTimeout: self.connectTimeout, log: log) ) } } diff --git a/Sources/SocketForwarder/UDPForwarder.swift b/Sources/SocketForwarder/UDPForwarder.swift index 54d472e1f..031e8dc15 100644 --- a/Sources/SocketForwarder/UDPForwarder.swift +++ b/Sources/SocketForwarder/UDPForwarder.swift @@ -26,6 +26,12 @@ private final class UDPProxyBackend: ChannelInboundHandler { typealias InboundIn = AddressedEnvelope typealias OutboundOut = AddressedEnvelope + // Datagrams sent by the client before the outbound backend channel finishes binding are + // queued here. The window is normally tiny (a local bind, not a network round-trip), so this + // cap is a safety valve, not the primary defense. UDP has no delivery guarantee, so dropping + // the newest datagram once full is an acceptable, protocol-consistent fallback. + private static let maxQueuedPayloads = 8 + private struct State { var queuedPayloads: Deque var channel: (any Channel)? @@ -72,10 +78,12 @@ private final class UDPProxyBackend: ChannelInboundHandler { self.log?.trace("backend - writing datagram to server") let outbound: UDPProxyBackend.OutboundOut = OutboundOut(remoteAddress: self.serverAddress, data: data) channel.writeAndFlush(outbound, promise: nil) - } else { + } else if state.queuedPayloads.count < Self.maxQueuedPayloads { // channel is initializing, queue self.log?.trace("backend - queuing datagram") state.queuedPayloads.append(data) + } else { + self.log?.trace("backend - queue full, dropping datagram") } } diff --git a/Tests/ContainerAPIClientTests/ParserTest.swift b/Tests/ContainerAPIClientTests/ParserTest.swift index 0dcc6f7cf..3e39698bd 100644 --- a/Tests/ContainerAPIClientTests/ParserTest.swift +++ b/Tests/ContainerAPIClientTests/ParserTest.swift @@ -633,6 +633,18 @@ struct ParserTest { #expect(Set(result) == Set(["FOO=fromuser", "BAR=fromimage", "BAZ=fromfile"])) } + @Test + func testAllEnvRejectsBareNameFromImage() throws { + // Image config is untrusted: a bare name (no "=") must be dropped rather + // than expanded from the host process's environment. + let result = try Parser.allEnv( + imageEnvs: ["PATH", "FOO=fromimage"], + envFiles: [], + envs: [] + ) + #expect(Set(result) == Set(["FOO=fromimage"])) + } + private func tmpFileWithContent(_ content: String) throws -> URL { let tempDir = FileManager.default.temporaryDirectory let tempFile = tempDir.appendingPathComponent("envfile-test-\(UUID().uuidString)") diff --git a/Tests/ContainerBuildTests/BuildFSSyncTests.swift b/Tests/ContainerBuildTests/BuildFSSyncTests.swift new file mode 100644 index 000000000..5d6d607d2 --- /dev/null +++ b/Tests/ContainerBuildTests/BuildFSSyncTests.swift @@ -0,0 +1,397 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationOS +import Foundation +import SystemPackage +import Testing + +@testable import ContainerBuild + +// Tests for BuildFSSync — the handler that serves build-context files to the +// builder over the FSSync protocol. The suite creates real files on disk and +// calls the actor methods directly, bypassing the Walk/cache layer in the shim +// so that the macOS-side boundary enforcement is exercised in isolation. +@Suite class BuildFSSyncTests { + let fm = FileManager.default + let base: URL + let contextDir: URL + let outsideDir: URL + + init() throws { + base = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + contextDir = base.appendingPathComponent("context") + outsideDir = base.appendingPathComponent("outside") + try fm.createDirectory(at: URL(fileURLWithPath: contextDir.path(percentEncoded: false)), withIntermediateDirectories: true) + try fm.createDirectory(at: URL(fileURLWithPath: outsideDir.path(percentEncoded: false)), withIntermediateDirectories: true) + } + + deinit { + try? fm.removeItem(at: base) + } + + // MARK: - Helpers + + private func write(_ content: String, to url: URL) throws { + try content.data(using: .utf8)!.write(to: url) + } + + /// Returns a minimal BuildTransfer packet for the given context-relative source path. + private func readPacket(source: String) -> BuildTransfer { + var p = BuildTransfer() + p.id = UUID().uuidString + p.source = source + return p + } + + // MARK: - read(): symlink boundary enforcement + // + // The tests below call read() directly and expect it to throw when the + // requested path resolves outside the context directory. + // + // Final-component symlink tests (testReadRejectsAbsoluteSymlinkOutsideContext, + // testReadRejectsRelativeSymlinkOutsideContext): the source path is itself a + // symlink that resolves outside the context. + // + // Intermediate-component symlink test (testReadRejectsIntermediateDirectorySymlinkOutsideContext): + // the source path looks like a normal relative path ("subdir/secret.txt"), but + // an intermediate directory component is a symlink that escapes the context. + // read() unconditionally resolves the full path and checks parentOf, so this + // case is caught as well. + + @Test func testReadRejectsAbsoluteSymlinkOutsideContext() async throws { + let secretFile = outsideDir.appendingPathComponent("secret.txt") + try write("supersecret", to: secretFile) + + // Symlink inside context → absolute path outside context. + try fm.createSymbolicLink( + atPath: contextDir.appendingPathComponent("leak").path(percentEncoded: false), + withDestinationPath: secretFile.path(percentEncoded: false) + ) + + let fssync = try BuildFSSync(contextDir) + var continuation: AsyncStream.Continuation! + _ = AsyncStream { continuation = $0 } + defer { continuation.finish() } + + do { + try await fssync.read(continuation, readPacket(source: "leak"), "build-0") + Issue.record("read() should throw BuildFSSync.Error for a symlink that resolves outside the context") + } catch is BuildFSSync.Error { + // expected + } + } + + @Test func testReadRejectsRelativeSymlinkOutsideContext() async throws { + let secretFile = outsideDir.appendingPathComponent("secret.txt") + try write("supersecret", to: secretFile) + + // Symlink inside context → relative path that traverses above the context root. + // contextDir = base/context, outsideDir = base/outside, so the relative + // path from base/context/leak to base/outside/secret.txt is ../outside/secret.txt. + try fm.createSymbolicLink( + atPath: contextDir.appendingPathComponent("leak").path(percentEncoded: false), + withDestinationPath: "../outside/secret.txt" + ) + + let fssync = try BuildFSSync(contextDir) + var continuation: AsyncStream.Continuation! + _ = AsyncStream { continuation = $0 } + defer { continuation.finish() } + + do { + try await fssync.read(continuation, readPacket(source: "leak"), "build-0") + Issue.record("read() should throw BuildFSSync.Error for a relative symlink that resolves outside the context") + } catch is BuildFSSync.Error { + // expected + } + } + + @Test func testReadRejectsIntermediateDirectorySymlinkOutsideContext() async throws { + let secretFile = outsideDir.appendingPathComponent("secret.txt") + try write("supersecret", to: secretFile) + + // Directory symlink inside context → directory outside context. + // The requested source "subdir/secret.txt" has a plain file as its final + // component, but read() resolves the full path unconditionally, so the + // intermediate symlink escape is caught by the parentOf check. + try fm.createSymbolicLink( + atPath: contextDir.appendingPathComponent("subdir").path(percentEncoded: false), + withDestinationPath: outsideDir.path(percentEncoded: false) + ) + + let fssync = try BuildFSSync(contextDir) + var continuation: AsyncStream.Continuation! + _ = AsyncStream { continuation = $0 } + defer { continuation.finish() } + + do { + try await fssync.read(continuation, readPacket(source: "subdir/secret.txt"), "build-0") + Issue.record("read() should throw BuildFSSync.Error when an intermediate path component is a symlink outside the context") + } catch is BuildFSSync.Error { + // expected + } + } + + // MARK: - info(): symlink boundary enforcement + // + // info() is the metadata-only half of the shim's FS.Open() fallback path. + // It must enforce the same context boundary as read(). All three tests + // below should pass: the fix unconditionally resolves the full path and + // checks parentOf before serving any metadata. + + @Test func testInfoRejectsAbsoluteSymlinkOutsideContext() async throws { + let secretFile = outsideDir.appendingPathComponent("secret.txt") + try write("supersecret", to: secretFile) + + try fm.createSymbolicLink( + atPath: contextDir.appendingPathComponent("leak").path(percentEncoded: false), + withDestinationPath: secretFile.path(percentEncoded: false) + ) + + let fssync = try BuildFSSync(contextDir) + var continuation: AsyncStream.Continuation! + _ = AsyncStream { continuation = $0 } + defer { continuation.finish() } + + do { + try await fssync.info(continuation, readPacket(source: "leak"), "build-0") + Issue.record("info() should throw BuildFSSync.Error for a symlink that resolves outside the context") + } catch is BuildFSSync.Error { + // expected + } + } + + @Test func testInfoRejectsRelativeSymlinkOutsideContext() async throws { + let secretFile = outsideDir.appendingPathComponent("secret.txt") + try write("supersecret", to: secretFile) + + try fm.createSymbolicLink( + atPath: contextDir.appendingPathComponent("leak").path(percentEncoded: false), + withDestinationPath: "../outside/secret.txt" + ) + + let fssync = try BuildFSSync(contextDir) + var continuation: AsyncStream.Continuation! + _ = AsyncStream { continuation = $0 } + defer { continuation.finish() } + + do { + try await fssync.info(continuation, readPacket(source: "leak"), "build-0") + Issue.record("info() should throw BuildFSSync.Error for a relative symlink that resolves outside the context") + } catch is BuildFSSync.Error { + // expected + } + } + + @Test func testInfoRejectsIntermediateDirectorySymlinkOutsideContext() async throws { + let secretFile = outsideDir.appendingPathComponent("secret.txt") + try write("supersecret", to: secretFile) + + try fm.createSymbolicLink( + atPath: contextDir.appendingPathComponent("subdir").path(percentEncoded: false), + withDestinationPath: outsideDir.path(percentEncoded: false) + ) + + let fssync = try BuildFSSync(contextDir) + var continuation: AsyncStream.Continuation! + _ = AsyncStream { continuation = $0 } + defer { continuation.finish() } + + do { + try await fssync.info(continuation, readPacket(source: "subdir/secret.txt"), "build-0") + Issue.record("info() should throw BuildFSSync.Error when an intermediate path component is a symlink outside the context") + } catch is BuildFSSync.Error { + // expected + } + } + + // MARK: - walk(): directory symlink boundary enforcement + // + // walk() is the primary data path — every build goes through it, and its + // results are what gets packed into the tar sent to the builder. A + // directory symlink inside the context must not let anything physically + // outside the context root end up in those results. The test below + // asserts that directly. + + @Test func testWalkDoesNotFollowDirectorySymlinkOutsideContext() async throws { + let secretFile = outsideDir.appendingPathComponent("secret.txt") + try write("supersecret", to: secretFile) + + // Directory symlink inside context → directory outside context. + try fm.createSymbolicLink( + atPath: contextDir.appendingPathComponent("subdir").path(percentEncoded: false), + withDestinationPath: outsideDir.path(percentEncoded: false) + ) + + let fssync = try BuildFSSync(contextDir) + // Request the symlinked directory. Globber.childrenRecursive follows it + // and currently returns the external file as if it were in the context. + let urls = try await fssync.walk(root: contextDir, includePatterns: ["subdir"]) + + // The directory symlink itself is expected in results (Rule 2: it appears + // as a symlink entry in the tar). What must not appear are non-symlink + // entries — regular files or directories — that physically reside outside + // the context root. + let leaked = urls.filter { url in + guard !url.isSymlink else { return false } + let resolved = url.resolvingSymlinksInPath() + return !self.contextDir.parentOf(resolved) + && resolved.cleanPath != self.contextDir.cleanPath + } + + #expect(leaked.isEmpty, "walk() returned non-symlink URLs that physically resolve outside the context: \(leaked)") + } + + // MARK: - walk(): JSON/FileInfo metadata paths + // + // walk() has two response formats: a tar stream (the primary data path, + // handled above) and a JSON array of FileInfo, used for metadata-only + // walks. FileInfo.target reports the same literal, unresolved on-disk + // symlink destination for every symlink — in-context or not — that tar + // mode already exposes via Archiver's use of destinationOfSymbolicLink. + // It must never be an absolute or canonicalized path derived from + // resolvingSymlinksInPath(), which would disclose a host-resolved path + // for a symlink that escapes the context. + + /// Returns a minimal BuildTransfer packet requesting a JSON-mode walk. + private func walkJSONPacket(followPaths: [String]) -> BuildTransfer { + var p = BuildTransfer() + p.id = UUID().uuidString + p.source = "." + p.metadata = [ + "followpaths": followPaths.joined(separator: ","), + "mode": "json", + ] + return p + } + + /// Drives the actor's real walk(_:_:_:) method in JSON mode and decodes + /// the resulting FileInfo array. + private func walkJSON(_ fssync: BuildFSSync, followPaths: [String] = ["*"]) async throws -> [BuildFSSync.FileInfo] { + var continuation: AsyncStream.Continuation! + let stream = AsyncStream { continuation = $0 } + try await fssync.walk(continuation, walkJSONPacket(followPaths: followPaths), "build-0") + continuation.finish() + + var fileInfos: [BuildFSSync.FileInfo] = [] + for await resp in stream { + let data = resp.buildTransfer.data + if !data.isEmpty { + fileInfos += try JSONDecoder().decode([BuildFSSync.FileInfo].self, from: data) + } + } + return fileInfos + } + + @Test func testWalkJSONReportsEmptyTargetForRegularFile() async throws { + try write("hello", to: contextDir.appendingPathComponent("plain.txt")) + + let fssync = try BuildFSSync(contextDir) + let infos = try await walkJSON(fssync) + + let plain = infos.first { $0.name == "plain.txt" } + #expect(plain != nil, "the regular file should appear in walk() results") + #expect(plain?.target == "", "a regular file should report an empty target") + } + + @Test func testWalkJSONReportsLiteralTargetForInContextSymlink() async throws { + try write("hello", to: contextDir.appendingPathComponent("real.txt")) + try fm.createSymbolicLink( + atPath: contextDir.appendingPathComponent("alias").path(percentEncoded: false), + withDestinationPath: "real.txt" + ) + + let fssync = try BuildFSSync(contextDir) + let infos = try await walkJSON(fssync) + + let alias = infos.first { $0.name == "alias" } + #expect(alias != nil, "the in-context symlink should appear in walk() results") + #expect(alias?.target == "real.txt", "in-context symlink target should be the literal on-disk value, got \(alias?.target ?? "nil")") + } + + @Test func testWalkJSONReportsLiteralTargetForAbsoluteSymlinkOutsideContext() async throws { + let secretFile = outsideDir.appendingPathComponent("secret.txt") + try write("supersecret", to: secretFile) + + let literalDestination = secretFile.path(percentEncoded: false) + try fm.createSymbolicLink( + atPath: contextDir.appendingPathComponent("leak").path(percentEncoded: false), + withDestinationPath: literalDestination + ) + + let fssync = try BuildFSSync(contextDir) + let infos = try await walkJSON(fssync) + + let leak = infos.first { $0.name == "leak" } + #expect(leak != nil, "the escaping symlink should still appear in walk() results") + #expect( + leak?.target == literalDestination, + "target should be the literal symlink destination, not a resolved/canonicalized path: \(leak?.target ?? "nil")") + } + + @Test func testWalkJSONReportsLiteralTargetForRelativeSymlinkOutsideContext() async throws { + let secretFile = outsideDir.appendingPathComponent("secret.txt") + try write("supersecret", to: secretFile) + + // contextDir = base/context, outsideDir = base/outside, so the + // relative destination from context/leak to outside/secret.txt is + // ../outside/secret.txt. Resolving this always yields an absolute + // path, so a literal-vs-resolved mismatch here is deterministic and + // does not depend on any symlink quirks in the host's temp dir. + let literalDestination = "../outside/secret.txt" + try fm.createSymbolicLink( + atPath: contextDir.appendingPathComponent("leak").path(percentEncoded: false), + withDestinationPath: literalDestination + ) + + let fssync = try BuildFSSync(contextDir) + let infos = try await walkJSON(fssync) + + let leak = infos.first { $0.name == "leak" } + #expect(leak != nil, "the escaping symlink should still appear in walk() results") + #expect( + leak?.target == literalDestination, + "target should be the literal (relative) symlink destination, never a resolved absolute host path: \(leak?.target ?? "nil")" + ) + #expect(leak?.target.hasPrefix("/") == false, "target must not be an absolute host path for an out-of-context symlink") + } + + @Test func testWalkJSONReportsLiteralTargetForDirectorySymlinkOutsideContext() async throws { + let secretFile = outsideDir.appendingPathComponent("secret.txt") + try write("supersecret", to: secretFile) + + let literalDestination = outsideDir.path(percentEncoded: false) + try fm.createSymbolicLink( + atPath: contextDir.appendingPathComponent("subdir").path(percentEncoded: false), + withDestinationPath: literalDestination + ) + + let fssync = try BuildFSSync(contextDir) + let infos = try await walkJSON(fssync, followPaths: ["subdir"]) + + let leak = infos.first { $0.name == "subdir" } + #expect(leak != nil, "the escaping directory symlink should still appear in walk() results") + #expect( + leak?.target == literalDestination, + "target should be the literal symlink destination, not the resolved external directory path: \(leak?.target ?? "nil")") + + // Nothing from inside the external directory should have leaked in as + // its own entry — the fixed Globber must not have descended into it. + let secretLeak = infos.first { $0.name.hasSuffix("secret.txt") } + #expect(secretLeak == nil, "no entry for the external file should appear in walk() results: \(infos.map { $0.name })") + } +} diff --git a/Tests/ContainerBuildTests/GlobberTests.swift b/Tests/ContainerBuildTests/GlobberTests.swift index fff0fc2dd..b2d2a2c51 100644 --- a/Tests/ContainerBuildTests/GlobberTests.swift +++ b/Tests/ContainerBuildTests/GlobberTests.swift @@ -14,6 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerizationOS import Foundation import Testing @@ -179,7 +180,8 @@ let testCases = [ #expect(throws: Never.self) { try globber.match(test.pattern) let found: Bool = !globber.results.isEmpty - #expect(found == test.expectSuccess, "expected match to be \(test.expectSuccess), instead got \(found) \(tempDir.childrenRecursive)") + let onDisk = FileManager.default.enumerator(at: tempDir, includingPropertiesForKeys: nil)?.allObjects ?? [] + #expect(found == test.expectSuccess, "expected match to be \(test.expectSuccess), instead got \(found) \(onDisk)") } } @@ -202,4 +204,116 @@ let testCases = [ #expect(globber.results.isEmpty, "expected to find no matches, instead found \(globber.results)") } } + + // MARK: - Directory symlink traversal + // + // Globber must be able to descend through a directory symlink whose fully + // resolved target is still inside the match root (the same containment + // check BuildFSSync.read()/info() apply before serving content), while + // continuing to treat a symlink that escapes the root as a leaf with no + // children. See the discussion on + // https://github.com/apple/container-ghsa-2v2q-4q35-h585/pull/2. + + @Test("Match descends through an in-context directory symlink") + func testMatchFollowsInContextDirectorySymlink() throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let real = tempDir.appendingPathComponent("real") + let link = tempDir.appendingPathComponent("link") + let file = real.appendingPathComponent("file.txt") + + try FileManager.default.createDirectory(at: real, withIntermediateDirectories: true) + try "hello".write(to: file, atomically: true, encoding: .utf8) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: real) + + defer { try? FileManager.default.removeItem(at: tempDir) } + + let globber = Globber(tempDir) + try globber.match("link/file.txt") + + // Compare by name rather than exact path: on macOS /var is itself a + // symlink to /private/var, and FileManager.contentsOfDirectory vs. + // URL.resolvingSymlinksInPath() normalize that inconsistently, so a + // temp-dir-rooted URL built by hand won't reliably string-match a URL + // Globber discovered through the real filesystem APIs. + #expect( + globber.results.contains { $0.isSymlink && $0.lastPathComponent == "link" }, + "expected the symlink itself to be preserved in results, got \(globber.results)" + ) + #expect( + globber.results.contains { $0.lastPathComponent == "file.txt" }, + "expected the resolved physical file to be present in results, got \(globber.results)" + ) + } + + @Test("Recursive glob descends through an in-context directory symlink") + func testMatchRecursiveGlobFollowsInContextDirectorySymlink() throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let real = tempDir.appendingPathComponent("real") + let link = tempDir.appendingPathComponent("link") + let file = real.appendingPathComponent("file.txt") + + try FileManager.default.createDirectory(at: real, withIntermediateDirectories: true) + try "hello".write(to: file, atomically: true, encoding: .utf8) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: real) + + defer { try? FileManager.default.removeItem(at: tempDir) } + + let globber = Globber(tempDir) + try globber.match("link/**") + + #expect(globber.results.contains { $0.isSymlink && $0.lastPathComponent == "link" }) + #expect(globber.results.contains { $0.lastPathComponent == "real" }) + #expect(globber.results.contains { $0.lastPathComponent == "file.txt" }) + } + + @Test("Match does not descend through a directory symlink that escapes the root") + func testMatchDoesNotDescendThroughSymlinkEscapingRoot() throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let outsideDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let link = tempDir.appendingPathComponent("link") + let secret = outsideDir.appendingPathComponent("secret.txt") + + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: outsideDir, withIntermediateDirectories: true) + try "supersecret".write(to: secret, atomically: true, encoding: .utf8) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: outsideDir) + + defer { + try? FileManager.default.removeItem(at: tempDir) + try? FileManager.default.removeItem(at: outsideDir) + } + + let globber = Globber(tempDir) + try globber.match("link/secret.txt") + + #expect(globber.results.isEmpty, "expected no match through a symlink escaping the root, got \(globber.results)") + } + + @Test("Recursive glob through a symlink escaping the root never leaks out-of-root files") + func testMatchRecursiveGlobDoesNotLeakOutsideRoot() throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let outsideDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let link = tempDir.appendingPathComponent("link") + let secret = outsideDir.appendingPathComponent("secret.txt") + + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: outsideDir, withIntermediateDirectories: true) + try "supersecret".write(to: secret, atomically: true, encoding: .utf8) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: outsideDir) + + defer { + try? FileManager.default.removeItem(at: tempDir) + try? FileManager.default.removeItem(at: outsideDir) + } + + let globber = Globber(tempDir) + try globber.match("link/**") + + let leaked = globber.results.filter { url in + guard !url.isSymlink else { return false } + let resolved = url.resolvingSymlinksInPath() + return !tempDir.parentOf(resolved) && resolved.cleanPath != tempDir.cleanPath + } + #expect(leaked.isEmpty, "match() produced non-symlink results that physically resolve outside the root: \(leaked)") + } } diff --git a/Tests/ContainerPluginTests/PluginFactoryTest.swift b/Tests/ContainerPluginTests/PluginFactoryTest.swift index cc154560b..20f53643e 100644 --- a/Tests/ContainerPluginTests/PluginFactoryTest.swift +++ b/Tests/ContainerPluginTests/PluginFactoryTest.swift @@ -14,6 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerizationError import Foundation import Logging import Testing @@ -328,4 +329,78 @@ struct PluginFactoryTest { #expect(plugin.name == name) #expect(plugin.config.abstract == "TOML service") } + + @Test + func testDefaultFactoryRejectsTraversalName() async throws { + let fm = FileManager.default + let tempURL = try fm.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + defer { try? FileManager.default.removeItem(at: tempURL) } + + // A complete, loadable plugin layout planted outside the plugin parent directory. + let outsideURL = tempURL.appending(path: "outside") + let binDirURL = outsideURL.appending(path: "bin") + try fm.createDirectory(at: binDirURL, withIntermediateDirectories: true) + try "abstract = \"payload\"\nauthor = \"Apple\"" + .write(to: outsideURL.appending(path: "config.toml"), atomically: true, encoding: .utf8) + try "".write(to: binDirURL.appending(path: "outside"), atomically: true, encoding: .utf8) + + let pluginParent = tempURL.appending(path: "plugins") + try fm.createDirectory(at: pluginParent, withIntermediateDirectories: true) + + let factory = DefaultPluginFactory(logger: Logger(label: "test")) + #expect(throws: ContainerizationError.self) { + try factory.create(parentURL: pluginParent, name: "../outside") + } + } + + @Test + func testDefaultFactoryRejectsParentDirectoryName() async throws { + let factory = DefaultPluginFactory(logger: Logger(label: "test")) + #expect(throws: ContainerizationError.self) { + try factory.create(parentURL: URL(fileURLWithPath: "/tmp"), name: "..") + } + } + + @Test + func testDefaultFactoryRejectsNullByteTraversalName() async throws { + let factory = DefaultPluginFactory(logger: Logger(label: "test")) + #expect(throws: ContainerizationError.self) { + try factory.create(parentURL: URL(fileURLWithPath: "/tmp"), name: "evil\u{0}/../../../../etc") + } + } + + @Test + func testAppBundleFactoryRejectsTraversalName() async throws { + let fm = FileManager.default + let tempURL = try fm.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + defer { try? FileManager.default.removeItem(at: tempURL) } + + // A complete, loadable app-bundle plugin layout planted outside the plugin parent directory. + let outsideURL = tempURL.appending(path: "outside.app") + let resourcesURL = outsideURL.appending(path: "Contents").appending(path: "Resources") + try fm.createDirectory(at: resourcesURL, withIntermediateDirectories: true) + try "abstract = \"payload\"\nauthor = \"Apple\"" + .write(to: resourcesURL.appending(path: "config.toml"), atomically: true, encoding: .utf8) + let macosURL = outsideURL.appending(path: "Contents").appending(path: "MacOS") + try fm.createDirectory(at: macosURL, withIntermediateDirectories: true) + try "".write(to: macosURL.appending(path: "outside"), atomically: true, encoding: .utf8) + + let pluginParent = tempURL.appending(path: "plugins") + try fm.createDirectory(at: pluginParent, withIntermediateDirectories: true) + + let factory = AppBundlePluginFactory(logger: Logger(label: "test")) + #expect(throws: ContainerizationError.self) { + try factory.create(parentURL: pluginParent, name: "../outside") + } + } } diff --git a/Tests/IntegrationTests/Build/TestCLIBuilder.swift b/Tests/IntegrationTests/Build/TestCLIBuilder.swift new file mode 100644 index 000000000..95d4f74f8 --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilder.swift @@ -0,0 +1,1033 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerTestSupport +import Darwin +import Foundation +import Testing + +// Convenience alias for the verbose entry type. +typealias FSEntry = ContainerFixture.FileSystemEntry + +struct TestCLIBuilder { + + // MARK: - Basic build tests + + @Test func testBuildDefaultParams() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext(dir: dir, dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20") + // No tags — runtime generates one and prints it to stdout. + let output = try f.buildWithPaths(contextDir: dir) + let generatedTag = output.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(!generatedTag.isEmpty, "build should print the generated image tag to stdout") + try f.assertImageBuilt(generatedTag) + } + } + + @Test func testBuildDotFileSucceeds() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD emptyFile /", + context: [ + .file("emptyFile", content: .zeroFilled(size: 1)), + .file(".dockerignore", content: .data(".dockerignore\n".data(using: .utf8)!)), + ]) + let image = "registry.local/dot-file:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildFromPreviousStage() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 AS layer1 + RUN sh -c "echo 'layer1' > /layer1.txt" + FROM layer1 + CMD ["cat", "/layer1.txt"] + """) + let image = "registry.local/from-previous-layer:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildFromLocalImage() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD emptyFile /", + context: [ + .file("emptyFile", content: .zeroFilled(size: 0)), + .file(".dockerignore", content: .data(".dockerignore\n".data(using: .utf8)!)), + ]) + let image = "local-only:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + + let dir2 = try f.createTempDir() + try f.createContext( + dir: dir2, + dockerfile: "FROM \(image)", + context: []) + let image2 = "from-local:\(UUID().uuidString)" + try f.build(tag: image2, contextDir: dir2) + try f.assertImageBuilt(image2) + } + } + + @Test func testBuildAddFromSpecialDirs() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD emptyFile /", + context: [.file("emptyFile", content: .zeroFilled(size: 1))]) + let image = "registry.local/scratch-add-special-dir:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildScratchAdd() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD emptyFile /", + context: [.file("emptyFile", content: .zeroFilled(size: 1))]) + let image = "registry.local/scratch-add:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildAddAll() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + ADD . . + RUN cat emptyFile + RUN cat Test/testempty + """, + context: [ + .directory("Test"), + .file("Test/testempty", content: .zeroFilled(size: 1)), + .file("emptyFile", content: .zeroFilled(size: 1)), + ]) + let image = "registry.local/add-all:\(UUID().uuidString)" + let output = try f.build(tag: image, contextDir: dir) + #expect(output.contains(image)) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildArg() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "ARG TAG=unknown\nFROM ghcr.io/linuxcontainers/alpine:${TAG}") + let image = "registry.local/build-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir, buildArgs: ["TAG=3.20"]) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildSecret() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + RUN --mount=type=secret,id=ENV1 \\ + --mount=type=secret,id=env2 \\ + --mount=type=secret,id=env3 \\ + test xyyzzz = "`cat /run/secrets/ENV1 /run/secrets/env2 /run/secrets/env3`" + RUN --mount=type=secret,id=file \\ + awk 'BEGIN {for(i=0; i<17; i++) for(c=0; c<256; c++) printf("%c", c)}' > /tmp/foo && \\ + cmp /tmp/foo /run/secrets/file && \\ + rm /tmp/foo + RUN --mount=type=secret,id=empty \\ + ! test -e /run/secrets/file && \\ + test -e /run/secrets/empty && \\ + cmp /dev/null /run/secrets/empty + """) + + setenv("ENV1", "x", 1) + setenv("ENV_VAR", "yy", 1) + setenv("env3", "zzz", 1) + f.addCleanup { + unsetenv("ENV1") + unsetenv("ENV_VAR") + unsetenv("env3") + } + + let testData = Data((0..<17).flatMap { _ in Array(0...255) }) + let secretFile = try f.createTempFile(suffix: " _f,i=l.e+ ", contents: testData) + let emptyFile = try f.createTempFile(suffix: "file2", contents: Data()) + + let image = "registry.local/secrets:\(UUID().uuidString)" + try f.build( + tag: image, contextDir: dir, + otherArgs: [ + "--secret", "id=ENV1", + "--secret", "id=env2,env=ENV_VAR", + "--secret", "id=env3,env=env3", + "--secret", "id=file,src=\(secretFile.string)", + "--secret", "id=empty,src=\(emptyFile.string)", + ]) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildNetworkAccess() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + ARG HTTP_PROXY + ARG HTTPS_PROXY + ARG NO_PROXY + ARG http_proxy + ARG https_proxy + ARG no_proxy + RUN apk add --no-cache curl + """) + var buildArgs: [String] = [] + for key in ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"] { + if let v = ProcessInfo.processInfo.environment[key] { buildArgs.append("\(key)=\(v)") } + } + let image = "registry.local/build-network-access:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir, buildArgs: buildArgs) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildDockerfileKeywords() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + ARG TAG=3.20 + FROM ghcr.io/linuxcontainers/alpine:${TAG} + FROM ghcr.io/linuxcontainers/alpine:3.20 + RUN echo "Hello, World!" > /hello.txt + FROM ghcr.io/linuxcontainers/alpine:3.20 + RUN ["sh", "-c", "echo 'Exec form' > /exec.txt"] + FROM ghcr.io/linuxcontainers/alpine:3.20 + CMD ["echo", "Exec default"] + FROM ghcr.io/linuxcontainers/alpine:3.20 + LABEL version="1.0" description="Test image" + FROM ghcr.io/linuxcontainers/alpine:3.20 + EXPOSE 8080 + FROM ghcr.io/linuxcontainers/alpine:3.20 + ENV MY_ENV=hello + RUN echo $MY_ENV > /env.txt + FROM ghcr.io/linuxcontainers/alpine:3.20 + ADD emptyFile / + FROM ghcr.io/linuxcontainers/alpine:3.20 + COPY toCopy /toCopy + FROM ghcr.io/linuxcontainers/alpine:3.20 + ENTRYPOINT ["echo", "entrypoint!"] + FROM ghcr.io/linuxcontainers/alpine:3.20 + VOLUME /data + FROM ghcr.io/linuxcontainers/alpine:3.20 + RUN adduser -D myuser + USER myuser + CMD whoami + FROM ghcr.io/linuxcontainers/alpine:3.20 + WORKDIR /app + RUN pwd > /pwd.out + FROM ghcr.io/linuxcontainers/alpine:3.20 + ARG MY_VAR=default + RUN echo $MY_VAR > /var.out + """, + context: [ + .file("emptyFile", content: .zeroFilled(size: 1)), + .file("toCopy", content: .zeroFilled(size: 1)), + ]) + let image = "registry.local/dockerfile-keywords:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildSymlink() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + ADD Test1Source Test1Source + ADD Test1Source2 Test1Source2 + RUN cat Test1Source2/test.yaml + FROM ghcr.io/linuxcontainers/alpine:3.20 + ADD Test2Source Test2Source + ADD Test2Source2 Test2Source2 + RUN cat Test2Source2/Test/test.txt + FROM ghcr.io/linuxcontainers/alpine:3.20 + ADD Test3Source Test3Source + ADD Test3Source2 Test3Source2 + RUN cat Test3Source2/Dest/test.txt + """ + let context: [FSEntry] = [ + .directory("Test1Source"), .directory("Test1Source2"), + .file("Test1Source/test.yaml", content: .zeroFilled(size: 200)), + .symbolicLink("Test1Source2/test.yaml", target: "Test1Source/test.yaml"), + .directory("Test2Source"), .directory("Test2Source2"), + .file("Test2Source/Test/Test/test.yaml", content: .zeroFilled(size: 300)), + .symbolicLink("Test2Source2/Test/test.yaml", target: "Test2Source/Test/Test/test.yaml"), + .directory("Test3Source/Source"), .directory("Test3Source2"), + .file("Test3Source/Source/test.txt", content: .zeroFilled(size: 1)), + .symbolicLink("Test3Source2/Dest", target: "Test3Source/Source"), + ] + try f.createContext(dir: dir, dockerfile: dockerfile, context: context) + let image = "registry.local/build-symlinks:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildAndRun() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nRUN echo \"foobar\" > /file") + let image = "\(f.testID)-build-and-run:latest" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + try await f.withContainer(image: image) { name in + let output = try f.doExec(name, cmd: ["cat", "/file"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == "foobar") + } + } + } + + @Test func testBuildDifferentPaths() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + RUN ls ./ + COPY . /root + RUN cat /root/Test/test.txt + """, + context: [ + .directory(".git"), + .file(".git/FETCH", content: .zeroFilled(size: 1)), + .directory("Test"), + .file("Test/test.txt", content: .zeroFilled(size: 1)), + ]) + let image = "registry.local/build-diff-context:\(UUID().uuidString)" + try f.buildWithPaths(tags: [image], contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildMultiArch() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + ADD . . + RUN cat emptyFile + RUN cat Test/testempty + """, + context: [ + .directory("Test"), + .file("Test/testempty", content: .zeroFilled(size: 1)), + .file("emptyFile", content: .zeroFilled(size: 1)), + ]) + let image = "registry.local/multi-arch:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir, otherArgs: ["--arch", "amd64,arm64"]) + try f.assertImageBuilt(image) + + let output = try f.doInspectImages(image) + #expect(output.count == 1, "expected single inspect result") + let archs = Set(output[0].variants.map { $0.platform.architecture }) + #expect(archs == Set(["amd64", "arm64"]), "expected amd64 and arm64 variants") + } + } + + @Test func testBuildMultipleTags() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD emptyFile /", + context: [.file("emptyFile", content: .zeroFilled(size: 1))]) + let uuid = UUID().uuidString + let tag1 = "registry.local/multi-tag-test:\(uuid)" + let tag2 = "registry.local/multi-tag-test:latest" + let tag3 = "registry.local/multi-tag-test:v1.0.0" + let output = try f.buildWithPaths(tags: [tag1, tag2, tag3], contextDir: dir) + #expect(output.contains(tag1)) + #expect(output.contains(tag2)) + #expect(output.contains(tag3)) + try f.assertImageBuilt(tag1) + try f.assertImageBuilt(tag2) + try f.assertImageBuilt(tag3) + } + } + + @Test func testBuildAfterContextChange() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let initialContent = "initial".data(using: .utf8)! + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nCOPY foo /foo\nCOPY bar /bar", + context: [ + .file("foo", content: .data(Data((0..<4 * 1024 * 1024).map { UInt8($0 % 256) }))), + .file("bar", content: .data(initialContent)), + ]) + + let image1 = "\(f.testID)-build-context-change:v1" + try f.build(tag: image1, contextDir: dir) + try await f.withContainer(image: image1) { name in + let out = try f.doExec(name, cmd: ["cat", "/bar"]) + #expect(out == "initial") + } + + let contextBar = dir.appending("context").appending("bar") + try "updated".data(using: .utf8)!.write(to: URL(filePath: contextBar.string), options: .atomic) + + let image2 = "\(f.testID)-build-context-change:v2" + try f.build(tag: image2, contextDir: dir) + try await f.withContainer(image: image2) { name in + let out = try f.doExec(name, cmd: ["cat", "/bar"]) + #expect(out == "updated") + } + } + } + + @Test func testBuildWithDockerfileFromStdin() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = "FROM scratch\nADD emptyFile /" + try f.createContext( + dir: dir, dockerfile: "", + context: [.file("emptyFile", content: .zeroFilled(size: 1))]) + let image = "registry.local/stdin-file:\(UUID().uuidString)" + try f.buildWithStdin(tags: [image], contextDir: dir, dockerfileContents: dockerfile) + try f.assertImageBuilt(image) + } + } + + @Test func testLowercaseDockerfile() async throws { + try await ContainerFixture.with { f in + let files: [(String, String, String)] = [ + ("COPY . /app", "copy-uppercase", "COPY"), + ("copy . /app", "copy-lowercase", "copy"), + ("ADD . /app", "add-uppercase", "ADD"), + ("add . /app", "add-lowercase", "add"), + ] + for (instruction, name, _) in files { + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + \(instruction) + RUN test -f /app/testfile.txt + """, + context: [.file("testfile.txt", content: .data("test".data(using: .utf8)!))]) + let image = "registry.local/\(name):\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testRunWithBindMount() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + RUN --mount=type=bind,source=.,target=/mnt/context \\ + set -e; \\ + if [ ! -f /mnt/context/app.py ]; then echo "ERROR: app.py missing"; exit 1; fi; \\ + if [ ! -f /mnt/context/config.yaml ]; then echo "ERROR: config.yaml missing"; exit 1; fi; \\ + cp /mnt/context/app.py /app.py + RUN cat /app.py + """, + context: [ + .file("app.py", content: .data("print('Hello from bind mount')".data(using: .utf8)!)), + .file("config.yaml", content: .data("key: value".data(using: .utf8)!)), + ]) + let image = "registry.local/bind-mount-test:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + // MARK: - .dockerignore tests + + @Test func testBuildDockerIgnore() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerignore = """ + secret.txt + *.log + **/*.log + !important.log + *.tmp + **/*.tmp + temp/ + node_modules/ + """ + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + COPY . /app + RUN set -e; [ ! -f /app/secret.txt ] || exit 1 + RUN set -e; [ ! -f /app/debug.log ] || exit 1 + RUN set -e; [ -f /app/important.log ] || exit 1 + RUN set -e; find /app -name "*.tmp" | grep . && exit 1; true + RUN set -e; [ ! -d /app/temp ] || exit 1 + RUN set -e; [ ! -d /app/node_modules ] || exit 1 + RUN set -e; [ -f /app/main.go ] && [ -f /app/README.md ] && [ -f /app/src/app.go ] + """, + context: [ + .file(".dockerignore", content: .data(dockerignore.data(using: .utf8)!)), + .file("secret.txt", content: .data("secret".data(using: .utf8)!)), + .file("debug.log", content: .data("debug".data(using: .utf8)!)), + .file("important.log", content: .data("important".data(using: .utf8)!)), + .file("cache.tmp", content: .data("cache".data(using: .utf8)!)), + .file("main.go", content: .data("package main".data(using: .utf8)!)), + .file("README.md", content: .data("# README".data(using: .utf8)!)), + .directory("temp"), + .file("logs/app.log", content: .data("app log".data(using: .utf8)!)), + .directory("node_modules"), + .directory("src"), + .file("src/app.go", content: .data("package src".data(using: .utf8)!)), + ]) + let image = "registry.local/dockerignore-test:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testDockerIgnoreBasic() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, + dockerfile: dockerfile, + context: [ + .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file("included.txt", content: .data("included\n".data(using: .utf8)!)), + .file("ignored.txt", content: .data("ignored\n".data(using: .utf8)!)), + .file(".dockerignore", content: .data("ignored.txt\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let image = "registry.local/dockerignore-basic:\(UUID().uuidString)" + let result = try f.run([ + "build", "-f", contextDir.appending("Dockerfile").string, + "-t", image, contextDir.string, + ]) + try result.check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerHasFile(name, at: "/app/included.txt") + try f.assertContainerMissingFile(name, at: "/app/ignored.txt") + } + } + } + + @Test func testDockerIgnoreDockerfileSpecific() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: dockerfile, + context: [ + .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file(".dockerignore", content: .data("general.txt\n".data(using: .utf8)!)), + .file("Dockerfile.dockerignore", content: .data("specific.txt\n".data(using: .utf8)!)), + .file("general.txt", content: .data("general\n".data(using: .utf8)!)), + .file("specific.txt", content: .data("specific\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let image = "registry.local/dockerignore-specific:\(UUID().uuidString)" + try f.run([ + "build", "-f", contextDir.appending("Dockerfile").string, + "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerMissingFile(name, at: "/app/specific.txt", "specific.txt should be ignored by Dockerfile.dockerignore") + try f.assertContainerHasFile(name, at: "/app/general.txt", "general.txt should be present (Dockerfile.dockerignore takes precedence)") + } + } + } + + @Test func testDockerIgnoreOutsideContext() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: dockerfile, + context: [ + .file(".dockerignore", content: .data("general.txt\n".data(using: .utf8)!)), + .file("general.txt", content: .data("general\n".data(using: .utf8)!)), + .file("specific.txt", content: .data("specific\n".data(using: .utf8)!)), + ]) + try "specific.txt\n".data(using: .utf8)!.write(to: URL(filePath: dir.appending("Dockerfile.dockerignore").string), options: .atomic) + let image = "registry.local/dockerignore-outside:\(UUID().uuidString)" + try f.run([ + "build", "-f", dir.appending("Dockerfile").string, + "-t", image, dir.appending("context").string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerMissingFile(name, at: "/app/specific.txt") + try f.assertContainerHasFile(name, at: "/app/general.txt") + } + } + } + + @Test func testDockerIgnoreIgnoredDockerfile() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: dockerfile, + context: [ + .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file(".dockerignore", content: .data("Dockerfile\n.dockerignore\n".data(using: .utf8)!)), + .file("test.txt", content: .data("test\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let image = "registry.local/dockerignore-ignored-dockerfile:\(UUID().uuidString)" + try f.run([ + "build", "-f", contextDir.appending("Dockerfile").string, + "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerMissingFile(name, at: "/app/Dockerfile") + try f.assertContainerMissingFile(name, at: "/app/.dockerignore") + try f.assertContainerHasFile(name, at: "/app/test.txt") + } + } + } + + @Test func testDockerIgnoreSubdirDockerfile() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: dockerfile, + context: [ + .file(".dockerignore", content: .data("included.txt\n".data(using: .utf8)!)), + .file("included.txt", content: .data("included\n".data(using: .utf8)!)), + .file("secret.txt", content: .data("secret\n".data(using: .utf8)!)), + .file("nested/secret.txt", content: .data("nested secret\n".data(using: .utf8)!)), + .file("nested/project/Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file("nested/project/Dockerfile.dockerignore", content: .data("secret.txt\n**/secret.txt\n".data(using: .utf8)!)), + .file("nested/project/config.txt", content: .data("config\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let nestedDockerfile = contextDir.appending("nested").appending("project").appending("Dockerfile") + let image = "registry.local/dockerignore-subdir:\(UUID().uuidString)" + try f.run([ + "build", "-f", nestedDockerfile.string, "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerHasFile(name, at: "/app/included.txt") + try f.assertContainerMissingFile(name, at: "/app/secret.txt") + try f.assertContainerMissingFile(name, at: "/app/nested/secret.txt") + try f.assertContainerHasFile(name, at: "/app/nested/project/config.txt") + } + } + } + + @Test func testDockerIgnoreCustomDockerfileName() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: "", // no top-level Dockerfile + context: [ + .file(".dockerignore", content: .data("generic.txt\n".data(using: .utf8)!)), + .file("app1.Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file("app1.Dockerfile.dockerignore", content: .data("app1-specific.txt\n".data(using: .utf8)!)), + .file("app1-specific.txt", content: .data("app1 specific\n".data(using: .utf8)!)), + .file("generic.txt", content: .data("generic\n".data(using: .utf8)!)), + .file("included.txt", content: .data("included\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let image = "registry.local/dockerignore-custom-name:\(UUID().uuidString)" + try f.run([ + "build", "-f", contextDir.appending("app1.Dockerfile").string, + "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerMissingFile(name, at: "/app/app1-specific.txt") + try f.assertContainerHasFile(name, at: "/app/generic.txt") + try f.assertContainerHasFile(name, at: "/app/included.txt") + } + } + } + + @Test func testDockerIgnoreCustomNameSubdir() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: "", + context: [ + .file(".dockerignore", content: .data("from-root-ignore.txt\n".data(using: .utf8)!)), + .file("from-root-ignore.txt", content: .data("root ignore\n".data(using: .utf8)!)), + .file("from-app2-ignore.txt", content: .data("app2 ignore\n".data(using: .utf8)!)), + .file("always-included.txt", content: .data("always\n".data(using: .utf8)!)), + .file("nested/project/app2.Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file("nested/project/app2.Dockerfile.dockerignore", content: .data("from-app2-ignore.txt\n".data(using: .utf8)!)), + .file("nested/project/config.yaml", content: .data("config\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let nestedDockerfile = contextDir.appending("nested").appending("project").appending("app2.Dockerfile") + let image = "registry.local/dockerignore-custom-subdir:\(UUID().uuidString)" + try f.run([ + "build", "-f", nestedDockerfile.string, "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerMissingFile(name, at: "/app/from-app2-ignore.txt") + try f.assertContainerHasFile(name, at: "/app/from-root-ignore.txt") + try f.assertContainerHasFile(name, at: "/app/always-included.txt") + try f.assertContainerHasFile(name, at: "/app/nested/project/config.yaml") + } + } + } + + @Test func testDockerIgnoreCoexistingDockerfiles() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let appDockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: "", + context: [ + .file("Dockerfile", content: .data("FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . .\n".data(using: .utf8)!)), + .file("Dockerfile.dockerignore", content: .data("dockerfile-specific.txt\n".data(using: .utf8)!)), + .file("app.Dockerfile", content: .data(appDockerfile.data(using: .utf8)!)), + .file("app.Dockerfile.dockerignore", content: .data("app-specific.txt\n".data(using: .utf8)!)), + .file("dockerfile-specific.txt", content: .data("df specific\n".data(using: .utf8)!)), + .file("app-specific.txt", content: .data("app specific\n".data(using: .utf8)!)), + .file("included.txt", content: .data("included\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let image = "registry.local/dockerignore-coexisting:\(UUID().uuidString)" + try f.run([ + "build", "-f", contextDir.appending("app.Dockerfile").string, + "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerMissingFile(name, at: "/app/app-specific.txt") + try f.assertContainerHasFile(name, at: "/app/dockerfile-specific.txt") + try f.assertContainerHasFile(name, at: "/app/included.txt") + } + } + } + + @Test func testDockerIgnoreReadonlyContext() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: dockerfile, + context: [ + .file("included.txt", content: .data("included\n".data(using: .utf8)!)), + .file("secret.txt", content: .data("secret\n".data(using: .utf8)!)), + ]) + try "secret.txt\n".data(using: .utf8)!.write(to: URL(filePath: dir.appending("Dockerfile.dockerignore").string), options: .atomic) + + let contextDir = dir.appending("context") + // Make the context read-only, then restore before cleanup. + try FileManager.default.setAttributes( + [.posixPermissions: 0o555], ofItemAtPath: contextDir.string) + f.addCleanup { + try? FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: contextDir.string) + } + + let image = "registry.local/dockerignore-readonly:\(UUID().uuidString.prefix(6))" + try f.run([ + "build", "-f", dir.appending("Dockerfile").string, + "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerHasFile(name, at: "/app/included.txt") + try f.assertContainerMissingFile(name, at: "/app/secret.txt") + } + } + } + + @Test func testNonExistingDockerfile() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let image = "registry.local/non-existing-dockerfile:\(UUID().uuidString)" + let r1 = try f.run(["build", "-f", "non-existing-path", "-t", image, dir.string]) + #expect(r1.status != 0) + let r2 = try f.run(["build", "-t", image, dir.string]) + #expect(r2.status != 0) + } + } + + // MARK: - Dockerfile ARG quoting + + @Test func testBuildQuotedImageDockerfileArg() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "ARG IMAGE=\"ghcr.io/linuxcontainers/alpine:3.20\"\nFROM $IMAGE\nRUN test -f /etc/alpine-release") + let image = "registry.local/quoted-image-dockerfile-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildQuotedStringDockerfileArg() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nARG MYSTRING='\"Hello, world!\"'\nRUN test \"$MYSTRING\" = '\"Hello, world!\"'") + let image = "registry.local/quoted-string-dockerfile-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildForwardReferencedDockerfileArg() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + ARG ALPINE="ghcr.io/linuxcontainers/alpine" + ARG IMAGE="${ALPINE}:3.20" + FROM $IMAGE + RUN test -f /etc/alpine-release + """) + let image = "registry.local/forward-referenced-dockerfile-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildQuotedImageBuildArg() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "ARG IMAGE\nFROM $IMAGE\nRUN test -f /etc/alpine-release") + let image = "registry.local/quoted-image-build-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir, buildArgs: ["IMAGE=ghcr.io/linuxcontainers/alpine:3.20"]) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildQuotedStringBuildArg() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nARG MYSTRING\nRUN test \"$MYSTRING\" = '\"Hello, world!\"'") + let image = "registry.local/quoted-string-build-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir, buildArgs: ["MYSTRING=\"Hello, world!\""]) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildForwardReferencedBuildArg() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + ARG ALPINE + ARG IMAGE="$ALPINE:3.20" + FROM $IMAGE + RUN test -f /etc/alpine-release + """) + let image = "registry.local/forward-referenced-build-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir, buildArgs: ["ALPINE=ghcr.io/linuxcontainers/alpine"]) + try f.assertImageBuilt(image) + } + } + + // MARK: - COPY --from tests + + @Test func testCopyFromLocalImage() async throws { + try await ContainerFixture.with { f in + let baseDir = try f.createTempDir() + let baseName = "local-base:\(UUID().uuidString)" + try f.createContext( + dir: baseDir, + dockerfile: "FROM scratch\nADD hello.txt /hello.txt", + context: [.file("hello.txt", content: .data("hello\n".data(using: .utf8)!))]) + try f.build(tag: baseName, contextDir: baseDir) + try f.assertImageBuilt(baseName) + + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nCOPY --from=\(baseName) /hello.txt /copied.txt\nRUN cat /copied.txt") + let image = "registry.local/copy-from-local:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testCopyFromBuildStage() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM scratch AS builder + ADD hello.txt /hello.txt + FROM ghcr.io/linuxcontainers/alpine:3.20 + COPY --from=builder /hello.txt /copied.txt + RUN cat /copied.txt + """, + context: [.file("hello.txt", content: .data("hello\n".data(using: .utf8)!))]) + let image = "registry.local/copy-from-stage:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testCopyRenameFromStage() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM scratch AS builder + ADD hello.txt /hello.txt + FROM ghcr.io/linuxcontainers/alpine:3.20 + COPY --from=builder /hello.txt /renamed.txt + RUN cat /renamed.txt + """, + context: [.file("hello.txt", content: .data("hello\n".data(using: .utf8)!))]) + let image = "registry.local/copy-rename:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testCopyMissingFileFails() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM scratch AS builder + FROM ghcr.io/linuxcontainers/alpine:3.20 + COPY --from=builder /does-not-exist.txt /copied.txt + """) + let image = "registry.local/copy-missing:\(UUID().uuidString)" + let result = try f.run([ + "build", "-f", dir.appending("Dockerfile").string, + "-t", image, dir.appending("context").string, + ]) + #expect(result.status != 0, "build should fail when source file is missing") + } + } + + @Test func testCopyInvalidStageFails() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nCOPY --from=not_a_stage /hello.txt /copied.txt") + let image = "registry.local/copy-invalid-stage:\(UUID().uuidString)" + let result = try f.run([ + "build", "-f", dir.appending("Dockerfile").string, + "-t", image, dir.appending("context").string, + ]) + #expect(result.status != 0, "build should fail with invalid stage name") + } + } + + @Test func testCopyFromNonexistentImageFails() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nCOPY --from=doesnotexist:latest /hello.txt /copied.txt") + let image = "registry.local/copy-bad-image:\(UUID().uuidString)" + let result = try f.run([ + "build", "-f", dir.appending("Dockerfile").string, + "-t", image, dir.appending("context").string, + ]) + #expect(result.status != 0, "build should fail when source image does not exist") + } + } + + /// Regression test: the context *root* itself (not an entry inside it) is a + /// symlink to a sibling directory, e.g. `context -> real-context`. The build + /// must resolve the symlink and use the real directory's contents. + @Test func testBuildContextRootSymlink() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + COPY hello.txt hello.txt + RUN cat hello.txt + """ + try f.createContext( + dir: dir, + dockerfile: dockerfile, + context: [ + .file("hello.txt", content: .data(Data("hello from real-context".utf8))) + ]) + + let contextPath = dir.appending("context") + let realContextPath = dir.appending("real-context") + try FileManager.default.moveItem(atPath: contextPath.string, toPath: realContextPath.string) + try FileManager.default.createSymbolicLink( + atPath: contextPath.string, withDestinationPath: "real-context") + + let image = "registry.local/build-context-root-symlink:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } +} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderEnvOnly.swift b/Tests/IntegrationTests/Build/TestCLIBuilderEnvOnly.swift new file mode 100644 index 000000000..5bcb0f2a1 --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilderEnvOnly.swift @@ -0,0 +1,128 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerTestSupport +import Foundation +import Testing + +struct TestCLIBuilderEnvOnly { + @Test func testBuildEnvironmentOnlyImageFromScratch() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = + """ + FROM scratch + ARG BUILD_DATE + ARG VERSION=1.0.0 + ENV TERM=xterm \\ + BUILD_DATE=${BUILD_DATE} \\ + APP_VERSION=${VERSION} \\ + PATH=/usr/local/bin:/usr/bin:/bin + LABEL maintainer="test@example.com" version="${VERSION}" + """ + try f.createContext(dir: dir, dockerfile: dockerfile) + let imageName = "test-env-only:\(UUID().uuidString)" + try f.build(tag: imageName, contextDir: dir, buildArgs: ["BUILD_DATE=2025-01-01", "VERSION=2.0.0"]) + try f.assertImageBuilt(imageName) + } + } + + @Test func testBuildEnvironmentOnlyImageFromAlpine() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = + """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + ENV APP_NAME=myapp APP_VERSION=1.0.0 APP_ENV=production + LABEL maintainer="test@example.com" version="1.0.0" + """ + try f.createContext(dir: dir, dockerfile: dockerfile) + let imageName = "test-alpine-env:\(UUID().uuidString)" + try f.build(tag: imageName, contextDir: dir) + try f.assertImageBuilt(imageName) + } + } + + @Test func testMultiStageBuildWithEnvOnlyBase() async throws { + try await ContainerFixture.with { f in + let baseDir = try f.createTempDir() + let baseDockerfile = + """ + FROM scratch + ARG JOBS=6 + ARG ARCH=amd64 + ENV MAKEOPTS="-j${JOBS}" ARCH="${ARCH}" PATH=/usr/local/bin:/usr/bin + """ + try f.createContext(dir: baseDir, dockerfile: baseDockerfile) + let baseImageName = "test-env-base:\(UUID().uuidString)" + try f.build(tag: baseImageName, contextDir: baseDir, buildArgs: ["JOBS=8", "ARCH=arm64"]) + try f.assertImageBuilt(baseImageName) + + let downstreamDir = try f.createTempDir() + let downstreamDockerfile = + """ + FROM \(baseImageName) + LABEL test="env-inherited" + """ + try f.createContext(dir: downstreamDir, dockerfile: downstreamDockerfile) + let downstreamImageName = "test-env-child:\(UUID().uuidString)" + try f.build(tag: downstreamImageName, contextDir: downstreamDir) + try f.assertImageBuilt(downstreamImageName) + } + } + + @Test func testComplexArgAndEnvCombinations() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = + """ + FROM scratch + ARG JOBS=6 + ARG MAXLOAD=7.00 + ARG ARCH=amd64 + ARG PROFILE_PATH=23.0/split-usr/no-multilib + ARG CHOST=x86_64-pc-linux-gnu + ARG CFLAGS=-O2 -pipe + ENV JOBS="${JOBS}" MAXLOAD="${MAXLOAD}" \\ + GENTOO_PROFILE="default/linux/${ARCH}/${PROFILE_PATH}" \\ + CHOST="${CHOST}" MAKEOPTS="-j${JOBS}" \\ + CFLAGS="${CFLAGS}" CXXFLAGS="${CFLAGS}" + LABEL maintainer="test@example.com" + """ + try f.createContext(dir: dir, dockerfile: dockerfile) + let imageName = "test-complex-env:\(UUID().uuidString)" + try f.build(tag: imageName, contextDir: dir, buildArgs: ["JOBS=12", "ARCH=arm64"]) + try f.assertImageBuilt(imageName) + } + } + + @Test func testLabelOnlyDockerfile() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = + """ + FROM scratch + LABEL maintainer="test@example.com" version="1.0.0" \\ + description="Test image with only labels" \\ + org.opencontainers.image.title="Test Image" + """ + try f.createContext(dir: dir, dockerfile: dockerfile) + let imageName = "test-label-only:\(UUID().uuidString)" + try f.build(tag: imageName, contextDir: dir) + try f.assertImageBuilt(imageName) + } + } +} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderEnvOnlySerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderEnvOnlySerial.swift deleted file mode 100644 index 0e5349790..000000000 --- a/Tests/IntegrationTests/Build/TestCLIBuilderEnvOnlySerial.swift +++ /dev/null @@ -1,139 +0,0 @@ -//===----------------------------------------------------------------------===// -// 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 ContainerTestSupport -import Foundation -import Testing - -@Suite(.serialized) -struct TestCLIBuilderEnvOnlySerial { - @Test func testBuildEnvironmentOnlyImageFromScratch() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - let dockerfile = - """ - FROM scratch - ARG BUILD_DATE - ARG VERSION=1.0.0 - ENV TERM=xterm \\ - BUILD_DATE=${BUILD_DATE} \\ - APP_VERSION=${VERSION} \\ - PATH=/usr/local/bin:/usr/bin:/bin - LABEL maintainer="test@example.com" version="${VERSION}" - """ - try f.createContext(dir: dir, dockerfile: dockerfile) - let imageName = "test-env-only:\(UUID().uuidString)" - try f.build(tag: imageName, contextDir: dir, buildArgs: ["BUILD_DATE=2025-01-01", "VERSION=2.0.0"]) - try f.assertImageBuilt(imageName) - } - } - } - - @Test func testBuildEnvironmentOnlyImageFromAlpine() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - let dockerfile = - """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - ENV APP_NAME=myapp APP_VERSION=1.0.0 APP_ENV=production - LABEL maintainer="test@example.com" version="1.0.0" - """ - try f.createContext(dir: dir, dockerfile: dockerfile) - let imageName = "test-alpine-env:\(UUID().uuidString)" - try f.build(tag: imageName, contextDir: dir) - try f.assertImageBuilt(imageName) - } - } - } - - @Test func testMultiStageBuildWithEnvOnlyBase() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let baseDir = try f.createTempDir() - let baseDockerfile = - """ - FROM scratch - ARG JOBS=6 - ARG ARCH=amd64 - ENV MAKEOPTS="-j${JOBS}" ARCH="${ARCH}" PATH=/usr/local/bin:/usr/bin - """ - try f.createContext(dir: baseDir, dockerfile: baseDockerfile) - let baseImageName = "test-env-base:\(UUID().uuidString)" - try f.build(tag: baseImageName, contextDir: baseDir, buildArgs: ["JOBS=8", "ARCH=arm64"]) - try f.assertImageBuilt(baseImageName) - - let downstreamDir = try f.createTempDir() - let downstreamDockerfile = - """ - FROM \(baseImageName) - LABEL test="env-inherited" - """ - try f.createContext(dir: downstreamDir, dockerfile: downstreamDockerfile) - let downstreamImageName = "test-env-child:\(UUID().uuidString)" - try f.build(tag: downstreamImageName, contextDir: downstreamDir) - try f.assertImageBuilt(downstreamImageName) - } - } - } - - @Test func testComplexArgAndEnvCombinations() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - let dockerfile = - """ - FROM scratch - ARG JOBS=6 - ARG MAXLOAD=7.00 - ARG ARCH=amd64 - ARG PROFILE_PATH=23.0/split-usr/no-multilib - ARG CHOST=x86_64-pc-linux-gnu - ARG CFLAGS=-O2 -pipe - ENV JOBS="${JOBS}" MAXLOAD="${MAXLOAD}" \\ - GENTOO_PROFILE="default/linux/${ARCH}/${PROFILE_PATH}" \\ - CHOST="${CHOST}" MAKEOPTS="-j${JOBS}" \\ - CFLAGS="${CFLAGS}" CXXFLAGS="${CFLAGS}" - LABEL maintainer="test@example.com" - """ - try f.createContext(dir: dir, dockerfile: dockerfile) - let imageName = "test-complex-env:\(UUID().uuidString)" - try f.build(tag: imageName, contextDir: dir, buildArgs: ["JOBS=12", "ARCH=arm64"]) - try f.assertImageBuilt(imageName) - } - } - } - - @Test func testLabelOnlyDockerfile() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - let dockerfile = - """ - FROM scratch - LABEL maintainer="test@example.com" version="1.0.0" \\ - description="Test image with only labels" \\ - org.opencontainers.image.title="Test Image" - """ - try f.createContext(dir: dir, dockerfile: dockerfile) - let imageName = "test-label-only:\(UUID().uuidString)" - try f.build(tag: imageName, contextDir: dir) - try f.assertImageBuilt(imageName) - } - } - } -} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderLifecycleSerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderLifecycleSerial.swift index 16b646999..e8c38f602 100644 --- a/Tests/IntegrationTests/Build/TestCLIBuilderLifecycleSerial.swift +++ b/Tests/IntegrationTests/Build/TestCLIBuilderLifecycleSerial.swift @@ -21,56 +21,115 @@ import Testing /// Tests for `container builder start`, `stop`, and `delete` lifecycle commands. /// -/// These tests manage the builder manually — they do not use ``withBuilder`` -/// because they are specifically testing the lifecycle commands themselves. -/// They acquire the shared builder lock via ``withBuilderLock`` to serialise -/// correctly with tests that use ``withBuilder(_:)``. +/// Serialized because they stop/delete the shared `buildkit` container, which +/// would race with in-flight builds in the concurrent pool. @Suite(.serialized) struct TestCLIBuilderLifecycleSerial { @Test func testBuilderStartStopCommand() async throws { try await ContainerFixture.with { f in - try await f.withBuilderLock { - f.addCleanup { try? f.builderDelete(force: true) } + f.addCleanup { try? f.builderDelete(force: true) } - try f.builderStart() - try await f.waitForBuilderRunning() - let status1 = try f.getContainerStatus("buildkit") - #expect(status1 == "running", "buildkit container should be running") + try f.builderStart() + try await f.waitForBuilderRunning() + let status1 = try f.getContainerStatus("buildkit") + #expect(status1 == "running", "buildkit container should be running") - try f.builderStop() - let status2 = try f.getContainerStatus("buildkit") - #expect(status2 == "stopped", "buildkit container should be stopped") - } + try f.builderStop() + let status2 = try f.getContainerStatus("buildkit") + #expect(status2 == "stopped", "buildkit container should be stopped") } } @Test func testBuilderEnvironmentColors() async throws { try await ContainerFixture.with { f in - try await f.withBuilderLock { - let originalColors = ProcessInfo.processInfo.environment["BUILDKIT_COLORS"] - let originalNoColor = ProcessInfo.processInfo.environment["NO_COLOR"] - f.addCleanup { - if let c = originalColors { setenv("BUILDKIT_COLORS", c, 1) } else { unsetenv("BUILDKIT_COLORS") } - if let n = originalNoColor { setenv("NO_COLOR", n, 1) } else { unsetenv("NO_COLOR") } - _ = try? f.builderDelete(force: true) + let originalColors = ProcessInfo.processInfo.environment["BUILDKIT_COLORS"] + let originalNoColor = ProcessInfo.processInfo.environment["NO_COLOR"] + f.addCleanup { + if let c = originalColors { setenv("BUILDKIT_COLORS", c, 1) } else { unsetenv("BUILDKIT_COLORS") } + if let n = originalNoColor { setenv("NO_COLOR", n, 1) } else { unsetenv("NO_COLOR") } + _ = try? f.builderDelete(force: true) + } + + _ = try? f.builderDelete(force: true) + setenv("BUILDKIT_COLORS", "run=green:warning=yellow:error=red:cancel=cyan", 1) + setenv("NO_COLOR", "true", 1) + + try f.run(["builder", "start"]).check() + try await f.waitForBuilderRunning() + + let container = try f.inspectContainer("buildkit") + let env = container.configuration.initProcess.environment + #expect( + env.contains("BUILDKIT_COLORS=run=green:warning=yellow:error=red:cancel=cyan"), + "BUILDKIT_COLORS should be forwarded to the buildkit container") + #expect( + env.contains("NO_COLOR=true"), + "NO_COLOR should be forwarded to the buildkit container") + } + } + + @Test func testBuildWithSSHDefaultForwarding() async throws { + try await ContainerFixture.with { f in + let socketDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: socketDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: socketDir) + } + + let socketPath = socketDir.appendingPathComponent("ssh-auth.sock").path + + let serverFd = socket(AF_UNIX, SOCK_STREAM, 0) + precondition(serverFd >= 0, "socket() failed") + defer { + Darwin.close(serverFd) + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + withUnsafeMutableBytes(of: &addr.sun_path) { bytes in + socketPath.withCString { cStr in + bytes.copyMemory(from: UnsafeRawBufferPointer(start: cStr, count: socketPath.utf8.count + 1)) } + } - _ = try? f.builderDelete(force: true) - setenv("BUILDKIT_COLORS", "run=green:warning=yellow:error=red:cancel=cyan", 1) - setenv("NO_COLOR", "true", 1) - - try f.run(["builder", "start"]).check() - try await f.waitForBuilderRunning() - - let container = try f.inspectContainer("buildkit") - let env = container.configuration.initProcess.environment - #expect( - env.contains("BUILDKIT_COLORS=run=green:warning=yellow:error=red:cancel=cyan"), - "BUILDKIT_COLORS should be forwarded to the buildkit container") - #expect( - env.contains("NO_COLOR=true"), - "NO_COLOR should be forwarded to the buildkit container") + let bindResult = withUnsafePointer(to: addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + bind(serverFd, sockaddrPtr, socklen_t(MemoryLayout.size)) + } } + precondition(bindResult == 0, "bind() failed: \(errno)") + precondition(listen(serverFd, 5) == 0, "listen() failed") + + let acceptThread = Thread { + while true { + let clientFd = accept(serverFd, nil, nil) + if clientFd < 0 { break } + Darwin.close(clientFd) + } + } + acceptThread.start() + + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + RUN --mount=type=ssh \\ + test -n "$SSH_AUTH_SOCK" && \\ + test -S "$SSH_AUTH_SOCK" + """) + + let image = "registry.local/ssh-default-forwarding:\(UUID().uuidString)" + try f.run( + [ + "build", + "--ssh", "default", + "-f", dir.appending("Dockerfile").string, + "-t", image, + dir.appending("context").string, + ], env: ["SSH_AUTH_SOCK": socketPath] + ).check() + try f.assertImageBuilt(image) } } } diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderLocalOutput.swift b/Tests/IntegrationTests/Build/TestCLIBuilderLocalOutput.swift new file mode 100644 index 000000000..64151afdd --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilderLocalOutput.swift @@ -0,0 +1,141 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerTestSupport +import Foundation +import Testing + +struct TestCLIBuilderLocalOutput { + @Test func testBuildLocalOutputHappyPath() async throws { + try await ContainerFixture.with { f in + // Comprehensive multi-stage build with context and build args. + let dir = try f.createTempDir() + let dockerfile = + """ + ARG MESSAGE=default + FROM scratch AS builder + ADD build.txt /build.txt + ADD testfile.txt /hello.txt + FROM scratch + COPY --from=builder /build.txt /final.txt + COPY --from=builder /hello.txt /app/hello.txt + ADD message.txt /message.txt + """ + let context: [ContainerFixture.FileSystemEntry] = [ + .file("build.txt", content: .data("Building stage\n".data(using: .utf8)!)), + .file("testfile.txt", content: .data("Hello from local build\n".data(using: .utf8)!)), + .file("message.txt", content: .data("Hello from build args\n".data(using: .utf8)!)), + ] + try f.createContext(dir: dir, dockerfile: dockerfile, context: context) + let outputDir = dir.appending("comprehensive-local-output") + let imageName = "local-comprehensive-test:\(UUID().uuidString)" + let response = try f.buildWithPathsAndLocalOutput( + tag: imageName, contextDir: dir, outputDir: outputDir, + buildArgs: ["MESSAGE=Hello from build args"]) + #expect(response.contains(outputDir.string), "output should reference the export path") + #expect(FileManager.default.fileExists(atPath: outputDir.string)) + let contents = try FileManager.default.contentsOfDirectory(atPath: outputDir.string) + #expect(!contents.isEmpty, "output directory should contain files") + + // Basic local output. + let basicDir = try f.createTempDir() + try f.createContext( + dir: basicDir, + dockerfile: "FROM scratch\nADD testfile.txt /hello.txt", + context: [.file("testfile.txt", content: .data("Hello from basic build\n".data(using: .utf8)!))]) + let basicOutputDir = basicDir.appending("basic-local-output") + let basicResponse = try f.buildWithPathsAndLocalOutput( + tag: "local-basic-test:\(UUID().uuidString)", contextDir: basicDir, outputDir: basicOutputDir) + #expect(basicResponse.contains(basicOutputDir.string)) + #expect(FileManager.default.fileExists(atPath: basicOutputDir.string)) + + // Build with context (COPY instruction). + let ctxDir = try f.createTempDir() + try f.createContext( + dir: ctxDir, + dockerfile: "FROM scratch\nCOPY testfile.txt /app/testfile.txt", + context: [.file("testfile.txt", content: .data("Test content\n".data(using: .utf8)!))]) + let ctxOutputDir = ctxDir.appending("context-local-output") + let ctxResponse = try f.buildWithPathsAndLocalOutput( + tag: "local-context-test:\(UUID().uuidString)", contextDir: ctxDir, outputDir: ctxOutputDir) + #expect(ctxResponse.contains(ctxOutputDir.string)) + #expect(FileManager.default.fileExists(atPath: ctxOutputDir.string)) + } + } + + @Test func testBuildLocalOutputEdgeCases() async throws { + try await ContainerFixture.with { f in + // Different paths for Dockerfile context and build context. + let dockerfileDir = try f.createTempDir() + try f.createContext( + dir: dockerfileDir, + dockerfile: "FROM scratch\nCOPY . /app", + context: [.file("dockerfile-context.txt", content: .data("Dockerfile context\n".data(using: .utf8)!))]) + + let buildContextDir = try f.createTempDir() + try f.createContext( + dir: buildContextDir, dockerfile: "", + context: [.file("build-context.txt", content: .data("Build context\n".data(using: .utf8)!))]) + + let outputDir = dockerfileDir.appending("diffpaths-local-output") + let response = try f.buildWithPathsAndLocalOutput( + tag: "local-diffpaths-test:\(UUID().uuidString)", + contextDir: buildContextDir, + dockerfilePath: dockerfileDir.appending("Dockerfile"), + outputDir: outputDir) + #expect(response.contains(outputDir.string)) + #expect(FileManager.default.fileExists(atPath: outputDir.string)) + + // Build into an existing output directory (should merge/overwrite). + let existingDir = try f.createTempDir() + try f.createContext( + dir: existingDir, + dockerfile: "FROM scratch\nADD newfile.txt /newfile.txt", + context: [.file("newfile.txt", content: .data("New content\n".data(using: .utf8)!))]) + let existingOutputDir = existingDir.appending("existing-output") + try FileManager.default.createDirectory( + atPath: existingOutputDir.string, withIntermediateDirectories: true, attributes: nil) + try "Existing content\n".data(using: .utf8)! + .write(to: URL(filePath: existingOutputDir.appending("existing.txt").string), options: .atomic) + let existingResponse = try f.buildWithPathsAndLocalOutput( + tag: "local-existing-test:\(UUID().uuidString)", + contextDir: existingDir, outputDir: existingOutputDir) + #expect(existingResponse.contains(existingOutputDir.string)) + let contents = try FileManager.default.contentsOfDirectory(atPath: existingOutputDir.string) + #expect(!contents.isEmpty) + } + } + + @Test func testBuildLocalOutputFailure() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD test.txt /test.txt", + context: [.file("test.txt", content: .data("test\n".data(using: .utf8)!))]) + + // An uncreateable path should cause the build to fail. + let result = try f.run([ + "build", + "-f", dir.appending("Dockerfile").string, + "-t", "local-invalid-test:\(UUID().uuidString)", + "--output", "type=local,dest=/nonexistent/invalid/path", + dir.appending("context").string, + ]) + #expect(result.status != 0, "build with invalid output path should fail") + } + } +} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderLocalOutputSerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderLocalOutputSerial.swift deleted file mode 100644 index 54a012cf4..000000000 --- a/Tests/IntegrationTests/Build/TestCLIBuilderLocalOutputSerial.swift +++ /dev/null @@ -1,148 +0,0 @@ -//===----------------------------------------------------------------------===// -// 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 ContainerTestSupport -import Foundation -import Testing - -@Suite(.serialized) -struct TestCLIBuilderLocalOutputSerial { - @Test func testBuildLocalOutputHappyPath() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - // Comprehensive multi-stage build with context and build args. - let dir = try f.createTempDir() - let dockerfile = - """ - ARG MESSAGE=default - FROM scratch AS builder - ADD build.txt /build.txt - ADD testfile.txt /hello.txt - FROM scratch - COPY --from=builder /build.txt /final.txt - COPY --from=builder /hello.txt /app/hello.txt - ADD message.txt /message.txt - """ - let context: [ContainerFixture.FileSystemEntry] = [ - .file("build.txt", content: .data("Building stage\n".data(using: .utf8)!)), - .file("testfile.txt", content: .data("Hello from local build\n".data(using: .utf8)!)), - .file("message.txt", content: .data("Hello from build args\n".data(using: .utf8)!)), - ] - try f.createContext(dir: dir, dockerfile: dockerfile, context: context) - let outputDir = dir.appending("comprehensive-local-output") - let imageName = "local-comprehensive-test:\(UUID().uuidString)" - let response = try f.buildWithPathsAndLocalOutput( - tag: imageName, contextDir: dir, outputDir: outputDir, - buildArgs: ["MESSAGE=Hello from build args"]) - #expect(response.contains(outputDir.string), "output should reference the export path") - #expect(FileManager.default.fileExists(atPath: outputDir.string)) - let contents = try FileManager.default.contentsOfDirectory(atPath: outputDir.string) - #expect(!contents.isEmpty, "output directory should contain files") - - // Basic local output. - let basicDir = try f.createTempDir() - try f.createContext( - dir: basicDir, - dockerfile: "FROM scratch\nADD testfile.txt /hello.txt", - context: [.file("testfile.txt", content: .data("Hello from basic build\n".data(using: .utf8)!))]) - let basicOutputDir = basicDir.appending("basic-local-output") - let basicResponse = try f.buildWithPathsAndLocalOutput( - tag: "local-basic-test:\(UUID().uuidString)", contextDir: basicDir, outputDir: basicOutputDir) - #expect(basicResponse.contains(basicOutputDir.string)) - #expect(FileManager.default.fileExists(atPath: basicOutputDir.string)) - - // Build with context (COPY instruction). - let ctxDir = try f.createTempDir() - try f.createContext( - dir: ctxDir, - dockerfile: "FROM scratch\nCOPY testfile.txt /app/testfile.txt", - context: [.file("testfile.txt", content: .data("Test content\n".data(using: .utf8)!))]) - let ctxOutputDir = ctxDir.appending("context-local-output") - let ctxResponse = try f.buildWithPathsAndLocalOutput( - tag: "local-context-test:\(UUID().uuidString)", contextDir: ctxDir, outputDir: ctxOutputDir) - #expect(ctxResponse.contains(ctxOutputDir.string)) - #expect(FileManager.default.fileExists(atPath: ctxOutputDir.string)) - } - } - } - - @Test func testBuildLocalOutputEdgeCases() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - // Different paths for Dockerfile context and build context. - let dockerfileDir = try f.createTempDir() - try f.createContext( - dir: dockerfileDir, - dockerfile: "FROM scratch\nCOPY . /app", - context: [.file("dockerfile-context.txt", content: .data("Dockerfile context\n".data(using: .utf8)!))]) - - let buildContextDir = try f.createTempDir() - try f.createContext( - dir: buildContextDir, dockerfile: "", - context: [.file("build-context.txt", content: .data("Build context\n".data(using: .utf8)!))]) - - let outputDir = dockerfileDir.appending("diffpaths-local-output") - let response = try f.buildWithPathsAndLocalOutput( - tag: "local-diffpaths-test:\(UUID().uuidString)", - contextDir: buildContextDir, - dockerfilePath: dockerfileDir.appending("Dockerfile"), - outputDir: outputDir) - #expect(response.contains(outputDir.string)) - #expect(FileManager.default.fileExists(atPath: outputDir.string)) - - // Build into an existing output directory (should merge/overwrite). - let existingDir = try f.createTempDir() - try f.createContext( - dir: existingDir, - dockerfile: "FROM scratch\nADD newfile.txt /newfile.txt", - context: [.file("newfile.txt", content: .data("New content\n".data(using: .utf8)!))]) - let existingOutputDir = existingDir.appending("existing-output") - try FileManager.default.createDirectory( - atPath: existingOutputDir.string, withIntermediateDirectories: true, attributes: nil) - try "Existing content\n".data(using: .utf8)! - .write(to: URL(filePath: existingOutputDir.appending("existing.txt").string), options: .atomic) - let existingResponse = try f.buildWithPathsAndLocalOutput( - tag: "local-existing-test:\(UUID().uuidString)", - contextDir: existingDir, outputDir: existingOutputDir) - #expect(existingResponse.contains(existingOutputDir.string)) - let contents = try FileManager.default.contentsOfDirectory(atPath: existingOutputDir.string) - #expect(!contents.isEmpty) - } - } - } - - @Test func testBuildLocalOutputFailure() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: "FROM scratch\nADD test.txt /test.txt", - context: [.file("test.txt", content: .data("test\n".data(using: .utf8)!))]) - - // An uncreateable path should cause the build to fail. - let result = try f.run([ - "build", - "-f", dir.appending("Dockerfile").string, - "-t", "local-invalid-test:\(UUID().uuidString)", - "--output", "type=local,dest=/nonexistent/invalid/path", - dir.appending("context").string, - ]) - #expect(result.status != 0, "build with invalid output path should fail") - } - } - } -} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderSerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderSerial.swift deleted file mode 100644 index f5a19ffc5..000000000 --- a/Tests/IntegrationTests/Build/TestCLIBuilderSerial.swift +++ /dev/null @@ -1,1105 +0,0 @@ -//===----------------------------------------------------------------------===// -// 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 ContainerTestSupport -import Darwin -import Foundation -import Testing - -// Convenience alias for the verbose entry type. -typealias FSEntry = ContainerFixture.FileSystemEntry - -@Suite(.serialized) -struct TestCLIBuilderSerial { - - // MARK: - Basic build tests - - @Test func testBuildDefaultParams() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext(dir: dir, dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20") - // No tags — runtime generates one and prints it to stdout. - let output = try f.buildWithPaths(contextDir: dir) - let generatedTag = output.trimmingCharacters(in: .whitespacesAndNewlines) - #expect(!generatedTag.isEmpty, "build should print the generated image tag to stdout") - try f.assertImageBuilt(generatedTag) - } - } - } - - @Test func testBuildDotFileSucceeds() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: "FROM scratch\nADD emptyFile /", - context: [ - .file("emptyFile", content: .zeroFilled(size: 1)), - .file(".dockerignore", content: .data(".dockerignore\n".data(using: .utf8)!)), - ]) - let image = "registry.local/dot-file:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildFromPreviousStage() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: """ - FROM ghcr.io/linuxcontainers/alpine:3.20 AS layer1 - RUN sh -c "echo 'layer1' > /layer1.txt" - FROM layer1 - CMD ["cat", "/layer1.txt"] - """) - let image = "registry.local/from-previous-layer:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildFromLocalImage() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: "FROM scratch\nADD emptyFile /", - context: [ - .file("emptyFile", content: .zeroFilled(size: 0)), - .file(".dockerignore", content: .data(".dockerignore\n".data(using: .utf8)!)), - ]) - let image = "local-only:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - - let dir2 = try f.createTempDir() - try f.createContext( - dir: dir2, - dockerfile: "FROM \(image)", - context: []) - let image2 = "from-local:\(UUID().uuidString)" - try f.build(tag: image2, contextDir: dir2) - try f.assertImageBuilt(image2) - } - } - } - - @Test func testBuildAddFromSpecialDirs() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: "FROM scratch\nADD emptyFile /", - context: [.file("emptyFile", content: .zeroFilled(size: 1))]) - let image = "registry.local/scratch-add-special-dir:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildScratchAdd() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: "FROM scratch\nADD emptyFile /", - context: [.file("emptyFile", content: .zeroFilled(size: 1))]) - let image = "registry.local/scratch-add:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildAddAll() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - ADD . . - RUN cat emptyFile - RUN cat Test/testempty - """, - context: [ - .directory("Test"), - .file("Test/testempty", content: .zeroFilled(size: 1)), - .file("emptyFile", content: .zeroFilled(size: 1)), - ]) - let image = "registry.local/add-all:\(UUID().uuidString)" - let output = try f.build(tag: image, contextDir: dir) - #expect(output.contains(image)) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildArg() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: "ARG TAG=unknown\nFROM ghcr.io/linuxcontainers/alpine:${TAG}") - let image = "registry.local/build-arg:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir, buildArgs: ["TAG=3.20"]) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildSecret() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - RUN --mount=type=secret,id=ENV1 \\ - --mount=type=secret,id=env2 \\ - --mount=type=secret,id=env3 \\ - test xyyzzz = "`cat /run/secrets/ENV1 /run/secrets/env2 /run/secrets/env3`" - RUN --mount=type=secret,id=file \\ - awk 'BEGIN {for(i=0; i<17; i++) for(c=0; c<256; c++) printf("%c", c)}' > /tmp/foo && \\ - cmp /tmp/foo /run/secrets/file && \\ - rm /tmp/foo - RUN --mount=type=secret,id=empty \\ - ! test -e /run/secrets/file && \\ - test -e /run/secrets/empty && \\ - cmp /dev/null /run/secrets/empty - """) - - setenv("ENV1", "x", 1) - setenv("ENV_VAR", "yy", 1) - setenv("env3", "zzz", 1) - f.addCleanup { - unsetenv("ENV1") - unsetenv("ENV_VAR") - unsetenv("env3") - } - - let testData = Data((0..<17).flatMap { _ in Array(0...255) }) - let secretFile = try f.createTempFile(suffix: " _f,i=l.e+ ", contents: testData) - let emptyFile = try f.createTempFile(suffix: "file2", contents: Data()) - - let image = "registry.local/secrets:\(UUID().uuidString)" - try f.build( - tag: image, contextDir: dir, - otherArgs: [ - "--secret", "id=ENV1", - "--secret", "id=env2,env=ENV_VAR", - "--secret", "id=env3,env=env3", - "--secret", "id=file,src=\(secretFile.string)", - "--secret", "id=empty,src=\(emptyFile.string)", - ]) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildNetworkAccess() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - ARG HTTP_PROXY - ARG HTTPS_PROXY - ARG NO_PROXY - ARG http_proxy - ARG https_proxy - ARG no_proxy - RUN apk add --no-cache curl - """) - var buildArgs: [String] = [] - for key in ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"] { - if let v = ProcessInfo.processInfo.environment[key] { buildArgs.append("\(key)=\(v)") } - } - let image = "registry.local/build-network-access:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir, buildArgs: buildArgs) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildDockerfileKeywords() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: """ - ARG TAG=3.20 - FROM ghcr.io/linuxcontainers/alpine:${TAG} - FROM ghcr.io/linuxcontainers/alpine:3.20 - RUN echo "Hello, World!" > /hello.txt - FROM ghcr.io/linuxcontainers/alpine:3.20 - RUN ["sh", "-c", "echo 'Exec form' > /exec.txt"] - FROM ghcr.io/linuxcontainers/alpine:3.20 - CMD ["echo", "Exec default"] - FROM ghcr.io/linuxcontainers/alpine:3.20 - LABEL version="1.0" description="Test image" - FROM ghcr.io/linuxcontainers/alpine:3.20 - EXPOSE 8080 - FROM ghcr.io/linuxcontainers/alpine:3.20 - ENV MY_ENV=hello - RUN echo $MY_ENV > /env.txt - FROM ghcr.io/linuxcontainers/alpine:3.20 - ADD emptyFile / - FROM ghcr.io/linuxcontainers/alpine:3.20 - COPY toCopy /toCopy - FROM ghcr.io/linuxcontainers/alpine:3.20 - ENTRYPOINT ["echo", "entrypoint!"] - FROM ghcr.io/linuxcontainers/alpine:3.20 - VOLUME /data - FROM ghcr.io/linuxcontainers/alpine:3.20 - RUN adduser -D myuser - USER myuser - CMD whoami - FROM ghcr.io/linuxcontainers/alpine:3.20 - WORKDIR /app - RUN pwd > /pwd.out - FROM ghcr.io/linuxcontainers/alpine:3.20 - ARG MY_VAR=default - RUN echo $MY_VAR > /var.out - """, - context: [ - .file("emptyFile", content: .zeroFilled(size: 1)), - .file("toCopy", content: .zeroFilled(size: 1)), - ]) - let image = "registry.local/dockerfile-keywords:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildSymlink() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - let dockerfile = """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - ADD Test1Source Test1Source - ADD Test1Source2 Test1Source2 - RUN cat Test1Source2/test.yaml - FROM ghcr.io/linuxcontainers/alpine:3.20 - ADD Test2Source Test2Source - ADD Test2Source2 Test2Source2 - RUN cat Test2Source2/Test/test.txt - FROM ghcr.io/linuxcontainers/alpine:3.20 - ADD Test3Source Test3Source - ADD Test3Source2 Test3Source2 - RUN cat Test3Source2/Dest/test.txt - """ - let context: [FSEntry] = [ - .directory("Test1Source"), .directory("Test1Source2"), - .file("Test1Source/test.yaml", content: .zeroFilled(size: 200)), - .symbolicLink("Test1Source2/test.yaml", target: "Test1Source/test.yaml"), - .directory("Test2Source"), .directory("Test2Source2"), - .file("Test2Source/Test/Test/test.yaml", content: .zeroFilled(size: 300)), - .symbolicLink("Test2Source2/Test/test.yaml", target: "Test2Source/Test/Test/test.yaml"), - .directory("Test3Source/Source"), .directory("Test3Source2"), - .file("Test3Source/Source/test.txt", content: .zeroFilled(size: 1)), - .symbolicLink("Test3Source2/Dest", target: "Test3Source/Source"), - ] - try f.createContext(dir: dir, dockerfile: dockerfile, context: context) - let image = "registry.local/build-symlinks:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildAndRun() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nRUN echo \"foobar\" > /file") - let image = "\(f.testID)-build-and-run:latest" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - try await f.withContainer(image: image) { name in - let output = try f.doExec(name, cmd: ["cat", "/file"]) - .trimmingCharacters(in: .whitespacesAndNewlines) - #expect(output == "foobar") - } - } - } - } - - @Test func testBuildDifferentPaths() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - RUN ls ./ - COPY . /root - RUN cat /root/Test/test.txt - """, - context: [ - .directory(".git"), - .file(".git/FETCH", content: .zeroFilled(size: 1)), - .directory("Test"), - .file("Test/test.txt", content: .zeroFilled(size: 1)), - ]) - let image = "registry.local/build-diff-context:\(UUID().uuidString)" - try f.buildWithPaths(tags: [image], contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildMultiArch() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - ADD . . - RUN cat emptyFile - RUN cat Test/testempty - """, - context: [ - .directory("Test"), - .file("Test/testempty", content: .zeroFilled(size: 1)), - .file("emptyFile", content: .zeroFilled(size: 1)), - ]) - let image = "registry.local/multi-arch:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir, otherArgs: ["--arch", "amd64,arm64"]) - try f.assertImageBuilt(image) - - let output = try f.doInspectImages(image) - #expect(output.count == 1, "expected single inspect result") - let archs = Set(output[0].variants.map { $0.platform.architecture }) - #expect(archs == Set(["amd64", "arm64"]), "expected amd64 and arm64 variants") - } - } - } - - @Test func testBuildMultipleTags() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: "FROM scratch\nADD emptyFile /", - context: [.file("emptyFile", content: .zeroFilled(size: 1))]) - let uuid = UUID().uuidString - let tag1 = "registry.local/multi-tag-test:\(uuid)" - let tag2 = "registry.local/multi-tag-test:latest" - let tag3 = "registry.local/multi-tag-test:v1.0.0" - let output = try f.buildWithPaths(tags: [tag1, tag2, tag3], contextDir: dir) - #expect(output.contains(tag1)) - #expect(output.contains(tag2)) - #expect(output.contains(tag3)) - try f.assertImageBuilt(tag1) - try f.assertImageBuilt(tag2) - try f.assertImageBuilt(tag3) - } - } - } - - @Test func testBuildAfterContextChange() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - let initialContent = "initial".data(using: .utf8)! - try f.createContext( - dir: dir, - dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nCOPY foo /foo\nCOPY bar /bar", - context: [ - .file("foo", content: .data(Data((0..<4 * 1024 * 1024).map { UInt8($0 % 256) }))), - .file("bar", content: .data(initialContent)), - ]) - - let image1 = "\(f.testID)-build-context-change:v1" - try f.build(tag: image1, contextDir: dir) - try await f.withContainer(image: image1) { name in - let out = try f.doExec(name, cmd: ["cat", "/bar"]) - #expect(out == "initial") - } - - let contextBar = dir.appending("context").appending("bar") - try "updated".data(using: .utf8)!.write(to: URL(filePath: contextBar.string), options: .atomic) - - let image2 = "\(f.testID)-build-context-change:v2" - try f.build(tag: image2, contextDir: dir) - try await f.withContainer(image: image2) { name in - let out = try f.doExec(name, cmd: ["cat", "/bar"]) - #expect(out == "updated") - } - } - } - } - - @Test func testBuildWithDockerfileFromStdin() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - let dockerfile = "FROM scratch\nADD emptyFile /" - try f.createContext( - dir: dir, dockerfile: "", - context: [.file("emptyFile", content: .zeroFilled(size: 1))]) - let image = "registry.local/stdin-file:\(UUID().uuidString)" - try f.buildWithStdin(tags: [image], contextDir: dir, dockerfileContents: dockerfile) - try f.assertImageBuilt(image) - } - } - } - - @Test func testLowercaseDockerfile() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let files: [(String, String, String)] = [ - ("COPY . /app", "copy-uppercase", "COPY"), - ("copy . /app", "copy-lowercase", "copy"), - ("ADD . /app", "add-uppercase", "ADD"), - ("add . /app", "add-lowercase", "add"), - ] - for (instruction, name, _) in files { - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - \(instruction) - RUN test -f /app/testfile.txt - """, - context: [.file("testfile.txt", content: .data("test".data(using: .utf8)!))]) - let image = "registry.local/\(name):\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - } - - @Test func testRunWithBindMount() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - RUN --mount=type=bind,source=.,target=/mnt/context \\ - set -e; \\ - if [ ! -f /mnt/context/app.py ]; then echo "ERROR: app.py missing"; exit 1; fi; \\ - if [ ! -f /mnt/context/config.yaml ]; then echo "ERROR: config.yaml missing"; exit 1; fi; \\ - cp /mnt/context/app.py /app.py - RUN cat /app.py - """, - context: [ - .file("app.py", content: .data("print('Hello from bind mount')".data(using: .utf8)!)), - .file("config.yaml", content: .data("key: value".data(using: .utf8)!)), - ]) - let image = "registry.local/bind-mount-test:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - // MARK: - .dockerignore tests - - @Test func testBuildDockerIgnore() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - let dockerignore = """ - secret.txt - *.log - **/*.log - !important.log - *.tmp - **/*.tmp - temp/ - node_modules/ - """ - try f.createContext( - dir: dir, - dockerfile: """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - COPY . /app - RUN set -e; [ ! -f /app/secret.txt ] || exit 1 - RUN set -e; [ ! -f /app/debug.log ] || exit 1 - RUN set -e; [ -f /app/important.log ] || exit 1 - RUN set -e; find /app -name "*.tmp" | grep . && exit 1; true - RUN set -e; [ ! -d /app/temp ] || exit 1 - RUN set -e; [ ! -d /app/node_modules ] || exit 1 - RUN set -e; [ -f /app/main.go ] && [ -f /app/README.md ] && [ -f /app/src/app.go ] - """, - context: [ - .file(".dockerignore", content: .data(dockerignore.data(using: .utf8)!)), - .file("secret.txt", content: .data("secret".data(using: .utf8)!)), - .file("debug.log", content: .data("debug".data(using: .utf8)!)), - .file("important.log", content: .data("important".data(using: .utf8)!)), - .file("cache.tmp", content: .data("cache".data(using: .utf8)!)), - .file("main.go", content: .data("package main".data(using: .utf8)!)), - .file("README.md", content: .data("# README".data(using: .utf8)!)), - .directory("temp"), - .file("logs/app.log", content: .data("app log".data(using: .utf8)!)), - .directory("node_modules"), - .directory("src"), - .file("src/app.go", content: .data("package src".data(using: .utf8)!)), - ]) - let image = "registry.local/dockerignore-test:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testDockerIgnoreBasic() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." - try f.createContext( - dir: dir, - dockerfile: dockerfile, - context: [ - .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), - .file("included.txt", content: .data("included\n".data(using: .utf8)!)), - .file("ignored.txt", content: .data("ignored\n".data(using: .utf8)!)), - .file(".dockerignore", content: .data("ignored.txt\n".data(using: .utf8)!)), - ]) - let contextDir = dir.appending("context") - let image = "registry.local/dockerignore-basic:\(UUID().uuidString)" - let result = try f.run([ - "build", "-f", contextDir.appending("Dockerfile").string, - "-t", image, contextDir.string, - ]) - try result.check() - try await f.withContainer(image: image, tag: "c") { name in - try f.assertContainerHasFile(name, at: "/app/included.txt") - try f.assertContainerMissingFile(name, at: "/app/ignored.txt") - } - } - } - } - - @Test func testDockerIgnoreDockerfileSpecific() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." - try f.createContext( - dir: dir, dockerfile: dockerfile, - context: [ - .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), - .file(".dockerignore", content: .data("general.txt\n".data(using: .utf8)!)), - .file("Dockerfile.dockerignore", content: .data("specific.txt\n".data(using: .utf8)!)), - .file("general.txt", content: .data("general\n".data(using: .utf8)!)), - .file("specific.txt", content: .data("specific\n".data(using: .utf8)!)), - ]) - let contextDir = dir.appending("context") - let image = "registry.local/dockerignore-specific:\(UUID().uuidString)" - try f.run([ - "build", "-f", contextDir.appending("Dockerfile").string, - "-t", image, contextDir.string, - ]).check() - try await f.withContainer(image: image, tag: "c") { name in - try f.assertContainerMissingFile(name, at: "/app/specific.txt", "specific.txt should be ignored by Dockerfile.dockerignore") - try f.assertContainerHasFile(name, at: "/app/general.txt", "general.txt should be present (Dockerfile.dockerignore takes precedence)") - } - } - } - } - - @Test func testDockerIgnoreOutsideContext() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." - try f.createContext( - dir: dir, dockerfile: dockerfile, - context: [ - .file(".dockerignore", content: .data("general.txt\n".data(using: .utf8)!)), - .file("general.txt", content: .data("general\n".data(using: .utf8)!)), - .file("specific.txt", content: .data("specific\n".data(using: .utf8)!)), - ]) - try "specific.txt\n".data(using: .utf8)!.write(to: URL(filePath: dir.appending("Dockerfile.dockerignore").string), options: .atomic) - let image = "registry.local/dockerignore-outside:\(UUID().uuidString)" - try f.run([ - "build", "-f", dir.appending("Dockerfile").string, - "-t", image, dir.appending("context").string, - ]).check() - try await f.withContainer(image: image, tag: "c") { name in - try f.assertContainerMissingFile(name, at: "/app/specific.txt") - try f.assertContainerHasFile(name, at: "/app/general.txt") - } - } - } - } - - @Test func testDockerIgnoreIgnoredDockerfile() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." - try f.createContext( - dir: dir, dockerfile: dockerfile, - context: [ - .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), - .file(".dockerignore", content: .data("Dockerfile\n.dockerignore\n".data(using: .utf8)!)), - .file("test.txt", content: .data("test\n".data(using: .utf8)!)), - ]) - let contextDir = dir.appending("context") - let image = "registry.local/dockerignore-ignored-dockerfile:\(UUID().uuidString)" - try f.run([ - "build", "-f", contextDir.appending("Dockerfile").string, - "-t", image, contextDir.string, - ]).check() - try await f.withContainer(image: image, tag: "c") { name in - try f.assertContainerMissingFile(name, at: "/app/Dockerfile") - try f.assertContainerMissingFile(name, at: "/app/.dockerignore") - try f.assertContainerHasFile(name, at: "/app/test.txt") - } - } - } - } - - @Test func testDockerIgnoreSubdirDockerfile() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." - try f.createContext( - dir: dir, dockerfile: dockerfile, - context: [ - .file(".dockerignore", content: .data("included.txt\n".data(using: .utf8)!)), - .file("included.txt", content: .data("included\n".data(using: .utf8)!)), - .file("secret.txt", content: .data("secret\n".data(using: .utf8)!)), - .file("nested/secret.txt", content: .data("nested secret\n".data(using: .utf8)!)), - .file("nested/project/Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), - .file("nested/project/Dockerfile.dockerignore", content: .data("secret.txt\n**/secret.txt\n".data(using: .utf8)!)), - .file("nested/project/config.txt", content: .data("config\n".data(using: .utf8)!)), - ]) - let contextDir = dir.appending("context") - let nestedDockerfile = contextDir.appending("nested").appending("project").appending("Dockerfile") - let image = "registry.local/dockerignore-subdir:\(UUID().uuidString)" - try f.run([ - "build", "-f", nestedDockerfile.string, "-t", image, contextDir.string, - ]).check() - try await f.withContainer(image: image, tag: "c") { name in - try f.assertContainerHasFile(name, at: "/app/included.txt") - try f.assertContainerMissingFile(name, at: "/app/secret.txt") - try f.assertContainerMissingFile(name, at: "/app/nested/secret.txt") - try f.assertContainerHasFile(name, at: "/app/nested/project/config.txt") - } - } - } - } - - @Test func testDockerIgnoreCustomDockerfileName() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." - try f.createContext( - dir: dir, dockerfile: "", // no top-level Dockerfile - context: [ - .file(".dockerignore", content: .data("generic.txt\n".data(using: .utf8)!)), - .file("app1.Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), - .file("app1.Dockerfile.dockerignore", content: .data("app1-specific.txt\n".data(using: .utf8)!)), - .file("app1-specific.txt", content: .data("app1 specific\n".data(using: .utf8)!)), - .file("generic.txt", content: .data("generic\n".data(using: .utf8)!)), - .file("included.txt", content: .data("included\n".data(using: .utf8)!)), - ]) - let contextDir = dir.appending("context") - let image = "registry.local/dockerignore-custom-name:\(UUID().uuidString)" - try f.run([ - "build", "-f", contextDir.appending("app1.Dockerfile").string, - "-t", image, contextDir.string, - ]).check() - try await f.withContainer(image: image, tag: "c") { name in - try f.assertContainerMissingFile(name, at: "/app/app1-specific.txt") - try f.assertContainerHasFile(name, at: "/app/generic.txt") - try f.assertContainerHasFile(name, at: "/app/included.txt") - } - } - } - } - - @Test func testDockerIgnoreCustomNameSubdir() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." - try f.createContext( - dir: dir, dockerfile: "", - context: [ - .file(".dockerignore", content: .data("from-root-ignore.txt\n".data(using: .utf8)!)), - .file("from-root-ignore.txt", content: .data("root ignore\n".data(using: .utf8)!)), - .file("from-app2-ignore.txt", content: .data("app2 ignore\n".data(using: .utf8)!)), - .file("always-included.txt", content: .data("always\n".data(using: .utf8)!)), - .file("nested/project/app2.Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), - .file("nested/project/app2.Dockerfile.dockerignore", content: .data("from-app2-ignore.txt\n".data(using: .utf8)!)), - .file("nested/project/config.yaml", content: .data("config\n".data(using: .utf8)!)), - ]) - let contextDir = dir.appending("context") - let nestedDockerfile = contextDir.appending("nested").appending("project").appending("app2.Dockerfile") - let image = "registry.local/dockerignore-custom-subdir:\(UUID().uuidString)" - try f.run([ - "build", "-f", nestedDockerfile.string, "-t", image, contextDir.string, - ]).check() - try await f.withContainer(image: image, tag: "c") { name in - try f.assertContainerMissingFile(name, at: "/app/from-app2-ignore.txt") - try f.assertContainerHasFile(name, at: "/app/from-root-ignore.txt") - try f.assertContainerHasFile(name, at: "/app/always-included.txt") - try f.assertContainerHasFile(name, at: "/app/nested/project/config.yaml") - } - } - } - } - - @Test func testDockerIgnoreCoexistingDockerfiles() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - let appDockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." - try f.createContext( - dir: dir, dockerfile: "", - context: [ - .file("Dockerfile", content: .data("FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . .\n".data(using: .utf8)!)), - .file("Dockerfile.dockerignore", content: .data("dockerfile-specific.txt\n".data(using: .utf8)!)), - .file("app.Dockerfile", content: .data(appDockerfile.data(using: .utf8)!)), - .file("app.Dockerfile.dockerignore", content: .data("app-specific.txt\n".data(using: .utf8)!)), - .file("dockerfile-specific.txt", content: .data("df specific\n".data(using: .utf8)!)), - .file("app-specific.txt", content: .data("app specific\n".data(using: .utf8)!)), - .file("included.txt", content: .data("included\n".data(using: .utf8)!)), - ]) - let contextDir = dir.appending("context") - let image = "registry.local/dockerignore-coexisting:\(UUID().uuidString)" - try f.run([ - "build", "-f", contextDir.appending("app.Dockerfile").string, - "-t", image, contextDir.string, - ]).check() - try await f.withContainer(image: image, tag: "c") { name in - try f.assertContainerMissingFile(name, at: "/app/app-specific.txt") - try f.assertContainerHasFile(name, at: "/app/dockerfile-specific.txt") - try f.assertContainerHasFile(name, at: "/app/included.txt") - } - } - } - } - - @Test func testDockerIgnoreReadonlyContext() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." - try f.createContext( - dir: dir, dockerfile: dockerfile, - context: [ - .file("included.txt", content: .data("included\n".data(using: .utf8)!)), - .file("secret.txt", content: .data("secret\n".data(using: .utf8)!)), - ]) - try "secret.txt\n".data(using: .utf8)!.write(to: URL(filePath: dir.appending("Dockerfile.dockerignore").string), options: .atomic) - - let contextDir = dir.appending("context") - // Make the context read-only, then restore before cleanup. - try FileManager.default.setAttributes( - [.posixPermissions: 0o555], ofItemAtPath: contextDir.string) - f.addCleanup { - try? FileManager.default.setAttributes( - [.posixPermissions: 0o755], ofItemAtPath: contextDir.string) - } - - let image = "registry.local/dockerignore-readonly:\(UUID().uuidString.prefix(6))" - try f.run([ - "build", "-f", dir.appending("Dockerfile").string, - "-t", image, contextDir.string, - ]).check() - try await f.withContainer(image: image, tag: "c") { name in - try f.assertContainerHasFile(name, at: "/app/included.txt") - try f.assertContainerMissingFile(name, at: "/app/secret.txt") - } - } - } - } - - @Test func testNonExistingDockerfile() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - let image = "registry.local/non-existing-dockerfile:\(UUID().uuidString)" - let r1 = try f.run(["build", "-f", "non-existing-path", "-t", image, dir.string]) - #expect(r1.status != 0) - let r2 = try f.run(["build", "-t", image, dir.string]) - #expect(r2.status != 0) - } - } - } - - @Test func testBuildNoCachePullLatestImage() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: "FROM \(ContainerFixture.warmupImages[0])\nADD emptyFile /", - context: [.file("emptyFile", content: .zeroFilled(size: 1))]) - let image = "registry.local/no-cache-pull:\(UUID().uuidString)" - try f.buildWithPaths(tags: [image], contextDir: dir, otherArgs: ["--pull", "--no-cache"]) - try f.assertImageBuilt(image) - } - } - } - - // MARK: - Dockerfile ARG quoting - - @Test func testBuildQuotedImageDockerfileArg() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: "ARG IMAGE=\"ghcr.io/linuxcontainers/alpine:3.20\"\nFROM $IMAGE\nRUN test -f /etc/alpine-release") - let image = "registry.local/quoted-image-dockerfile-arg:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildQuotedStringDockerfileArg() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nARG MYSTRING='\"Hello, world!\"'\nRUN test \"$MYSTRING\" = '\"Hello, world!\"'") - let image = "registry.local/quoted-string-dockerfile-arg:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildForwardReferencedDockerfileArg() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: """ - ARG ALPINE="ghcr.io/linuxcontainers/alpine" - ARG IMAGE="${ALPINE}:3.20" - FROM $IMAGE - RUN test -f /etc/alpine-release - """) - let image = "registry.local/forward-referenced-dockerfile-arg:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildQuotedImageBuildArg() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: "ARG IMAGE\nFROM $IMAGE\nRUN test -f /etc/alpine-release") - let image = "registry.local/quoted-image-build-arg:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir, buildArgs: ["IMAGE=ghcr.io/linuxcontainers/alpine:3.20"]) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildQuotedStringBuildArg() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nARG MYSTRING\nRUN test \"$MYSTRING\" = '\"Hello, world!\"'") - let image = "registry.local/quoted-string-build-arg:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir, buildArgs: ["MYSTRING=\"Hello, world!\""]) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildForwardReferencedBuildArg() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: """ - ARG ALPINE - ARG IMAGE="$ALPINE:3.20" - FROM $IMAGE - RUN test -f /etc/alpine-release - """) - let image = "registry.local/forward-referenced-build-arg:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir, buildArgs: ["ALPINE=ghcr.io/linuxcontainers/alpine"]) - try f.assertImageBuilt(image) - } - } - } - - // MARK: - COPY --from tests - - @Test func testCopyFromLocalImage() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let baseDir = try f.createTempDir() - let baseName = "local-base:\(UUID().uuidString)" - try f.createContext( - dir: baseDir, - dockerfile: "FROM scratch\nADD hello.txt /hello.txt", - context: [.file("hello.txt", content: .data("hello\n".data(using: .utf8)!))]) - try f.build(tag: baseName, contextDir: baseDir) - try f.assertImageBuilt(baseName) - - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nCOPY --from=\(baseName) /hello.txt /copied.txt\nRUN cat /copied.txt") - let image = "registry.local/copy-from-local:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testCopyFromBuildStage() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: """ - FROM scratch AS builder - ADD hello.txt /hello.txt - FROM ghcr.io/linuxcontainers/alpine:3.20 - COPY --from=builder /hello.txt /copied.txt - RUN cat /copied.txt - """, - context: [.file("hello.txt", content: .data("hello\n".data(using: .utf8)!))]) - let image = "registry.local/copy-from-stage:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testCopyRenameFromStage() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: """ - FROM scratch AS builder - ADD hello.txt /hello.txt - FROM ghcr.io/linuxcontainers/alpine:3.20 - COPY --from=builder /hello.txt /renamed.txt - RUN cat /renamed.txt - """, - context: [.file("hello.txt", content: .data("hello\n".data(using: .utf8)!))]) - let image = "registry.local/copy-rename:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testCopyMissingFileFails() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: """ - FROM scratch AS builder - FROM ghcr.io/linuxcontainers/alpine:3.20 - COPY --from=builder /does-not-exist.txt /copied.txt - """) - let image = "registry.local/copy-missing:\(UUID().uuidString)" - let result = try f.run([ - "build", "-f", dir.appending("Dockerfile").string, - "-t", image, dir.appending("context").string, - ]) - #expect(result.status != 0, "build should fail when source file is missing") - } - } - } - - @Test func testCopyInvalidStageFails() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nCOPY --from=not_a_stage /hello.txt /copied.txt") - let image = "registry.local/copy-invalid-stage:\(UUID().uuidString)" - let result = try f.run([ - "build", "-f", dir.appending("Dockerfile").string, - "-t", image, dir.appending("context").string, - ]) - #expect(result.status != 0, "build should fail with invalid stage name") - } - } - } - - @Test func testCopyFromNonexistentImageFails() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nCOPY --from=doesnotexist:latest /hello.txt /copied.txt") - let image = "registry.local/copy-bad-image:\(UUID().uuidString)" - let result = try f.run([ - "build", "-f", dir.appending("Dockerfile").string, - "-t", image, dir.appending("context").string, - ]) - #expect(result.status != 0, "build should fail when source image does not exist") - } - } - } -} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderTarExport.swift b/Tests/IntegrationTests/Build/TestCLIBuilderTarExport.swift new file mode 100644 index 000000000..888e0d490 --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilderTarExport.swift @@ -0,0 +1,117 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerTestSupport +import Foundation +import Testing + +struct TestCLIBuilderTarExport { + @Test func testBuildExportTar() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD emptyFile /", + context: [.file("emptyFile", content: .zeroFilled(size: 1))]) + + let exportPath = dir.appending("export.tar") + let result = try f.run([ + "build", + "-f", dir.appending("Dockerfile").string, + "-o", "type=tar,dest=\(exportPath.string)", + dir.appending("context").string, + ]) + #expect(result.status == 0, "build with tar export should succeed") + #expect(FileManager.default.fileExists(atPath: exportPath.string), "tar file should exist") + #expect(result.output.contains(exportPath.string), "output should reference export path") + let attrs = try FileManager.default.attributesOfItem(atPath: exportPath.string) + #expect((attrs[.size] as? Int ?? 0) > 0, "exported tar should not be empty") + } + } + + @Test func testBuildExportTarToDirectory() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nRUN echo \"test\" > /test.txt") + + let exportDir = dir.appending("exports") + try FileManager.default.createDirectory( + atPath: exportDir.string, withIntermediateDirectories: true, attributes: nil) + + let result = try f.run([ + "build", + "-f", dir.appending("Dockerfile").string, + "-o", "type=tar,dest=\(exportDir.string)", + dir.appending("context").string, + ]) + #expect(result.status == 0, "build with tar export to directory should succeed") + let expectedTar = exportDir.appending("out.tar") + #expect( + FileManager.default.fileExists(atPath: expectedTar.string), + "tar file should exist at out.tar") + #expect(result.output.contains(expectedTar.string), "output should reference out.tar") + } + } + + @Test func testBuildExportTarMultipleRuns() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD testFile /", + context: [.file("testFile", content: .data("test data".data(using: .utf8)!))]) + + let exportDir = dir.appending("exports") + try FileManager.default.createDirectory( + atPath: exportDir.string, withIntermediateDirectories: true, attributes: nil) + + let buildArgs = [ + "build", + "-f", dir.appending("Dockerfile").string, + "-o", "type=tar,dest=\(exportDir.string)", + dir.appending("context").string, + ] + + let r1 = try f.run(buildArgs) + #expect(r1.status == 0, "first build should succeed") + #expect(FileManager.default.fileExists(atPath: exportDir.appending("out.tar").string)) + + let r2 = try f.run(buildArgs) + #expect(r2.status == 0, "second build should succeed") + #expect( + FileManager.default.fileExists(atPath: exportDir.appending("out.tar.1").string), + "second tar should exist at out.tar.1") + } + } + + @Test func testBuildExportTarInvalidDest() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext(dir: dir, dockerfile: "FROM scratch") + + let result = try f.run([ + "build", + "-f", dir.appending("Dockerfile").string, + "-o", "type=tar", // missing dest + dir.appending("context").string, + ]) + #expect(result.status != 0, "build without dest should fail") + #expect(result.error.contains("dest field is required")) + } + } +} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderTarExportSerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderTarExportSerial.swift deleted file mode 100644 index 9952c90a0..000000000 --- a/Tests/IntegrationTests/Build/TestCLIBuilderTarExportSerial.swift +++ /dev/null @@ -1,126 +0,0 @@ -//===----------------------------------------------------------------------===// -// 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 ContainerTestSupport -import Foundation -import Testing - -@Suite(.serialized) -struct TestCLIBuilderTarExportSerial { - @Test func testBuildExportTar() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: "FROM scratch\nADD emptyFile /", - context: [.file("emptyFile", content: .zeroFilled(size: 1))]) - - let exportPath = dir.appending("export.tar") - let result = try f.run([ - "build", - "-f", dir.appending("Dockerfile").string, - "-o", "type=tar,dest=\(exportPath.string)", - dir.appending("context").string, - ]) - #expect(result.status == 0, "build with tar export should succeed") - #expect(FileManager.default.fileExists(atPath: exportPath.string), "tar file should exist") - #expect(result.output.contains(exportPath.string), "output should reference export path") - let attrs = try FileManager.default.attributesOfItem(atPath: exportPath.string) - #expect((attrs[.size] as? Int ?? 0) > 0, "exported tar should not be empty") - } - } - } - - @Test func testBuildExportTarToDirectory() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nRUN echo \"test\" > /test.txt") - - let exportDir = dir.appending("exports") - try FileManager.default.createDirectory( - atPath: exportDir.string, withIntermediateDirectories: true, attributes: nil) - - let result = try f.run([ - "build", - "-f", dir.appending("Dockerfile").string, - "-o", "type=tar,dest=\(exportDir.string)", - dir.appending("context").string, - ]) - #expect(result.status == 0, "build with tar export to directory should succeed") - let expectedTar = exportDir.appending("out.tar") - #expect( - FileManager.default.fileExists(atPath: expectedTar.string), - "tar file should exist at out.tar") - #expect(result.output.contains(expectedTar.string), "output should reference out.tar") - } - } - } - - @Test func testBuildExportTarMultipleRuns() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: "FROM scratch\nADD testFile /", - context: [.file("testFile", content: .data("test data".data(using: .utf8)!))]) - - let exportDir = dir.appending("exports") - try FileManager.default.createDirectory( - atPath: exportDir.string, withIntermediateDirectories: true, attributes: nil) - - let buildArgs = [ - "build", - "-f", dir.appending("Dockerfile").string, - "-o", "type=tar,dest=\(exportDir.string)", - dir.appending("context").string, - ] - - let r1 = try f.run(buildArgs) - #expect(r1.status == 0, "first build should succeed") - #expect(FileManager.default.fileExists(atPath: exportDir.appending("out.tar").string)) - - let r2 = try f.run(buildArgs) - #expect(r2.status == 0, "second build should succeed") - #expect( - FileManager.default.fileExists(atPath: exportDir.appending("out.tar.1").string), - "second tar should exist at out.tar.1") - } - } - } - - @Test func testBuildExportTarInvalidDest() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - try f.createContext(dir: dir, dockerfile: "FROM scratch") - - let result = try f.run([ - "build", - "-f", dir.appending("Dockerfile").string, - "-o", "type=tar", // missing dest - dir.appending("context").string, - ]) - #expect(result.status != 0, "build without dest should fail") - #expect(result.error.contains("dest field is required")) - } - } - } -} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderWarmupPullSerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderWarmupPullSerial.swift new file mode 100644 index 000000000..c923295fe --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilderWarmupPullSerial.swift @@ -0,0 +1,37 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerTestSupport +import Foundation +import Testing + +/// Serial because this repulls the shared warmup alpine image with `--no-cache`, +/// which would race with concurrent-pool tests relying on it already being cached. +@Suite(.serialized) +struct TestCLIBuilderWarmupPullSerial { + @Test func testBuildNoCachePullLatestImage() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM \(WarmupImage.alpine320.rawValue)\nADD emptyFile /", + context: [.file("emptyFile", content: .zeroFilled(size: 1))]) + let image = "registry.local/no-cache-pull:\(UUID().uuidString)" + try f.buildWithPaths(tags: [image], contextDir: dir, otherArgs: ["--pull", "--no-cache"]) + try f.assertImageBuilt(image) + } + } +} diff --git a/Tests/IntegrationTests/Containers/TestCLIClean.swift b/Tests/IntegrationTests/Containers/TestCLIClean.swift index ea0d1e998..629cdbcf2 100644 --- a/Tests/IntegrationTests/Containers/TestCLIClean.swift +++ b/Tests/IntegrationTests/Containers/TestCLIClean.swift @@ -14,186 +14,234 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerTestSupport +import Darwin import Foundation import Testing @Suite -class TestCLIClean: CLITest { +struct TestCLIClean { private struct StatusJSON: Codable { let appRoot: String } - private func getTestName() -> String { - Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() - } - - private func appRoot() throws -> URL { - let result = try run(arguments: ["system", "status", "--format", "json"]).check() + private func appRoot(_ fixture: ContainerFixture) throws -> URL { + let result = try fixture.run(["system", "status", "--format", "json"]).check() let status = try JSONDecoder().decode(StatusJSON.self, from: result.outputData) - return URL(fileURLWithPath: status.appRoot, isDirectory: true) + return URL(filePath: status.appRoot, directoryHint: .isDirectory) } private func allocatedBytes(at url: URL) throws -> Int64 { - let values = try url.resourceValues(forKeys: [.fileAllocatedSizeKey, .totalFileAllocatedSizeKey]) - let allocated = values.totalFileAllocatedSize ?? values.fileAllocatedSize - guard let allocated else { - throw CLIError.executionFailed("failed to read allocated size for \(url.path)") + var fileStatus = stat() + guard lstat(url.path, &fileStatus) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } - return Int64(allocated) + return Int64(fileStatus.st_blocks) * 512 } - private func containerRootfsBlockURL(name: String) throws -> URL { - let id = try getContainerId(name) - return try appRoot() - .appendingPathComponent("containers", isDirectory: true) - .appendingPathComponent(id, isDirectory: true) - .appendingPathComponent("rootfs.ext4", isDirectory: false) + private func containerRootfsBlockURL(_ fixture: ContainerFixture, name: String) throws -> URL { + let id = try fixture.getContainerId(name) + return try appRoot(fixture) + .appending(path: "containers", directoryHint: .isDirectory) + .appending(path: id, directoryHint: .isDirectory) + .appending(path: "rootfs.ext4", directoryHint: .notDirectory) } - private func volumeBlockURL(name: String) throws -> URL { - try appRoot() - .appendingPathComponent("volumes", isDirectory: true) - .appendingPathComponent(name, isDirectory: true) - .appendingPathComponent("volume.img", isDirectory: false) + private func volumeBlockURL(_ fixture: ContainerFixture, name: String) throws -> URL { + try appRoot(fixture) + .appending(path: "volumes", directoryHint: .isDirectory) + .appending(path: name, directoryHint: .isDirectory) + .appending(path: "volume.img", directoryHint: .notDirectory) } - private func assertCleanReclaimedSpace(beforeWrite: Int64, afterWrite: Int64, afterClean: Int64) { - let writeAllocated = afterWrite - beforeWrite - #expect(writeAllocated > 0) + private func expectReclaimedSpace(beforeWrite: Int64, afterWrite: Int64, afterClean: Int64) { + let allocatedByWrite = afterWrite - beforeWrite + #expect(allocatedByWrite > 0, "test write should allocate host storage") let reclaimed = afterWrite - afterClean - #expect(reclaimed > 0) + #expect(reclaimed > 0, "clean should reclaim host storage") - let minExpectedReclaimed = Int64(Double(writeAllocated) * 0.8) - #expect(reclaimed >= minExpectedReclaimed) + let minimumExpectedReclaimed = Int64(Double(allocatedByWrite) * 0.8) + #expect( + reclaimed >= minimumExpectedReclaimed, + "clean should reclaim at least 80% of storage allocated by the test write") } - @Test func testCleanStoppedContainerFails() throws { - let name = getTestName() - try doLongRun(name: name, autoRemove: false) - defer { try? doRemove(name: name) } - - try waitForContainerRunning(name) - try doStop(name: name) - - let status = try getContainerStatus(name) - #expect(status == "stopped") - - // Clean should fail on stopped container - let (_, _, _, exitStatus) = try run(arguments: ["clean", name]) - #expect(exitStatus != 0) + private func waitForStableAllocatedSpace( + at url: URL, + timeout: TimeInterval = 10 + ) async throws -> Int64 { + let deadline = Date.now.addingTimeInterval(timeout) + var previous = try allocatedBytes(at: url) + var unchangedSamples = 0 + + while Date.now < deadline { + try await Task.sleep(for: .milliseconds(250)) + let current = try allocatedBytes(at: url) + if current == previous { + unchangedSamples += 1 + if unchangedSamples == 4 { + return current + } + } else { + previous = current + unchangedSamples = 0 + } + } + return previous } - @Test func testCleanMultipleContainers() throws { - let name1 = getTestName() + "1" - let name2 = getTestName() + "2" - - try doLongRun(name: name1, autoRemove: false) - try doLongRun(name: name2, autoRemove: false) - - defer { - try? doStop(name: name1) - try? doStop(name: name2) - try? doRemove(name: name1) - try? doRemove(name: name2) + private func waitForAllocatedSpace( + after baseline: Int64, + at url: URL, + timeout: TimeInterval = 10 + ) async throws -> Int64 { + let deadline = Date.now.addingTimeInterval(timeout) + var allocated = try allocatedBytes(at: url) + + while allocated <= baseline, Date.now < deadline { + try await Task.sleep(for: .milliseconds(250)) + allocated = try allocatedBytes(at: url) } - - try waitForContainerRunning(name1) - try waitForContainerRunning(name2) - - // Clean both containers - let (_, _, _, exitStatus1) = try run(arguments: ["clean", name1, name2]) - #expect(exitStatus1 == 0) - - // Both containers should still be running - let status1 = try getContainerStatus(name1) - let status2 = try getContainerStatus(name2) - #expect(status1 == "running") - #expect(status2 == "running") + return allocated } - @Test func testCleanAfterFileCreation() throws { - let name = getTestName() - try doLongRun(name: name, autoRemove: false) - defer { - try? doStop(name: name) - try? doRemove(name: name) + private func waitForReclaimedSpace( + beforeWrite: Int64, + afterWrite: Int64, + at url: URL, + timeout: TimeInterval = 10 + ) async throws -> Int64 { + let allocatedByWrite = afterWrite - beforeWrite + let minimumExpectedReclaimed = Int64(Double(allocatedByWrite) * 0.8) + let deadline = Date.now.addingTimeInterval(timeout) + var afterClean = try allocatedBytes(at: url) + + while afterWrite - afterClean < minimumExpectedReclaimed, Date.now < deadline { + try await Task.sleep(for: .milliseconds(250)) + afterClean = try allocatedBytes(at: url) } - - try waitForContainerRunning(name) - - let rootfsBlockURL = try containerRootfsBlockURL(name: name) - let beforeWrite = try allocatedBytes(at: rootfsBlockURL) - - // Create some files to exercise the filesystem trim path - _ = try doExec(name: name, cmd: ["sh", "-c", "dd if=/dev/urandom of=/test-file bs=1M count=10"]) - _ = try doExec(name: name, cmd: ["sync"]) - let afterWrite = try allocatedBytes(at: rootfsBlockURL) - - _ = try doExec(name: name, cmd: ["rm", "/test-file"]) - - // Clean should succeed - try doClean(name: name) - _ = try doExec(name: name, cmd: ["sync"]) - let afterClean = try allocatedBytes(at: rootfsBlockURL) - assertCleanReclaimedSpace(beforeWrite: beforeWrite, afterWrite: afterWrite, afterClean: afterClean) - - // Container should still be running - let status = try getContainerStatus(name) - #expect(status == "running") + return afterClean } - @Test func testCleanWithVolume() throws { - let name = getTestName() - let volumeName = name + "-vol" - - // Create a volume - let (_, _, volError, volStatus) = try run(arguments: ["volume", "create", volumeName]) - guard volStatus == 0 else { - throw CLIError.executionFailed("volume create failed: \(volError)") + @Test func cleanRejectsStoppedContainer() async throws { + try await ContainerFixture.with { fixture in + let name = "\(fixture.testID)-clean-stopped" + try await fixture.doLongRun( + name: name, + autoRemove: false, + waitUntilRunning: true) + fixture.addCleanup { try? fixture.doRemove(name, force: true) } + + try fixture.doStop(name) + #expect(try fixture.getContainerStatus(name) == "stopped") + + let result = try fixture.run(["clean", name]) + #expect(result.status != 0, "clean should reject a stopped container") + #expect( + result.error.contains("not running"), + "clean should report that the stopped container is not running; stderr: \(result.error)") } + } - defer { - try? run(arguments: ["volume", "rm", volumeName]) + @Test func cleanSupportsMultipleRunningContainers() async throws { + try await ContainerFixture.with { fixture in + let primary = "\(fixture.testID)-clean-primary" + let secondary = "\(fixture.testID)-clean-secondary" + + try await fixture.doLongRun( + name: primary, + autoRemove: false, + waitUntilRunning: true) + fixture.addCleanup { try? fixture.doRemove(primary, force: true) } + + try await fixture.doLongRun( + name: secondary, + autoRemove: false, + waitUntilRunning: true) + fixture.addCleanup { try? fixture.doRemove(secondary, force: true) } + + try fixture.run(["clean", primary, secondary]).check() + #expect(try fixture.getContainerStatus(primary) == "running") + #expect(try fixture.getContainerStatus(secondary) == "running") } + } - // Create container with volume mount - try doCreate( - name: name, - image: nil, - args: nil, - volumes: ["\(volumeName):/mnt/vol"], - networks: [], - ports: [] - ) - - try doStart(name: name) - - defer { - try? doStop(name: name) - try? doRemove(name: name) + @Test func cleanReclaimsRootFilesystemSpace() async throws { + try await ContainerFixture.with { fixture in + let name = "\(fixture.testID)-clean-rootfs-reclaim" + try await fixture.doLongRun( + name: name, + autoRemove: false, + waitUntilRunning: true) + fixture.addCleanup { try? fixture.doRemove(name, force: true) } + + let rootfsBlockURL = try containerRootfsBlockURL(fixture, name: name) + try fixture.doClean(name) + let beforeWrite = try await waitForStableAllocatedSpace(at: rootfsBlockURL) + + try fixture.doExec( + name, + cmd: ["sh", "-c", "dd if=/dev/urandom of=/rootfs-reclaim.dat bs=1M count=256"]) + try fixture.doExec(name, cmd: ["sync"]) + let afterWrite = try await waitForAllocatedSpace(after: beforeWrite, at: rootfsBlockURL) + + try fixture.doExec(name, cmd: ["rm", "/rootfs-reclaim.dat"]) + try fixture.doExec(name, cmd: ["sync"]) + try fixture.doClean(name) + let afterClean = try await waitForReclaimedSpace( + beforeWrite: beforeWrite, + afterWrite: afterWrite, + at: rootfsBlockURL) + print("rootfs allocated bytes before=\(beforeWrite) afterWrite=\(afterWrite) afterClean=\(afterClean)") + + expectReclaimedSpace( + beforeWrite: beforeWrite, + afterWrite: afterWrite, + afterClean: afterClean) + #expect(try fixture.getContainerStatus(name) == "running") } + } - try waitForContainerRunning(name) - - let volumeBlockURL = try volumeBlockURL(name: volumeName) - let beforeWrite = try allocatedBytes(at: volumeBlockURL) - - // Write to volume - _ = try doExec(name: name, cmd: ["sh", "-c", "dd if=/dev/urandom of=/mnt/vol/test bs=1M count=5"]) - _ = try doExec(name: name, cmd: ["sync"]) - let afterWrite = try allocatedBytes(at: volumeBlockURL) - - _ = try doExec(name: name, cmd: ["rm", "/mnt/vol/test"]) - - // Clean should succeed and also trim the volume - try doClean(name: name) - _ = try doExec(name: name, cmd: ["sync"]) - let afterClean = try allocatedBytes(at: volumeBlockURL) - assertCleanReclaimedSpace(beforeWrite: beforeWrite, afterWrite: afterWrite, afterClean: afterClean) - - // Container should still be running - let status = try getContainerStatus(name) - #expect(status == "running") + @Test func cleanReclaimsNamedVolumeSpace() async throws { + try await ContainerFixture.with { fixture in + let name = "\(fixture.testID)-clean-volume-reclaim" + let volumeName = "\(fixture.testID)-clean-reclaim-data" + + try fixture.doVolumeCreate(volumeName) + fixture.addCleanup { fixture.doVolumeDeleteIfExists(volumeName) } + + try fixture.doCreate( + name: name, + volumes: ["\(volumeName):/mnt/reclaim-data"]) + fixture.addCleanup { try? fixture.doRemove(name, force: true) } + try fixture.doStart(name) + try await fixture.waitForContainerRunning(name) + + let volumeBlockURL = try volumeBlockURL(fixture, name: volumeName) + try fixture.doClean(name) + let beforeWrite = try await waitForStableAllocatedSpace(at: volumeBlockURL) + + try fixture.doExec( + name, + cmd: ["sh", "-c", "dd if=/dev/urandom of=/mnt/reclaim-data/volume-reclaim.dat bs=1M count=256"]) + try fixture.doExec(name, cmd: ["sync"]) + let afterWrite = try await waitForAllocatedSpace(after: beforeWrite, at: volumeBlockURL) + + try fixture.doExec(name, cmd: ["rm", "/mnt/reclaim-data/volume-reclaim.dat"]) + try fixture.doExec(name, cmd: ["sync"]) + try fixture.doClean(name) + let afterClean = try await waitForReclaimedSpace( + beforeWrite: beforeWrite, + afterWrite: afterWrite, + at: volumeBlockURL) + print("volume allocated bytes before=\(beforeWrite) afterWrite=\(afterWrite) afterClean=\(afterClean)") + + expectReclaimedSpace( + beforeWrite: beforeWrite, + afterWrite: afterWrite, + afterClean: afterClean) + #expect(try fixture.getContainerStatus(name) == "running") + } } } diff --git a/Tests/IntegrationTests/Containers/TestCLICopyCommand.swift b/Tests/IntegrationTests/Containers/TestCLICopyCommand.swift index adf357172..30a247995 100644 --- a/Tests/IntegrationTests/Containers/TestCLICopyCommand.swift +++ b/Tests/IntegrationTests/Containers/TestCLICopyCommand.swift @@ -25,7 +25,7 @@ struct TestCLICopyCommand { @Test func testCopyHostToContainer() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let src = f.testDir.appending("testfile.txt") let content = "hello from host" @@ -39,7 +39,7 @@ struct TestCLICopyCommand { @Test func testCopyContainerToHost() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let content = "hello from container" try f.doExec(name, cmd: ["sh", "-c", "echo -n '\(content)' > /tmp/containerfile.txt"]) @@ -53,7 +53,7 @@ struct TestCLICopyCommand { @Test func testCopyUsingCpAlias() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let src = f.testDir.appending("aliasfile.txt") let content = "testing cp alias" @@ -74,7 +74,7 @@ struct TestCLICopyCommand { @Test func testCopyContainerToContainerFails() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue let name = "\(f.testID)-c" try f.doCreate(name: name, image: image) f.addCleanup { try f.doRemoveIfExists(name, ignoreFailure: true) } @@ -85,7 +85,7 @@ struct TestCLICopyCommand { @Test func testCopyToNonRunningContainerFails() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue let name = "\(f.testID)-c" try f.doCreate(name: name, image: image) f.addCleanup { try f.doRemoveIfExists(name, ignoreFailure: true) } @@ -98,7 +98,7 @@ struct TestCLICopyCommand { @Test func testCopyDirectoryHostToContainer() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let srcDir = f.testDir.appending("hostdir") try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true, attributes: nil) @@ -115,7 +115,7 @@ struct TestCLICopyCommand { @Test func testCopyDirectoryContainerToHost() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/guestdir && echo -n 'aaa' > /tmp/guestdir/a.txt && echo -n 'bbb' > /tmp/guestdir/b.txt"]) let dest = f.testDir.appending("guestdir") @@ -130,7 +130,7 @@ struct TestCLICopyCommand { @Test func testCopyNestedDirectoryHostToContainer() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let srcDir = f.testDir.appending("nested") let subDir = srcDir.appending("sub") @@ -148,7 +148,7 @@ struct TestCLICopyCommand { @Test func testCopyNestedDirectoryContainerToHost() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in try f.doExec(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 dest = f.testDir.appending("nested") @@ -165,7 +165,7 @@ struct TestCLICopyCommand { @Test func testCopyOutFileToExistingFile() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let content = "container content" try f.doExec(name, cmd: ["sh", "-c", "echo -n '\(content)' > /tmp/source.txt"]) @@ -180,7 +180,7 @@ struct TestCLICopyCommand { @Test func testCopyOutDirectoryToExistingFileFails() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'x' > /tmp/srcdir/file.txt"]) let dest = f.testDir.appending("existing.txt") @@ -193,7 +193,7 @@ struct TestCLICopyCommand { @Test func testCopyOutFileToExistingDirectory() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let content = "container content" try f.doExec(name, cmd: ["sh", "-c", "echo -n '\(content)' > /tmp/source.txt"]) @@ -208,7 +208,7 @@ struct TestCLICopyCommand { @Test func testCopyOutDirectoryToExistingDirectory() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'hello' > /tmp/srcdir/file.txt"]) let destDir = f.testDir.appending("dstdir") @@ -224,7 +224,7 @@ struct TestCLICopyCommand { @Test func testCopyOutFileToNonExistingTrailingSlashFails() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in try f.doExec(name, cmd: ["sh", "-c", "echo -n 'x' > /tmp/source.txt"]) let dest = f.testDir.appending("nonexistent").string + "/" @@ -236,7 +236,7 @@ struct TestCLICopyCommand { @Test func testCopyOutDirectoryToNonExistingTrailingSlash() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'hello' > /tmp/srcdir/file.txt"]) let destDir = f.testDir.appending("newdir") @@ -251,7 +251,7 @@ struct TestCLICopyCommand { @Test func testCopyOutFileToExistingDirectoryTrailingSlash() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let content = "container content" try f.doExec(name, cmd: ["sh", "-c", "echo -n '\(content)' > /tmp/source.txt"]) @@ -266,7 +266,7 @@ struct TestCLICopyCommand { @Test func testCopyOutDirectoryToExistingDirectoryTrailingSlash() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'hello' > /tmp/srcdir/file.txt"]) let destDir = f.testDir.appending("dstdir") @@ -282,7 +282,7 @@ struct TestCLICopyCommand { @Test func testCopyOutDirectoryContentsToNonExisting() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir/sub && echo -n 'hello' > /tmp/srcdir/file.txt"]) let destDir = f.testDir.appending("newdir") @@ -297,7 +297,7 @@ struct TestCLICopyCommand { @Test func testCopyOutDirectoryContentsToExistingFileFails() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'x' > /tmp/srcdir/file.txt"]) let dest = f.testDir.appending("existing.txt") @@ -310,7 +310,7 @@ struct TestCLICopyCommand { @Test func testCopyOutDirectoryContentsToExistingDirectory() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'hello' > /tmp/srcdir/file.txt"]) let destDir = f.testDir.appending("dstdir") @@ -326,7 +326,7 @@ struct TestCLICopyCommand { @Test func testCopyOutDirectoryContentsToNonExistingTrailingSlash() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'hello' > /tmp/srcdir/file.txt"]) let destDir = f.testDir.appending("newdir") @@ -339,7 +339,7 @@ struct TestCLICopyCommand { @Test func testCopyOutDirectoryContentsToExistingDirectoryTrailingSlash() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'hello' > /tmp/srcdir/file.txt"]) let destDir = f.testDir.appending("dstdir") @@ -355,7 +355,7 @@ struct TestCLICopyCommand { @Test func testCopyInFileToExistingFile() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let content = "new content" let src = f.testDir.appending("source.txt") @@ -370,7 +370,7 @@ struct TestCLICopyCommand { @Test func testCopyInDirectoryToExistingFileFails() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let srcDir = f.testDir.appending("srcdir") try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true, attributes: nil) @@ -384,7 +384,7 @@ struct TestCLICopyCommand { @Test func testCopyInFileToExistingDirectory() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let content = "host content" let src = f.testDir.appending("source.txt") @@ -399,7 +399,7 @@ struct TestCLICopyCommand { @Test func testCopyInDirectoryToExistingDirectory() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let srcDir = f.testDir.appending("srcdir") try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true, attributes: nil) @@ -416,7 +416,7 @@ struct TestCLICopyCommand { @Test func testCopyInFileToNonExistingTrailingSlashFails() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let src = f.testDir.appending("source.txt") try "x".write(toFile: src.string, atomically: true, encoding: .utf8) @@ -428,7 +428,7 @@ struct TestCLICopyCommand { @Test func testCopyInDirectoryToNonExistingTrailingSlash() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let srcDir = f.testDir.appending("srcdir") try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true, attributes: nil) @@ -444,7 +444,7 @@ struct TestCLICopyCommand { @Test func testCopyInDirectoryContentsToNonExisting() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let srcDir = f.testDir.appending("srcdir") let subDir = srcDir.appending("sub") @@ -459,7 +459,7 @@ struct TestCLICopyCommand { @Test func testCopyInDirectoryContentsToExistingFileFails() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let srcDir = f.testDir.appending("srcdir") try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true, attributes: nil) @@ -473,7 +473,7 @@ struct TestCLICopyCommand { @Test func testCopyInDirectoryContentsToExistingDirectory() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let srcDir = f.testDir.appending("srcdir") try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true, attributes: nil) @@ -490,7 +490,7 @@ struct TestCLICopyCommand { @Test func testCopyInDirectoryContentsToNonExistingTrailingSlash() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let srcDir = f.testDir.appending("srcdir") try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true, attributes: nil) @@ -504,7 +504,7 @@ struct TestCLICopyCommand { @Test func testCopyInDirectoryContentsToExistingDirectoryTrailingSlash() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let srcDir = f.testDir.appending("srcdir") try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true, attributes: nil) @@ -521,7 +521,7 @@ struct TestCLICopyCommand { @Test func testCopyInRelativeSourcePath() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let content = "relative source" try content.write(toFile: f.testDir.appending("relfile.txt").string, atomically: true, encoding: .utf8) @@ -534,7 +534,7 @@ struct TestCLICopyCommand { @Test func testCopyOutRelativeDestinationPath() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let content = "relative dest" try f.doExec(name, cmd: ["sh", "-c", "echo -n '\(content)' > /tmp/relfile.txt"]) diff --git a/Tests/IntegrationTests/Containers/TestCLICreateCommand.swift b/Tests/IntegrationTests/Containers/TestCLICreateCommand.swift index a8a0cd375..052dd50a2 100644 --- a/Tests/IntegrationTests/Containers/TestCLICreateCommand.swift +++ b/Tests/IntegrationTests/Containers/TestCLICreateCommand.swift @@ -23,7 +23,7 @@ import Testing struct TestCLICreateCommand { @Test func testCreateArgsPassthrough() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue let name = "\(f.testID)-c" try f.doCreate(name: name, image: image, args: ["echo", "-n", "hello", "world"]) try f.doRemove(name) @@ -32,7 +32,7 @@ struct TestCLICreateCommand { @Test func testCreateWithMACAddress() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue let name = "\(f.testID)-c" let expectedMAC = try MACAddress("02:42:ac:11:00:03") @@ -52,7 +52,7 @@ struct TestCLICreateCommand { @Test func testPublishPortParserMaxPorts() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue let name = "\(f.testID)-c" var args: [String] = ["create", "--name", name] for i in 0..<64 { @@ -68,7 +68,7 @@ struct TestCLICreateCommand { @Test func testPublishPortParserTooManyPorts() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue let name = "\(f.testID)-c" var args: [String] = ["create", "--name", name] for i in 0..<65 { @@ -84,7 +84,7 @@ struct TestCLICreateCommand { @Test func testCreateWithFQDNName() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue // Prefix with testID to avoid name collisions; hostname is the first FQDN component. let name = "\(f.testID).example.com" let expectedHostname = f.testID diff --git a/Tests/IntegrationTests/Containers/TestCLIExecCommand.swift b/Tests/IntegrationTests/Containers/TestCLIExecCommand.swift index c76964cba..2ecdffeff 100644 --- a/Tests/IntegrationTests/Containers/TestCLIExecCommand.swift +++ b/Tests/IntegrationTests/Containers/TestCLIExecCommand.swift @@ -21,7 +21,7 @@ import Testing struct TestCLIExecCommand { @Test func testCreateExecCommand() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue let name = "\(f.testID)-c" try f.doCreate(name: name, image: image) f.addCleanup { try? f.doStop(name) } @@ -36,7 +36,7 @@ struct TestCLIExecCommand { @Test func testExecDetach() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue let name = "\(f.testID)-c" try f.doCreate(name: name, image: image) f.addCleanup { try? f.doStop(name) } @@ -70,21 +70,22 @@ struct TestCLIExecCommand { @Test func testExecDetachProcessRunning() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue let name = "\(f.testID)-c" try f.doCreate(name: name, image: image) f.addCleanup { try? f.doStop(name) } try f.doStart(name) try await f.waitForContainerRunning(name) - let output = try f.doExec(name, cmd: ["sleep", "10"], detach: true) + // Generous duration: ContainersService's host-wide lock can queue this exec's start behind other containers' multi-second VM boots. + let output = try f.doExec(name, cmd: ["sleep", "60"], detach: true) try #require( output.trimmingCharacters(in: .whitespacesAndNewlines) == name, "exec --detach should print the container name") let ps = try f.doExec(name, cmd: ["ps", "aux"]) .trimmingCharacters(in: .whitespacesAndNewlines) - try #require(ps.contains("sleep 10"), "detached 'sleep 10' should appear in ps output") + try #require(ps.contains("sleep 60"), "detached 'sleep 60' should appear in ps output") try f.doStop(name) } @@ -92,10 +93,10 @@ struct TestCLIExecCommand { @Test func testExecOnExitingContainer() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue let name = "\(f.testID)-c" // sh exits immediately in detached mode with no stdin; container stops on its own. - try f.doLongRun(name: name, image: image, containerArgs: ["sh"], autoRemove: false) + try await f.doLongRun(name: name, image: image, containerArgs: ["sh"], autoRemove: false) f.addCleanup { try? f.doRemove(name) } try await Task.sleep(for: .seconds(1)) diff --git a/Tests/IntegrationTests/Containers/TestCLIExportCommand.swift b/Tests/IntegrationTests/Containers/TestCLIExportCommand.swift index bd5747465..a851136c8 100644 --- a/Tests/IntegrationTests/Containers/TestCLIExportCommand.swift +++ b/Tests/IntegrationTests/Containers/TestCLIExportCommand.swift @@ -23,7 +23,7 @@ import Testing struct TestCLIExportCommand { @Test func testExportCommand() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image, autoRemove: false) { name in let mustBeInImage = "must-be-in-image" try f.doExec(name, cmd: ["sh", "-c", "echo \(mustBeInImage) > /foo"]) @@ -53,4 +53,28 @@ struct TestCLIExportCommand { } } } + + @Test func testExportCommandRunningContainerAndOverwrite() async throws { + try await ContainerFixture.with { f in + let image = WarmupImage.alpine320.rawValue + try await f.withContainer(image: image, autoRemove: false) { name in + let mustBeInImage = "must-be-in-image-live" + try f.doExec(name, cmd: ["sh", "-c", "echo \(mustBeInImage) > /foo-live"]) + + let exportPath = f.testDir.appending("export-live.tar") + try f.run(["export", name, "-o", exportPath.string]).check() + try f.run(["export", name, "-o", exportPath.string]).check() + + let exportURL = URL(filePath: exportPath.string) + let attrs = try FileManager.default.attributesOfItem(atPath: exportPath.string) + let fileSize = attrs[.size] as! UInt64 + #expect(fileSize > 0) + + let reader = try ArchiveReader(file: exportURL) + let (fooLive, fooLiveData) = try reader.extractFile(path: "/foo-live") + #expect(fooLive.fileType == .regular) + #expect(String(data: fooLiveData, encoding: .utf8)?.starts(with: mustBeInImage) ?? false) + } + } + } } diff --git a/Tests/IntegrationTests/Containers/TestCLIPruneCommandSerial.swift b/Tests/IntegrationTests/Containers/TestCLIPruneCommandSerial.swift index 414ba3e21..592993921 100644 --- a/Tests/IntegrationTests/Containers/TestCLIPruneCommandSerial.swift +++ b/Tests/IntegrationTests/Containers/TestCLIPruneCommandSerial.swift @@ -15,7 +15,6 @@ //===----------------------------------------------------------------------===// import ContainerTestSupport -import Foundation import Testing /// Serial prune tests — `container prune` affects all stopped containers regardless of name. @@ -37,7 +36,7 @@ struct TestCLIPruneCommandSerial { @Test func testContainerPruneStoppedContainers() async throws { try await ContainerFixture.with { f in - let image = ContainerFixture.warmupImages[0] + let image = WarmupImage.alpine320.rawValue if try !f.isImagePresent(image) { try f.doPull(image) } // One running container that must survive the prune. @@ -56,16 +55,8 @@ struct TestCLIPruneCommandSerial { try f.doStop(pc1Name) // Poll until both containers reach stopped state. - let deadline = Date().addingTimeInterval(30) - while true { - let s0 = try f.getContainerStatus(pc0Name) - let s1 = try f.getContainerStatus(pc1Name) - if s0 == "stopped" && s1 == "stopped" { break } - guard Date() < deadline else { - throw CommandError.executionFailed( - "Timeout waiting for containers to stop: pc0=\(s0), pc1=\(s1)") - } - try await Task.sleep(for: .milliseconds(200)) + try await f.retry(attempts: 150, delay: .milliseconds(200)) { + try f.getContainerStatus(pc0Name) == "stopped" && f.getContainerStatus(pc1Name) == "stopped" } let result = try f.run(["prune"]).check() diff --git a/Tests/IntegrationTests/Containers/TestCLIRemove.swift b/Tests/IntegrationTests/Containers/TestCLIRemove.swift index 5dc0c10bb..a646d98d5 100644 --- a/Tests/IntegrationTests/Containers/TestCLIRemove.swift +++ b/Tests/IntegrationTests/Containers/TestCLIRemove.swift @@ -23,7 +23,7 @@ import Testing struct TestCLIRemove { @Test func testDeleteStopped() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue let name = "\(f.testID)-c" // create without --rm so the container persists after being stopped try f.doCreate(name: name, image: image) @@ -35,7 +35,7 @@ struct TestCLIRemove { @Test func testDeleteAlias() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue let name = "\(f.testID)-c" try f.doCreate(name: name, image: image) try f.run(["rm", name]).check("rm alias failed") @@ -46,7 +46,7 @@ struct TestCLIRemove { @Test func testDeleteForceRunning() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in try f.doRemove(name, force: true) let result = try f.run(["inspect", name]) @@ -72,7 +72,7 @@ struct TestCLIRemove { @Test func testDeleteDuplicateIds() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue let name = "\(f.testID)-c" try f.doCreate(name: name, image: image) f.addCleanup { try f.doRemoveIfExists(name, force: true, ignoreFailure: true) } diff --git a/Tests/IntegrationTests/Containers/TestCLIRemoveSerial.swift b/Tests/IntegrationTests/Containers/TestCLIRemoveSerial.swift index 131b3e2e6..8dcb6ad65 100644 --- a/Tests/IntegrationTests/Containers/TestCLIRemoveSerial.swift +++ b/Tests/IntegrationTests/Containers/TestCLIRemoveSerial.swift @@ -23,7 +23,7 @@ import Testing struct TestCLIRemoveSerial { @Test func testDeleteAllStopped() async throws { try await ContainerFixture.with { f in - let image = ContainerFixture.warmupImages[0] + let image = WarmupImage.alpine320.rawValue if try !f.isImagePresent(image) { try f.doPull(image) } let name1 = "\(f.testID)-c1" let name2 = "\(f.testID)-c2" @@ -41,12 +41,12 @@ struct TestCLIRemoveSerial { @Test func testDeleteAllSkipsRunning() async throws { try await ContainerFixture.with { f in - let image = ContainerFixture.warmupImages[0] + let image = WarmupImage.alpine320.rawValue if try !f.isImagePresent(image) { try f.doPull(image) } let runningName = "\(f.testID)-running" let stoppedName = "\(f.testID)-stopped" - try f.doLongRun(name: runningName, image: image, autoRemove: false) + try await f.doLongRun(name: runningName, image: image, autoRemove: false) f.addCleanup { try? f.doStop(runningName) try? f.doRemove(runningName) @@ -63,10 +63,10 @@ struct TestCLIRemoveSerial { @Test func testDeleteAllForce() async throws { try await ContainerFixture.with { f in - let image = ContainerFixture.warmupImages[0] + let image = WarmupImage.alpine320.rawValue if try !f.isImagePresent(image) { try f.doPull(image) } let name = "\(f.testID)-c" - try f.doLongRun(name: name, image: image, autoRemove: false) + try await f.doLongRun(name: name, image: image, autoRemove: false) f.addCleanup { try f.doRemoveIfExists(name, force: true, ignoreFailure: true) } try f.run(["delete", "--all", "--force"]).check() diff --git a/Tests/IntegrationTests/Containers/TestCLIRmRaceCondition.swift b/Tests/IntegrationTests/Containers/TestCLIRmRaceCondition.swift index f49b86609..529fcbb63 100644 --- a/Tests/IntegrationTests/Containers/TestCLIRmRaceCondition.swift +++ b/Tests/IntegrationTests/Containers/TestCLIRmRaceCondition.swift @@ -61,30 +61,18 @@ struct TestCLIRmRaceCondition { if raceConditionPrevented { return } - // Race detected — wait for background cleanup then retry with backoff. + // Race detected — wait for background cleanup then retry. try await Task.sleep(for: .seconds(2)) - var attempts = 0 - let maxAttempts = 5 - while attempts < maxAttempts { - guard (try? f.getContainerStatus(name)) != nil else { break } + try await f.retry(attempts: 5, delay: .seconds(3)) { + guard (try? f.getContainerStatus(name)) != nil else { return true } do { try f.doRemove(name) - break - } catch CommandError.nonZeroExit(_, let message) { - if message.contains("not found") { break } - guard attempts < maxAttempts - 1 else { - throw CommandError.executionFailed( - "Failed to remove container after \(maxAttempts) attempts: \(message)") - } - let delay = 1 << attempts - try await Task.sleep(for: .seconds(delay)) - attempts += 1 + return true + } catch CommandError.nonZeroExit(_, let message) where message.contains("not found") { + return true } catch { - guard attempts < maxAttempts - 1 else { throw error } - let delay = 1 << attempts - try await Task.sleep(for: .seconds(delay)) - attempts += 1 + return false } } } diff --git a/Tests/IntegrationTests/Containers/TestCLIStatsCommand.swift b/Tests/IntegrationTests/Containers/TestCLIStatsCommand.swift index c86ac86c6..9e02b68c4 100644 --- a/Tests/IntegrationTests/Containers/TestCLIStatsCommand.swift +++ b/Tests/IntegrationTests/Containers/TestCLIStatsCommand.swift @@ -23,7 +23,7 @@ import Testing struct TestCLIStatsCommand { @Test func testStatsNoStreamJSONFormat() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let result = try f.run(["stats", "--format", "json", "--no-stream", name]).check() let stats = try JSONDecoder().decode([ContainerStats].self, from: result.outputData) @@ -39,7 +39,7 @@ struct TestCLIStatsCommand { @Test func testStatsIdleCPUPercentage() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image, containerArgs: ["sleep", "3600"]) { name in let result = try f.run(["stats", "--no-stream", name]).check() let lines = result.output.components(separatedBy: .newlines) @@ -57,7 +57,7 @@ struct TestCLIStatsCommand { @Test func testStatsHighCPUPercentage() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image, containerArgs: ["sh", "-c", "while true; do :; done"]) { name in let result = try f.run(["stats", "--no-stream", name]).check() let lines = result.output.components(separatedBy: .newlines) @@ -76,7 +76,7 @@ struct TestCLIStatsCommand { @Test func testStatsTableFormat() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let result = try f.run(["stats", "--no-stream", name]).check() #expect(result.output.contains("Container ID"), "output should contain table header") @@ -89,7 +89,7 @@ struct TestCLIStatsCommand { @Test func testStatsAllContainers() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue // Run two containers simultaneously so both appear in the global stats snapshot. try await f.withContainer(image: image, tag: "c1") { name1 in try await f.withContainer(image: image, tag: "c2") { name2 in diff --git a/Tests/IntegrationTests/Containers/TestCLIStop.swift b/Tests/IntegrationTests/Containers/TestCLIStop.swift index 4efc23b4a..3bb0e8e57 100644 --- a/Tests/IntegrationTests/Containers/TestCLIStop.swift +++ b/Tests/IntegrationTests/Containers/TestCLIStop.swift @@ -21,7 +21,7 @@ import Testing struct TestCLIStop { @Test func testStopWithExplicitSignal() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image, autoRemove: false) { name in try f.doStop(name, signal: "SIGTERM") #expect(try f.getContainerStatus(name) == "stopped") @@ -31,7 +31,7 @@ struct TestCLIStop { @Test func testStopWithoutSignal() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image, autoRemove: false) { name in try f.doStop(name, signal: nil) #expect(try f.getContainerStatus(name) == "stopped") @@ -41,7 +41,7 @@ struct TestCLIStop { @Test func testStopSignalInInspect() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image, autoRemove: false) { name in let inspect = try f.inspectContainer(name) // Alpine doesn't set a STOPSIGNAL, so this should be nil. @@ -52,7 +52,7 @@ struct TestCLIStop { @Test func testStopIdempotent() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image, autoRemove: false) { name in try f.doStop(name, signal: "SIGKILL") #expect(try f.getContainerStatus(name) == "stopped") diff --git a/Tests/IntegrationTests/Images/TestCLIImagePruneSerial.swift b/Tests/IntegrationTests/Images/TestCLIImagePruneSerial.swift index 3d798b125..40ea36449 100644 --- a/Tests/IntegrationTests/Images/TestCLIImagePruneSerial.swift +++ b/Tests/IntegrationTests/Images/TestCLIImagePruneSerial.swift @@ -17,8 +17,8 @@ import ContainerTestSupport import Testing -private let alpine = ContainerFixture.warmupImages[0] -private let busybox = ContainerFixture.warmupImages[2] +private let alpine = WarmupImage.alpine320.rawValue +private let busybox = WarmupImage.busybox136.rawValue /// Serial tests for `image prune` and `--max-concurrent-downloads`. /// These use `image rm --all` which affects global state. @@ -90,8 +90,7 @@ struct TestCLIImagePruneSerial { #expect(try f.isImagePresent(busybox), "expected \(busybox) to be pulled") // Keep alpine in use via a running container. - try f.doLongRun(name: containerName, image: alpine, autoRemove: false) - try await f.waitForContainerRunning(containerName) + try await f.doLongRun(name: containerName, image: alpine, autoRemove: false, waitUntilRunning: true) let result = try f.run(["image", "prune", "-a"]).check() #expect(result.output.contains(busybox), "should prune busybox image") diff --git a/Tests/IntegrationTests/Images/TestCLIImagesCommand.swift b/Tests/IntegrationTests/Images/TestCLIImagesCommand.swift index 90aafe228..01cda69d8 100644 --- a/Tests/IntegrationTests/Images/TestCLIImagesCommand.swift +++ b/Tests/IntegrationTests/Images/TestCLIImagesCommand.swift @@ -22,9 +22,9 @@ import Testing @Suite struct TestCLIImagesCommand { - private let alpine = ContainerFixture.warmupImages[0] // ghcr.io/linuxcontainers/alpine:3.20 - private let alpine318 = ContainerFixture.warmupImages[1] // ghcr.io/linuxcontainers/alpine:3.18 - private let busybox = ContainerFixture.warmupImages[2] // ghcr.io/containerd/busybox:1.36 + private let alpine = WarmupImage.alpine320.rawValue // ghcr.io/linuxcontainers/alpine:3.20 + private let alpine318 = WarmupImage.alpine318.rawValue // ghcr.io/linuxcontainers/alpine:3.18 + private let busybox = WarmupImage.busybox136.rawValue // ghcr.io/containerd/busybox:1.36 /// Host architecture string for platform tests. private var hostArchitecture: String { diff --git a/Tests/IntegrationTests/Images/TestCLIProgressAuto.swift b/Tests/IntegrationTests/Images/TestCLIProgressAuto.swift index ae677453d..ef024aeef 100644 --- a/Tests/IntegrationTests/Images/TestCLIProgressAuto.swift +++ b/Tests/IntegrationTests/Images/TestCLIProgressAuto.swift @@ -19,7 +19,7 @@ import Testing @Suite struct TestCLIProgressAuto { - private let alpine = ContainerFixture.warmupImages[0] + private let alpine = WarmupImage.alpine320.rawValue @Test func testAutoProgressFallsBackToPlainWhenPiped() async throws { try await ContainerFixture.with { f in diff --git a/Tests/IntegrationTests/Machine/TestCLIMachineCommand.swift b/Tests/IntegrationTests/Machine/TestCLIMachineCommand.swift index 201532634..e670c642b 100644 --- a/Tests/IntegrationTests/Machine/TestCLIMachineCommand.swift +++ b/Tests/IntegrationTests/Machine/TestCLIMachineCommand.swift @@ -41,19 +41,17 @@ struct TestCLIMachineCommand { } } - @Test func testCreateNameLongestValid() async { - await withKnownIssue("XPC timeout on machine-apiserver.bootMachine", isIntermittent: true) { - try await ContainerFixture.with { f in - // Start with container ID or DNS label length, whichever is shorter. - // Reduce by length of UUID suffix. - // Reduce by 1 for dash separator between ID and suffix. - let maxHostnameLength = min(LinuxContainer.maxIDLength, 63) - let maxNameLength = maxHostnameLength - MachineConfiguration.containerUUIDLength - 2 - let name = String(repeating: "a", count: maxNameLength) - f.addCleanup { f.cleanupMachine(name) } - try f.doMachineCreate(name: name, image: machineImage) - try f.doMachineBoot(name: name) - } + @Test func testCreateNameLongestValid() async throws { + try await ContainerFixture.with { f in + // Start with container ID or DNS label length, whichever is shorter. + // Reduce by length of UUID suffix. + // Reduce by 1 for dash separator between ID and suffix. + let maxHostnameLength = min(LinuxContainer.maxIDLength, 63) + let maxNameLength = maxHostnameLength - MachineConfiguration.containerUUIDLength - 2 + let name = String(repeating: "a", count: maxNameLength) + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) } } diff --git a/Tests/IntegrationTests/Network/TestCLINetwork.swift b/Tests/IntegrationTests/Network/TestCLINetwork.swift index ab35ee869..ff0b79b03 100644 --- a/Tests/IntegrationTests/Network/TestCLINetwork.swift +++ b/Tests/IntegrationTests/Network/TestCLINetwork.swift @@ -42,16 +42,15 @@ struct TestCLINetwork { #expect(networkIds == networkIds.sorted(), "network IDs should be sorted") let port = UInt16.random(in: 50000..<60000) - try f.doLongRun( + try await f.doLongRun( name: c, image: "docker.io/library/python:alpine", args: ["--network", net], containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"], - autoRemove: false) + autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let container = try f.inspectContainer(c) #expect(container.networks.count > 0) @@ -112,14 +111,13 @@ struct TestCLINetwork { @Test func testNetworkMTU() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--network", "default,mtu=1500"], autoRemove: false) + try await f.doLongRun(name: c, image: image, args: ["--network", "default,mtu=1500"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let output = try f.doExec(c, cmd: ["ip", "link", "show", "eth0"]) #expect(output.contains("mtu 1500"), "expected mtu 1500 in ip link output: \(output)") } @@ -143,12 +141,11 @@ struct TestCLINetwork { try f.doNetworkCreate(net, args: ["--internal"]) let port = UInt16.random(in: 50000..<60000) - try f.doLongRun( + try await f.doLongRun( name: server, image: pythonImage, args: ["--network", net], containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"], - autoRemove: false) - try await f.waitForContainerRunning(server) + autoRemove: false, waitUntilRunning: true) let container = try f.inspectContainer(server) #expect(container.networks.count > 0) diff --git a/Tests/IntegrationTests/Network/TestCLINetworkPruneSerial.swift b/Tests/IntegrationTests/Network/TestCLINetworkPruneSerial.swift index ff001e637..49cc34c45 100644 --- a/Tests/IntegrationTests/Network/TestCLINetworkPruneSerial.swift +++ b/Tests/IntegrationTests/Network/TestCLINetworkPruneSerial.swift @@ -71,13 +71,12 @@ struct TestCLINetworkPruneSerial { try f.doNetworkCreate(netUnused) let port = UInt16.random(in: 50000..<60000) - try f.doLongRun( + try await f.doLongRun( name: containerName, image: "docker.io/library/python:alpine", args: ["--network", netInUse], containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"], - autoRemove: false) - try await f.waitForContainerRunning(containerName) + autoRemove: false, waitUntilRunning: true) let container = try f.inspectContainer(containerName) #expect(container.networks.count > 0) @@ -103,13 +102,12 @@ struct TestCLINetworkPruneSerial { try f.doNetworkCreate(networkName) let port = UInt16.random(in: 50000..<60000) - try f.doLongRun( + try await f.doLongRun( name: containerName, image: "docker.io/library/python:alpine", args: ["--network", networkName], containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"], - autoRemove: false) - try await f.waitForContainerRunning(containerName) + autoRemove: false, waitUntilRunning: true) // Network is attached to a running container — prune must skip it. try f.run(["network", "prune"]).check() diff --git a/Tests/IntegrationTests/Run/TestCLIRunCapabilities.swift b/Tests/IntegrationTests/Run/TestCLIRunCapabilities.swift index 9389de0bd..c7e41a433 100644 --- a/Tests/IntegrationTests/Run/TestCLIRunCapabilities.swift +++ b/Tests/IntegrationTests/Run/TestCLIRunCapabilities.swift @@ -20,13 +20,13 @@ import Testing @Suite struct TestCLIRunCapabilities { - private let alpine = ContainerFixture.warmupImages[0] + private let alpine = WarmupImage.alpine320 // MARK: - Invalid capability names @Test func testCapDropInvalid() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let result = try f.run(["run", "--rm", "--cap-drop=CHWOWZERS", image, "ls"]) #expect(result.status != 0) #expect(result.error.contains("CHWOWZERS") || result.error.contains("invalid")) @@ -35,7 +35,7 @@ struct TestCLIRunCapabilities { @Test func testCapAddInvalid() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let result = try f.run(["run", "--rm", "--cap-add=CHWOWZERS", image, "ls"]) #expect(result.status != 0) #expect(result.error.contains("CHWOWZERS") || result.error.contains("invalid")) @@ -46,10 +46,9 @@ struct TestCLIRunCapabilities { @Test func testCapAddStored() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--cap-add", "NET_ADMIN"], autoRemove: false) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, args: ["--cap-add", "NET_ADMIN"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) @@ -63,10 +62,9 @@ struct TestCLIRunCapabilities { @Test func testCapDropStored() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--cap-drop", "MKNOD"], autoRemove: false) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, args: ["--cap-drop", "MKNOD"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) @@ -80,13 +78,12 @@ struct TestCLIRunCapabilities { @Test func testCapAddDropALLStored() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun( + try await f.doLongRun( name: c, image: image, args: ["--cap-drop", "ALL", "--cap-add", "SETGID", "--cap-add", "NET_RAW"], - autoRemove: false) - try await f.waitForContainerRunning(c) + autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) @@ -101,10 +98,9 @@ struct TestCLIRunCapabilities { @Test func testCapAddALLStored() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--cap-add", "ALL"], autoRemove: false) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, args: ["--cap-add", "ALL"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) @@ -117,10 +113,9 @@ struct TestCLIRunCapabilities { @Test func testCapDropLowerCase() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--cap-drop", "mknod"], autoRemove: false) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, args: ["--cap-drop", "mknod"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) @@ -135,10 +130,9 @@ struct TestCLIRunCapabilities { @Test func testCapDropMknodCannotMknod() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--cap-drop", "MKNOD"], autoRemove: false) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, args: ["--cap-drop", "MKNOD"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) @@ -152,10 +146,9 @@ struct TestCLIRunCapabilities { @Test func testCapDropMknodLowerCaseCannotMknod() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--cap-drop", "mknod"], autoRemove: false) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, args: ["--cap-drop", "mknod"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) @@ -169,12 +162,11 @@ struct TestCLIRunCapabilities { @Test func testCapDropALLCannotMknod() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun( + try await f.doLongRun( name: c, image: image, - args: ["--cap-drop", "ALL", "--cap-add", "SETGID"], autoRemove: false) - try await f.waitForContainerRunning(c) + args: ["--cap-drop", "ALL", "--cap-add", "SETGID"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) @@ -188,13 +180,12 @@ struct TestCLIRunCapabilities { @Test func testCapDropALLAddMknodCanMknod() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun( + try await f.doLongRun( name: c, image: image, args: ["--cap-drop", "ALL", "--cap-add", "MKNOD", "--cap-add", "SETGID"], - autoRemove: false) - try await f.waitForContainerRunning(c) + autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) @@ -207,10 +198,9 @@ struct TestCLIRunCapabilities { @Test func testCapAddALLCanDownInterface() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--cap-add", "ALL"], autoRemove: false) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, args: ["--cap-add", "ALL"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) @@ -223,12 +213,11 @@ struct TestCLIRunCapabilities { @Test func testCapAddALLDropNetAdminCannotDownInterface() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun( + try await f.doLongRun( name: c, image: image, - args: ["--cap-add", "ALL", "--cap-drop", "NET_ADMIN"], autoRemove: false) - try await f.waitForContainerRunning(c) + args: ["--cap-add", "ALL", "--cap-drop", "NET_ADMIN"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) @@ -242,10 +231,9 @@ struct TestCLIRunCapabilities { @Test func testCapAddNetAdminCanDownInterface() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--cap-add", "NET_ADMIN"], autoRemove: false) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, args: ["--cap-add", "NET_ADMIN"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) @@ -260,10 +248,9 @@ struct TestCLIRunCapabilities { @Test func testDefaultCapChown() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, autoRemove: false) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) @@ -275,10 +262,9 @@ struct TestCLIRunCapabilities { @Test func testNonRootUserCannotReadShadow() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, autoRemove: false) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) @@ -292,10 +278,9 @@ struct TestCLIRunCapabilities { @Test func testCapDropChown() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--cap-drop", "chown"], autoRemove: false) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, args: ["--cap-drop", "chown"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) @@ -308,10 +293,9 @@ struct TestCLIRunCapabilities { @Test func testDefaultCapFowner() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, autoRemove: false) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) @@ -325,13 +309,12 @@ struct TestCLIRunCapabilities { @Test func testCapDropALLShowsZeroCaps() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun( + try await f.doLongRun( name: c, image: image, args: ["--cap-drop", "ALL", "--cap-add", "SETUID", "--cap-add", "SETGID"], - autoRemove: false) - try await f.waitForContainerRunning(c) + autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) @@ -348,10 +331,9 @@ struct TestCLIRunCapabilities { @Test func testNoCapFlagsUsesDefaultCaps() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, autoRemove: false) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) @@ -367,10 +349,9 @@ struct TestCLIRunCapabilities { @Test func testCapAddALLShowsFullCaps() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--cap-add", "ALL"], autoRemove: false) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, args: ["--cap-add", "ALL"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) @@ -386,10 +367,9 @@ struct TestCLIRunCapabilities { @Test func testCapDropALLOnlyShowsZeroEffective() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--cap-drop", "ALL"], autoRemove: false) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, args: ["--cap-drop", "ALL"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) @@ -405,16 +385,15 @@ struct TestCLIRunCapabilities { @Test func testMultipleCapAddDrop() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun( + try await f.doLongRun( name: c, image: image, args: [ "--cap-add", "SYS_ADMIN", "--cap-add", "NET_RAW", "--cap-drop", "MKNOD", "--cap-drop", "CHOWN", ], - autoRemove: false) - try await f.waitForContainerRunning(c) + autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) diff --git a/Tests/IntegrationTests/Run/TestCLIRunCommand.swift b/Tests/IntegrationTests/Run/TestCLIRunCommand.swift index 399ce9078..7d8d5ad05 100644 --- a/Tests/IntegrationTests/Run/TestCLIRunCommand.swift +++ b/Tests/IntegrationTests/Run/TestCLIRunCommand.swift @@ -23,34 +23,32 @@ import Testing @Suite struct TestCLIRunCommand { - private let alpine = ContainerFixture.warmupImages[0] + private let alpine = WarmupImage.alpine320 // MARK: - Basic run options @Test func testRunCommand() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, autoRemove: false) + try await f.doLongRun(name: c, image: image, autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) _ = try f.doExec(c, cmd: ["date"]) } } @Test func testRunCommandCWD() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--cwd", "/tmp"], autoRemove: false) + try await f.doLongRun(name: c, image: image, args: ["--cwd", "/tmp"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let output = try f.doExec(c, cmd: ["pwd"]).trimmingCharacters(in: .whitespacesAndNewlines) #expect(output == "/tmp") } @@ -58,14 +56,13 @@ struct TestCLIRunCommand { @Test func testRunCommandEnv() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--env", "FOO=bar"], autoRemove: false) + try await f.doLongRun(name: c, image: image, args: ["--env", "FOO=bar"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let inspect = try f.inspectContainer(c) #expect(inspect.configuration.initProcess.environment.contains("FOO=bar")) } @@ -73,18 +70,17 @@ struct TestCLIRunCommand { @Test func testRunCommandEnvFile() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" let envFile = f.testDir.appending("test.env") let content = "# comment\nFOO=bar\nBAR=baz wow\nURL=https://foo.bar?baz=wow\n" try content.write(toFile: envFile.string, atomically: true, encoding: .utf8) - try f.doLongRun(name: c, image: image, args: ["--env-file", envFile.string], autoRemove: false) + try await f.doLongRun(name: c, image: image, args: ["--env-file", envFile.string], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let inspect = try f.inspectContainer(c) for expected in ["FOO=bar", "BAR=baz wow", "URL=https://foo.bar?baz=wow"] { @@ -95,14 +91,13 @@ struct TestCLIRunCommand { @Test func testRunCommandUserIDGroupID() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--uid", "10", "--gid", "100"], autoRemove: false) + try await f.doLongRun(name: c, image: image, args: ["--uid", "10", "--gid", "100"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let output = try f.doExec(c, cmd: ["id"]).trimmingCharacters(in: .whitespacesAndNewlines) try #expect(output.contains(Regex("uid=10.*?gid=100.*"))) } @@ -110,14 +105,13 @@ struct TestCLIRunCommand { @Test func testRunCommandUser() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--user", "nobody"], autoRemove: false) + try await f.doLongRun(name: c, image: image, args: ["--user", "nobody"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let output = try f.doExec(c, cmd: ["whoami"]).trimmingCharacters(in: .whitespacesAndNewlines) #expect(output == "nobody") } @@ -125,14 +119,13 @@ struct TestCLIRunCommand { @Test func testRunCommandCPUs() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--cpus", "2"], autoRemove: false) + try await f.doLongRun(name: c, image: image, args: ["--cpus", "2"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let output = try f.doExec(c, cmd: ["cat", "/sys/fs/cgroup/cpu.max"]) .trimmingCharacters(in: .whitespacesAndNewlines) let fields = output.components(separatedBy: .whitespaces) @@ -146,14 +139,13 @@ struct TestCLIRunCommand { @Test func testRunCommandMemory() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--memory", "1024M"], autoRemove: false) + try await f.doLongRun(name: c, image: image, args: ["--memory", "1024M"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let inspect = try f.inspectContainer(c) let expectedBytes = UInt64(1024) * 1024 * 1024 #expect(inspect.configuration.resources.memoryInBytes == expectedBytes) @@ -162,14 +154,13 @@ struct TestCLIRunCommand { @Test func testRunCommandUlimitNofile() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--ulimit", "nofile=1024:2048"], autoRemove: false) + try await f.doLongRun(name: c, image: image, args: ["--ulimit", "nofile=1024:2048"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let inspect = try f.inspectContainer(c) let nofile = inspect.configuration.initProcess.rlimits.first { $0.limit == "RLIMIT_NOFILE" } @@ -185,14 +176,13 @@ struct TestCLIRunCommand { @Test func testRunCommandUlimitNproc() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--ulimit", "nproc=256"], autoRemove: false) + try await f.doLongRun(name: c, image: image, args: ["--ulimit", "nproc=256"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let inspect = try f.inspectContainer(c) let nproc = inspect.configuration.initProcess.rlimits.first { $0.limit == "RLIMIT_NPROC" } @@ -208,17 +198,16 @@ struct TestCLIRunCommand { @Test func testRunCommandMultipleUlimits() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun( + try await f.doLongRun( name: c, image: image, args: ["--ulimit", "nofile=1024:2048", "--ulimit", "nproc=512", "--ulimit", "stack=8388608"], - autoRemove: false) + autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let rlimits = try f.inspectContainer(c).configuration.initProcess.rlimits #expect(rlimits.count == 3) @@ -235,21 +224,20 @@ struct TestCLIRunCommand { @Test func testRunCommandMount() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" let testData = "hello world" let hostFile = f.testDir.appending("testfile.txt") try testData.write(toFile: hostFile.string, atomically: true, encoding: .utf8) - try f.doLongRun( + try await f.doLongRun( name: c, image: image, args: ["--mount", "type=virtiofs,source=\(f.testDir.string),target=/tmp/testmount,readonly"], - autoRemove: false) + autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let output = try f.doExec(c, cmd: ["cat", "/tmp/testmount/testfile.txt"]) .trimmingCharacters(in: .whitespacesAndNewlines) @@ -259,14 +247,9 @@ struct TestCLIRunCommand { @Test func testRunCommandUnixSocketMount() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - // sockaddr_un.sun_path is 104 bytes on macOS — use /tmp to keep - // the host socket path short regardless of project directory depth. - let socketDir = "/tmp/\(f.testID)-sock" - try FileManager.default.createDirectory( - atPath: socketDir, withIntermediateDirectories: true, attributes: nil) - f.addCleanup { try? FileManager.default.removeItem(atPath: socketDir) } + let socketDir = try f.makeShortSocketDir("sock") let socketPath = socketDir + "/ssh-auth.sock" let guestSocketPath = "/run/ssh-auth.sock" @@ -275,15 +258,14 @@ struct TestCLIRunCommand { try socket.listen() f.addCleanup { try? socket.close() } - try f.doLongRun( + try await f.doLongRun( name: c, image: image, args: ["-v", "\(socketPath):\(guestSocketPath)", "-e", "SSH_AUTH_SOCK=\(guestSocketPath)"], - autoRemove: false) + autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) _ = try f.doExec(c, cmd: ["apk", "add", "netcat-openbsd"]) let perms = try f.doExec( @@ -297,14 +279,13 @@ struct TestCLIRunCommand { @Test func testRunCommandTmpfs() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--tmpfs", "/tmp/testtmpfs"], autoRemove: false) + try await f.doLongRun(name: c, image: image, args: ["--tmpfs", "/tmp/testtmpfs"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let output = try f.doExec(c, cmd: ["df", "/tmp/testtmpfs"]) let lines = output.split(separator: "\n") @@ -316,14 +297,13 @@ struct TestCLIRunCommand { @Test func testRunCommandShmSize() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--shm-size", "128m"], autoRemove: false) + try await f.doLongRun(name: c, image: image, args: ["--shm-size", "128m"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let output = try f.doExec(c, cmd: ["mount"]) let shmLine = output.split(separator: "\n").first { $0.contains("/dev/shm") } @@ -334,20 +314,19 @@ struct TestCLIRunCommand { @Test func testRunCommandVolume() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" let testData = "one small step" let volumeFile = f.testDir.appending("data.txt") try testData.write(toFile: volumeFile.string, atomically: true, encoding: .utf8) - try f.doLongRun( + try await f.doLongRun( name: c, image: image, - args: ["--volume", "\(f.testDir.string):/tmp/testvolume"], autoRemove: false) + args: ["--volume", "\(f.testDir.string):/tmp/testvolume"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let output = try f.doExec(c, cmd: ["cat", "/tmp/testvolume/data.txt"]) .trimmingCharacters(in: .whitespacesAndNewlines) @@ -357,16 +336,15 @@ struct TestCLIRunCommand { @Test func testRunCommandCidfile() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" let cidfile = f.testDir.appending("container.cid") - try f.doLongRun(name: c, image: image, args: ["--cidfile", cidfile.string], autoRemove: false) + try await f.doLongRun(name: c, image: image, args: ["--cidfile", cidfile.string], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let actualID = try String(contentsOfFile: cidfile.string, encoding: .utf8) #expect(actualID == c) @@ -377,14 +355,13 @@ struct TestCLIRunCommand { @Test func testRunCommandNoDNS() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--no-dns"], autoRemove: false) + try await f.doLongRun(name: c, image: image, args: ["--no-dns"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let result = try f.run(["exec", c, "cat", "/etc/resolv.conf"]) #expect(result.status != 0) } @@ -392,14 +369,13 @@ struct TestCLIRunCommand { @Test func testRunCommandDefaultResolvConf() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, autoRemove: false) + try await f.doLongRun(name: c, image: image, autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let output = try f.doExec(c, cmd: ["cat", "/etc/resolv.conf"]) let actualLines = output.components(separatedBy: .newlines) @@ -421,20 +397,19 @@ struct TestCLIRunCommand { @Test func testRunCommandNonDefaultResolvConf() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun( + try await f.doLongRun( name: c, image: image, args: [ "--dns", "8.8.8.8", "--dns-domain", "example.com", "--dns-search", "test.com", "--dns-option", "debug", ], - autoRemove: false) + autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let output = try f.doExec(c, cmd: ["cat", "/etc/resolv.conf"]) let actualLines = output.components(separatedBy: .newlines) @@ -453,14 +428,13 @@ struct TestCLIRunCommand { @Test func testRunDefaultHostsEntries() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, autoRemove: false) + try await f.doLongRun(name: c, image: image, autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let inspect = try f.inspectContainer(c) let ip = inspect.networks[0].ipv4Address.address.description @@ -481,7 +455,7 @@ struct TestCLIRunCommand { @Test func testPrivilegedPortError() async throws { try #require(geteuid() != 0) try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" f.addCleanup { try? f.doRemove(c, force: true) } let result = try f.run(["run", "--name", c, "--publish", "127.0.0.1:80:80", image]) @@ -495,14 +469,13 @@ struct TestCLIRunCommand { @Test func testRunCommandOSArch() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--os", "linux", "--arch", "amd64"], autoRemove: false) + try await f.doLongRun(name: c, image: image, args: ["--os", "linux", "--arch", "amd64"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let output = try f.doExec(c, cmd: ["uname", "-sm"]) .trimmingCharacters(in: .whitespacesAndNewlines).lowercased() #expect(output == "linux x86_64") @@ -511,14 +484,13 @@ struct TestCLIRunCommand { @Test func testRunCommandPlatform() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--platform", "linux/amd64"], autoRemove: false) + try await f.doLongRun(name: c, image: image, args: ["--platform", "linux/amd64"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let output = try f.doExec(c, cmd: ["uname", "-sm"]) .trimmingCharacters(in: .whitespacesAndNewlines).lowercased() #expect(output == "linux x86_64") @@ -529,14 +501,13 @@ struct TestCLIRunCommand { @Test func testRunCommandInit() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--init"], autoRemove: false) + try await f.doLongRun(name: c, image: image, args: ["--init"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let inspect = try f.inspectContainer(c) #expect(inspect.configuration.useInit == true) @@ -549,14 +520,13 @@ struct TestCLIRunCommand { @Test func testRunCommandInitReapsZombies() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--init"], autoRemove: false) + try await f.doLongRun(name: c, image: image, args: ["--init"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) _ = try f.doExec(c, cmd: ["sh", "-c", "sh -c 'sh -c \"exit 0\" &' && sleep 1"]) let ps = try f.doExec(c, cmd: ["sh", "-c", "ps aux | grep -c '\\[sh\\]' || true"]) @@ -567,14 +537,13 @@ struct TestCLIRunCommand { @Test func testRunCommandWithoutInitDefault() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, autoRemove: false) + try await f.doLongRun(name: c, image: image, autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let inspect = try f.inspectContainer(c) #expect(inspect.configuration.useInit == false) } @@ -584,14 +553,13 @@ struct TestCLIRunCommand { @Test func testRunCommandReadOnly() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["--read-only"], autoRemove: false) + try await f.doLongRun(name: c, image: image, args: ["--read-only"], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } - try await f.waitForContainerRunning(c) let result = try f.run(["exec", c, "touch", "/testfile"]) #expect(result.status != 0, "touch on read-only rootfs should fail") } @@ -601,7 +569,7 @@ struct TestCLIRunCommand { @Test func testRunCommandEnvFileFromNamedPipe() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" let pipePath = f.testDir.appending("envfile.pipe") guard mkfifo(pipePath.string, 0o600) == 0 else { @@ -618,14 +586,13 @@ struct TestCLIRunCommand { } defer { writeTask.cancel() } - try f.doLongRun(name: c, image: image, args: ["--env-file", pipePath.string], autoRemove: false) + try await f.doLongRun(name: c, image: image, args: ["--env-file", pipePath.string], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) } try await writeTask.value - try await f.waitForContainerRunning(c) let inspect = try f.inspectContainer(c) #expect(inspect.configuration.initProcess.environment.contains("FOO=bar")) #expect(inspect.configuration.initProcess.environment.contains("BAR=baz")) @@ -639,7 +606,7 @@ struct TestCLIRunCommand { let c = "\(f.testID)-c" let proxyPort = UInt16.random(in: 50000..<55000) let serverPort = UInt16.random(in: 55000..<60000) - try f.doLongRun( + try await f.doLongRun( name: c, image: "docker.io/library/python:alpine", args: ["--publish", "127.0.0.1:\(proxyPort):\(serverPort)/tcp"], containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(serverPort)"], @@ -651,16 +618,7 @@ struct TestCLIRunCommand { let client = f.makeHTTPClient() defer { _ = client.shutdown() } - try await f.retry(attempts: 10, delay: .seconds(3)) { - do { - var req = HTTPClientRequest(url: "http://127.0.0.1:\(proxyPort)") - req.method = .GET - let resp = try await client.execute(req, timeout: .seconds(3)) - return resp.status.code >= 200 && resp.status.code < 300 - } catch { - return false - } - } + try await f.waitForHTTPOk("http://127.0.0.1:\(proxyPort)", using: client, delay: .seconds(3)) } } @@ -670,7 +628,7 @@ struct TestCLIRunCommand { let proxyPortStart = UInt16.random(in: 50000..<55000) let serverPortStart = UInt16.random(in: 55000..<60000) let c = "\(f.testID)-c" - try f.doLongRun( + try await f.doLongRun( name: c, image: "docker.io/library/python:alpine", args: ["--publish", "127.0.0.1:\(proxyPortStart)-\(proxyPortStart + range):\(serverPortStart)-\(serverPortStart + range)/tcp"], containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(serverPortStart)"], @@ -682,16 +640,7 @@ struct TestCLIRunCommand { let client2 = f.makeHTTPClient() defer { _ = client2.shutdown() } - try await f.retry(attempts: 10, delay: .seconds(3)) { - do { - var req = HTTPClientRequest(url: "http://127.0.0.1:\(proxyPortStart)") - req.method = .GET - let resp = try await client2.execute(req, timeout: .seconds(3)) - return resp.status.code >= 200 && resp.status.code < 300 - } catch { - return false - } - } + try await f.waitForHTTPOk("http://127.0.0.1:\(proxyPortStart)", using: client2, delay: .seconds(3)) } } @@ -701,7 +650,7 @@ struct TestCLIRunCommand { let c = "\(f.testID)-c" let proxyPort = UInt16.random(in: 50000..<55000) let serverPort = UInt16.random(in: 55000..<60000) - try f.doLongRun( + try await f.doLongRun( name: c, image: "docker.io/library/node:alpine", args: ["--publish", "[::1]:\(proxyPort):\(serverPort)/tcp"], containerArgs: ["npx", "http-server", "-a", "::", "-p", "\(serverPort)"], @@ -713,16 +662,7 @@ struct TestCLIRunCommand { let client3 = f.makeHTTPClient() defer { _ = client3.shutdown() } - try await f.retry(attempts: 10, delay: .seconds(3)) { - do { - var req = HTTPClientRequest(url: "http://[::1]:\(proxyPort)") - req.method = .GET - let resp = try await client3.execute(req, timeout: .seconds(3)) - return resp.status.code >= 200 && resp.status.code < 300 - } catch { - return false - } - } + try await f.waitForHTTPOk("http://[::1]:\(proxyPort)", using: client3, delay: .seconds(3)) } } } diff --git a/Tests/IntegrationTests/Run/TestCLIRunFilesystem.swift b/Tests/IntegrationTests/Run/TestCLIRunFilesystem.swift index 02f1dd91b..230f550c4 100644 --- a/Tests/IntegrationTests/Run/TestCLIRunFilesystem.swift +++ b/Tests/IntegrationTests/Run/TestCLIRunFilesystem.swift @@ -14,6 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerTestSupport import Foundation import Testing @@ -31,7 +32,7 @@ import Testing // therefore 1024+92=1116 and 1024+256=1280. @Suite struct TestCLIRunFilesystem { - private let alpine = ContainerFixture.warmupImages[0] + private let alpine = WarmupImage.alpine320 private static let featureCompatOffset = 1116 private static let defaultMountOptsOffset = 1280 @@ -40,10 +41,9 @@ struct TestCLIRunFilesystem { @Test func testRootFilesystemHasOrderedJournal() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, autoRemove: false) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) diff --git a/Tests/IntegrationTests/Run/TestCLIRunInitImage.swift b/Tests/IntegrationTests/Run/TestCLIRunInitImage.swift index 008158228..f8436c837 100644 --- a/Tests/IntegrationTests/Run/TestCLIRunInitImage.swift +++ b/Tests/IntegrationTests/Run/TestCLIRunInitImage.swift @@ -26,11 +26,11 @@ import Testing /// once a test init image is published to the registry. @Suite struct TestCLIRunInitImage { - private let alpine = ContainerFixture.warmupImages[0] + private let alpine = WarmupImage.alpine320 @Test func testRunWithNonExistentInitImage() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" f.addCleanup { try? f.doRemove(c, force: true) } let result = try f.run([ @@ -52,7 +52,7 @@ struct TestCLIRunInitImage { @Test func testCreateWithNonExistentInitImage() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" f.addCleanup { try? f.doRemove(c, force: true) } let result = try f.run([ @@ -66,13 +66,12 @@ struct TestCLIRunInitImage { @Test func testRunWithExplicitDefaultInitImage() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" let config = try f.getSystemConfig() - try f.doLongRun( + try await f.doLongRun( name: c, image: image, - args: ["--init-image", config.vminit.image], autoRemove: false) - try await f.waitForContainerRunning(c) + args: ["--init-image", config.vminit.image], autoRemove: false, waitUntilRunning: true) f.addCleanup { try? f.doStop(c) try? f.doRemove(c) diff --git a/Tests/IntegrationTests/Run/TestCLIRunLifecycle.swift b/Tests/IntegrationTests/Run/TestCLIRunLifecycle.swift index d019bb5d2..720d885d4 100644 --- a/Tests/IntegrationTests/Run/TestCLIRunLifecycle.swift +++ b/Tests/IntegrationTests/Run/TestCLIRunLifecycle.swift @@ -23,7 +23,7 @@ import Testing struct TestCLIRunLifecycle { @Test func testRunFailureCleanup() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue let name = "\(f.testID)-c" // First attempt with an invalid user — must fail. @@ -43,7 +43,7 @@ struct TestCLIRunLifecycle { @Test func testStartIdempotent() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let result = try f.run(["start", name]) #expect(result.status == 0, "expected start to succeed on already running container") @@ -57,7 +57,7 @@ struct TestCLIRunLifecycle { @Test func testStartIdempotentAttachFails() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let result = try f.run(["start", "-a", name]) #expect( @@ -70,7 +70,7 @@ struct TestCLIRunLifecycle { @Test func testRunInvalidExecutable() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue let name = "\(f.testID)-c" f.addCleanup { try f.doRemoveIfExists(name, force: true, ignoreFailure: true) } let result = try f.run(["run", "--rm", "--name", name, "-d", image, "foobarbaz"]) @@ -80,7 +80,7 @@ struct TestCLIRunLifecycle { @Test func testExecInvalidExecutable() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue try await f.withContainer(image: image) { name in let result = try f.run(["exec", name, "foobarbaz"]) #expect(result.status != 0, "executing invalid executable must fail, not hang") @@ -90,7 +90,7 @@ struct TestCLIRunLifecycle { @Test func testSSHForwarding() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue let socketPath = try f.makeFakeSSHAgentSocket() diff --git a/Tests/IntegrationTests/Run/TestCLIRunLifecycleSerial.swift b/Tests/IntegrationTests/Run/TestCLIRunLifecycleSerial.swift index 71aa3da50..6241b4404 100644 --- a/Tests/IntegrationTests/Run/TestCLIRunLifecycleSerial.swift +++ b/Tests/IntegrationTests/Run/TestCLIRunLifecycleSerial.swift @@ -32,7 +32,7 @@ struct TestCLIRunLifecycleSerial { f.addCleanup { try? f.doRemove(name) } let server = "\(f.testID)-server" - try f.doLongRun( + try await f.doLongRun( name: server, image: serverImage, args: ["--publish", "\(port):\(port)"], diff --git a/Tests/IntegrationTests/Run/TestCLITermIO.swift b/Tests/IntegrationTests/Run/TestCLITermIO.swift index f8622828e..4a0d07827 100644 --- a/Tests/IntegrationTests/Run/TestCLITermIO.swift +++ b/Tests/IntegrationTests/Run/TestCLITermIO.swift @@ -23,7 +23,7 @@ import Testing struct TestCLITermIO { @Test func testTermIODoesNotPanic() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let image = WarmupImage.alpine320.rawValue let name = "\(f.testID)-c" f.addCleanup { try f.doRemoveIfExists(name, force: true, ignoreFailure: true) } diff --git a/Tests/IntegrationTests/System/TestCLIKernelSetSerial.swift b/Tests/IntegrationTests/System/TestCLIKernelSetSerial.swift index e2750ae1b..80e9f9dcb 100644 --- a/Tests/IntegrationTests/System/TestCLIKernelSetSerial.swift +++ b/Tests/IntegrationTests/System/TestCLIKernelSetSerial.swift @@ -167,7 +167,7 @@ struct TestCLIKernelSetSerial { /// on the host — that is, `uname -r` matches the release parsed from the kernel /// binary filename (see ``expectedKernelRelease``). private func validateGuestKernel(_ f: ContainerFixture) async throws { - let image = ContainerFixture.warmupImages[0] + let image = WarmupImage.alpine320.rawValue if try !f.isImagePresent(image) { try f.doPull(image) } try await f.withContainer(image: image) { name in let release = try f.doExec(name, cmd: ["uname", "-r"]) diff --git a/Tests/IntegrationTests/System/TestCLISystemDFSerial.swift b/Tests/IntegrationTests/System/TestCLISystemDFSerial.swift index 15477a898..fbf3a97ef 100644 --- a/Tests/IntegrationTests/System/TestCLISystemDFSerial.swift +++ b/Tests/IntegrationTests/System/TestCLISystemDFSerial.swift @@ -32,7 +32,7 @@ struct TestCLISystemDFSerial { let total: Int } - private let alpine = ContainerFixture.warmupImages[0] + private let alpine = WarmupImage.alpine320.rawValue // Issue #1526: reported image size must include content blobs, not just unpacked snapshots. @Test func imageDiskUsageIsPopulatedAfterPull() async throws { diff --git a/Tests/IntegrationTests/Volumes/TestCLIAnonymousVolumes.swift b/Tests/IntegrationTests/Volumes/TestCLIAnonymousVolumes.swift index 30f95f939..2129ba359 100644 --- a/Tests/IntegrationTests/Volumes/TestCLIAnonymousVolumes.swift +++ b/Tests/IntegrationTests/Volumes/TestCLIAnonymousVolumes.swift @@ -25,14 +25,13 @@ import Testing /// global volume state, so the suite runs in the concurrent pass. @Suite struct TestCLIAnonymousVolumes { - private let alpine = ContainerFixture.warmupImages[0] + private let alpine = WarmupImage.alpine320 @Test func testAnonymousVolumeCreationAndPersistence() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false, waitUntilRunning: true) let volumeIDs = try f.getContainerMountedVolumeNames(c) try #require(volumeIDs.count == 1, "should have exactly one anonymous volume") @@ -47,10 +46,9 @@ struct TestCLIAnonymousVolumes { @Test func testAnonymousVolumePersistenceWithoutRm() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c1" - try f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false, waitUntilRunning: true) _ = try f.doExec(c, cmd: ["sh", "-c", "echo 'persistent-data' > /data/test.txt"]) let volumeIDs = try f.getContainerMountedVolumeNames(c) @@ -63,8 +61,7 @@ struct TestCLIAnonymousVolumes { #expect(try f.volumeExists(volumeID), "anonymous volume should persist without --rm") let c2 = "\(f.testID)-c2" - try f.doLongRun(name: c2, image: image, args: ["-v", "\(volumeID):/data"], autoRemove: false) - try await f.waitForContainerRunning(c2) + try await f.doLongRun(name: c2, image: image, args: ["-v", "\(volumeID):/data"], autoRemove: false, waitUntilRunning: true) let output = try f.doExec(c2, cmd: ["cat", "/data/test.txt"]) .trimmingCharacters(in: .whitespacesAndNewlines) #expect(output == "persistent-data") @@ -75,12 +72,11 @@ struct TestCLIAnonymousVolumes { @Test func testMultipleAnonymousVolumes() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun( + try await f.doLongRun( name: c, image: image, - args: ["-v", "/data1", "-v", "/data2", "-v", "/data3"], autoRemove: false) - try await f.waitForContainerRunning(c) + args: ["-v", "/data1", "-v", "/data2", "-v", "/data3"], autoRemove: false, waitUntilRunning: true) let volumeIDs = try f.getContainerMountedVolumeNames(c) #expect(volumeIDs.count == 3, "should have 3 anonymous volumes") @@ -94,12 +90,11 @@ struct TestCLIAnonymousVolumes { @Test func testAnonymousMountSyntax() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun( + try await f.doLongRun( name: c, image: image, - args: ["--mount", "type=volume,dst=/mydata"], autoRemove: false) - try await f.waitForContainerRunning(c) + args: ["--mount", "type=volume,dst=/mydata"], autoRemove: false, waitUntilRunning: true) let volumeIDs = try f.getContainerMountedVolumeNames(c) #expect(volumeIDs.count == 1, "should have one anonymous volume from --mount syntax") @@ -112,10 +107,9 @@ struct TestCLIAnonymousVolumes { @Test func testAnonymousVolumeUUIDFormat() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false, waitUntilRunning: true) // Capture volume IDs before any stop/remove so cleanup and assert can use them. let volumeIDs = try f.getContainerMountedVolumeNames(c) @@ -133,10 +127,9 @@ struct TestCLIAnonymousVolumes { @Test func testAnonymousVolumeMetadata() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false, waitUntilRunning: true) // Capture volume ID before stop/remove. let volumeIDs = try f.getContainerMountedVolumeNames(c) @@ -163,14 +156,13 @@ struct TestCLIAnonymousVolumes { @Test func testAnonymousVolumeListDisplay() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let namedVol = "\(f.testID)-namedvol" let c = "\(f.testID)-c" try f.doVolumeCreate(namedVol) f.addCleanup { f.doVolumeDeleteIfExists(namedVol) } - try f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false, waitUntilRunning: true) // Capture volume IDs while container is running. let volumeIDs = try f.getContainerMountedVolumeNames(c) @@ -190,16 +182,15 @@ struct TestCLIAnonymousVolumes { @Test func testAnonymousVolumeMixedWithNamedVolume() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let namedVol = "\(f.testID)-namedvol" let c = "\(f.testID)-c" try f.doVolumeCreate(namedVol) f.addCleanup { f.doVolumeDeleteIfExists(namedVol) } - try f.doLongRun( + try await f.doLongRun( name: c, image: image, - args: ["-v", "\(namedVol):/named", "-v", "/anon"], autoRemove: false) - try await f.waitForContainerRunning(c) + args: ["-v", "\(namedVol):/named", "-v", "/anon"], autoRemove: false, waitUntilRunning: true) let allVolumeIDs = try f.getContainerMountedVolumeNames(c) let anonVols = allVolumeIDs.filter { $0 != namedVol } @@ -215,10 +206,9 @@ struct TestCLIAnonymousVolumes { @Test func testAnonymousVolumeManualDeletion() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false, waitUntilRunning: true) let volumeIDs = try f.getContainerMountedVolumeNames(c) try #require(volumeIDs.count == 1) @@ -234,10 +224,9 @@ struct TestCLIAnonymousVolumes { @Test func testAnonymousVolumeDetachedMode() async throws { try await ContainerFixture.with { f in - let image = try f.copyWarmupImage(alpine) + let image = alpine.rawValue let c = "\(f.testID)-c" - try f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: true) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: true, waitUntilRunning: true) // Capture volume IDs while the container is still running; --rm means // doStop will also remove it. diff --git a/Tests/IntegrationTests/Volumes/TestCLIVolumes.swift b/Tests/IntegrationTests/Volumes/TestCLIVolumes.swift index fa6e918ef..df39e9547 100644 --- a/Tests/IntegrationTests/Volumes/TestCLIVolumes.swift +++ b/Tests/IntegrationTests/Volumes/TestCLIVolumes.swift @@ -20,7 +20,7 @@ import Testing @Suite struct TestCLIVolumes { - private let alpine = ContainerFixture.warmupImages[0] + private let alpine = WarmupImage.alpine320.rawValue @Test func testVolumeDataPersistenceAcrossContainers() async throws { try await ContainerFixture.with { f in @@ -35,13 +35,11 @@ struct TestCLIVolumes { } try f.doVolumeCreate(vol) - try f.doLongRun(name: c1, image: image, args: ["-v", "\(vol):/data"], autoRemove: false) - try await f.waitForContainerRunning(c1) + try await f.doLongRun(name: c1, image: image, args: ["-v", "\(vol):/data"], autoRemove: false, waitUntilRunning: true) _ = try f.doExec(c1, cmd: ["sh", "-c", "echo 'persistent-data-test' > /data/test.txt"]) try f.doStop(c1) try f.doRemove(c1) - try f.doLongRun(name: c2, image: image, args: ["-v", "\(vol):/data"], autoRemove: false) - try await f.waitForContainerRunning(c2) + try await f.doLongRun(name: c2, image: image, args: ["-v", "\(vol):/data"], autoRemove: false, waitUntilRunning: true) let output = try f.doExec(c2, cmd: ["cat", "/data/test.txt"]) .trimmingCharacters(in: .whitespacesAndNewlines) #expect(output == "persistent-data-test") @@ -65,8 +63,7 @@ struct TestCLIVolumes { } try f.doVolumeCreate(vol) - try f.doLongRun(name: c1, image: image, args: ["-v", "\(vol):/data"], autoRemove: false) - try await f.waitForContainerRunning(c1) + try await f.doLongRun(name: c1, image: image, args: ["-v", "\(vol):/data"], autoRemove: false, waitUntilRunning: true) let result = try f.run(["run", "--name", c2, "-v", "\(vol):/data", image, "sleep", "infinity"]) #expect(result.status != 0, "second container should fail when volume is already in use") @@ -89,8 +86,7 @@ struct TestCLIVolumes { } try f.doVolumeCreate(vol) - try f.doLongRun(name: c, image: image, args: ["-v", "\(vol):/data"], autoRemove: false) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, args: ["-v", "\(vol):/data"], autoRemove: false, waitUntilRunning: true) #expect(try f.doesVolumeDeleteFail(vol), "volume delete should fail while in use") @@ -245,13 +241,11 @@ struct TestCLIVolumes { } try f.doVolumeCreate(vol, opts: ["journal=ordered"]) - try f.doLongRun(name: c1, image: image, args: ["-v", "\(vol):/data"], autoRemove: false) - try await f.waitForContainerRunning(c1) + try await f.doLongRun(name: c1, image: image, args: ["-v", "\(vol):/data"], autoRemove: false, waitUntilRunning: true) _ = try f.doExec(c1, cmd: ["sh", "-c", "echo 'journaled-data' > /data/test.txt"]) try f.doStop(c1) try f.doRemove(c1) - try f.doLongRun(name: c2, image: image, args: ["-v", "\(vol):/data"], autoRemove: false) - try await f.waitForContainerRunning(c2) + try await f.doLongRun(name: c2, image: image, args: ["-v", "\(vol):/data"], autoRemove: false, waitUntilRunning: true) let output = try f.doExec(c2, cmd: ["cat", "/data/test.txt"]) .trimmingCharacters(in: .whitespacesAndNewlines) #expect(output == "journaled-data") diff --git a/Tests/IntegrationTests/Volumes/TestCLIVolumesSerial.swift b/Tests/IntegrationTests/Volumes/TestCLIVolumesSerial.swift index 053637f4f..d0aa799c2 100644 --- a/Tests/IntegrationTests/Volumes/TestCLIVolumesSerial.swift +++ b/Tests/IntegrationTests/Volumes/TestCLIVolumesSerial.swift @@ -20,7 +20,7 @@ import Testing @Suite(.serialized) struct TestCLIVolumesSerial { - private let alpine = ContainerFixture.warmupImages[0] + private let alpine = WarmupImage.alpine320.rawValue @Test func testVolumePruneNoVolumes() async throws { try await ContainerFixture.with { f in @@ -69,8 +69,7 @@ struct TestCLIVolumesSerial { try f.doVolumeCreate(vInUse) try f.doVolumeCreate(vUnused) - try f.doLongRun(name: c, image: image, args: ["-v", "\(vInUse):/data"], autoRemove: false) - try await f.waitForContainerRunning(c) + try await f.doLongRun(name: c, image: image, args: ["-v", "\(vInUse):/data"], autoRemove: false, waitUntilRunning: true) try f.run(["volume", "prune"]).check() diff --git a/Tests/IntegrationTests/Warmup/ImageWarmup.swift b/Tests/IntegrationTests/Warmup/ImageWarmup.swift index 8c46d2f10..aa22d7bf6 100644 --- a/Tests/IntegrationTests/Warmup/ImageWarmup.swift +++ b/Tests/IntegrationTests/Warmup/ImageWarmup.swift @@ -17,16 +17,16 @@ import ContainerTestSupport import Testing -/// Pulls each image in ``ContainerFixture/warmupImages`` in parallel before -/// concurrent integration tests run. The Makefile's warmup pass runs this -/// suite first so that ``ContainerFixture/copyWarmupImage(_:)`` can tag -/// from a pre-populated store rather than pulling on demand. +/// Pulls each image in ``WarmupImage`` in parallel before concurrent +/// integration tests run. The Makefile's warmup pass runs this suite first +/// so that ``ContainerFixture/copyWarmupImage(_:)`` can tag from a +/// pre-populated store rather than pulling on demand. @Suite struct ImageWarmup { - @Test(arguments: ContainerFixture.warmupImages) - func pull(image: String) async throws { + @Test(arguments: WarmupImage.allCases) + func pull(image: WarmupImage) async throws { try await ContainerFixture.with { f in - try f.run(["image", "pull", image]).check("failed to pull \(image)") + try f.run(["image", "pull", image.rawValue]).check("failed to pull \(image.rawValue)") } } } diff --git a/docs/command-reference.md b/docs/command-reference.md index f6597220c..f6677b41e 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -157,6 +157,7 @@ container build [] [] * `--pull`: Pull latest image * `-q, --quiet`: Suppress build output * `--secret `: Set build-time secrets (format: id=[,env=|,src=]) +* `--ssh `: Forward SSH agent authentication to the build. Only `--ssh default` is currently supported. * `-t, --tag `: Name for the built image (can be specified multiple times) * `--target `: Set the target build stage * `--vsock-port `: Builder shim vsock port (default: 8088) @@ -381,7 +382,7 @@ container exec [--detach] [--env ...] [--env-file ...] [--gid < ### `container export` -Exports a stopped container's filesystem as a tar archive. The container must be stopped before exporting. If no output file is specified, the tar stream is written to stdout. +Exports a container's filesystem as a tar archive. For running containers, export automatically takes a runtime snapshot to preserve consistency. If no output file is specified, the tar stream is written to stdout. **Usage** diff --git a/docs/how-to.md b/docs/how-to.md index 8ec845dab..1aeb08f67 100644 --- a/docs/how-to.md +++ b/docs/how-to.md @@ -649,7 +649,7 @@ Use `container system property list` to show all properties that have set defaul cpus = 2 memory = "2048mb" rosetta = true -image = "ghcr.io/apple/container-builder-shim/builder:0.13.0" +image = "ghcr.io/apple/container-builder-shim/builder:0.13.1" [container] cpus = 4