From 93261751f7776417c80fc6b13419a9650ef476b0 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Sat, 4 Oct 2025 01:34:40 -0700 Subject: [PATCH 01/28] create helper macros and basic version of OptionGroupPassthrough MemberMacro --- Package.resolved | 15 +- Package.swift | 16 ++ Sources/ContainerClient/Flags.swift | 2 + Sources/HelperMacros/HelperMacros.swift | 11 ++ Sources/HelperMacros/PublicStructs.swift | 18 ++ .../HelperMacrosMacros.swift | 31 +++ .../OptionGroupPassthrough.swift | 184 ++++++++++++++++++ 7 files changed, 274 insertions(+), 3 deletions(-) create mode 100644 Sources/HelperMacros/HelperMacros.swift create mode 100644 Sources/HelperMacros/PublicStructs.swift create mode 100644 Sources/HelperMacrosMacros/HelperMacrosMacros.swift create mode 100644 Sources/HelperMacrosMacros/OptionGroupPassthrough.swift diff --git a/Package.resolved b/Package.resolved index 6738b8e32..68279dceb 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e8a26d708e3d286b0e29d74d94b6ff1539848a7a9fd209a230faedae5e285003", + "originHash" : "8316ed85ab8e011fad4661cdd43184f2fa28eb6d719b0cd7009a69822996e7b7", "pins" : [ { "identity" : "async-http-client", @@ -22,7 +22,7 @@ { "identity" : "dns", "kind" : "remoteSourceControl", - "location" : "https://github.com/Bouke/DNS", + "location" : "https://github.com/Bouke/DNS.git", "state" : { "revision" : "78bbd1589890a90b202d11d5f9e1297050cf0eb2", "version" : "1.2.0" @@ -31,7 +31,7 @@ { "identity" : "dnsclient", "kind" : "remoteSourceControl", - "location" : "https://github.com/orlandos-nl/DNSClient", + "location" : "https://github.com/orlandos-nl/DNSClient.git", "state" : { "revision" : "551fbddbf4fa728d4cd86f6a5208fe4f925f0549", "version" : "2.4.4" @@ -235,6 +235,15 @@ "version" : "2.8.0" } }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" + } + }, { "identity" : "swift-system", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 274c34527..59f5e9eb9 100644 --- a/Package.swift +++ b/Package.swift @@ -19,6 +19,7 @@ import Foundation import PackageDescription +import CompilerPluginSupport let releaseVersion = ProcessInfo.processInfo.environment["RELEASE_VERSION"] ?? "0.0.0" let gitCommit = ProcessInfo.processInfo.environment["GIT_COMMIT"] ?? "unspecified" @@ -55,6 +56,7 @@ let package = Package( .package(url: "https://github.com/orlandos-nl/DNSClient.git", from: "2.4.1"), .package(url: "https://github.com/Bouke/DNS.git", from: "1.2.0"), .package(url: "https://github.com/apple/containerization.git", exact: Version(stringLiteral: scVersion)), + .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "602.0.0"), ], targets: [ .executableTarget( @@ -259,6 +261,7 @@ let package = Package( "ContainerPlugin", "ContainerXPC", "TerminalProgress", + "HelperMacros", ] ), .testTarget( @@ -373,5 +376,18 @@ let package = Package( .define("BUILDER_SHIM_VERSION", to: "\"\(builderShimVersion)\""), ] ), + .macro( + name: "HelperMacrosMacros", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax") + ] + ), + + // Library that exposes a macro as part of its API, which is used in client programs. + .target( + name: "HelperMacros", + dependencies: ["HelperMacrosMacros"] + ), ] ) diff --git a/Sources/ContainerClient/Flags.swift b/Sources/ContainerClient/Flags.swift index 3a01a2a33..50092883e 100644 --- a/Sources/ContainerClient/Flags.swift +++ b/Sources/ContainerClient/Flags.swift @@ -17,6 +17,7 @@ import ArgumentParser import ContainerizationError import Foundation +import HelperMacros public struct Flags { public struct Global: ParsableArguments { @@ -26,6 +27,7 @@ public struct Flags { public var debug = false } + @OptionGroupPassthrough public struct Process: ParsableArguments { public init() {} diff --git a/Sources/HelperMacros/HelperMacros.swift b/Sources/HelperMacros/HelperMacros.swift new file mode 100644 index 000000000..afdbfebca --- /dev/null +++ b/Sources/HelperMacros/HelperMacros.swift @@ -0,0 +1,11 @@ +// +// HelperMacros.swift +// container +// +// Created by Morris Richman on 10/3/25. +// + +import Foundation + +@attached(member, names: arbitrary) +public macro OptionGroupPassthrough() = #externalMacro(module: "HelperMacrosMacros", type: "OptionGroupPassthrough") diff --git a/Sources/HelperMacros/PublicStructs.swift b/Sources/HelperMacros/PublicStructs.swift new file mode 100644 index 000000000..4dedbe2f4 --- /dev/null +++ b/Sources/HelperMacros/PublicStructs.swift @@ -0,0 +1,18 @@ +// +// PublicStructs.swift +// container +// +// Created by Morris Richman on 10/4/25. +// + +import Foundation + +public struct CommandOutline { + let type: `Type` + let flag: String + let variable: String + + public enum `Type` { + case flag, option + } +} diff --git a/Sources/HelperMacrosMacros/HelperMacrosMacros.swift b/Sources/HelperMacrosMacros/HelperMacrosMacros.swift new file mode 100644 index 000000000..c428d952a --- /dev/null +++ b/Sources/HelperMacrosMacros/HelperMacrosMacros.swift @@ -0,0 +1,31 @@ +// +// HelperMacrosMacros.swift +// container +// +// Created by Morris Richman on 10/3/25. +// + +import SwiftCompilerPlugin +import SwiftSyntaxMacros +import SwiftDiagnostics + +@main +struct SwiftMacrosAndMePlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + OptionGroupPassthrough.self + ] +} + +extension String: @retroactive Error { +} + +enum MacroExpansionError: Error { + case unsupportedDeclaration + + var localizedDescription: String { + switch self { + case .unsupportedDeclaration: + return "Unsupported declaration for macro expansion." + } + } +} diff --git a/Sources/HelperMacrosMacros/OptionGroupPassthrough.swift b/Sources/HelperMacrosMacros/OptionGroupPassthrough.swift new file mode 100644 index 000000000..db9eb54ed --- /dev/null +++ b/Sources/HelperMacrosMacros/OptionGroupPassthrough.swift @@ -0,0 +1,184 @@ +// +// OptionGroupPassthrough.swift +// container +// +// Created by Morris Richman on 10/3/25. +// + +import Foundation +import SwiftSyntax +import SwiftSyntaxMacros + +public struct OptionGroupPassthrough: MemberMacro { + public static func expansion(of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, conformingTo protocols: [TypeSyntax], in context: some MacroExpansionContext) throws -> [DeclSyntax] { + guard let structDecl = declaration.as(StructDeclSyntax.self) else { + throw MacroExpansionError.unsupportedDeclaration + } + let members = structDecl.memberBlock.members.filter({ $0.decl.is(VariableDeclSyntax.self) }) + var commands: [CommandOutline] = [] + +// throw members.debugDescription + for member in members { + guard let decl = member.decl.as(VariableDeclSyntax.self) else { + continue + } + if let option = decl.attributes.first(where: { $0.as(AttributeSyntax.self)?.attributeName.as(IdentifierTypeSyntax.self)?.name.text == "Option" }), + let option = option.as(AttributeSyntax.self) { + commands.append(try getOptionPropertyCommands(option, decl: decl)) + } else if let option = decl.attributes.first(where: { $0.as(AttributeSyntax.self)?.attributeName.as(IdentifierTypeSyntax.self)?.name.text == "Flag" }), + let option = option.as(AttributeSyntax.self) + { + commands.append(try getFlagPropertyCommands(option, decl: decl)) + } + } + + var function = """ + public func passThroughCommands() -> [String] { + var commands: [String] = [] + + """ + + for command in commands { + function.append(command.code) + function.append("") + } + + function.append("return commands\n}") + + return [.init(stringLiteral: function)] + } + + private static func getFlagPropertyCommands(_ option: AttributeSyntax, decl: VariableDeclSyntax) throws -> CommandOutline { + let (optionType, customName) = try getOptionNameType(option) + guard let identifierBinding = decl.bindings.first(where: { $0.pattern.is(IdentifierPatternSyntax.self) }), + let parameter = identifierBinding.pattern.as(IdentifierPatternSyntax.self)?.identifier + else { + throw "Could Not Determine Variable" + } + + let optionName = customName ?? parameter.text + + let nameCommand = optionType.tacks + optionName + + return CommandOutline(type: .flag, flag: nameCommand, variable: parameter.text) + } + + private static func getOptionPropertyCommands(_ option: AttributeSyntax, decl: VariableDeclSyntax) throws -> CommandOutline { + let (optionType, customName) = try getOptionNameType(option) + guard let identifierBinding = decl.bindings.first(where: { $0.pattern.is(IdentifierPatternSyntax.self) }), + let parameter = identifierBinding.pattern.as(IdentifierPatternSyntax.self)?.identifier + else { + throw "Could Not Determine Variable" + } + + let optionName = customName ?? parameter.text + + let nameCommand = optionType.tacks + optionName + + return CommandOutline(type: .option, flag: nameCommand, variable: parameter.text) + } + + private static func getOptionNameType(_ option: AttributeSyntax) throws -> (OptionNameType, String?) { + guard let attribute = option.arguments?.as(LabeledExprListSyntax.self)?.first(where: { $0.label?.text == "name" }) else { + throw "Error Parsing Option Name Attribute" + } + let expression: MemberAccessExprSyntax = try _getOptionNameTypeExpressionFromExpression(attribute.expression) + guard let optionType = OptionNameType(baseName: expression.declName.baseName) else { + throw "Error Parsing Option Name" + } + + var customString: String? + if [OptionNameType.customLong, .customShort].contains(optionType) { + if let arrayExpression = attribute.expression.as(ArrayExprSyntax.self), + let last = arrayExpression.elements.last { + customString = try _getCustomOptionNameFromExpression(last.expression) + } else { + customString = try _getCustomOptionNameFromExpression(attribute.expression) + } + } + + return (optionType, customString) + } + + private static func _getOptionNameTypeExpressionFromExpression(_ expression: ExprSyntax) throws -> MemberAccessExprSyntax { + if let expr = expression.as(MemberAccessExprSyntax.self) { + return expr + } else if let function = expression.as(FunctionCallExprSyntax.self), + let expr = function.calledExpression.as(MemberAccessExprSyntax.self) { + return expr + } else if let array = expression.as(ArrayExprSyntax.self), + let last = array.elements.last { + return try _getOptionNameTypeExpressionFromExpression(last.expression) + } else { + throw "Error Parsing Option Name Expression: \(expression)" + } + } + private static func _getCustomOptionNameFromExpression(_ expression: ExprSyntax) throws -> String? { + let customNameArguments = expression.as(FunctionCallExprSyntax.self)?.arguments + guard let customNameArg = customNameArguments?.first, + let segment = customNameArg.expression.as(StringLiteralExprSyntax.self)?.segments.first + else { + throw "Error Parsing Custom Option Name" + } + return segment.as(StringSegmentSyntax.self)?.content.text + } + + private enum OptionNameType: String { + case short, long, customLong, customShort + + init?(baseName: TokenSyntax) { + guard let result = OptionNameType(baseName: baseName.text) else { + return nil + } + + self = result + } + + init?(baseName: String) { + switch baseName { + case "shortAndLong": self = .long + case "customLong": self = .customLong + case "long": self = .long + case "customShort": self = .customShort + case "short": self = .short + default: return nil + } + } + + var tacks: String { + switch self { + case .short, .customShort: + "-" + case .long, .customLong: + "--" + } + } + } +} + +private struct CommandOutline { + let type: `Type` + let flag: String + let variable: String + + enum `Type` { + case flag, option + } + + var code: String { + switch type { + case .flag: + """ + if \(variable) { + commands.append("\(flag)") + } + """ + case .option: + """ + if "\\(\(variable), default: "%absolute-nil%")" != "%absolute-nil%" { + commands.append(contentsOf: ["\(flag)", "\\(\(variable), default: "%absolute-nil%")"]) + } + """ + } + } +} From 4f44ccde66ec5e94affa6aec959e8108a1129807 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Sat, 4 Oct 2025 01:34:43 -0700 Subject: [PATCH 02/28] chore(license): update licenses before push --- Sources/HelperMacros/HelperMacros.swift | 16 ++++++++++++++++ Sources/HelperMacros/PublicStructs.swift | 16 ++++++++++++++++ .../HelperMacrosMacros/HelperMacrosMacros.swift | 16 ++++++++++++++++ .../OptionGroupPassthrough.swift | 16 ++++++++++++++++ 4 files changed, 64 insertions(+) diff --git a/Sources/HelperMacros/HelperMacros.swift b/Sources/HelperMacros/HelperMacros.swift index afdbfebca..945d2c3c1 100644 --- a/Sources/HelperMacros/HelperMacros.swift +++ b/Sources/HelperMacros/HelperMacros.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 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. +//===----------------------------------------------------------------------===// + // // HelperMacros.swift // container diff --git a/Sources/HelperMacros/PublicStructs.swift b/Sources/HelperMacros/PublicStructs.swift index 4dedbe2f4..a086055c1 100644 --- a/Sources/HelperMacros/PublicStructs.swift +++ b/Sources/HelperMacros/PublicStructs.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 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. +//===----------------------------------------------------------------------===// + // // PublicStructs.swift // container diff --git a/Sources/HelperMacrosMacros/HelperMacrosMacros.swift b/Sources/HelperMacrosMacros/HelperMacrosMacros.swift index c428d952a..d20ec0c46 100644 --- a/Sources/HelperMacrosMacros/HelperMacrosMacros.swift +++ b/Sources/HelperMacrosMacros/HelperMacrosMacros.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 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. +//===----------------------------------------------------------------------===// + // // HelperMacrosMacros.swift // container diff --git a/Sources/HelperMacrosMacros/OptionGroupPassthrough.swift b/Sources/HelperMacrosMacros/OptionGroupPassthrough.swift index db9eb54ed..4741e1bdb 100644 --- a/Sources/HelperMacrosMacros/OptionGroupPassthrough.swift +++ b/Sources/HelperMacrosMacros/OptionGroupPassthrough.swift @@ -1,3 +1,19 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 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. +//===----------------------------------------------------------------------===// + // // OptionGroupPassthrough.swift // container From f4a71b8ecb5a1fc628ef9123874462d3a823d4ba Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Sat, 4 Oct 2025 01:34:46 -0700 Subject: [PATCH 03/28] chore(fmt): auto-format before push --- Package.swift | 4 +- Sources/HelperMacros/PublicStructs.swift | 2 +- .../HelperMacrosMacros.swift | 4 +- .../OptionGroupPassthrough.swift | 98 ++++++++++--------- 4 files changed, 57 insertions(+), 51 deletions(-) diff --git a/Package.swift b/Package.swift index 59f5e9eb9..8df8ec4ed 100644 --- a/Package.swift +++ b/Package.swift @@ -17,9 +17,9 @@ // The swift-tools-version declares the minimum version of Swift required to build this package. +import CompilerPluginSupport import Foundation import PackageDescription -import CompilerPluginSupport let releaseVersion = ProcessInfo.processInfo.environment["RELEASE_VERSION"] ?? "0.0.0" let gitCommit = ProcessInfo.processInfo.environment["GIT_COMMIT"] ?? "unspecified" @@ -380,7 +380,7 @@ let package = Package( name: "HelperMacrosMacros", dependencies: [ .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), - .product(name: "SwiftCompilerPlugin", package: "swift-syntax") + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), ] ), diff --git a/Sources/HelperMacros/PublicStructs.swift b/Sources/HelperMacros/PublicStructs.swift index a086055c1..604697f4c 100644 --- a/Sources/HelperMacros/PublicStructs.swift +++ b/Sources/HelperMacros/PublicStructs.swift @@ -27,7 +27,7 @@ public struct CommandOutline { let type: `Type` let flag: String let variable: String - + public enum `Type` { case flag, option } diff --git a/Sources/HelperMacrosMacros/HelperMacrosMacros.swift b/Sources/HelperMacrosMacros/HelperMacrosMacros.swift index d20ec0c46..7ab945780 100644 --- a/Sources/HelperMacrosMacros/HelperMacrosMacros.swift +++ b/Sources/HelperMacrosMacros/HelperMacrosMacros.swift @@ -22,8 +22,8 @@ // import SwiftCompilerPlugin -import SwiftSyntaxMacros import SwiftDiagnostics +import SwiftSyntaxMacros @main struct SwiftMacrosAndMePlugin: CompilerPlugin { @@ -37,7 +37,7 @@ extension String: @retroactive Error { enum MacroExpansionError: Error { case unsupportedDeclaration - + var localizedDescription: String { switch self { case .unsupportedDeclaration: diff --git a/Sources/HelperMacrosMacros/OptionGroupPassthrough.swift b/Sources/HelperMacrosMacros/OptionGroupPassthrough.swift index 4741e1bdb..1b8e21407 100644 --- a/Sources/HelperMacrosMacros/OptionGroupPassthrough.swift +++ b/Sources/HelperMacrosMacros/OptionGroupPassthrough.swift @@ -26,20 +26,23 @@ import SwiftSyntax import SwiftSyntaxMacros public struct OptionGroupPassthrough: MemberMacro { - public static func expansion(of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, conformingTo protocols: [TypeSyntax], in context: some MacroExpansionContext) throws -> [DeclSyntax] { + public static func expansion( + of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, conformingTo protocols: [TypeSyntax], in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { guard let structDecl = declaration.as(StructDeclSyntax.self) else { throw MacroExpansionError.unsupportedDeclaration } let members = structDecl.memberBlock.members.filter({ $0.decl.is(VariableDeclSyntax.self) }) var commands: [CommandOutline] = [] - -// throw members.debugDescription + + // throw members.debugDescription for member in members { guard let decl = member.decl.as(VariableDeclSyntax.self) else { continue } if let option = decl.attributes.first(where: { $0.as(AttributeSyntax.self)?.attributeName.as(IdentifierTypeSyntax.self)?.name.text == "Option" }), - let option = option.as(AttributeSyntax.self) { + let option = option.as(AttributeSyntax.self) + { commands.append(try getOptionPropertyCommands(option, decl: decl)) } else if let option = decl.attributes.first(where: { $0.as(AttributeSyntax.self)?.attributeName.as(IdentifierTypeSyntax.self)?.name.text == "Flag" }), let option = option.as(AttributeSyntax.self) @@ -47,53 +50,53 @@ public struct OptionGroupPassthrough: MemberMacro { commands.append(try getFlagPropertyCommands(option, decl: decl)) } } - + var function = """ public func passThroughCommands() -> [String] { var commands: [String] = [] - + """ - + for command in commands { function.append(command.code) function.append("") } - + function.append("return commands\n}") - + return [.init(stringLiteral: function)] } - + private static func getFlagPropertyCommands(_ option: AttributeSyntax, decl: VariableDeclSyntax) throws -> CommandOutline { let (optionType, customName) = try getOptionNameType(option) guard let identifierBinding = decl.bindings.first(where: { $0.pattern.is(IdentifierPatternSyntax.self) }), - let parameter = identifierBinding.pattern.as(IdentifierPatternSyntax.self)?.identifier + let parameter = identifierBinding.pattern.as(IdentifierPatternSyntax.self)?.identifier else { throw "Could Not Determine Variable" } - + let optionName = customName ?? parameter.text - + let nameCommand = optionType.tacks + optionName - + return CommandOutline(type: .flag, flag: nameCommand, variable: parameter.text) } - + private static func getOptionPropertyCommands(_ option: AttributeSyntax, decl: VariableDeclSyntax) throws -> CommandOutline { let (optionType, customName) = try getOptionNameType(option) guard let identifierBinding = decl.bindings.first(where: { $0.pattern.is(IdentifierPatternSyntax.self) }), - let parameter = identifierBinding.pattern.as(IdentifierPatternSyntax.self)?.identifier + let parameter = identifierBinding.pattern.as(IdentifierPatternSyntax.self)?.identifier else { throw "Could Not Determine Variable" } - + let optionName = customName ?? parameter.text - + let nameCommand = optionType.tacks + optionName - + return CommandOutline(type: .option, flag: nameCommand, variable: parameter.text) } - + private static func getOptionNameType(_ option: AttributeSyntax) throws -> (OptionNameType, String?) { guard let attribute = option.arguments?.as(LabeledExprListSyntax.self)?.first(where: { $0.label?.text == "name" }) else { throw "Error Parsing Option Name Attribute" @@ -102,28 +105,31 @@ public struct OptionGroupPassthrough: MemberMacro { guard let optionType = OptionNameType(baseName: expression.declName.baseName) else { throw "Error Parsing Option Name" } - + var customString: String? if [OptionNameType.customLong, .customShort].contains(optionType) { if let arrayExpression = attribute.expression.as(ArrayExprSyntax.self), - let last = arrayExpression.elements.last { + let last = arrayExpression.elements.last + { customString = try _getCustomOptionNameFromExpression(last.expression) } else { customString = try _getCustomOptionNameFromExpression(attribute.expression) } } - + return (optionType, customString) } - + private static func _getOptionNameTypeExpressionFromExpression(_ expression: ExprSyntax) throws -> MemberAccessExprSyntax { - if let expr = expression.as(MemberAccessExprSyntax.self) { + if let expr = expression.as(MemberAccessExprSyntax.self) { return expr } else if let function = expression.as(FunctionCallExprSyntax.self), - let expr = function.calledExpression.as(MemberAccessExprSyntax.self) { + let expr = function.calledExpression.as(MemberAccessExprSyntax.self) + { return expr } else if let array = expression.as(ArrayExprSyntax.self), - let last = array.elements.last { + let last = array.elements.last + { return try _getOptionNameTypeExpressionFromExpression(last.expression) } else { throw "Error Parsing Option Name Expression: \(expression)" @@ -132,24 +138,24 @@ public struct OptionGroupPassthrough: MemberMacro { private static func _getCustomOptionNameFromExpression(_ expression: ExprSyntax) throws -> String? { let customNameArguments = expression.as(FunctionCallExprSyntax.self)?.arguments guard let customNameArg = customNameArguments?.first, - let segment = customNameArg.expression.as(StringLiteralExprSyntax.self)?.segments.first + let segment = customNameArg.expression.as(StringLiteralExprSyntax.self)?.segments.first else { throw "Error Parsing Custom Option Name" } - return segment.as(StringSegmentSyntax.self)?.content.text + return segment.as(StringSegmentSyntax.self)?.content.text } - + private enum OptionNameType: String { case short, long, customLong, customShort - + init?(baseName: TokenSyntax) { guard let result = OptionNameType(baseName: baseName.text) else { return nil } - + self = result } - + init?(baseName: String) { switch baseName { case "shortAndLong": self = .long @@ -160,7 +166,7 @@ public struct OptionGroupPassthrough: MemberMacro { default: return nil } } - + var tacks: String { switch self { case .short, .customShort: @@ -176,25 +182,25 @@ private struct CommandOutline { let type: `Type` let flag: String let variable: String - + enum `Type` { case flag, option } - + var code: String { switch type { case .flag: - """ - if \(variable) { - commands.append("\(flag)") - } - """ + """ + if \(variable) { + commands.append("\(flag)") + } + """ case .option: - """ - if "\\(\(variable), default: "%absolute-nil%")" != "%absolute-nil%" { - commands.append(contentsOf: ["\(flag)", "\\(\(variable), default: "%absolute-nil%")"]) - } - """ + """ + if "\\(\(variable), default: "%absolute-nil%")" != "%absolute-nil%" { + commands.append(contentsOf: ["\(flag)", "\\(\(variable), default: "%absolute-nil%")"]) + } + """ } } } From 388015b6999bbbadf8bb156876851915150990ec Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Sat, 4 Oct 2025 01:35:48 -0700 Subject: [PATCH 04/28] Delete PublicStructs.swift --- Sources/HelperMacros/PublicStructs.swift | 34 ------------------------ 1 file changed, 34 deletions(-) delete mode 100644 Sources/HelperMacros/PublicStructs.swift diff --git a/Sources/HelperMacros/PublicStructs.swift b/Sources/HelperMacros/PublicStructs.swift deleted file mode 100644 index 604697f4c..000000000 --- a/Sources/HelperMacros/PublicStructs.swift +++ /dev/null @@ -1,34 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 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. -//===----------------------------------------------------------------------===// - -// -// PublicStructs.swift -// container -// -// Created by Morris Richman on 10/4/25. -// - -import Foundation - -public struct CommandOutline { - let type: `Type` - let flag: String - let variable: String - - public enum `Type` { - case flag, option - } -} From 4f1501505b7f7474a6d1073cfe2200dc1b554602 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Sat, 4 Oct 2025 01:39:53 -0700 Subject: [PATCH 05/28] applied OptionGroupPassthrough to all flag structs --- Sources/ContainerClient/Flags.swift | 5 +++++ Sources/HelperMacrosMacros/OptionGroupPassthrough.swift | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/ContainerClient/Flags.swift b/Sources/ContainerClient/Flags.swift index 50092883e..e8849d62a 100644 --- a/Sources/ContainerClient/Flags.swift +++ b/Sources/ContainerClient/Flags.swift @@ -20,6 +20,7 @@ import Foundation import HelperMacros public struct Flags { + @OptionGroupPassthrough public struct Global: ParsableArguments { public init() {} @@ -65,6 +66,7 @@ public struct Flags { public var cwd: String? } + @OptionGroupPassthrough public struct Resource: ParsableArguments { public init() {} @@ -78,6 +80,7 @@ public struct Flags { public var memory: String? } + @OptionGroupPassthrough public struct Registry: ParsableArguments { public init() {} @@ -89,6 +92,7 @@ public struct Flags { public var scheme: String = "auto" } + @OptionGroupPassthrough public struct Management: ParsableArguments { public init() {} @@ -203,6 +207,7 @@ public struct Flags { public var virtualization: Bool = false } + @OptionGroupPassthrough public struct Progress: ParsableArguments { public init() {} diff --git a/Sources/HelperMacrosMacros/OptionGroupPassthrough.swift b/Sources/HelperMacrosMacros/OptionGroupPassthrough.swift index 1b8e21407..75dcb8b04 100644 --- a/Sources/HelperMacrosMacros/OptionGroupPassthrough.swift +++ b/Sources/HelperMacrosMacros/OptionGroupPassthrough.swift @@ -99,7 +99,7 @@ public struct OptionGroupPassthrough: MemberMacro { private static func getOptionNameType(_ option: AttributeSyntax) throws -> (OptionNameType, String?) { guard let attribute = option.arguments?.as(LabeledExprListSyntax.self)?.first(where: { $0.label?.text == "name" }) else { - throw "Error Parsing Option Name Attribute" + return (.long, nil) } let expression: MemberAccessExprSyntax = try _getOptionNameTypeExpressionFromExpression(attribute.expression) guard let optionType = OptionNameType(baseName: expression.declName.baseName) else { From aaf115982249d33d14974d86ecc128aa20c72d8c Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Sat, 4 Oct 2025 01:47:57 -0700 Subject: [PATCH 06/28] add DocC Documentation --- Sources/HelperMacros/HelperMacros.swift | 3 ++- Sources/HelperMacrosMacros/OptionGroupPassthrough.swift | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/HelperMacros/HelperMacros.swift b/Sources/HelperMacros/HelperMacros.swift index 945d2c3c1..e73e653b8 100644 --- a/Sources/HelperMacros/HelperMacros.swift +++ b/Sources/HelperMacros/HelperMacros.swift @@ -23,5 +23,6 @@ import Foundation -@attached(member, names: arbitrary) +/// Creates a function in OptionGroups called `passThroughCommands` to return an array of strings to be appended and passed down for Plugin support. +@attached(member, names: named(passThroughCommands)) public macro OptionGroupPassthrough() = #externalMacro(module: "HelperMacrosMacros", type: "OptionGroupPassthrough") diff --git a/Sources/HelperMacrosMacros/OptionGroupPassthrough.swift b/Sources/HelperMacrosMacros/OptionGroupPassthrough.swift index 75dcb8b04..e4a9ffba7 100644 --- a/Sources/HelperMacrosMacros/OptionGroupPassthrough.swift +++ b/Sources/HelperMacrosMacros/OptionGroupPassthrough.swift @@ -25,6 +25,7 @@ import Foundation import SwiftSyntax import SwiftSyntaxMacros +/// Creates a function in OptionGroups called `passThroughCommands` to return an array of strings to be appended and passed down for Plugin support. public struct OptionGroupPassthrough: MemberMacro { public static func expansion( of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, conformingTo protocols: [TypeSyntax], in context: some MacroExpansionContext @@ -35,7 +36,6 @@ public struct OptionGroupPassthrough: MemberMacro { let members = structDecl.memberBlock.members.filter({ $0.decl.is(VariableDeclSyntax.self) }) var commands: [CommandOutline] = [] - // throw members.debugDescription for member in members { guard let decl = member.decl.as(VariableDeclSyntax.self) else { continue @@ -52,6 +52,7 @@ public struct OptionGroupPassthrough: MemberMacro { } var function = """ + /// Autogenerated by ``OptionGroupPassthrough``. This function returns the ``OptionGroup`` as an array of commands that can be passed down to a ``ContainerCommands`` command. public func passThroughCommands() -> [String] { var commands: [String] = [] From 16cfd157dc023fe49b547293d6e61afc60f8cd9f Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Sat, 4 Oct 2025 01:55:40 -0700 Subject: [PATCH 07/28] code cleanup and add comments --- .../HelperMacrosMacros/OptionGroupPassthrough.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/HelperMacrosMacros/OptionGroupPassthrough.swift b/Sources/HelperMacrosMacros/OptionGroupPassthrough.swift index e4a9ffba7..6bc52a65d 100644 --- a/Sources/HelperMacrosMacros/OptionGroupPassthrough.swift +++ b/Sources/HelperMacrosMacros/OptionGroupPassthrough.swift @@ -36,6 +36,7 @@ public struct OptionGroupPassthrough: MemberMacro { let members = structDecl.memberBlock.members.filter({ $0.decl.is(VariableDeclSyntax.self) }) var commands: [CommandOutline] = [] + // Append comman outlines for each member for member in members { guard let decl = member.decl.as(VariableDeclSyntax.self) else { continue @@ -51,6 +52,7 @@ public struct OptionGroupPassthrough: MemberMacro { } } + // Create begining of function var function = """ /// Autogenerated by ``OptionGroupPassthrough``. This function returns the ``OptionGroup`` as an array of commands that can be passed down to a ``ContainerCommands`` command. public func passThroughCommands() -> [String] { @@ -58,11 +60,13 @@ public struct OptionGroupPassthrough: MemberMacro { """ + // Append the code for each command for command in commands { function.append(command.code) function.append("") } + // Close function function.append("return commands\n}") return [.init(stringLiteral: function)] @@ -76,8 +80,8 @@ public struct OptionGroupPassthrough: MemberMacro { throw "Could Not Determine Variable" } + // Get string command tack let optionName = customName ?? parameter.text - let nameCommand = optionType.tacks + optionName return CommandOutline(type: .flag, flag: nameCommand, variable: parameter.text) @@ -91,8 +95,8 @@ public struct OptionGroupPassthrough: MemberMacro { throw "Could Not Determine Variable" } + // Get string command tack let optionName = customName ?? parameter.text - let nameCommand = optionType.tacks + optionName return CommandOutline(type: .option, flag: nameCommand, variable: parameter.text) @@ -100,6 +104,7 @@ public struct OptionGroupPassthrough: MemberMacro { private static func getOptionNameType(_ option: AttributeSyntax) throws -> (OptionNameType, String?) { guard let attribute = option.arguments?.as(LabeledExprListSyntax.self)?.first(where: { $0.label?.text == "name" }) else { + // Default to long if not described in PropertyWrapper return (.long, nil) } let expression: MemberAccessExprSyntax = try _getOptionNameTypeExpressionFromExpression(attribute.expression) @@ -107,6 +112,7 @@ public struct OptionGroupPassthrough: MemberMacro { throw "Error Parsing Option Name" } + // Get the name of the custom short/long if needed var customString: String? if [OptionNameType.customLong, .customShort].contains(optionType) { if let arrayExpression = attribute.expression.as(ArrayExprSyntax.self), From 7787d78682bf7f5325f7656ba8a79d6b8deaa137 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Wed, 3 Dec 2025 10:02:35 -0800 Subject: [PATCH 08/28] Update Package.swift --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 752585025..d5ab0821f 100644 --- a/Package.swift +++ b/Package.swift @@ -389,7 +389,7 @@ let package = Package( .target( name: "HelperMacros", dependencies: ["HelperMacrosMacros"] - ) + ), .target( name: "CAuditToken", dependencies: [], From 1559e6d2a2efbf64cb5931cb31be3fc85842afd0 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Mon, 19 Jan 2026 19:24:45 -0800 Subject: [PATCH 09/28] Squashed commit of the following: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit 69445b91b30c8dac6ebaebd7af9bf63d9526c572 Author: jwhur <57657645+JaewonHur@users.noreply.github.com> Date: Mon Jan 19 13:09:34 2026 -0800 Throw error when starting a container with invalid virtiofs source (#1051) Run = Create + Start 1) Mount source points to a valid directory - Run and Create + Start both correctly create the container with mount. 2) Mount source points to a file - Run fails bootstrapping the container, thus container not created. - Create creates the container, but Start fails bootstrapping, removing the container. (Thus, both are the same.) 3) Mount source deleted or replaced to file after container created - Start throw errors but do not delete the container. commit 08f48d9ab91bc36c28eedfd9a3f411ad5a11021c Author: Danny Canter Date: Fri Jan 16 21:48:58 2026 -0800 ContainerSvc: Handle unexpected sandbox svc exits (#1065) Closes https://github.com/apple/container/issues/1050 If the sandbox svc exits out of band of the usual stop (or regular exit) case the container svc's state is not properly updated for the container. This was due to the cleanup steps involving trying to send the shutdown rpc which cannot succeed as the sandbox svc does not exist to service it. To handle this, let's treat shutdown not returning successfully as non-fatal (as this is mostly best effort), log an error and continue the state cleanup. commit b928e3ff1d6d3fc95374ebc72cc5469c0036547b Author: Amir Alperin Date: Sat Jan 17 07:43:48 2026 +0200 fix: performance warning should not output ANSI codes if stderr redirected (#1059) commit 744e7f7c7a3c48d422641a448426eccddb741418 Author: J Logan Date: Fri Jan 16 16:26:13 2026 -0800 Update for containerization 0.21.0. (#1056) - Update image load and build to handle rejected paths during tar extraction. For the image load command there is now a `--force` function that fails extractions with rejected paths when false, and just warns about the rejected paths when true. - Update `container stats` for statistics API properties now all being optional. ## Type of Change - [x] Bug fix - [ ] New feature - [ ] Breaking change - [x] Documentation update ## Motivation and Context See above ## Testing - [x] Tested locally - [x] Added/updated tests - [x] Added/updated docs commit b1577d8d07882fb9d206171003c41b14623c2644 Author: J Logan Date: Fri Jan 16 15:50:47 2026 -0800 Adds opt-in pre-commit hook for format and header checks. (#1062) - Closes #639. - Adds swift format configuration that removes lint checks so we can use `swift lint` to perform format-only tests. - Adds `check` target that invokes format and header checks. - Adds pre-commit script that runs `make check`. - Adds `pre-commit` target that installs the check script as a pre-commit hook. ## Type of Change - [ ] Bug fix - [x] New feature - [ ] Breaking change - [x] Documentation update ## Motivation and Context Avoids wasting time and commit rewrites. ## Testing - [x] Tested locally - [ ] Added/updated tests - [x] Added/updated docs commit 3cf2c6ad8d98f8280a222280203ed941ad0c3e03 Author: J Logan Date: Fri Jan 16 13:41:32 2026 -0800 Fix unstable integration tests. (#1060) - TestCLIRunCommand now run so many tests concurrently that the API server gets swamped and tests randomly time out. - The parallelism options on `swift test` only work for XCTest, not swift-testing. - Work around this while retaining some parallelism (good for stress testing) by breaking the tests into two suites. commit 8897fcc4a08b33291c159db18eeb31da4bf80a7e Author: Manu Schiller <56154253+manuschillerdev@users.noreply.github.com> Date: Wed Jan 14 04:39:08 2026 +0100 fix: use pax instead of tar for pkg payload extraction (#1038) - It is common to have `gnu-tar` alongside other GNU tools installed and aliased for compatibility reasons. However, this breaks the current make build. - Use BSD-only binaries (no GNU equivalents that are commonly aliased), making the Makefile more portable. commit dbec1db03e0b67afda56dd07cc152304b2ffd53e Author: Ronit Sabhaya Date: Mon Jan 12 20:34:25 2026 -0600 Add support for aarch64 architecture alias (#1040) - Adds `aarch64` as an alias for `arm64` in the `Arch` enum. This addresses the maintainer's request to support this common architecture name, ensuring consistency with `x86_64` normalization and preventing failures for users expecting `aarch64` support. commit 837aa5eb8324054e2592a48cb5c88bd2fd37998c Author: jwhur <57657645+JaewonHur@users.noreply.github.com> Date: Mon Jan 12 14:36:10 2026 -0800 Fix the FS error when using Virtualization (#1041) - Fixes #614. - Use VZ cached mode instead of auto. Signed-off-by: jwhur commit e465b109b2e5a6d1d8464ae4b88275545795c8ff Author: 박성근 <117553364+ParkSeongGeun@users.noreply.github.com> Date: Tue Jan 13 03:30:51 2026 +0900 Fix relative path resolution in entrypoint (#987) - Fixes #962. - Adds test to exercise apple/containerization#473. - Updates containerization to 0.20.1. Signed-off-by: ParkSeongGeun commit aa7792807c66617299c5f580893da7e538bc3ef1 Author: Ronit Sabhaya Date: Mon Jan 12 12:04:46 2026 -0600 Fix: Support x86_64 architecture alias to prevent silent pull failure… (#1036) - Adds architecture name normalization to accept `x86_64` and `x86-64` as aliases for `amd64`. commit dc4682be742771c6c3efa74997f26300c9cd5e01 Author: Amir Alperin Date: Fri Jan 9 21:10:53 2026 +0200 fix: extract hostname from FQDN (#1011) (#1017) - Set the container hostname to the first DNS label derived from the container id, strip everything after the first dot. - Fixes #1011. commit 4af1cc01c4b73af9f9a64668b504c58ed82fca68 Author: Ronit Sabhaya Date: Thu Jan 8 21:27:43 2026 -0600 fix: improve error message when binding to privileged ports (fixes #978) (#1031) - The container fails to start with a generic "permission denied" error when attempting to publish privileged ports (ports below 1024) without root privileges. This provides a confusing user experience as the error doesn't explain why permission was denied. commit 21facf00a80cb8c7bc3370b67d1010e3bc0c2eff Author: J Logan Date: Thu Jan 8 17:02:22 2026 -0800 Add instructions for using locally built init filesystem. (#1032) - Closes #1030. commit b671690c17b4fc7b2d239faa8cfad3fa1026f513 Author: Danny Canter Date: Wed Jan 7 21:01:10 2026 -0800 ProgressBar: Various fixes (#1025) There's a couple things I don't think are intuitive about this. 1. Because of the internal task, render() can still be called even after finish() completes. Ideally async defers are supported and we could just await the final render completing after cancelling the task and setting .finished, but alas. To fix this we can just lock across the methods for now. 2. We always clear the screen in the destructor, even if we don't use the progress bar. I don't think we should honestly do anything in the destructor. Feels a programmer error not to defer { bar.finish() } or call it somewhere. 3. Our spaces based line clearing. Use the ansi escape sequence for clearing line; I think our calculations were slightly off and it would leave trailing output ( "s]" ) in some cases. 4. Shrinking the window until the output is smaller than the terminal window (and vice versa) is wonky on various term emulators. Truthfully, this is just a hard problem, but we can truncate our output and still provide some useful info. This fixes some single line output (cat /etc/hostname etc.) getting cleared in our atexit handler, as well as the need for the usleep. commit 98410fdb574b916761befc3369028edcc3f9c8a2 Author: J Logan Date: Wed Jan 7 18:23:31 2026 -0800 Adds IPv6 port forwarding. (#1029) - Closes #1006. commit 9d06475aafefc9dfbb0b6dc344d2bbe036842011 Author: Saehej Kang Date: Wed Jan 7 16:53:33 2026 -0800 [container]: add startedDate field (#1018) - Closes #302. - Closes #336 (obsoletes this PR). commit db8932ab0fdaa5b44d8ae882aba3ad278ee847a2 Author: J Logan Date: Wed Jan 7 15:35:35 2026 -0800 Resolve IPv6 address queries for container names. (#1016) - Closes #1005. - Adapt everything to use MACAddress type from containerization 0.20.0. - Allocate MAC addresses for every container so that we have deterministic IPv6 link local addresses. - Add AAAA handling to ContainerDNSHandler. - NOTE: Only works on Tahoe. On Sequoia, we don't have a good way to set or determine the IPv6 network prefix when networks are created, so we can't infer the IPv6 link local addresses for AAAA responses and we instead return `NODATA`. commit 5d6c750708cca724a1739be2752e9aa4998c536a Author: Danny Canter Date: Wed Jan 7 14:48:58 2026 -0800 CLI: Add read-only flag to run/create (#999) Closes #990 Sets the rootfs for a container to read-only. commit aac24576757b0eabb49ecbd64e535b6ff2c79279 Author: Danny Canter Date: Wed Jan 7 13:46:26 2026 -0800 Tests: Fix relative path mount tests (#1028) The tests are run in parallel on CI, and were split into three tests. They change the cwd, so it's kind of a gamble whether some of them pass. This just moves all the logic into one test mostly. commit 9cd5397b8cc17ed265cce5ddb152c5192afa4748 Author: J Logan Date: Wed Jan 7 10:35:19 2026 -0800 Update to containerization 0.20.0. (#1027) - Use MACAddress for Attachment and CZ interfaces. - Move data validation closer to API surface. commit 356c8d2f88d39cf2c07d328dd56fb1a9cc006d67 Author: J Logan Date: Tue Jan 6 08:27:14 2026 -0800 Reorganize client libraries. (#1020) - Closes #461. - Extract core types into ContainerResources target. - Extract ContainerNetworkServiceClient from ContainerNetworkService. - Relocate sandbox client from ContainerClient to ContainerSandboxServiceClient. - Relocate ContainerClient to ContainerAPIServiceClient. - Common structure from services and clients under Source/Services. Updated project hierarchy: ``` Sources/CAuditToken - audit token access wrapper Sources/CLI - CLI executable Sources/ContainerBuild - builder Sources/ContainerCommands - CLI command implementations Sources/ContainerLog - logging helpers Sources/ContainerPersistence - persistent data and system property helpers Sources/ContainerPlugin - plugin system Sources/ContainerResource - resource (container, image, volume, network) types Sources/ContainerVersion - version helpers Sources/ContainerXPC - XPC helpers Sources/CVersion - injected project version Sources/DNSServer - container DNS resolver Sources/Helpers - service executables Sources/Services/*/Client - service clients Sources/Services/*/Server - service implementations Sources/SocketForwarder - port forwarding Sources/TerminalProgress - progress bar ``` ## Type of Change - [ ] Bug fix - [ ] New feature - [x] Breaking change - [ ] Documentation update ## Motivation and Context The ContainerClient library was a bit of a grab bag. This refactor applies a more sensible project and library structure for resource data types, services, and clients. ## Testing - [x] Tested locally - [x] Added/updated tests - [ ] Added/updated docs commit 8c439cd3569c551dafbd1bfffdb0313724a21632 Author: Danny Canter Date: Mon Jan 5 13:50:57 2026 -0800 makefile: Add cli target (#1022) Often times I'll be making a change that only touches the cli and I don't feel like sitting through the potential song and dance of the other components building/installing. commit d6f052d2067a2690bf3b69c6691c67629b93f54f Author: Kathryn Baldauf Date: Mon Jan 5 13:09:34 2026 -0800 Update license header on all files to include the current year (#1024) ## Motivation and Context Now that we're in 2026, we need to update the license headers on all the files. Unfortunately, Hawkeye doesn't have an attribute for the current year to help us avoid this in the future. Instead, I had to work around this by doing the following: 1. Update licenserc.toml with: ``` [properties] ... (other properties) currentYear = "2026" ```   2. Update scripts/license-header.txt with ``` Copyright ©{{ " " }}{%- set created = attrs.git_file_created_year or attrs.disk_file_created_year -%}{%- set modified = props["currentYear"] -%}{%- if created != modified -%} {{created}}-{{modified}}{%- else -%}{{created}}{%- endif -%}{{ " " }}{{ props["copyrightOwner"] }}. ``` Then I removed these two changes before committing. After this PR is merged, all files will have recently had git updates, so the existing code for setting the modified year should work as intended. Signed-off-by: Kathryn Baldauf commit 20dc0bcfee931616a138caa57c60800fa0fc9f38 Author: Danny Canter Date: Sun Jan 4 11:11:09 2026 -0800 Parser: Support relative paths for --volume (#1013) commit 028e7e109fc1a36828b33b1df6f5f799ab90da0d Author: Danny Canter Date: Sun Jan 4 10:52:46 2026 -0800 Deps: Bump Containerization to 0.19.0 (#1015) Has read-only rootfs support. commit 020949ea2b5543323bbacaff7747016b92ad0b08 Author: Danny Canter Date: Sun Jan 4 10:51:20 2026 -0800 CLI: Small fixups for implicit envvars (#1014) We should only inherit from the host if there's no =. Additionally document the flag a little more to show that we can inherit from the host. commit df368b790efca200615bab70a2a42dbe3fdad3e1 Author: Amir Alperin Date: Sun Jan 4 20:49:22 2026 +0200 Fix port validation to allow same port for different protocols (#992) (#1000) - Fixes: #992 - Port validation previously rejected valid configurations when the same port number was used for different protocols (TCP and UDP). For example: `-p 1024:1024/udp -p 1024:1024/tcp` Although this is a valid and common use case, the validation logic treated it as a conflict. To fix this, I updated the validation key to include the protocol name. The validation now checks for overlapping port numbers only within the same protocol, rather than across all protocols. This change enables binding the same port number for both TCP and UDP, aligning the validation behavior with real-world networking requirements. ## Testing - [x] Tested locally - [x] Added/updated tests - [ ] Added/updated docs commit cf6461417332152929a703c41a0d41380ae44887 Author: J Logan Date: Fri Jan 2 14:10:48 2026 -0800 Update OSS header in Package.swift. (#1010) commit 375ce16a9964d562691d12297b303808f12817a9 Author: J Logan Date: Fri Jan 2 12:09:12 2026 -0800 Fix OSS header dates that break CI checks. (#1009) commit 580d8531c0539940fb0aaac64c3084861b368445 Author: c Date: Fri Jan 2 00:19:57 2026 -0500 Use full path for uninstall script in upgrade instructions (#983) - Makes the upgrade section consistent with the uninstall section by using the full path to the uninstall script. commit 4cadc401e1ed363bd4d5d3848983ed9b02b60533 Author: c Date: Thu Jan 1 22:53:56 2026 -0500 Clarify uninstall script location in README (#982) - Clarifies where the `uninstall-container.sh` script is located after installation - Updates example commands to use the full path commit 4e78e30b20da93da1d3daf0135466e6813b6450b Author: c Date: Thu Jan 1 20:57:47 2026 -0500 Fix grammar in tutorial.md (#985) ## Summary - Fixes a grammar error in the tutorial's publish section ## Details Line 287 of `docs/tutorial.md` had "you need push images" which should be "you need to push images". This is a simple grammar fix to improve readability. ## Test plan - [x] Verified the sentence now reads correctly commit 22dfd6e7c7a8b2e605e301196e9e12aaa410e107 Author: Danny Canter Date: Thu Jan 1 17:57:00 2026 -0800 CLI: Fix stop not signalling waiters (#972) commit 4958cf272fbd384e81323af210eb2c2cab9f4b2c Author: c Date: Thu Jan 1 20:51:10 2026 -0500 Fix bash completion source path in documentation (#981) - Corrects the source path for bash completion script when not using bash-completion package. commit 25ac79a0c4ce9df2854313afcc51fade00db4e76 Author: c Date: Thu Jan 1 20:50:19 2026 -0500 Fix MAC address option typo in how-to documentation (#980) - Corrects the MAC address example command in the how-to guide to use the correct `--network` flag syntax instead of the incorrect `--mac-address` flag. commit edadf155cd05e22258ab612aaca852ca75a1db13 Author: Raj Date: Thu Jan 1 15:10:39 2026 +0530 Fix container auto-delete on rapid stop/start (#841) Fixes #833. Currently, when stopping and immediately restarting a container, it would fail with the error: `“container expected to be in created state, got: shuttingDown”` and then be automatically deleted. The `SandboxService` process waits five seconds before exiting after shutdown. During this interval, a rapid restart could reconnect to the still-terminating process in the `shuttingDown` state, triggering a state validation error. This fix forcefully terminates the `SandboxService` process with `SIGKILL` upon container exit, instead of waiting five seconds. The bootstrap now defensively checks for and cleans up any stale services before registering new ones, preventing reconnections to processes in the `shuttingDown` state. commit 5064b0ffd55bf7e9e43b2f0140e46802fc17a139 Author: J Logan Date: Mon Dec 22 10:16:14 2025 -0800 Adds network IPv6 configuration. (#975) - Part of work for #460. - Enable set/get of IPv6 network prefix in ReservedVmnetNetwork. - Show IPv6 prefix in `network list` full output. - Option for setting IPv6 prefix when creating a network. - System property for default IPv6 prefix. ## Type of Change - [ ] Bug fix - [x] New feature - [ ] Breaking change - [x] Documentation update ## Motivation and Context See #460. ## Testing - [x] Tested locally - [ ] Added/updated tests - [x] Added/updated docs commit 9c239aa36cdecfcb56b3aae83ea5724edef99e69 Author: Volodymyr Bortniak <25820601+Bortnyak@users.noreply.github.com> Date: Sat Dec 20 00:36:02 2025 +0100 Add support for reading env from named pipes (#974) This is a fix for [issue#956](https://github.com/apple/container/issues/956) `FileManager.default.contents(atPath:)` returns `nil` for named pipes (FIFOs) and process substitutions like `/dev/fd/XX` because: 1. It expects regular files with a known size 2. Named pipes are stream-based and block until data arrives ## Solution Use `FileHandle(forReadingFrom:)` instead, which: - Properly handles blocking I/O - Works with named pipes, process substitutions, and regular files (mentioned in the [doc](https://developer.apple.com/documentation/foundation/filehandle)) Co-authored-by: Bortniak Volodymyr commit 3c3a83c98a9862465499a041e9b5616bb692e443 Author: Danny Canter Date: Thu Dec 18 16:28:44 2025 -0800 Turn on oops=panic kernel cmdline (#971) commit b1b99809d412479ee20a4ad9c1d367fd7d0ce242 Author: Michael Gathara Date: Wed Dec 17 20:58:50 2025 -0600 Fix: Kubes Cluster in Container Crashing Container (IS#923) (#930) - Fixes issue #923 - I fixed a race condition in `ConnectHandler.swift` where an asynchronous network connection could complete after the handler had already been removed from the pipeline. - This prevents the EXC_BREAKPOINT crash in container-runtime-linux that occurred when kinc (Kubernetes in Container) created rapid connections. - The actual fix was inadvertently applied in #957, so this PR contains only the test code. commit 9f4efe0c4ce026a4fb2a93682627a4a4f5297d47 Author: Saehej Kang Date: Wed Dec 17 00:30:33 2025 -0800 [networks]: add prune command (#914) - Closes #893 commit 4f887251582e63594cd3037f7b5790ab5c7111a5 Author: J Logan Date: Tue Dec 16 16:34:13 2025 -0800 Use new IP/CIDR types from Containerization. (#957) - Part of work for #460. - With CZ release 0.17.0, the IP and CIDR address types changed from String to IPv4Address and CIDRv4, respectively. This PR applies the corresponding adaptations to container. commit 8e16bb239e10a5a8151a1e4b9e1c9055b9a59b72 Author: Salman Chishti Date: Tue Dec 16 20:14:45 2025 +0000 Upgrade GitHub Actions to latest versions (#959) - Upgrade GitHub Actions to their latest versions for improved features, bug fixes, and security updates. Signed-off-by: Salman Muin Kayser Chishti <13schishti@gmail.com> commit 0c7dca4d2565192d59f76a367c74622631a75fd1 Author: Salman Chishti Date: Tue Dec 16 19:23:31 2025 +0000 Add Dependabot for GitHub Actions updates (#960) ## Summary Add Dependabot configuration to automatically keep GitHub Actions up to date. ## Changes Adds `.github/dependabot.yml` configured to: - Check for GitHub Actions updates weekly - Group all action updates together for easier review - Use `ci` prefix for commit messages ## Why As discussed in #958, this helps: - Keep actions up to date with security patches automatically - Handle Node runtime deprecations proactively (e.g., Node 20 → Node 24) - Reduce manual maintenance burden ## Reference Based on the pattern used in [swift-nio](https://github.com/apple/swift-nio/blob/main/.github/dependabot.yml). commit 637c8f11a9a4549392133602ea2dd8171863383f Author: Salman Chishti Date: Tue Dec 16 18:15:42 2025 +0000 Upgrade GitHub Actions for Node 24 compatibility (#958) ## Summary Upgrade GitHub Actions to their latest versions to ensure compatibility with Node 24, as Node 20 will reach end-of-life in April 2026. ## Changes | Action | Old Version(s) | New Version | SHA | |--------|---------------|-------------|-----| | `actions/checkout` | v4 | v6 | `8e8c483` | | `actions/download-artifact` | v4 | v7 | `37930b1` | | `actions/upload-artifact` | v4 | v6 | `b7c566a` | | `actions/labeler` | v5 | v6 | `634933e` | | `actions/configure-pages` | v5 | v5 | `983d773` | | `actions/upload-pages-artifact` | v3 | v3 | `56afc60` | | `softprops/action-gh-release` | v2 | v2 | `a06a81a` | ## Context Per [GitHub's announcement](https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/), Node 20 is being deprecated and runners will begin using Node 24 by default starting March 4th, 2026. ### Why this matters - **Node 20 EOL**: April 2026 - **Node 24 default**: March 4th, 2026 - **Action**: Update to latest action versions that support Node 24 ### Security All actions are now **pinned to commit SHAs** instead of mutable version tags. This provides: - Protection against tag hijacking attacks - Immutable, reproducible builds - Version comments for readability ### Automated Updates A follow-up PR (#960) adds Dependabot configuration to automatically keep these actions updated with new SHA-pinned versions. ### Testing These changes only affect CI/CD workflow configurations and should not impact application functionality. The workflows should be tested by running them on a branch before merging. Signed-off-by: Salman Muin Kayser Chishti <13schishti@gmail.com> commit c22f1289fc677b857335d5b11c8f59c9690b6df5 Author: karen heckel Date: Mon Dec 15 21:16:55 2025 -0800 Feat: customize console output with env variable (#952) Fixes apple#915 Added a new feature to support the passing of buildkit colors for customizing console output. commit 9b7cfd852e60ae90465b557c3fe88421bd5636f3 Author: Saehej Kang Date: Mon Dec 15 17:52:00 2025 -0800 [images]: refactor prune command (#941) - Updates to `image prune` for consistency with how other `prune` commands are done. Added missing test cases as well for the command - Relates to the discussion from #914 commit 7d30720028c012a58659d941a771095defcd631f Author: Danny Canter Date: Thu Dec 11 05:36:15 2025 -0800 CLI: Fix -it not being able to pipe stdout (#951) Fixes #949 Typically if one fd is a tty, it's common for all 3 of stdio to be the same, but that is not always the case. In our case we were using our Terminal type from Containerization to comb through err/out/in and give us a type backed by one of the 3 if -t was supplied. It happens that stderr is the first we check, so our Terminal() is backed by fd 2. This change modifies things so that we always initialize our Terminal if asked for with fd 0, and out/err are backed by their corresponding correct fd number. ## Type of Change - [x] Bug fix - [ ] New feature - [ ] Breaking change - [ ] Documentation update ## Testing - [x] Tested locally - [ ] Added/updated tests - [ ] Added/updated docs commit a2901e051739a394aaf78739d2d6b739fdb667c4 Author: wangxiaolei Date: Wed Dec 10 10:04:40 2025 +0800 feat: implement version sub command (#911) - closes #383 - implement version sub command, give more info --------- Co-authored-by: fatelei commit 0cde1efe30ec549dcbca04aa1af4cda4f93be63f Author: Danny Canter Date: Tue Dec 9 13:24:45 2025 -0800 Deps: Bump Containerization to 0.16.2 (#947) Closes https://github.com/apple/container/issues/928 Has a cgroup fix when stopping certain containers commit 38960553cb11b76618163d131f14adec5a1dc6e8 Author: Dmitry Kovba Date: Tue Dec 9 12:32:28 2025 -0800 Lowercase error messages (#945) ## Type of Change - [x] Bug fix - [ ] New feature - [ ] Breaking change - [ ] Documentation update ## Motivation and Context For consistency, all error messages are lowercased. ## Testing - [ ] Tested locally - [ ] Added/updated tests - [ ] Added/updated docs --------- Co-authored-by: J Logan commit 0733a81a6de26a0f1e929d2b4f890c3bf809ae5c Author: Saehej Kang Date: Tue Dec 9 10:54:37 2025 -0800 [volumes]: refactor prune command (#940) - Refactor the `volume prune` command to follow a client-side approach. The `volumeDiskUsage` is calculated in the service file, so it made sense to leave that there. - Relates to the discussion from #914 commit 42528e6c4310307c27accc6767a40da3f632ad10 Author: Kathryn Baldauf Date: Tue Dec 9 10:42:27 2025 -0800 Update CONTRIBUTORS to MAINTAINERS and point at containerization (#942) ## Type of Change - [x] Documentation update ## Motivation and Context See https://github.com/apple/containerization/pull/435 for more information on this change. commit a64bd77b156f5d004424332a829f4608d8d20a3d Author: J Logan Date: Tue Dec 9 14:35:34 2025 -0300 Fix broken image integration tests. (#944) - Fixes #943. - Use images other than alpine:3.20 for image concurrency test so as not to interfere with tests using that image. - Rename test files to match suite names. commit ab92f3938e3547ff447bf4f5e660cf99febab442 Author: TTtie Date: Mon Dec 8 18:17:10 2025 +0100 fix(TerminalProgress): make the progress bar respect locale-specific decimal separator (#936) - The `ProgressBar#adjustFormattedSize` function currently expects a decimal dot when adding the additional ".0" to the size. This, however, breaks when a region with a non-dot decimal separator is used. commit 420be748f18afc685d11987ac5118c928e174c19 Author: J Logan Date: Mon Dec 8 03:00:02 2025 -0300 Data integrity: bump to cz 0.16.1, adjust sync mode. (#939) - 0.16.1 changes an ext4 superblock setting that might have been causing problems. - #877 fixed an issue where the cache and sync settings for block filesystems weren't being passed down to the VZ virtual machine configuration. The default sync value getting passed down is `full`, which reduces I/O performance. Relax this to use `fsync` for now. ## Type of Change - [*] Bug fix - [ ] New feature - [ ] Breaking change - [ ] Documentation update ## Motivation and Context May address problems reported in #877. ## Testing - [x] Tested locally - [ ] Added/updated tests - [ ] Added/updated docs commit f7bcb687fd9befc2d082dde7ea88df9364d11a8a Author: Santosh Bhavani Date: Sun Dec 7 10:56:50 2025 -0800 Add --max-concurrent-downloads flag for parallel layer downloads (#716) Adds `--max-concurrent-downloads` flag to `container image pull` for configurable concurrent layer downloads. Fixes #715 Depends on apple/containerization#311 **Usage**: ```bash container image pull nginx:latest --max-concurrent-downloads 6 ``` **Changes**: - Add CLI flag (default: 3) - Thread parameter through XPC stack - Update to use forked containerization with configurable concurrency **Performance**: ~1.2-1.3x faster pulls for multi-layer images with higher concurrency **Tests**: Included standalone tests verify concurrency behavior and parameter flow --------- Co-authored-by: Claude --- .github/dependabot.yml | 12 + .github/workflows/common.yml | 10 +- .github/workflows/pr-label-analysis.yml | 2 +- .github/workflows/pr-label-apply.yml | 6 +- .github/workflows/release-build.yml | 2 +- .github/workflows/release.yml | 2 +- .spi.yml | 7 +- .swift-format-nolint | 68 ++++ BUILDING.md | 27 +- CONTRIBUTORS.txt | 16 - MAINTAINERS.txt | 3 + Makefile | 34 +- Package.resolved | 30 +- Package.swift | 249 ++++++++------ Protobuf.Makefile | 2 +- README.md | 8 +- Sources/CAuditToken/AuditToken.c | 2 +- Sources/CAuditToken/include/AuditToken.h | 2 +- Sources/CLI/ContainerCLI.swift | 4 +- Sources/CVersion/Version.c | 2 +- Sources/CVersion/include/Version.h | 2 +- .../ContainerBuild/BuildAPI+Extensions.swift | 2 +- Sources/ContainerBuild/BuildFSSync.swift | 4 +- Sources/ContainerBuild/BuildFile.swift | 2 +- .../ContainerBuild/BuildImageResolver.swift | 4 +- .../ContainerBuild/BuildPipelineHandler.swift | 4 +- .../BuildRemoteContentProxy.swift | 4 +- Sources/ContainerBuild/BuildStdio.swift | 2 +- Sources/ContainerBuild/Builder.grpc.swift | 2 +- Sources/ContainerBuild/Builder.pb.swift | 2 +- Sources/ContainerBuild/Builder.swift | 4 +- Sources/ContainerBuild/Globber.swift | 2 +- Sources/ContainerBuild/TerminalCommand.swift | 2 +- Sources/ContainerBuild/URL+Extensions.swift | 8 +- Sources/ContainerCommands/Application.swift | 11 +- Sources/ContainerCommands/BuildCommand.swift | 13 +- .../ContainerCommands/Builder/Builder.swift | 2 +- .../Builder/BuilderDelete.swift | 4 +- .../Builder/BuilderStart.swift | 37 ++- .../Builder/BuilderStatus.swift | 6 +- .../Builder/BuilderStop.swift | 4 +- Sources/ContainerCommands/Codable+JSON.swift | 2 +- .../Container/ContainerCreate.swift | 9 +- .../Container/ContainerDelete.swift | 4 +- .../Container/ContainerExec.swift | 4 +- .../Container/ContainerInspect.swift | 4 +- .../Container/ContainerKill.swift | 4 +- .../Container/ContainerList.swift | 13 +- .../Container/ContainerLogs.swift | 4 +- .../Container/ContainerRun.swift | 9 +- .../Container/ContainerStart.swift | 11 +- .../Container/ContainerStats.swift | 71 ++-- .../Container/ContainerStop.swift | 5 +- .../Container/ProcessUtils.swift | 4 +- .../ContainerCommands/DefaultCommand.swift | 8 +- .../Image/ImageCommand.swift | 2 +- .../ContainerCommands/Image/ImageDelete.swift | 4 +- .../Image/ImageInspect.swift | 4 +- .../ContainerCommands/Image/ImageList.swift | 4 +- .../ContainerCommands/Image/ImageLoad.swift | 18 +- .../ContainerCommands/Image/ImagePrune.swift | 46 +-- .../ContainerCommands/Image/ImagePull.swift | 10 +- .../ContainerCommands/Image/ImagePush.swift | 4 +- .../ContainerCommands/Image/ImageSave.swift | 5 +- .../ContainerCommands/Image/ImageTag.swift | 4 +- .../Network/NetworkCommand.swift | 3 +- .../Network/NetworkCreate.swift | 30 +- .../Network/NetworkDelete.swift | 6 +- .../Network/NetworkInspect.swift | 5 +- .../Network/NetworkList.swift | 8 +- .../Network/NetworkPrune.swift | 66 ++++ .../ContainerCommands/Registry/Login.swift | 4 +- .../ContainerCommands/Registry/Logout.swift | 4 +- .../Registry/RegistryCommand.swift | 2 +- .../System/DNS/DNSCreate.swift | 4 +- .../System/DNS/DNSDelete.swift | 4 +- .../System/DNS/DNSList.swift | 4 +- .../System/Kernel/KernelSet.swift | 10 +- .../System/Property/PropertyClear.swift | 4 +- .../System/Property/PropertyGet.swift | 4 +- .../System/Property/PropertyList.swift | 4 +- .../System/Property/PropertySet.swift | 11 +- .../System/SystemCommand.swift | 3 +- .../ContainerCommands/System/SystemDF.swift | 6 +- .../ContainerCommands/System/SystemDNS.swift | 2 +- .../System/SystemKernel.swift | 2 +- .../ContainerCommands/System/SystemLogs.swift | 4 +- .../System/SystemProperty.swift | 2 +- .../System/SystemStart.swift | 4 +- .../System/SystemStatus.swift | 4 +- .../ContainerCommands/System/SystemStop.swift | 5 +- .../ContainerCommands/System/Version.swift | 89 +++++ .../Volume/VolumeCommand.swift | 2 +- .../Volume/VolumeCreate.swift | 4 +- .../Volume/VolumeDelete.swift | 5 +- .../Volume/VolumeInspect.swift | 5 +- .../ContainerCommands/Volume/VolumeList.swift | 5 +- .../Volume/VolumePrune.swift | 50 ++- Sources/ContainerLog/OSLogHandler.swift | 2 +- Sources/ContainerLog/StderrLogHandler.swift | 2 +- .../ContainerPersistence/DefaultsStore.swift | 14 +- .../ContainerPersistence/EntityStore.swift | 2 +- Sources/ContainerPlugin/ApplicationRoot.swift | 2 +- Sources/ContainerPlugin/InstallRoot.swift | 2 +- Sources/ContainerPlugin/LaunchPlist.swift | 2 +- Sources/ContainerPlugin/Plugin.swift | 2 +- Sources/ContainerPlugin/PluginConfig.swift | 2 +- Sources/ContainerPlugin/PluginFactory.swift | 2 +- Sources/ContainerPlugin/PluginLoader.swift | 10 +- Sources/ContainerPlugin/ServiceManager.swift | 6 +- .../Container}/Bundle.swift | 10 +- .../Container}/ContainerConfiguration.swift | 17 +- .../Container}/ContainerCreateOptions.swift | 2 +- .../Container}/ContainerSnapshot.swift | 10 +- .../Container}/ContainerStats.swift | 34 +- .../Container}/ContainerStopOptions.swift | 2 +- .../Container}/Filesystem.swift | 12 +- .../Container}/ProcessConfiguration.swift | 2 +- .../Container}/PublishPort.swift | 26 +- .../Container}/PublishSocket.swift | 2 +- .../Container}/RuntimeStatus.swift | 2 +- .../Image}/ImageDescription.swift | 2 +- .../Image}/ImageDetail.swift | 30 +- .../Network}/Attachment.swift | 29 +- .../Network}/AttachmentConfiguration.swift | 8 +- .../Network}/NetworkConfiguration.swift | 43 ++- .../Network}/NetworkMode.swift | 2 +- .../Network}/NetworkState.swift | 24 +- .../Volume}/Volume.swift | 14 +- .../ContainerVersion/Bundle+AppBundle.swift | 2 +- .../CommandLine+Executable.swift | 4 +- Sources/ContainerVersion/ReleaseVersion.swift | 15 +- Sources/ContainerXPC/XPCClient.swift | 2 +- Sources/ContainerXPC/XPCMessage.swift | 2 +- Sources/ContainerXPC/XPCServer.swift | 8 +- Sources/DNSServer/DNSHandler.swift | 2 +- Sources/DNSServer/DNSServer+Handle.swift | 2 +- Sources/DNSServer/DNSServer.swift | 2 +- .../Handlers/CompositeResolver.swift | 2 +- .../Handlers/HostTableResolver.swift | 2 +- .../DNSServer/Handlers/NxDomainResolver.swift | 2 +- .../Handlers/StandardQueryValidator.swift | 2 +- Sources/DNSServer/Types.swift | 2 +- .../Helpers/APIServer/APIServer+Start.swift | 7 +- Sources/Helpers/APIServer/APIServer.swift | 2 +- .../APIServer/ContainerDNSHandler.swift | 41 ++- Sources/Helpers/Images/ImagesHelper.swift | 2 +- .../NetworkVmnetHelper+Start.swift | 21 +- .../NetworkVmnet/NetworkVmnetHelper.swift | 2 +- .../IsolatedInterfaceStrategy.swift | 8 +- .../NonisolatedInterfaceStrategy.swift | 8 +- .../RuntimeLinuxHelper+Start.swift | 5 +- .../RuntimeLinux/RuntimeLinuxHelper.swift | 2 +- .../ContainerAPIService/Client}/Arch.swift | 13 +- .../Client}/Archiver.swift | 2 +- .../Client}/Array+Dedupe.swift | 2 +- .../Client}/ClientContainer.swift | 9 +- .../Client}/ClientDiskUsage.swift | 4 +- .../Client}/ClientHealthCheck.swift | 17 +- .../Client}/ClientImage.swift | 71 +++- .../Client}/ClientKernel.swift | 6 +- .../Client}/ClientNetwork.swift | 4 +- .../Client}/ClientProcess.swift | 3 +- .../Client}/ClientVolume.swift | 18 +- .../Client}/Constants.swift | 2 +- .../ContainerizationProgressAdapter.swift | 2 +- .../Client}/DiskUsage.swift | 2 +- .../Client}/FileDownloader.swift | 2 +- .../ContainerAPIService/Client}/Flags.swift | 14 +- .../Client}/HostDNSResolver.swift | 2 +- .../Client/ImageLoadResult.swift | 25 ++ .../Client}/Measurement+Parse.swift | 2 +- .../ContainerAPIService/Client}/Parser.swift | 89 ++--- .../Client}/ProcessIO.swift | 13 +- .../Client}/ProgressUpdateClient.swift | 2 +- .../Client}/ProgressUpdateService.swift | 2 +- .../Client}/RequestScheme.swift | 2 +- .../Client}/SignalThreshold.swift | 2 +- .../Client}/String+Extensions.swift | 2 +- .../Client}/SystemHealth.swift | 8 +- .../Client}/TableOutput.swift | 2 +- .../ContainerAPIService/Client}/Utility.swift | 36 +-- .../ContainerAPIService/Client}/XPC+.swift | 8 +- .../Containers/ContainersHarness.swift | 4 +- .../Containers/ContainersService.swift | 93 +++--- .../DiskUsage/DiskUsageHarness.swift | 4 +- .../DiskUsage/DiskUsageService.swift | 4 +- .../HealthCheck/HealthCheckHarness.swift | 7 +- .../{ => Server}/Kernel/KernelHarness.swift | 4 +- .../{ => Server}/Kernel/KernelService.swift | 6 +- .../Networks/NetworksHarness.swift | 4 +- .../Networks/NetworksService.swift | 44 ++- .../{ => Server}/Plugin/PluginsHarness.swift | 2 +- .../{ => Server}/Plugin/PluginsService.swift | 2 +- .../{ => Server}/Volumes/VolumesHarness.swift | 13 +- .../{ => Server}/Volumes/VolumesService.swift | 58 +--- .../Client/ImageServiceXPCKeys.swift | 5 +- .../Client/ImageServiceXPCRoutes.swift | 2 +- .../Client/RemoteContentStoreClient.swift | 2 +- .../Server/ContentServiceHarness.swift | 2 +- .../Server/ContentStoreService.swift | 2 +- .../Server/ImageService.swift | 30 +- .../Server/ImagesServiceHarness.swift | 21 +- .../Server/SnapshotStore.swift | 7 +- .../{ => Client}/NetworkClient.swift | 21 +- .../{ => Client}/NetworkKeys.swift | 2 +- .../{ => Client}/NetworkRoutes.swift | 2 +- .../AllocationOnlyVmnetNetwork.swift | 18 +- .../{ => Server}/AttachmentAllocator.swift | 12 +- .../{ => Server}/Network.swift | 3 +- .../{ => Server}/NetworkService.swift | 52 ++- .../{ => Server}/ReservedVmnetNetwork.swift | 81 +++-- .../Client/Bundle+Log.swift | 25 ++ .../Client}/ExitMonitor.swift | 2 +- .../Client}/SandboxClient.swift | 47 +-- .../Client/SandboxKeys.swift | 45 +++ .../Client}/SandboxRoutes.swift | 2 +- .../Client}/SandboxSnapshot.swift | 4 +- .../{ => Server}/InterfaceStrategy.swift | 4 +- .../{ => Server}/SandboxService.swift | 202 ++++++------ Sources/SocketForwarder/ConnectHandler.swift | 7 +- Sources/SocketForwarder/GlueHandler.swift | 2 +- Sources/SocketForwarder/LRUCache.swift | 2 +- Sources/SocketForwarder/SocketForwarder.swift | 2 +- .../SocketForwarderResult.swift | 2 +- Sources/SocketForwarder/TCPForwarder.swift | 2 +- Sources/SocketForwarder/UDPForwarder.swift | 2 +- Sources/TerminalProgress/Int+Formatted.swift | 2 +- .../TerminalProgress/Int64+Formatted.swift | 2 +- .../TerminalProgress/ProgressBar+Add.swift | 2 +- .../TerminalProgress/ProgressBar+State.swift | 10 +- .../ProgressBar+Terminal.swift | 46 +-- Sources/TerminalProgress/ProgressBar.swift | 271 ++++++++++------ Sources/TerminalProgress/ProgressConfig.swift | 4 +- .../ProgressTaskCoordinator.swift | 2 +- Sources/TerminalProgress/ProgressTheme.swift | 2 +- Sources/TerminalProgress/ProgressUpdate.swift | 2 +- Sources/TerminalProgress/StandardError.swift | 2 +- .../Subcommands/Build/CLIBuildBase.swift | 2 +- .../Build/CLIBuilderEnvOnlyTest.swift | 2 +- .../Build/CLIBuilderLifecycleTest.swift | 49 ++- .../Build/CLIBuilderLocalOutputTest.swift | 2 +- .../Build/CLIBuilderTarExportTest.swift | 2 +- .../Subcommands/Build/CLIBuilderTest.swift | 2 +- .../Subcommands/Build/CLIRunBase.swift | 2 +- .../Subcommands/Build/TestCLITermIO.swift | 2 +- .../Containers/TestCLICreate.swift | 35 +- .../Subcommands/Containers/TestCLIExec.swift | 2 +- .../Containers/TestCLIRmRace.swift | 2 +- .../Subcommands/Containers/TestCLIStats.swift | 10 +- ...mages.swift => TestCLIImagesCommand.swift} | 255 +++++++++++---- .../Subcommands/Networks/TestCLINetwork.swift | 6 +- .../Plugins/TestCLIPluginErrors.swift | 2 +- ...nOptions.swift => TestCLIRunCommand.swift} | 202 +++++++++++- .../Subcommands/Run/TestCLIRunLifecycle.swift | 2 +- .../Subcommands/System/TestCLIVersion.swift | 102 ++++++ .../Subcommands/System/TestKernelSet.swift | 8 +- .../Volumes/TestCLIAnonymousVolumes.swift | 4 +- .../Subcommands/Volumes/TestCLIVolumes.swift | 6 +- Tests/CLITests/TestCLINoParallelCases.swift | 306 ++++++++++++++++++ Tests/CLITests/Utilities/CLITest.swift | 79 ++++- Tests/ContainerAPIClientTests/ArchTests.swift | 72 +++++ .../DiskUsageTests.swift | 4 +- .../HostDNSResolverTest.swift | 4 +- .../Measurement+ParseTests.swift | 4 +- .../ParserTest.swift | 262 +++++++++++++-- .../RequestSchemeTests.swift | 4 +- .../UtilityTests.swift | 44 ++- Tests/ContainerBuildTests/BuildFile.swift | 2 +- .../BuilderExtensionsTests.swift | 2 +- Tests/ContainerBuildTests/GlobberTests.swift | 2 +- .../AttachmentAllocatorTest.swift | 207 ++++++++++++ .../CommandLine+ExecutableTest.swift | 2 +- .../MockPluginFactory.swift | 2 +- .../PluginConfigTest.swift | 2 +- .../PluginFactoryTest.swift | 2 +- .../PluginLoaderTest.swift | 2 +- Tests/ContainerPluginTests/PluginTest.swift | 2 +- .../NetworkConfigurationTest.swift | 36 +-- .../PublishPortTests.swift | 52 +++ .../VolumeValidationTests.swift | 4 +- .../CompositeResolverTest.swift | 2 +- .../HostTableResolverTest.swift | 2 +- Tests/DNSServerTests/MockHandlers.swift | 2 +- .../DNSServerTests/NxDomainResolverTest.swift | 2 +- .../StandardQueryValidatorTest.swift | 2 +- .../ConnectHandlerRaceTest.swift | 66 ++++ Tests/SocketForwarderTests/LRUCacheTest.swift | 2 +- .../SocketForwarderTests/TCPEchoHandler.swift | 2 +- .../SocketForwarderTests/TCPEchoServer.swift | 2 +- .../TCPForwarderTest.swift | 2 +- .../SocketForwarderTests/UDPEchoHandler.swift | 2 +- .../SocketForwarderTests/UDPEchoServer.swift | 2 +- .../UDPForwarderTest.swift | 2 +- .../ProgressBarTests.swift | 2 +- docs/command-reference.md | 70 +++- docs/how-to.md | 108 ++++--- docs/tutorial.md | 10 +- scripts/ensure-container-stopped.sh | 2 +- scripts/ensure-hawkeye-exists.sh | 2 +- scripts/install-hawkeye.sh | 2 +- scripts/install-init.sh | 2 +- scripts/make-docs.sh | 9 +- scripts/pre-commit.fmt | 16 + scripts/uninstall-container.sh | 2 +- 305 files changed, 3926 insertions(+), 1445 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .swift-format-nolint delete mode 100644 CONTRIBUTORS.txt create mode 100644 MAINTAINERS.txt create mode 100644 Sources/ContainerCommands/Network/NetworkPrune.swift create mode 100644 Sources/ContainerCommands/System/Version.swift rename Sources/{ContainerClient/Core => ContainerResource/Container}/Bundle.swift (93%) rename Sources/{ContainerClient/Core => ContainerResource/Container}/ContainerConfiguration.swift (88%) rename Sources/{ContainerClient/Core => ContainerResource/Container}/ContainerCreateOptions.swift (93%) rename Sources/{ContainerClient/Core => ContainerResource/Container}/ContainerSnapshot.swift (84%) rename Sources/{ContainerClient/Core => ContainerResource/Container}/ContainerStats.swift (72%) rename Sources/{ContainerClient/Core => ContainerResource/Container}/ContainerStopOptions.swift (94%) rename Sources/{ContainerClient/Core => ContainerResource/Container}/Filesystem.swift (93%) rename Sources/{ContainerClient/Core => ContainerResource/Container}/ProcessConfiguration.swift (98%) rename Sources/{ContainerClient/Core => ContainerResource/Container}/PublishPort.swift (74%) rename Sources/{ContainerClient/Core => ContainerResource/Container}/PublishSocket.swift (95%) rename Sources/{ContainerClient/Core => ContainerResource/Container}/RuntimeStatus.swift (94%) rename Sources/{ContainerClient/Core => ContainerResource/Image}/ImageDescription.swift (95%) rename Sources/{ContainerClient/Core => ContainerResource/Image}/ImageDetail.swift (51%) rename Sources/{Services/ContainerNetworkService => ContainerResource/Network}/Attachment.swift (58%) rename Sources/{Services/ContainerNetworkService => ContainerResource/Network}/AttachmentConfiguration.swift (88%) rename Sources/{Services/ContainerNetworkService => ContainerResource/Network}/NetworkConfiguration.swift (74%) rename Sources/{Services/ContainerNetworkService => ContainerResource/Network}/NetworkMode.swift (95%) rename Sources/{Services/ContainerNetworkService => ContainerResource/Network}/NetworkState.swift (73%) rename Sources/{ContainerClient/Core => ContainerResource/Volume}/Volume.swift (89%) rename Sources/{ContainerClient => Services/ContainerAPIService/Client}/Arch.swift (74%) rename Sources/{ContainerClient => Services/ContainerAPIService/Client}/Archiver.swift (99%) rename Sources/{ContainerClient => Services/ContainerAPIService/Client}/Array+Dedupe.swift (93%) rename Sources/{ContainerClient/Core => Services/ContainerAPIService/Client}/ClientContainer.swift (97%) rename Sources/{ContainerClient/Core => Services/ContainerAPIService/Client}/ClientDiskUsage.swift (92%) rename Sources/{ContainerClient/Core => Services/ContainerAPIService/Client}/ClientHealthCheck.swift (74%) rename Sources/{ContainerClient/Core => Services/ContainerAPIService/Client}/ClientImage.swift (85%) rename Sources/{ContainerClient/Core => Services/ContainerAPIService/Client}/ClientKernel.swift (93%) rename Sources/{ContainerClient/Core => Services/ContainerAPIService/Client}/ClientNetwork.swift (97%) rename Sources/{ContainerClient/Core => Services/ContainerAPIService/Client}/ClientProcess.swift (97%) rename Sources/{ContainerClient/Core => Services/ContainerAPIService/Client}/ClientVolume.swift (86%) rename Sources/{ContainerClient/Core => Services/ContainerAPIService/Client}/Constants.swift (92%) rename Sources/{ContainerClient => Services/ContainerAPIService/Client}/ContainerizationProgressAdapter.swift (96%) rename Sources/{ContainerClient/Core => Services/ContainerAPIService/Client}/DiskUsage.swift (96%) rename Sources/{ContainerClient => Services/ContainerAPIService/Client}/FileDownloader.swift (97%) rename Sources/{ContainerClient => Services/ContainerAPIService/Client}/Flags.swift (94%) rename Sources/{ContainerClient => Services/ContainerAPIService/Client}/HostDNSResolver.swift (98%) create mode 100644 Sources/Services/ContainerAPIService/Client/ImageLoadResult.swift rename Sources/{ContainerClient => Services/ContainerAPIService/Client}/Measurement+Parse.swift (97%) rename Sources/{ContainerClient => Services/ContainerAPIService/Client}/Parser.swift (93%) rename Sources/{ContainerClient => Services/ContainerAPIService/Client}/ProcessIO.swift (97%) rename Sources/{ContainerClient => Services/ContainerAPIService/Client}/ProgressUpdateClient.swift (99%) rename Sources/{ContainerClient => Services/ContainerAPIService/Client}/ProgressUpdateService.swift (98%) rename Sources/{ContainerClient => Services/ContainerAPIService/Client}/RequestScheme.swift (97%) rename Sources/{ContainerClient => Services/ContainerAPIService/Client}/SignalThreshold.swift (97%) rename Sources/{ContainerClient => Services/ContainerAPIService/Client}/String+Extensions.swift (96%) rename Sources/{ContainerClient/Core => Services/ContainerAPIService/Client}/SystemHealth.swift (82%) rename Sources/{ContainerClient => Services/ContainerAPIService/Client}/TableOutput.swift (97%) rename Sources/{ContainerClient => Services/ContainerAPIService/Client}/Utility.swift (93%) rename Sources/{ContainerClient/Core => Services/ContainerAPIService/Client}/XPC+.swift (97%) rename Sources/Services/ContainerAPIService/{ => Server}/Containers/ContainersHarness.swift (99%) rename Sources/Services/ContainerAPIService/{ => Server}/Containers/ContainersService.swift (90%) rename Sources/Services/ContainerAPIService/{ => Server}/DiskUsage/DiskUsageHarness.swift (94%) rename Sources/Services/ContainerAPIService/{ => Server}/DiskUsage/DiskUsageService.swift (97%) rename Sources/Services/ContainerAPIService/{ => Server}/HealthCheck/HealthCheckHarness.swift (84%) rename Sources/Services/ContainerAPIService/{ => Server}/Kernel/KernelHarness.swift (97%) rename Sources/Services/ContainerAPIService/{ => Server}/Kernel/KernelService.swift (97%) rename Sources/Services/ContainerAPIService/{ => Server}/Networks/NetworksHarness.swift (96%) rename Sources/Services/ContainerAPIService/{ => Server}/Networks/NetworksService.swift (87%) rename Sources/Services/ContainerAPIService/{ => Server}/Plugin/PluginsHarness.swift (97%) rename Sources/Services/ContainerAPIService/{ => Server}/Plugin/PluginsService.swift (98%) rename Sources/Services/ContainerAPIService/{ => Server}/Volumes/VolumesHarness.swift (89%) rename Sources/Services/ContainerAPIService/{ => Server}/Volumes/VolumesService.swift (81%) rename Sources/Services/ContainerNetworkService/{ => Client}/NetworkClient.swift (88%) rename Sources/Services/ContainerNetworkService/{ => Client}/NetworkKeys.swift (93%) rename Sources/Services/ContainerNetworkService/{ => Client}/NetworkRoutes.swift (95%) rename Sources/Services/ContainerNetworkService/{ => Server}/AllocationOnlyVmnetNetwork.swift (83%) rename Sources/Services/ContainerNetworkService/{ => Server}/AttachmentAllocator.swift (86%) rename Sources/Services/ContainerNetworkService/{ => Server}/Network.swift (92%) rename Sources/Services/ContainerNetworkService/{ => Server}/NetworkService.swift (73%) rename Sources/Services/ContainerNetworkService/{ => Server}/ReservedVmnetNetwork.swift (63%) create mode 100644 Sources/Services/ContainerSandboxService/Client/Bundle+Log.swift rename Sources/{ContainerClient => Services/ContainerSandboxService/Client}/ExitMonitor.swift (98%) rename Sources/{ContainerClient => Services/ContainerSandboxService/Client}/SandboxClient.swift (85%) create mode 100644 Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift rename Sources/{ContainerClient => Services/ContainerSandboxService/Client}/SandboxRoutes.swift (97%) rename Sources/{ContainerClient => Services/ContainerSandboxService/Client}/SandboxSnapshot.swift (93%) rename Sources/Services/ContainerSandboxService/{ => Server}/InterfaceStrategy.swift (94%) rename Sources/Services/ContainerSandboxService/{ => Server}/SandboxService.swift (87%) rename Tests/CLITests/Subcommands/Images/{TestCLIImages.swift => TestCLIImagesCommand.swift} (63%) rename Tests/CLITests/Subcommands/Run/{TestCLIRunOptions.swift => TestCLIRunCommand.swift} (77%) create mode 100644 Tests/CLITests/Subcommands/System/TestCLIVersion.swift create mode 100644 Tests/CLITests/TestCLINoParallelCases.swift create mode 100644 Tests/ContainerAPIClientTests/ArchTests.swift rename Tests/{ContainerClientTests => ContainerAPIClientTests}/DiskUsageTests.swift (97%) rename Tests/{ContainerClientTests => ContainerAPIClientTests}/HostDNSResolverTest.swift (97%) rename Tests/{ContainerClientTests => ContainerAPIClientTests}/Measurement+ParseTests.swift (98%) rename Tests/{ContainerClientTests => ContainerAPIClientTests}/ParserTest.swift (69%) rename Tests/{ContainerClientTests => ContainerAPIClientTests}/RequestSchemeTests.swift (96%) rename Tests/{ContainerClientTests => ContainerAPIClientTests}/UtilityTests.swift (77%) create mode 100644 Tests/ContainerNetworkServiceTests/AttachmentAllocatorTest.swift rename Tests/{ContainerNetworkServiceTests => ContainerResourceTests}/NetworkConfigurationTest.swift (74%) create mode 100644 Tests/ContainerResourceTests/PublishPortTests.swift rename Tests/{ContainerClientTests => ContainerResourceTests}/VolumeValidationTests.swift (98%) create mode 100644 Tests/SocketForwarderTests/ConnectHandlerRaceTest.swift create mode 100755 scripts/pre-commit.fmt diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..b6b49dbac --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + github-actions: + patterns: + - "*" + commit-message: + prefix: "ci" diff --git a/.github/workflows/common.yml b/.github/workflows/common.yml index 10d030930..ab39ff490 100644 --- a/.github/workflows/common.yml +++ b/.github/workflows/common.yml @@ -22,7 +22,7 @@ jobs: packages: read steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 with: fetch-depth: 0 @@ -78,14 +78,14 @@ jobs: DEVELOPER_DIR: "/Applications/Xcode-latest.app/Contents/Developer" - name: Save documentation artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: api-docs path: "./_site.tgz" retention-days: 14 - name: Save package artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: container-package path: ${{ github.workspace }}/outputs @@ -102,7 +102,7 @@ jobs: uses: actions/configure-pages@v5 - name: Download a single artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: api-docs @@ -111,6 +111,6 @@ jobs: tar xfz _site.tgz - name: Upload Artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v4 with: path: "./_site" diff --git a/.github/workflows/pr-label-analysis.yml b/.github/workflows/pr-label-analysis.yml index e8b207389..cf889ebf3 100644 --- a/.github/workflows/pr-label-analysis.yml +++ b/.github/workflows/pr-label-analysis.yml @@ -19,7 +19,7 @@ jobs: echo "${{ github.event.pull_request.number }}" > ./pr-metadata/pr-number.txt - name: Upload PR metadata as artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: pr-metadata-${{ github.event.pull_request.number }} path: pr-metadata/ diff --git a/.github/workflows/pr-label-apply.yml b/.github/workflows/pr-label-apply.yml index a68f8bffc..350ef756e 100644 --- a/.github/workflows/pr-label-apply.yml +++ b/.github/workflows/pr-label-apply.yml @@ -20,10 +20,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - name: Download PR metadata artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id }} @@ -47,7 +47,7 @@ jobs: echo "PR Number: ${PR_NUMBER}" - name: Apply labels using labeler - uses: actions/labeler@v5 + uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6 with: pr-number: ${{ steps.pr-number.outputs.number }} repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 7aedc32c9..46f0f66ac 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -32,7 +32,7 @@ jobs: pages: write steps: - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: outputs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7aedc32c9..46f0f66ac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: pages: write steps: - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: outputs diff --git a/.spi.yml b/.spi.yml index 51909d5b0..491ffa597 100644 --- a/.spi.yml +++ b/.spi.yml @@ -3,10 +3,15 @@ builder: configs: - documentation_targets: + - ContainerAPIService + - ContainerAPIClient - ContainerSandboxService + - ContainerSandboxServiceClient - ContainerNetworkService + - ContainerNetworkServiceClient - ContainerImagesService - - ContainerClient + - ContainerImagesServiceClient + - ContainerResource - ContainerLog - ContainerPlugin - ContainerXPC diff --git a/.swift-format-nolint b/.swift-format-nolint new file mode 100644 index 000000000..8f600e50e --- /dev/null +++ b/.swift-format-nolint @@ -0,0 +1,68 @@ +{ + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "private" + }, + "indentation" : { + "spaces" : 4 + }, + "indentConditionalCompilationBlocks" : false, + "indentSwitchCaseLabels" : false, + "lineBreakAroundMultilineExpressionChainComponents" : false, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : false, + "lineBreakBeforeEachGenericRequirement" : false, + "lineLength" : 180, + "maximumBlankLines" : 1, + "multiElementCollectionTrailingCommas" : true, + "noAssignmentInExpressions" : { + "allowedFunctions" : [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether" : false, + "respectsExistingLineBreaks" : true, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : false, + "AlwaysUseLowerCamelCase" : false, + "AmbiguousTrailingClosureOverload" : false, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : false, + "FileScopedDeclarationPrivacy" : false, + "FullyIndirectEnum" : false, + "GroupNumericLiterals" : false, + "IdentifiersMustBeASCII" : false, + "NeverForceUnwrap" : false, + "NeverUseForceTry" : false, + "NeverUseImplicitlyUnwrappedOptionals" : false, + "NoAccessLevelOnExtensionDeclaration" : false, + "NoAssignmentInExpressions" : false, + "NoBlockComments" : false, + "NoCasesWithOnlyFallthrough" : false, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : false, + "NoLeadingUnderscores" : false, + "NoParensAroundConditions" : true, + "NoPlaygroundLiterals" : false, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : false, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : false, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : false, + "ReturnVoidInsteadOfEmptyTuple" : false, + "TypeNamesShouldBeCapitalized" : false, + "UseEarlyExits" : false, + "UseLetInEveryBoundCaseVariable" : false, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : false, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : false, + "ValidateDocumentationComments" : false + }, + "spacesAroundRangeFormationOperators" : false, + "tabWidth" : 2, + "version" : 1 +} diff --git a/BUILDING.md b/BUILDING.md index b90a35a2c..6f7da7939 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -71,13 +71,20 @@ to prepare your build environment. > ```swift > .package(path: "../containerization"), > ``` -5. Build `container`. + +5. If you want `container` to use any changes you made in the `vminit` subproject of Containerization, update the system property to use the locally built init filesystem image: + + ```bash + container system property set image.init vminit:latest + ``` + +6. Build `container`. ``` make clean all ``` -6. Restart the `container` services. +7. Restart the `container` services. ``` bin/container system stop @@ -86,22 +93,32 @@ to prepare your build environment. To revert to using the Containerization dependency from your `Package.swift`: -1. Use the Swift package manager to restore the normal `containerization` dependency and update your `Package.resolved` file. If you are using Xcode, revert your `Package.swift` change instead of using `swift package unedit`. +1. If you were using the local init filesystem, revert the system property to its default value: + + ```bash + container system property clear image.init + ``` + +2. Use the Swift package manager to restore the normal `containerization` dependency and update your `Package.resolved` file. If you are using Xcode, revert your `Package.swift` change instead of using `swift package unedit`. ```bash /usr/bin/swift package unedit containerization /usr/bin/swift package update containerization ``` -2. Rebuild `container`. +3. Rebuild `container`. ```bash make clean all ``` -3. Restart the `container` services. +4. Restart the `container` services. ```bash bin/container system stop bin/container system start ``` + +## Pre-commit hook + +Run `make pre-commit` to install a pre-commit hook that ensures that your changes have correct formatting and license headers when you run `git commit`. diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt deleted file mode 100644 index 1270c04d4..000000000 --- a/CONTRIBUTORS.txt +++ /dev/null @@ -1,16 +0,0 @@ -This file contains a list of contributors who have made meaningful changes to this repository. -Please add your name and GitHub handle to this file as an optional step in the contribution process for attribution. -Email is not required. - -### Contributors - -Aditya Ramani (adityaramani) -Danny Canter (dcantah) -Dmitry Kovba (dkovba) -Eric Ernst (egernst) -Evan Hazlett (ehazlett) -John Logan (jglogan) -Kathryn Baldauf (katiewasnothere) -Madhu Venugopal (mavenugo) -Michael Crosby (crosbymichael) -Sidhartha Mani (wlan0) diff --git a/MAINTAINERS.txt b/MAINTAINERS.txt new file mode 100644 index 000000000..76326cc62 --- /dev/null +++ b/MAINTAINERS.txt @@ -0,0 +1,3 @@ +# Maintainers + +See [MAINTAINERS](https://github.com/apple/containerization/blob/main/MAINTAINERS.txt) for the list of current and former maintainers of this project. Thank you for all your contributions! diff --git a/Makefile b/Makefile index a7c034ba7..241f9a6af 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -# Copyright © 2025 Apple Inc. and the container project authors. +# Copyright © 2025-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. @@ -57,6 +57,15 @@ build: @$(SWIFT) --version @$(SWIFT) build -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) +.PHONY: cli +cli: + @echo Building container CLI... + @$(SWIFT) --version + @$(SWIFT) build -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --product container + @echo Installing container CLI to bin/... + @mkdir -p bin + @install "$(BUILD_BIN_DIR)/container" "bin/container" + .PHONY: container # Install binaries under project directory container: build @@ -76,7 +85,7 @@ install: installer-pkg @if [ -z "$(SUDO)" ] ; then \ temp_dir=$$(mktemp -d) ; \ xar -xf $(PKG_PATH) -C $${temp_dir} ; \ - (cd $${temp_dir} && tar -xf Payload -C "$(DEST_DIR)") ; \ + (cd "$(DEST_DIR)" && pax -rz -f $${temp_dir}/Payload) ; \ rm -rf $${temp_dir} ; \ else \ $(SUDO) installer -pkg $(PKG_PATH) -target / ; \ @@ -175,7 +184,9 @@ integration: init-block $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunLifecycle || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIExecCommand || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLICreateCommand || exit_code=1 ; \ - $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunCommand || exit_code=1 ; \ + $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunCommand1 || exit_code=1 ; \ + $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunCommand2 || exit_code=1 ; \ + $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunCommand3 || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIStatsCommand || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIImagesCommand || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunBase || exit_code=1 ; \ @@ -183,6 +194,7 @@ integration: init-block $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIVolumes || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIKernelSet || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIAnonymousVolumes || exit_code=1 ; \ + $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLINoParallelCases || exit_code=1 ; \ echo Ensuring apiserver stopped after the CLI integration tests ; \ scripts/ensure-container-stopped.sh ; \ exit $${exit_code} ; \ @@ -191,12 +203,19 @@ integration: init-block .PHONY: fmt fmt: swift-fmt update-licenses +.PHONY: check +check: swift-fmt-check check-licenses + .PHONY: swift-fmt SWIFT_SRC = $(shell find . -type f -name '*.swift' -not -path "*/.*" -not -path "*.pb.swift" -not -path "*.grpc.swift" -not -path "*/checkouts/*") swift-fmt: @echo Applying the standard code formatting... @$(SWIFT) format --recursive --configuration .swift-format -i $(SWIFT_SRC) +swift-fmt-check: + @echo Applying the standard code formatting... + @$(SWIFT) format lint --recursive --strict --configuration .swift-format-nolint $(SWIFT_SRC) + .PHONY: update-licenses update-licenses: @echo Updating license headers... @@ -209,6 +228,15 @@ check-licenses: @./scripts/ensure-hawkeye-exists.sh @.local/bin/hawkeye check --fail-if-unknown +.PHONY: pre-commit +pre-commit: + cp Scripts/pre-commit.fmt .git/hooks + touch .git/hooks/pre-commit + cat .git/hooks/pre-commit | grep -v 'hooks/pre-commit\.fmt' > /tmp/pre-commit.new || true + echo 'PRECOMMIT_NOFMT=$${PRECOMMIT_NOFMT} $$(git rev-parse --show-toplevel)/.git/hooks/pre-commit.fmt' >> /tmp/pre-commit.new + mv /tmp/pre-commit.new .git/hooks/pre-commit + chmod +x .git/hooks/pre-commit + .PHONY: serve-docs serve-docs: @echo 'to browse: open http://127.0.0.1:8000/container/documentation/' diff --git a/Package.resolved b/Package.resolved index 20bc8bd27..f0dd7942d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6b3046dae5a56f593367112450130e1d5e5ce015afbad2a2299c865c1e5d10f7", + "originHash" : "404d8d0e91cd9206e8cbf751ae0a4a9d75d4e631f05045e0dbee345d0144772b", "pins" : [ { "identity" : "async-http-client", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/containerization.git", "state" : { - "revision" : "c45fef7278394e386d3fbb4c52e9e18613e049b7", - "version" : "0.16.0" + "revision" : "f570b8734ebd11727655bc68d4597bda1656365e", + "version" : "0.21.0" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms.git", "state" : { - "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", - "version" : "1.0.4" + "revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804", + "version" : "1.1.1" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-certificates.git", "state" : { - "revision" : "c059d9c9d08d6654b9a92dda93d9049a278964c6", - "version" : "1.12.0" + "revision" : "133a347911b6ad0fc8fe3bf46ca90c66cff97130", + "version" : "1.17.0" } }, { @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-structured-headers.git", "state" : { - "revision" : "1625f271afb04375bf48737a5572613248d0e7a0", - "version" : "1.4.0" + "revision" : "76d7627bd88b47bf5a0f8497dd244885960dde0b", + "version" : "1.6.0" } }, { @@ -150,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-types.git", "state" : { - "revision" : "a0a57e949a8903563aba4615869310c0ebf14c03", - "version" : "1.4.0" + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" } }, { @@ -195,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-ssl.git", "state" : { - "revision" : "385f5bd783ffbfff46b246a7db7be8e4f04c53bd", - "version" : "2.33.0" + "revision" : "173cc69a058623525a58ae6710e2f5727c663793", + "version" : "2.36.0" } }, { @@ -213,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-numerics.git", "state" : { - "revision" : "e0ec0f5f3af6f3e4d5e7a19d2af26b481acb6ba8", - "version" : "1.0.3" + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" } }, { diff --git a/Package.swift b/Package.swift index d5ab0821f..cc5d66027 100644 --- a/Package.swift +++ b/Package.swift @@ -1,6 +1,6 @@ // swift-tools-version: 6.2 //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -24,19 +24,20 @@ import PackageDescription let releaseVersion = ProcessInfo.processInfo.environment["RELEASE_VERSION"] ?? "0.0.0" let gitCommit = ProcessInfo.processInfo.environment["GIT_COMMIT"] ?? "unspecified" let builderShimVersion = "0.7.0" -let scVersion = "0.16.0" +let scVersion = "0.21.0" let package = Package( name: "container", platforms: [.macOS("15")], products: [ - .library(name: "ContainerAPIService", targets: ["ContainerAPIService"]), - .library(name: "ContainerSandboxService", targets: ["ContainerSandboxService"]), - .library(name: "ContainerNetworkService", targets: ["ContainerNetworkService"]), - .library(name: "ContainerImagesService", targets: ["ContainerImagesService", "ContainerImagesServiceClient"]), .library(name: "ContainerCommands", targets: ["ContainerCommands"]), - .library(name: "ContainerClient", targets: ["ContainerClient"]), .library(name: "ContainerBuild", targets: ["ContainerBuild"]), + .library(name: "ContainerAPIService", targets: ["ContainerAPIService"]), + .library(name: "ContainerAPIClient", targets: ["ContainerAPIClient"]), + .library(name: "ContainerImagesService", targets: ["ContainerImagesService", "ContainerImagesServiceClient"]), + .library(name: "ContainerNetworkService", targets: ["ContainerNetworkService", "ContainerNetworkServiceClient"]), + .library(name: "ContainerSandboxService", targets: ["ContainerSandboxService", "ContainerSandboxServiceClient"]), + .library(name: "ContainerResource", targets: ["ContainerResource"]), .library(name: "ContainerLog", targets: ["ContainerLog"]), .library(name: "ContainerPersistence", targets: ["ContainerPersistence"]), .library(name: "ContainerPlugin", targets: ["ContainerPlugin"]), @@ -63,11 +64,24 @@ let package = Package( name: "container", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), - "ContainerClient", + "ContainerAPIClient", "ContainerCommands", ], path: "Sources/CLI" ), + .testTarget( + name: "CLITests", + 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: "ContainerizationOS", package: "containerization"), + "ContainerBuild", + "ContainerResource", + ], + path: "Tests/CLITests" + ), .target( name: "ContainerCommands", dependencies: [ @@ -78,15 +92,35 @@ let package = Package( .product(name: "ContainerizationOCI", package: "containerization"), .product(name: "ContainerizationOS", package: "containerization"), "ContainerBuild", - "ContainerClient", + "ContainerAPIClient", "ContainerLog", + "ContainerNetworkService", "ContainerPersistence", "ContainerPlugin", + "ContainerResource", "ContainerVersion", "TerminalProgress", ], path: "Sources/ContainerCommands" ), + .target( + name: "ContainerBuild", + dependencies: [ + .product(name: "Logging", package: "swift-log"), + .product(name: "NIO", package: "swift-nio"), + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationArchive", package: "containerization"), + .product(name: "ContainerizationOCI", package: "containerization"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + "ContainerAPIClient", + ] + ), + .testTarget( + name: "ContainerBuildTests", + dependencies: [ + "ContainerBuild" + ] + ), .executableTarget( name: "container-apiserver", dependencies: [ @@ -98,11 +132,12 @@ let package = Package( .product(name: "GRPC", package: "grpc-swift"), .product(name: "Logging", package: "swift-log"), "ContainerAPIService", - "ContainerClient", + "ContainerAPIClient", "ContainerLog", "ContainerNetworkService", "ContainerPersistence", "ContainerPlugin", + "ContainerResource", "ContainerVersion", "ContainerXPC", "DNSServer", @@ -113,50 +148,93 @@ let package = Package( name: "ContainerAPIService", dependencies: [ .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationArchive", package: "containerization"), .product(name: "ContainerizationExtras", package: "containerization"), .product(name: "ContainerizationOS", package: "containerization"), .product(name: "Logging", package: "swift-log"), - "ContainerClient", - "ContainerNetworkService", + "CVersion", + "ContainerAPIClient", + "ContainerNetworkServiceClient", "ContainerPersistence", "ContainerPlugin", - "ContainerSandboxService", + "ContainerResource", + "ContainerSandboxServiceClient", "ContainerVersion", "ContainerXPC", - "CVersion", "TerminalProgress", ], - path: "Sources/Services/ContainerAPIService" + path: "Sources/Services/ContainerAPIService/Server" + ), + .target( + name: "ContainerAPIClient", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Logging", package: "swift-log"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationArchive", package: "containerization"), + .product(name: "ContainerizationOCI", package: "containerization"), + .product(name: "ContainerizationOS", package: "containerization"), + "ContainerImagesServiceClient", + "ContainerPersistence", + "ContainerPlugin", + "ContainerResource", + "ContainerXPC", + "TerminalProgress", + ], + path: "Sources/Services/ContainerAPIService/Client" + ), + .testTarget( + name: "ContainerAPIClientTests", + dependencies: [ + .product(name: "Containerization", package: "containerization"), + "ContainerAPIClient", + "ContainerPersistence", + ] ), .executableTarget( - name: "container-runtime-linux", + name: "container-core-images", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Logging", package: "swift-log"), - .product(name: "GRPC", package: "grpc-swift"), .product(name: "Containerization", package: "containerization"), + "ContainerImagesService", "ContainerLog", - "ContainerNetworkService", - "ContainerSandboxService", + "ContainerPlugin", "ContainerVersion", "ContainerXPC", ], - path: "Sources/Helpers/RuntimeLinux" + path: "Sources/Helpers/Images" ), .target( - name: "ContainerSandboxService", + name: "ContainerImagesService", dependencies: [ .product(name: "Logging", package: "swift-log"), .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationArchive", package: "containerization"), + .product(name: "ContainerizationExtras", package: "containerization"), + .product(name: "ContainerizationOCI", package: "containerization"), .product(name: "ContainerizationOS", package: "containerization"), - .product(name: "ArgumentParser", package: "swift-argument-parser"), - "ContainerClient", - "ContainerNetworkService", + "ContainerAPIClient", + "ContainerImagesServiceClient", + "ContainerLog", "ContainerPersistence", + "ContainerResource", "ContainerXPC", - "SocketForwarder", + "TerminalProgress", + ], + path: "Sources/Services/ContainerImagesService/Server" + ), + .target( + name: "ContainerImagesServiceClient", + dependencies: [ + .product(name: "Logging", package: "swift-log"), + .product(name: "Containerization", package: "containerization"), + "ContainerXPC", + "ContainerLog", ], - path: "Sources/Services/ContainerSandboxService" + path: "Sources/Services/ContainerImagesService/Client" ), .executableTarget( name: "container-network-vmnet", @@ -169,8 +247,12 @@ let package = Package( .product(name: "ContainerizationOS", package: "containerization"), "ContainerLog", "ContainerNetworkService", + "ContainerNetworkServiceClient", + "ContainerResource", "ContainerVersion", "ContainerXPC", + "TerminalProgress", + "HelperMacros", ], path: "Sources/Helpers/NetworkVmnet" ), @@ -180,96 +262,92 @@ let package = Package( .product(name: "Logging", package: "swift-log"), .product(name: "Containerization", package: "containerization"), .product(name: "ContainerizationOS", package: "containerization"), + "ContainerNetworkServiceClient", "ContainerPersistence", + "ContainerResource", "ContainerXPC", ], - path: "Sources/Services/ContainerNetworkService" + path: "Sources/Services/ContainerNetworkService/Server" ), .testTarget( name: "ContainerNetworkServiceTests", dependencies: [ - "ContainerNetworkService" + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationExtras", package: "containerization"), + "ContainerNetworkService", ] ), + .target( + name: "ContainerNetworkServiceClient", + dependencies: [ + .product(name: "Logging", package: "swift-log"), + .product(name: "Containerization", package: "containerization"), + "ContainerLog", + "ContainerResource", + "ContainerXPC", + ], + path: "Sources/Services/ContainerNetworkService/Client" + ), .executableTarget( - name: "container-core-images", + name: "container-runtime-linux", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Logging", package: "swift-log"), + .product(name: "GRPC", package: "grpc-swift"), .product(name: "Containerization", package: "containerization"), - "ContainerImagesService", "ContainerLog", - "ContainerPlugin", + "ContainerResource", + "ContainerSandboxService", + "ContainerSandboxServiceClient", "ContainerVersion", "ContainerXPC", ], - path: "Sources/Helpers/Images" + path: "Sources/Helpers/RuntimeLinux" ), .target( - name: "ContainerImagesService", + name: "ContainerSandboxService", dependencies: [ .product(name: "Logging", package: "swift-log"), .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationExtras", package: "containerization"), + .product(name: "ContainerizationOS", package: "containerization"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + "ContainerAPIClient", + "ContainerNetworkServiceClient", + "ContainerPersistence", + "ContainerResource", + "ContainerSandboxServiceClient", "ContainerXPC", - "ContainerLog", - "ContainerClient", - "ContainerImagesServiceClient", + "SocketForwarder", ], - path: "Sources/Services/ContainerImagesService/Server" + path: "Sources/Services/ContainerSandboxService/Server" ), .target( - name: "ContainerImagesServiceClient", + name: "ContainerSandboxServiceClient", dependencies: [ - .product(name: "Logging", package: "swift-log"), - .product(name: "Containerization", package: "containerization"), + "ContainerResource", "ContainerXPC", - "ContainerLog", ], - path: "Sources/Services/ContainerImagesService/Client" + path: "Sources/Services/ContainerSandboxService/Client" ), .target( - name: "ContainerBuild", + name: "ContainerResource", dependencies: [ - .product(name: "Logging", package: "swift-log"), - .product(name: "NIO", package: "swift-nio"), - .product(name: "Containerization", package: "containerization"), - .product(name: "ContainerizationArchive", package: "containerization"), - .product(name: "ContainerizationOCI", package: "containerization"), - .product(name: "ArgumentParser", package: "swift-argument-parser"), - "ContainerClient", + .product(name: "Containerization", package: "containerization") ] ), .testTarget( - name: "ContainerBuildTests", + name: "ContainerResourceTests", dependencies: [ - "ContainerBuild" - ] - ), - .target( - name: "ContainerClient", - dependencies: [ - .product(name: "Logging", package: "swift-log"), - .product(name: "NIOCore", package: "swift-nio"), - .product(name: "NIOPosix", package: "swift-nio"), .product(name: "Containerization", package: "containerization"), - .product(name: "ContainerizationOCI", package: "containerization"), - .product(name: "ContainerizationOS", package: "containerization"), - .product(name: "ContainerizationArchive", package: "containerization"), - .product(name: "ArgumentParser", package: "swift-argument-parser"), - "ContainerImagesServiceClient", - "ContainerNetworkService", - "ContainerPlugin", - "ContainerXPC", - "TerminalProgress", - "HelperMacros", + .product(name: "ContainerizationExtras", package: "containerization"), + "ContainerResource", ] ), - .testTarget( - name: "ContainerClientTests", + .target( + name: "ContainerLog", dependencies: [ - .product(name: "Containerization", package: "containerization"), - "ContainerClient", - "ContainerPersistence", + .product(name: "Logging", package: "swift-log") ] ), .target( @@ -277,8 +355,8 @@ let package = Package( dependencies: [ .product(name: "Logging", package: "swift-log"), .product(name: "Containerization", package: "containerization"), - "ContainerVersion", "CVersion", + "ContainerVersion", ] ), .target( @@ -295,12 +373,6 @@ let package = Package( "ContainerPlugin" ] ), - .target( - name: "ContainerLog", - dependencies: [ - .product(name: "Logging", package: "swift-log") - ] - ), .target( name: "ContainerXPC", dependencies: [ @@ -349,19 +421,6 @@ let package = Package( name: "SocketForwarderTests", dependencies: ["SocketForwarder"] ), - .testTarget( - name: "CLITests", - dependencies: [ - .product(name: "AsyncHTTPClient", package: "async-http-client"), - .product(name: "Containerization", package: "containerization"), - .product(name: "ContainerizationExtras", package: "containerization"), - .product(name: "ContainerizationOS", package: "containerization"), - "ContainerBuild", - "ContainerClient", - "ContainerNetworkService", - ], - path: "Tests/CLITests" - ), .target( name: "ContainerVersion", dependencies: [ diff --git a/Protobuf.Makefile b/Protobuf.Makefile index cdff269c0..2ae284d8b 100644 --- a/Protobuf.Makefile +++ b/Protobuf.Makefile @@ -1,4 +1,4 @@ -# Copyright © 2025 Apple Inc. and the container project authors. +# Copyright © 2025-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. diff --git a/README.md b/README.md index b6ce1a3d1..3ef5c4e6f 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ If you're upgrading, first stop and uninstall your existing `container` (the `-k ```bash container system stop -uninstall-container.sh -k +/usr/local/bin/uninstall-container.sh -k ``` Download the latest signed installer package for `container` from the [GitHub release page](https://github.com/apple/container/releases). @@ -37,16 +37,16 @@ container system start ### Uninstall -Use the `uninstall-container.sh` script to remove `container` from your system. To remove your user data along with the tool, run: +Use the `uninstall-container.sh` script (installed to `/usr/local/bin`) to remove `container` from your system. To remove your user data along with the tool, run: ```bash -uninstall-container.sh -d +/usr/local/bin/uninstall-container.sh -d ``` To retain your user data so that it is available should you reinstall later, run: ```bash -uninstall-container.sh -k +/usr/local/bin/uninstall-container.sh -k ``` ## Next steps diff --git a/Sources/CAuditToken/AuditToken.c b/Sources/CAuditToken/AuditToken.c index 70b191b41..4820b9e88 100644 --- a/Sources/CAuditToken/AuditToken.c +++ b/Sources/CAuditToken/AuditToken.c @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/CAuditToken/include/AuditToken.h b/Sources/CAuditToken/include/AuditToken.h index 802b828dc..378d37980 100644 --- a/Sources/CAuditToken/include/AuditToken.h +++ b/Sources/CAuditToken/include/AuditToken.h @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/CLI/ContainerCLI.swift b/Sources/CLI/ContainerCLI.swift index d838500df..9646d9d69 100644 --- a/Sources/CLI/ContainerCLI.swift +++ b/Sources/CLI/ContainerCLI.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerCommands @main diff --git a/Sources/CVersion/Version.c b/Sources/CVersion/Version.c index 9209bfbe6..42694b86b 100644 --- a/Sources/CVersion/Version.c +++ b/Sources/CVersion/Version.c @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/CVersion/include/Version.h b/Sources/CVersion/include/Version.h index f265a202e..e8f1e7119 100644 --- a/Sources/CVersion/include/Version.h +++ b/Sources/CVersion/include/Version.h @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerBuild/BuildAPI+Extensions.swift b/Sources/ContainerBuild/BuildAPI+Extensions.swift index 7e9e3be0e..e90a23832 100644 --- a/Sources/ContainerBuild/BuildAPI+Extensions.swift +++ b/Sources/ContainerBuild/BuildAPI+Extensions.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerBuild/BuildFSSync.swift b/Sources/ContainerBuild/BuildFSSync.swift index c4cfb352e..c26f8597d 100644 --- a/Sources/ContainerBuild/BuildFSSync.swift +++ b/Sources/ContainerBuild/BuildFSSync.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import Collections -import ContainerClient +import ContainerAPIClient import ContainerizationArchive import ContainerizationOCI import Foundation diff --git a/Sources/ContainerBuild/BuildFile.swift b/Sources/ContainerBuild/BuildFile.swift index cfe1a24eb..98f704c9f 100644 --- a/Sources/ContainerBuild/BuildFile.swift +++ b/Sources/ContainerBuild/BuildFile.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerBuild/BuildImageResolver.swift b/Sources/ContainerBuild/BuildImageResolver.swift index f0d45ee13..cec4af61c 100644 --- a/Sources/ContainerBuild/BuildImageResolver.swift +++ b/Sources/ContainerBuild/BuildImageResolver.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -14,7 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerClient +import ContainerAPIClient import Containerization import ContainerizationOCI import Foundation diff --git a/Sources/ContainerBuild/BuildPipelineHandler.swift b/Sources/ContainerBuild/BuildPipelineHandler.swift index 525087733..6e91a4723 100644 --- a/Sources/ContainerBuild/BuildPipelineHandler.swift +++ b/Sources/ContainerBuild/BuildPipelineHandler.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -105,7 +105,7 @@ public actor BuildPipeline { throw NSError( domain: "untilFirstError", code: 1, - userInfo: [NSLocalizedDescriptionKey: "Failed to initialize task continuation"]) + userInfo: [NSLocalizedDescriptionKey: "failed to initialize task continuation"]) } defer { taskContinuation.finish() } let stream = AsyncStream { continuation in diff --git a/Sources/ContainerBuild/BuildRemoteContentProxy.swift b/Sources/ContainerBuild/BuildRemoteContentProxy.swift index bfe00b48e..7b46fac76 100644 --- a/Sources/ContainerBuild/BuildRemoteContentProxy.swift +++ b/Sources/ContainerBuild/BuildRemoteContentProxy.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -14,7 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerClient +import ContainerAPIClient import Containerization import ContainerizationArchive import ContainerizationOCI diff --git a/Sources/ContainerBuild/BuildStdio.swift b/Sources/ContainerBuild/BuildStdio.swift index 7916deaef..8df54ba70 100644 --- a/Sources/ContainerBuild/BuildStdio.swift +++ b/Sources/ContainerBuild/BuildStdio.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerBuild/Builder.grpc.swift b/Sources/ContainerBuild/Builder.grpc.swift index 349611fef..5c39eff26 100644 --- a/Sources/ContainerBuild/Builder.grpc.swift +++ b/Sources/ContainerBuild/Builder.grpc.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerBuild/Builder.pb.swift b/Sources/ContainerBuild/Builder.pb.swift index d0fe2d06b..5d3402561 100644 --- a/Sources/ContainerBuild/Builder.pb.swift +++ b/Sources/ContainerBuild/Builder.pb.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerBuild/Builder.swift b/Sources/ContainerBuild/Builder.swift index 14cc5ffa2..cc941bb17 100644 --- a/Sources/ContainerBuild/Builder.swift +++ b/Sources/ContainerBuild/Builder.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -14,7 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerClient +import ContainerAPIClient import Containerization import ContainerizationOCI import ContainerizationOS diff --git a/Sources/ContainerBuild/Globber.swift b/Sources/ContainerBuild/Globber.swift index 5d1344f30..baecf60d0 100644 --- a/Sources/ContainerBuild/Globber.swift +++ b/Sources/ContainerBuild/Globber.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerBuild/TerminalCommand.swift b/Sources/ContainerBuild/TerminalCommand.swift index 4149b7deb..24b255c2a 100644 --- a/Sources/ContainerBuild/TerminalCommand.swift +++ b/Sources/ContainerBuild/TerminalCommand.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerBuild/URL+Extensions.swift b/Sources/ContainerBuild/URL+Extensions.swift index 3dd9b5e92..4f0bd7ec1 100644 --- a/Sources/ContainerBuild/URL+Extensions.swift +++ b/Sources/ContainerBuild/URL+Extensions.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -228,7 +228,7 @@ public final class BufferedCopyReader: AsyncSequence { throw CocoaError( .fileReadUnsupportedScheme, userInfo: [ - NSLocalizedDescriptionKey: "Reset not supported with InputStream-based implementation" + NSLocalizedDescriptionKey: "reset not supported with InputStream-based implementation" ]) } @@ -240,7 +240,7 @@ public final class BufferedCopyReader: AsyncSequence { throw CocoaError( .fileReadUnsupportedScheme, userInfo: [ - NSLocalizedDescriptionKey: "Offset tracking not supported with InputStream-based implementation" + NSLocalizedDescriptionKey: "offset tracking not supported with InputStream-based implementation" ]) } @@ -252,7 +252,7 @@ public final class BufferedCopyReader: AsyncSequence { throw CocoaError( .fileReadUnsupportedScheme, userInfo: [ - NSLocalizedDescriptionKey: "Seeking not supported with InputStream-based implementation" + NSLocalizedDescriptionKey: "seeking not supported with InputStream-based implementation" ]) } diff --git a/Sources/ContainerCommands/Application.swift b/Sources/ContainerCommands/Application.swift index 1b8b49812..e42c1d510 100644 --- a/Sources/ContainerCommands/Application.swift +++ b/Sources/ContainerCommands/Application.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerLog import ContainerPlugin import ContainerVersion @@ -91,7 +91,12 @@ public struct Application: AsyncParsableCommand { #if DEBUG let warning = "Running debug build. Performance may be degraded." - let formattedWarning = "\u{001B}[33mWarning!\u{001B}[0m \(warning)\n" + let formattedWarning: String + if isatty(FileHandle.standardError.fileDescriptor) == 1 { + formattedWarning = "\u{001B}[33mWarning!\u{001B}[0m \(warning)\n" + } else { + formattedWarning = "Warning! \(warning)\n" + } let warningData = Data(formattedWarning.utf8) FileHandle.standardError.write(warningData) #endif diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift index d0f2afc3d..443f4ce4d 100644 --- a/Sources/ContainerCommands/BuildCommand.swift +++ b/Sources/ContainerCommands/BuildCommand.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,8 +15,8 @@ //===----------------------------------------------------------------------===// import ArgumentParser +import ContainerAPIClient import ContainerBuild -import ContainerClient import ContainerImagesServiceClient import Containerization import ContainerizationError @@ -354,9 +354,12 @@ extension Application { guard let dest = exp.destination else { throw ContainerizationError(.invalidArgument, message: "dest is required \(exp.rawValue)") } - let loaded = try await ClientImage.load(from: dest.absolutePath()) - - for image in loaded { + let result = try await ClientImage.load(from: dest.absolutePath(), force: false) + guard result.rejectedMembers.isEmpty else { + log.error("archive contains invalid members", metadata: ["paths": "\(result.rejectedMembers)"]) + throw ContainerizationError(.internalError, message: "failed to load archive") + } + for image in result.images { try Task.checkCancellation() try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: unpackProgress.handler)) diff --git a/Sources/ContainerCommands/Builder/Builder.swift b/Sources/ContainerCommands/Builder/Builder.swift index f01d85746..b100f9e26 100644 --- a/Sources/ContainerCommands/Builder/Builder.swift +++ b/Sources/ContainerCommands/Builder/Builder.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerCommands/Builder/BuilderDelete.swift b/Sources/ContainerCommands/Builder/BuilderDelete.swift index 24d327090..f340d3746 100644 --- a/Sources/ContainerCommands/Builder/BuilderDelete.swift +++ b/Sources/ContainerCommands/Builder/BuilderDelete.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerizationError import Foundation diff --git a/Sources/ContainerCommands/Builder/BuilderStart.swift b/Sources/ContainerCommands/Builder/BuilderStart.swift index 18e145d66..5aa4d3d03 100644 --- a/Sources/ContainerCommands/Builder/BuilderStart.swift +++ b/Sources/ContainerCommands/Builder/BuilderStart.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,10 +15,10 @@ //===----------------------------------------------------------------------===// import ArgumentParser +import ContainerAPIClient import ContainerBuild -import ContainerClient -import ContainerNetworkService import ContainerPersistence +import ContainerResource import Containerization import ContainerizationError import ContainerizationExtras @@ -88,10 +88,26 @@ extension Application { let builderPlatform = ContainerizationOCI.Platform(arch: "arm64", os: "linux", variant: "v8") + var targetEnvVars: [String] = [] + if let buildkitColors = ProcessInfo.processInfo.environment["BUILDKIT_COLORS"] { + targetEnvVars.append("BUILDKIT_COLORS=\(buildkitColors)") + } + if ProcessInfo.processInfo.environment["NO_COLOR"] != nil { + targetEnvVars.append("NO_COLOR=true") + } + targetEnvVars.sort() + let existingContainer = try? await ClientContainer.get(id: "buildkit") if let existingContainer { let existingImage = existingContainer.configuration.image.reference let existingResources = existingContainer.configuration.resources + let existingEnv = existingContainer.configuration.initProcess.environment + + let existingManagedEnv = existingEnv.filter { envVar in + envVar.hasPrefix("BUILDKIT_COLORS=") || envVar.hasPrefix("NO_COLOR=") + }.sorted() + + let envChanged = existingManagedEnv != targetEnvVars // Check if we need to recreate the builder due to different image let imageChanged = existingImage != builderImage @@ -115,7 +131,7 @@ extension Application { switch existingContainer.status { case .running: - guard imageChanged || cpuChanged || memChanged else { + guard imageChanged || cpuChanged || memChanged || envChanged else { // If image, mem and cpu are the same, continue using the existing builder return } @@ -125,7 +141,7 @@ 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 else { + guard imageChanged || cpuChanged || memChanged || envChanged else { try await existingContainer.startBuildKit(progressUpdate, nil) return } @@ -148,7 +164,7 @@ extension Application { ].compactMap { $0 } let id = "buildkit" - try ContainerClient.Utility.validEntityName(id) + try ContainerAPIClient.Utility.validEntityName(id) let image = try await ClientImage.fetch( reference: builderImage, @@ -173,10 +189,13 @@ extension Application { ) let imageConfig = try await image.config(for: builderPlatform).config + var environment = imageConfig?.env ?? [] + environment.append(contentsOf: targetEnvVars) + let processConfig = ProcessConfiguration( executable: "/usr/local/bin/container-builder-shim", arguments: shimArguments, - environment: imageConfig?.env ?? [], + environment: environment, workingDirectory: "/", terminal: false, user: .id(uid: 0, gid: 0) @@ -211,8 +230,8 @@ extension Application { throw ContainerizationError(.invalidState, message: "default network is not running") } config.networks = [AttachmentConfiguration(network: network.id, options: AttachmentOptions(hostname: id))] - let subnet = try CIDRAddress(networkStatus.address) - let nameserver = IPv4Address(fromValue: subnet.lower.value + 1).description + let subnet = networkStatus.ipv4Subnet + let nameserver = IPv4Address(subnet.lower.value + 1).description let nameservers = [nameserver] config.dns = ContainerConfiguration.DNSConfiguration(nameservers: nameservers) diff --git a/Sources/ContainerCommands/Builder/BuilderStatus.swift b/Sources/ContainerCommands/Builder/BuilderStatus.swift index 574eeae51..bd9b80e9d 100644 --- a/Sources/ContainerCommands/Builder/BuilderStatus.swift +++ b/Sources/ContainerCommands/Builder/BuilderStatus.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerizationError import ContainerizationExtras import Foundation @@ -94,7 +94,7 @@ extension ClientContainer { self.id, self.configuration.image.reference, self.status.rawValue, - self.networks.compactMap { try? CIDRAddress($0.address).address.description }.joined(separator: ","), + self.networks.compactMap { $0.ipv4Address.description }.joined(separator: ","), "\(self.configuration.resources.cpus)", "\(self.configuration.resources.memoryInBytes / (1024 * 1024)) MB", ] diff --git a/Sources/ContainerCommands/Builder/BuilderStop.swift b/Sources/ContainerCommands/Builder/BuilderStop.swift index 0a230e6f9..bff7ab7ab 100644 --- a/Sources/ContainerCommands/Builder/BuilderStop.swift +++ b/Sources/ContainerCommands/Builder/BuilderStop.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerizationError import Foundation diff --git a/Sources/ContainerCommands/Codable+JSON.swift b/Sources/ContainerCommands/Codable+JSON.swift index 52a658623..40fd30159 100644 --- a/Sources/ContainerCommands/Codable+JSON.swift +++ b/Sources/ContainerCommands/Codable+JSON.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerCommands/Container/ContainerCreate.swift b/Sources/ContainerCommands/Container/ContainerCreate.swift index 5e4ecd7c2..e084e8754 100644 --- a/Sources/ContainerCommands/Container/ContainerCreate.swift +++ b/Sources/ContainerCommands/Container/ContainerCreate.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,8 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient +import ContainerResource import ContainerizationError import Foundation import TerminalProgress @@ -40,6 +41,9 @@ extension Application { @OptionGroup(title: "Registry options") var registryFlags: Flags.Registry + @OptionGroup(title: "Image fetch options") + var imageFetchFlags: Flags.ImageFetch + @OptionGroup var global: Flags.Global @@ -73,6 +77,7 @@ extension Application { management: managementFlags, resource: resourceFlags, registry: registryFlags, + imageFetch: imageFetchFlags, progressUpdate: progress.handler ) diff --git a/Sources/ContainerCommands/Container/ContainerDelete.swift b/Sources/ContainerCommands/Container/ContainerDelete.swift index 8c262d68f..1e5234e04 100644 --- a/Sources/ContainerCommands/Container/ContainerDelete.swift +++ b/Sources/ContainerCommands/Container/ContainerDelete.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerizationError import Foundation diff --git a/Sources/ContainerCommands/Container/ContainerExec.swift b/Sources/ContainerCommands/Container/ContainerExec.swift index 2573dbbcc..6684b59c3 100644 --- a/Sources/ContainerCommands/Container/ContainerExec.swift +++ b/Sources/ContainerCommands/Container/ContainerExec.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerizationError import ContainerizationOS import Foundation diff --git a/Sources/ContainerCommands/Container/ContainerInspect.swift b/Sources/ContainerCommands/Container/ContainerInspect.swift index 68c1bc684..74bd97faa 100644 --- a/Sources/ContainerCommands/Container/ContainerInspect.swift +++ b/Sources/ContainerCommands/Container/ContainerInspect.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import Foundation import SwiftProtobuf diff --git a/Sources/ContainerCommands/Container/ContainerKill.swift b/Sources/ContainerCommands/Container/ContainerKill.swift index c6bc40799..c9bfbc208 100644 --- a/Sources/ContainerCommands/Container/ContainerKill.swift +++ b/Sources/ContainerCommands/Container/ContainerKill.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerizationError import ContainerizationOS import Darwin diff --git a/Sources/ContainerCommands/Container/ContainerList.swift b/Sources/ContainerCommands/Container/ContainerList.swift index dbc3149d4..ba3a0d6fe 100644 --- a/Sources/ContainerCommands/Container/ContainerList.swift +++ b/Sources/ContainerCommands/Container/ContainerList.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,8 +15,8 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient -import ContainerNetworkService +import ContainerAPIClient +import ContainerResource import ContainerizationExtras import Foundation import SwiftProtobuf @@ -48,7 +48,7 @@ extension Application { } private func createHeader() -> [[String]] { - [["ID", "IMAGE", "OS", "ARCH", "STATE", "ADDR", "CPUS", "MEMORY"]] + [["ID", "IMAGE", "OS", "ARCH", "STATE", "ADDR", "CPUS", "MEMORY", "STARTED"]] } private func printContainers(containers: [ClientContainer], format: ListFormat) throws { @@ -94,9 +94,10 @@ extension ClientContainer { self.configuration.platform.os, self.configuration.platform.architecture, self.status.rawValue, - self.networks.compactMap { try? CIDRAddress($0.address).address.description }.joined(separator: ","), + self.networks.compactMap { $0.ipv4Address.description }.joined(separator: ","), "\(self.configuration.resources.cpus)", "\(self.configuration.resources.memoryInBytes / (1024 * 1024)) MB", + self.startedDate.map { ISO8601DateFormatter().string(from: $0) } ?? "", ] } } @@ -105,10 +106,12 @@ struct PrintableContainer: Codable { let status: RuntimeStatus let configuration: ContainerConfiguration let networks: [Attachment] + let startedDate: Date? init(_ container: ClientContainer) { self.status = container.status self.configuration = container.configuration self.networks = container.networks + self.startedDate = container.startedDate } } diff --git a/Sources/ContainerCommands/Container/ContainerLogs.swift b/Sources/ContainerCommands/Container/ContainerLogs.swift index 35f09755c..160b2f5ef 100644 --- a/Sources/ContainerCommands/Container/ContainerLogs.swift +++ b/Sources/ContainerCommands/Container/ContainerLogs.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerizationError import Darwin import Dispatch diff --git a/Sources/ContainerCommands/Container/ContainerRun.swift b/Sources/ContainerCommands/Container/ContainerRun.swift index ec65329c3..31de0b243 100644 --- a/Sources/ContainerCommands/Container/ContainerRun.swift +++ b/Sources/ContainerCommands/Container/ContainerRun.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,8 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient +import ContainerResource import Containerization import ContainerizationError import ContainerizationExtras @@ -47,6 +48,9 @@ extension Application { @OptionGroup(title: "Progress options") var progressFlags: Flags.Progress + @OptionGroup(title: "Image fetch options") + var imageFetchFlags: Flags.ImageFetch + @OptionGroup var global: Flags.Global @@ -97,6 +101,7 @@ extension Application { management: managementFlags, resource: resourceFlags, registry: registryFlags, + imageFetch: imageFetchFlags, progressUpdate: progress.handler ) diff --git a/Sources/ContainerCommands/Container/ContainerStart.swift b/Sources/ContainerCommands/Container/ContainerStart.swift index cdef25a97..df93268c3 100644 --- a/Sources/ContainerCommands/Container/ContainerStart.swift +++ b/Sources/ContainerCommands/Container/ContainerStart.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,9 +15,10 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerizationError import ContainerizationOS +import Foundation import TerminalProgress extension Application { @@ -69,6 +70,12 @@ extension Application { return } + for mount in container.configuration.mounts where mount.isVirtiofs { + if !FileManager.default.fileExists(atPath: mount.source) { + throw ContainerizationError(.invalidState, message: "path '\(mount.source)' is not a directory") + } + } + do { let io = try ProcessIO.create( tty: container.configuration.initProcess.terminal, diff --git a/Sources/ContainerCommands/Container/ContainerStats.swift b/Sources/ContainerCommands/Container/ContainerStats.swift index 3ce906a8a..d54489577 100644 --- a/Sources/ContainerCommands/Container/ContainerStats.swift +++ b/Sources/ContainerCommands/Container/ContainerStats.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,8 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient +import ContainerResource import ContainerizationError import ContainerizationExtras import Foundation @@ -74,7 +75,7 @@ extension Application { guard let container = allContainers.first(where: { $0.id == containerId || $0.id.starts(with: containerId) }) else { throw ContainerizationError( .notFound, - message: "Error: No such container: \(containerId)" + message: "no such container: \(containerId)" ) } found.append(container) @@ -102,7 +103,7 @@ extension Application { guard allContainers.first(where: { $0.id == containerId || $0.id.starts(with: containerId) }) != nil else { throw ContainerizationError( .notFound, - message: "Error: No such container: \(containerId)" + message: "no such container: \(containerId)" ) } } @@ -140,7 +141,7 @@ extension Application { } } catch { clearScreen() - print("Error collecting stats: \(error)") + print("error collecting stats: \(error)") try await Task.sleep(for: .seconds(2)) } } @@ -148,8 +149,8 @@ extension Application { private struct StatsSnapshot { let container: ClientContainer - let stats1: ContainerClient.ContainerStats - let stats2: ContainerClient.ContainerStats + let stats1: ContainerResource.ContainerStats + let stats2: ContainerResource.ContainerStats } private func collectStats(for containers: [ClientContainer]) async throws -> [StatsSnapshot] { @@ -197,15 +198,15 @@ extension Application { /// - timeDeltaUsec: Time delta between samples in microseconds /// - Returns: CPU percentage where 100% = one fully utilized core static func calculateCPUPercent( - cpuUsageUsec1: UInt64, - cpuUsageUsec2: UInt64, - timeDeltaUsec: UInt64 + cpuUsage1: Duration, + cpuUsage2: Duration, + timeInterval: Duration ) -> Double { let cpuDelta = - cpuUsageUsec2 > cpuUsageUsec1 - ? cpuUsageUsec2 - cpuUsageUsec1 - : 0 - return (Double(cpuDelta) / Double(timeDeltaUsec)) * 100.0 + cpuUsage2 > cpuUsage1 + ? cpuUsage2 - cpuUsage1 + : .seconds(0) + return (cpuDelta / timeInterval) * 100.0 } static func formatBytes(_ bytes: UInt64) -> String { @@ -225,27 +226,43 @@ extension Application { } private func printStatsTable(_ statsData: [StatsSnapshot]) { - let header = [["Container ID", "Cpu %", "Memory Usage", "Net Rx/Tx", "Block I/O", "Pids"]] - var rows = header + let headerRow = ["Container ID", "Cpu %", "Memory Usage", "Net Rx/Tx", "Block I/O", "Pids"] + let notAvailable = "--" + var rows = [headerRow] for snapshot in statsData { + var row = [snapshot.container.id] let stats1 = snapshot.stats1 let stats2 = snapshot.stats2 - let cpuPercent = Self.calculateCPUPercent( - cpuUsageUsec1: stats1.cpuUsageUsec, - cpuUsageUsec2: stats2.cpuUsageUsec, - timeDeltaUsec: 2_000_000 // 2 seconds in microseconds - ) - let cpuStr = String(format: "%.2f%%", cpuPercent) + if let cpuUsageUsec1 = stats1.cpuUsageUsec, let cpuUsageUsec2 = stats2.cpuUsageUsec { + let cpuPercent = Self.calculateCPUPercent( + cpuUsage1: .microseconds(cpuUsageUsec1), + cpuUsage2: .microseconds(cpuUsageUsec2), + timeInterval: .seconds(2) + ) + let cpuStr = String(format: "%.2f%%", cpuPercent) + row.append(cpuStr) + } else { + row.append(notAvailable) + } + + let memUsageStr = stats2.memoryUsageBytes.map { Self.formatBytes($0) } ?? notAvailable + let memLimitStr = stats2.memoryLimitBytes.map { Self.formatBytes($0) } ?? notAvailable + row.append("\(memUsageStr) / \(memLimitStr)") + + let netRxStr = stats2.networkRxBytes.map { Self.formatBytes($0) } ?? notAvailable + let netTxStr = stats2.networkTxBytes.map { Self.formatBytes($0) } ?? notAvailable + row.append("\(netRxStr) / \(netTxStr)") - let memUsageStr = "\(Self.formatBytes(stats2.memoryUsageBytes)) / \(Self.formatBytes(stats2.memoryLimitBytes))" - let netStr = "\(Self.formatBytes(stats2.networkRxBytes)) / \(Self.formatBytes(stats2.networkTxBytes))" - let blockStr = "\(Self.formatBytes(stats2.blockReadBytes)) / \(Self.formatBytes(stats2.blockWriteBytes))" + let blkReadStr = stats2.blockReadBytes.map { Self.formatBytes($0) } ?? notAvailable + let blkWriteStr = stats2.blockWriteBytes.map { Self.formatBytes($0) } ?? notAvailable + row.append("\(blkReadStr) / \(blkWriteStr)") - let pidsStr = "\(stats2.numProcesses)" + let pidsStr = stats2.numProcesses.map { "\($0)" } ?? notAvailable + row.append(pidsStr) - rows.append([snapshot.container.id, cpuStr, memUsageStr, netStr, blockStr, pidsStr]) + rows.append(row) } // Always print header, even if no containers diff --git a/Sources/ContainerCommands/Container/ContainerStop.swift b/Sources/ContainerCommands/Container/ContainerStop.swift index 096546565..42cd25f84 100644 --- a/Sources/ContainerCommands/Container/ContainerStop.swift +++ b/Sources/ContainerCommands/Container/ContainerStop.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,8 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient +import ContainerResource import ContainerizationError import ContainerizationOS import Foundation diff --git a/Sources/ContainerCommands/Container/ProcessUtils.swift b/Sources/ContainerCommands/Container/ProcessUtils.swift index a2cb981d4..e37f41190 100644 --- a/Sources/ContainerCommands/Container/ProcessUtils.swift +++ b/Sources/ContainerCommands/Container/ProcessUtils.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -14,7 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerClient +import ContainerAPIClient import Containerization import ContainerizationError import ContainerizationOS diff --git a/Sources/ContainerCommands/DefaultCommand.swift b/Sources/ContainerCommands/DefaultCommand.swift index faec8d961..2c6de6f36 100644 --- a/Sources/ContainerCommands/DefaultCommand.swift +++ b/Sources/ContainerCommands/DefaultCommand.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerPlugin import Darwin import Foundation @@ -42,9 +42,9 @@ struct DefaultCommand: AsyncParsableCommand { // Check for edge cases and unknown options to match the behavior in the absence of plugins. if command.isEmpty { - throw ValidationError("Unknown argument '\(command)'") + throw ValidationError("unknown argument '\(command)'") } else if command.starts(with: "-") { - throw ValidationError("Unknown option '\(command)'") + throw ValidationError("unknown option '\(command)'") } // Compute canonical plugin directories to show in helpful errors (avoid hard-coded paths) diff --git a/Sources/ContainerCommands/Image/ImageCommand.swift b/Sources/ContainerCommands/Image/ImageCommand.swift index b1a9e0f1f..ef866b746 100644 --- a/Sources/ContainerCommands/Image/ImageCommand.swift +++ b/Sources/ContainerCommands/Image/ImageCommand.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerCommands/Image/ImageDelete.swift b/Sources/ContainerCommands/Image/ImageDelete.swift index ad62b11bf..ef7ee303f 100644 --- a/Sources/ContainerCommands/Image/ImageDelete.swift +++ b/Sources/ContainerCommands/Image/ImageDelete.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import Containerization import ContainerizationError import Foundation diff --git a/Sources/ContainerCommands/Image/ImageInspect.swift b/Sources/ContainerCommands/Image/ImageInspect.swift index 4ea5cfceb..785bf4865 100644 --- a/Sources/ContainerCommands/Image/ImageInspect.swift +++ b/Sources/ContainerCommands/Image/ImageInspect.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerizationError import Foundation import SwiftProtobuf diff --git a/Sources/ContainerCommands/Image/ImageList.swift b/Sources/ContainerCommands/Image/ImageList.swift index 4999d5f72..e403aeaf6 100644 --- a/Sources/ContainerCommands/Image/ImageList.swift +++ b/Sources/ContainerCommands/Image/ImageList.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import Containerization import ContainerizationError import ContainerizationOCI diff --git a/Sources/ContainerCommands/Image/ImageLoad.swift b/Sources/ContainerCommands/Image/ImageLoad.swift index ff0f46c35..3306a8e29 100644 --- a/Sources/ContainerCommands/Image/ImageLoad.swift +++ b/Sources/ContainerCommands/Image/ImageLoad.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import Containerization import ContainerizationError import Foundation @@ -36,6 +36,9 @@ extension Application { }) var input: String? + @Flag(name: .shortAndLong, help: "Load images even if the archive contains invalid files") + public var force = false + @OptionGroup var global: Flags.Global @@ -81,19 +84,24 @@ extension Application { progress.start() progress.set(description: "Loading tar archive") - let loaded = try await ClientImage.load(from: input ?? tempFile.path()) + let result = try await ClientImage.load( + from: input ?? tempFile.path(), + force: force) + if !result.rejectedMembers.isEmpty { + log.warning("archive contains invalid members", metadata: ["paths": "\(result.rejectedMembers)"]) + } let taskManager = ProgressTaskCoordinator() let unpackTask = await taskManager.startTask() progress.set(description: "Unpacking image") progress.set(itemsName: "entries") - for image in loaded { + for image in result.images { try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler)) } await taskManager.finish() progress.finish() print("Loaded images:") - for image in loaded { + for image in result.images { print(image.reference) } } diff --git a/Sources/ContainerCommands/Image/ImagePrune.swift b/Sources/ContainerCommands/Image/ImagePrune.swift index aa2ee5de5..3e18cdfce 100644 --- a/Sources/ContainerCommands/Image/ImagePrune.swift +++ b/Sources/ContainerCommands/Image/ImagePrune.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerizationOCI import Foundation @@ -35,7 +35,7 @@ extension Application { public func run() async throws { let allImages = try await ClientImage.list() - let imagesToDelete: [ClientImage] + let imagesToPrune: [ClientImage] if all { // Find all images not used by any container let containers = try await ClientContainer.list() @@ -43,40 +43,40 @@ extension Application { for container in containers { imagesInUse.insert(container.configuration.image.reference) } - imagesToDelete = allImages.filter { image in + imagesToPrune = allImages.filter { image in !imagesInUse.contains(image.reference) } } else { // Find dangling images (images with no tag) - imagesToDelete = allImages.filter { image in + imagesToPrune = allImages.filter { image in !hasTag(image.reference) } } - for image in imagesToDelete { - try await ClientImage.delete(reference: image.reference, garbageCollect: false) + var prunedImages = [String]() + + for image in imagesToPrune { + do { + try await ClientImage.delete(reference: image.reference, garbageCollect: false) + prunedImages.append(image.reference) + } catch { + log.error("Failed to prune image \(image.reference): \(error)") + } } let (deletedDigests, size) = try await ClientImage.cleanupOrphanedBlobs() + for image in imagesToPrune { + print("untagged \(image.reference)") + } + for digest in deletedDigests { + print("deleted \(digest)") + } + let formatter = ByteCountFormatter() formatter.countStyle = .file - - if imagesToDelete.isEmpty && deletedDigests.isEmpty { - print("No images to prune") - print("Reclaimed Zero KB in disk space") - } else { - print("Deleted images:") - for image in imagesToDelete { - print("untagged: \(image.reference)") - } - for digest in deletedDigests { - print("deleted: \(digest)") - } - print() - let freed = formatter.string(fromByteCount: Int64(size)) - print("Reclaimed \(freed) in disk space") - } + let freed = formatter.string(fromByteCount: Int64(size)) + print("Reclaimed \(freed) in disk space") } private func hasTag(_ reference: String) -> Bool { diff --git a/Sources/ContainerCommands/Image/ImagePull.swift b/Sources/ContainerCommands/Image/ImagePull.swift index 3f14cef81..8654de15a 100644 --- a/Sources/ContainerCommands/Image/ImagePull.swift +++ b/Sources/ContainerCommands/Image/ImagePull.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import Containerization import ContainerizationOCI import TerminalProgress @@ -36,6 +36,9 @@ extension Application { @OptionGroup var progressFlags: Flags.Progress + @OptionGroup + var imageFetchFlags: Flags.ImageFetch + @Option( name: .shortAndLong, help: "Limit the pull to the specified architecture" @@ -100,7 +103,8 @@ extension Application { let taskManager = ProgressTaskCoordinator() let fetchTask = await taskManager.startTask() let image = try await ClientImage.pull( - reference: processedReference, platform: p, scheme: scheme, progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progress.handler) + reference: processedReference, platform: p, scheme: scheme, progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progress.handler), + maxConcurrentDownloads: self.imageFetchFlags.maxConcurrentDownloads ) progress.set(description: "Unpacking image") diff --git a/Sources/ContainerCommands/Image/ImagePush.swift b/Sources/ContainerCommands/Image/ImagePush.swift index 3bd57f718..d92985688 100644 --- a/Sources/ContainerCommands/Image/ImagePush.swift +++ b/Sources/ContainerCommands/Image/ImagePush.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import Containerization import ContainerizationOCI import TerminalProgress diff --git a/Sources/ContainerCommands/Image/ImageSave.swift b/Sources/ContainerCommands/Image/ImageSave.swift index 0681bb859..1b9fe71b5 100644 --- a/Sources/ContainerCommands/Image/ImageSave.swift +++ b/Sources/ContainerCommands/Image/ImageSave.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,8 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient +import ContainerResource import Containerization import ContainerizationError import ContainerizationOCI diff --git a/Sources/ContainerCommands/Image/ImageTag.swift b/Sources/ContainerCommands/Image/ImageTag.swift index 364d1a2e4..2d8c243fa 100644 --- a/Sources/ContainerCommands/Image/ImageTag.swift +++ b/Sources/ContainerCommands/Image/ImageTag.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient extension Application { public struct ImageTag: AsyncParsableCommand { diff --git a/Sources/ContainerCommands/Network/NetworkCommand.swift b/Sources/ContainerCommands/Network/NetworkCommand.swift index 5ef907713..9bbeee3d2 100644 --- a/Sources/ContainerCommands/Network/NetworkCommand.swift +++ b/Sources/ContainerCommands/Network/NetworkCommand.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -26,6 +26,7 @@ extension Application { NetworkDelete.self, NetworkList.self, NetworkInspect.self, + NetworkPrune.self, ], aliases: ["n"] ) diff --git a/Sources/ContainerCommands/Network/NetworkCreate.swift b/Sources/ContainerCommands/Network/NetworkCreate.swift index 6b25e7718..560769945 100644 --- a/Sources/ContainerCommands/Network/NetworkCreate.swift +++ b/Sources/ContainerCommands/Network/NetworkCreate.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,9 +15,10 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient -import ContainerNetworkService +import ContainerAPIClient +import ContainerResource import ContainerizationError +import ContainerizationExtras import Foundation import TerminalProgress @@ -30,8 +31,19 @@ extension Application { @Option(name: .customLong("label"), help: "Set metadata for a network") var labels: [String] = [] - @Option(name: .customLong("subnet"), help: "Set subnet for a network") - var subnet: String? = nil + @Option( + name: .customLong("subnet"), help: "Set subnet for a network", + transform: { + try CIDRv4($0) + }) + var ipv4Subnet: CIDRv4? = nil + + @Option( + name: .customLong("subnet-v6"), help: "Set the IPv6 prefix for a network", + transform: { + try CIDRv6($0) + }) + var ipv6Subnet: CIDRv6? = nil @OptionGroup var global: Flags.Global @@ -43,7 +55,13 @@ extension Application { public func run() async throws { let parsedLabels = Utility.parseKeyValuePairs(labels) - let config = try NetworkConfiguration(id: self.name, mode: .nat, subnet: subnet, labels: parsedLabels) + let config = try NetworkConfiguration( + id: self.name, + mode: .nat, + ipv4Subnet: ipv4Subnet, + ipv6Subnet: ipv6Subnet, + labels: parsedLabels + ) let state = try await ClientNetwork.create(configuration: config) print(state.id) } diff --git a/Sources/ContainerCommands/Network/NetworkDelete.swift b/Sources/ContainerCommands/Network/NetworkDelete.swift index 35c57925e..c989aad61 100644 --- a/Sources/ContainerCommands/Network/NetworkDelete.swift +++ b/Sources/ContainerCommands/Network/NetworkDelete.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,8 +15,8 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient -import ContainerNetworkService +import ContainerAPIClient +import ContainerResource import ContainerizationError import Foundation diff --git a/Sources/ContainerCommands/Network/NetworkInspect.swift b/Sources/ContainerCommands/Network/NetworkInspect.swift index bde6873ef..78a4b3755 100644 --- a/Sources/ContainerCommands/Network/NetworkInspect.swift +++ b/Sources/ContainerCommands/Network/NetworkInspect.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,8 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient -import ContainerNetworkService +import ContainerAPIClient import Foundation import SwiftProtobuf diff --git a/Sources/ContainerCommands/Network/NetworkList.swift b/Sources/ContainerCommands/Network/NetworkList.swift index 53a5cdb0e..334c3d5ed 100644 --- a/Sources/ContainerCommands/Network/NetworkList.swift +++ b/Sources/ContainerCommands/Network/NetworkList.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,8 +15,8 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient -import ContainerNetworkService +import ContainerAPIClient +import ContainerResource import ContainerizationExtras import Foundation import SwiftProtobuf @@ -83,7 +83,7 @@ extension NetworkState { case .created(_): return [self.id, self.state, "none"] case .running(_, let status): - return [self.id, self.state, status.address] + return [self.id, self.state, status.ipv4Subnet.description] } } } diff --git a/Sources/ContainerCommands/Network/NetworkPrune.swift b/Sources/ContainerCommands/Network/NetworkPrune.swift new file mode 100644 index 000000000..7d85f4ef2 --- /dev/null +++ b/Sources/ContainerCommands/Network/NetworkPrune.swift @@ -0,0 +1,66 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025-2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ArgumentParser +import ContainerAPIClient +import Foundation + +extension Application.NetworkCommand { + public struct NetworkPrune: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( + commandName: "prune", + abstract: "Remove networks with no container connections" + ) + + @OptionGroup + var global: Flags.Global + + public func run() async throws { + let allContainers = try await ClientContainer.list() + let allNetworks = try await ClientNetwork.list() + + var networksInUse = Set() + for container in allContainers { + for network in container.configuration.networks { + networksInUse.insert(network.network) + } + } + + let networksToPrune = allNetworks.filter { network in + network.id != ClientNetwork.defaultNetworkName && !networksInUse.contains(network.id) + } + + var prunedNetworks = [String]() + + for network in networksToPrune { + do { + try await ClientNetwork.delete(id: network.id) + prunedNetworks.append(network.id) + } catch { + // Note: This failure may occur due to a race condition between the network/ + // container collection above and a container run command that attaches to a + // network listed in the networksToPrune collection. + log.error("Failed to prune network \(network.id): \(error)") + } + } + + for name in prunedNetworks { + print(name) + } + } + } +} diff --git a/Sources/ContainerCommands/Registry/Login.swift b/Sources/ContainerCommands/Registry/Login.swift index 687ee4cf4..7af61fc2b 100644 --- a/Sources/ContainerCommands/Registry/Login.swift +++ b/Sources/ContainerCommands/Registry/Login.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import Containerization import ContainerizationError import ContainerizationOCI diff --git a/Sources/ContainerCommands/Registry/Logout.swift b/Sources/ContainerCommands/Registry/Logout.swift index 95d1e93fa..ad7145e3b 100644 --- a/Sources/ContainerCommands/Registry/Logout.swift +++ b/Sources/ContainerCommands/Registry/Logout.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import Containerization import ContainerizationOCI diff --git a/Sources/ContainerCommands/Registry/RegistryCommand.swift b/Sources/ContainerCommands/Registry/RegistryCommand.swift index edb20b480..b32cbb60d 100644 --- a/Sources/ContainerCommands/Registry/RegistryCommand.swift +++ b/Sources/ContainerCommands/Registry/RegistryCommand.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerCommands/System/DNS/DNSCreate.swift b/Sources/ContainerCommands/System/DNS/DNSCreate.swift index de8f9b982..7ab7b1bc7 100644 --- a/Sources/ContainerCommands/System/DNS/DNSCreate.swift +++ b/Sources/ContainerCommands/System/DNS/DNSCreate.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerizationError import ContainerizationExtras import Foundation diff --git a/Sources/ContainerCommands/System/DNS/DNSDelete.swift b/Sources/ContainerCommands/System/DNS/DNSDelete.swift index 0bed6b556..8215d0a1d 100644 --- a/Sources/ContainerCommands/System/DNS/DNSDelete.swift +++ b/Sources/ContainerCommands/System/DNS/DNSDelete.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerizationError import Foundation diff --git a/Sources/ContainerCommands/System/DNS/DNSList.swift b/Sources/ContainerCommands/System/DNS/DNSList.swift index 9c236accf..8d2f67b59 100644 --- a/Sources/ContainerCommands/System/DNS/DNSList.swift +++ b/Sources/ContainerCommands/System/DNS/DNSList.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import Foundation extension Application { diff --git a/Sources/ContainerCommands/System/Kernel/KernelSet.swift b/Sources/ContainerCommands/System/Kernel/KernelSet.swift index 5931755fc..01486e203 100644 --- a/Sources/ContainerCommands/System/Kernel/KernelSet.swift +++ b/Sources/ContainerCommands/System/Kernel/KernelSet.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerPersistence import Containerization import ContainerizationError @@ -67,7 +67,7 @@ extension Application { private func setKernelFromBinary() async throws { guard let binaryPath else { - throw ArgumentParser.ValidationError("Missing argument '--binary'") + throw ArgumentParser.ValidationError("missing argument '--binary'") } let absolutePath = URL(fileURLWithPath: binaryPath, relativeTo: .currentDirectory()).absoluteURL.absoluteString let platform = try getSystemPlatform() @@ -76,10 +76,10 @@ extension Application { private func setKernelFromTar() async throws { guard let binaryPath else { - throw ArgumentParser.ValidationError("Missing argument '--binary'") + throw ArgumentParser.ValidationError("missing argument '--binary'") } guard let tarPath else { - throw ArgumentParser.ValidationError("Missing argument '--tar") + throw ArgumentParser.ValidationError("missing argument '--tar") } let platform = try getSystemPlatform() let localTarPath = URL(fileURLWithPath: tarPath, relativeTo: .currentDirectory()).path diff --git a/Sources/ContainerCommands/System/Property/PropertyClear.swift b/Sources/ContainerCommands/System/Property/PropertyClear.swift index 7062933c7..d523bfeb8 100644 --- a/Sources/ContainerCommands/System/Property/PropertyClear.swift +++ b/Sources/ContainerCommands/System/Property/PropertyClear.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerPersistence import ContainerizationError import Foundation diff --git a/Sources/ContainerCommands/System/Property/PropertyGet.swift b/Sources/ContainerCommands/System/Property/PropertyGet.swift index 7d1651d13..5c36b666e 100644 --- a/Sources/ContainerCommands/System/Property/PropertyGet.swift +++ b/Sources/ContainerCommands/System/Property/PropertyGet.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerPersistence import ContainerizationError import Foundation diff --git a/Sources/ContainerCommands/System/Property/PropertyList.swift b/Sources/ContainerCommands/System/Property/PropertyList.swift index 35f7081b6..1cfe6d6a6 100644 --- a/Sources/ContainerCommands/System/Property/PropertyList.swift +++ b/Sources/ContainerCommands/System/Property/PropertyList.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerPersistence import Foundation diff --git a/Sources/ContainerCommands/System/Property/PropertySet.swift b/Sources/ContainerCommands/System/Property/PropertySet.swift index dc01d84d7..24753a4db 100644 --- a/Sources/ContainerCommands/System/Property/PropertySet.swift +++ b/Sources/ContainerCommands/System/Property/PropertySet.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerPersistence import ContainerizationError import ContainerizationExtras @@ -70,10 +70,15 @@ extension Application { DefaultsStore.set(value: value, key: key) return case .defaultSubnet: - guard (try? CIDRAddress(value)) != nil else { + guard (try? CIDRv4(value)) != nil else { throw ContainerizationError(.invalidArgument, message: "invalid CIDRv4 address: \(value)") } DefaultsStore.set(value: value, key: key) + case .defaultIPv6Subnet: + guard (try? CIDRv6(value)) != nil else { + throw ContainerizationError(.invalidArgument, message: "invalid CIDRv6 address: \(value)") + } + DefaultsStore.set(value: value, key: key) } } } diff --git a/Sources/ContainerCommands/System/SystemCommand.swift b/Sources/ContainerCommands/System/SystemCommand.swift index 807e4d5c0..8869b4ebc 100644 --- a/Sources/ContainerCommands/System/SystemCommand.swift +++ b/Sources/ContainerCommands/System/SystemCommand.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -31,6 +31,7 @@ extension Application { SystemStart.self, SystemStatus.self, SystemStop.self, + SystemVersion.self, ], aliases: ["s"] ) diff --git a/Sources/ContainerCommands/System/SystemDF.swift b/Sources/ContainerCommands/System/SystemDF.swift index 75301df1a..09617980c 100644 --- a/Sources/ContainerCommands/System/SystemDF.swift +++ b/Sources/ContainerCommands/System/SystemDF.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerizationError import Foundation @@ -44,7 +44,7 @@ extension Application { guard let jsonString = String(data: data, encoding: .utf8) else { throw ContainerizationError( .internalError, - message: "Failed to encode JSON output" + message: "failed to encode JSON output" ) } print(jsonString) diff --git a/Sources/ContainerCommands/System/SystemDNS.swift b/Sources/ContainerCommands/System/SystemDNS.swift index b1cfb9493..e7de8f2eb 100644 --- a/Sources/ContainerCommands/System/SystemDNS.swift +++ b/Sources/ContainerCommands/System/SystemDNS.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerCommands/System/SystemKernel.swift b/Sources/ContainerCommands/System/SystemKernel.swift index fac09e98b..01c2df0c9 100644 --- a/Sources/ContainerCommands/System/SystemKernel.swift +++ b/Sources/ContainerCommands/System/SystemKernel.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerCommands/System/SystemLogs.swift b/Sources/ContainerCommands/System/SystemLogs.swift index 66e9a2521..5ffd5408e 100644 --- a/Sources/ContainerCommands/System/SystemLogs.swift +++ b/Sources/ContainerCommands/System/SystemLogs.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerizationError import ContainerizationOS import Foundation diff --git a/Sources/ContainerCommands/System/SystemProperty.swift b/Sources/ContainerCommands/System/SystemProperty.swift index 71b741912..9d86e90e3 100644 --- a/Sources/ContainerCommands/System/SystemProperty.swift +++ b/Sources/ContainerCommands/System/SystemProperty.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerCommands/System/SystemStart.swift b/Sources/ContainerCommands/System/SystemStart.swift index afd84870e..eae268003 100644 --- a/Sources/ContainerCommands/System/SystemStart.swift +++ b/Sources/ContainerCommands/System/SystemStart.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerPersistence import ContainerPlugin import ContainerizationError diff --git a/Sources/ContainerCommands/System/SystemStatus.swift b/Sources/ContainerCommands/System/SystemStatus.swift index d31d2092d..a69787574 100644 --- a/Sources/ContainerCommands/System/SystemStatus.swift +++ b/Sources/ContainerCommands/System/SystemStatus.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerPlugin import ContainerizationError import Foundation diff --git a/Sources/ContainerCommands/System/SystemStop.swift b/Sources/ContainerCommands/System/SystemStop.swift index a6617c729..53d80ecc0 100644 --- a/Sources/ContainerCommands/System/SystemStop.swift +++ b/Sources/ContainerCommands/System/SystemStop.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,8 +15,9 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import ContainerPlugin +import ContainerResource import ContainerizationOS import Foundation import Logging diff --git a/Sources/ContainerCommands/System/Version.swift b/Sources/ContainerCommands/System/Version.swift new file mode 100644 index 000000000..8ee4e468f --- /dev/null +++ b/Sources/ContainerCommands/System/Version.swift @@ -0,0 +1,89 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025-2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ArgumentParser +import ContainerAPIClient +import ContainerVersion +import Foundation + +extension Application { + public struct SystemVersion: AsyncParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "version", + abstract: "Show version information" + ) + + @Option(name: .long, help: "Format of the output") + var format: ListFormat = .table + + @OptionGroup + var global: Flags.Global + + public init() {} + + public func run() async throws { + let cliInfo = VersionInfo( + version: ReleaseVersion.version(), + buildType: ReleaseVersion.buildType(), + commit: ReleaseVersion.gitCommit() ?? "unspecified", + appName: "container" + ) + + // Try to get API server version info + let serverInfo: VersionInfo? + do { + let health = try await ClientHealthCheck.ping(timeout: .seconds(2)) + serverInfo = VersionInfo( + version: health.apiServerVersion, + buildType: health.apiServerBuild, + commit: health.apiServerCommit, + appName: health.apiServerAppName + ) + } catch { + serverInfo = nil + } + + let versions = [cliInfo, serverInfo].compactMap { $0 } + + switch format { + case .table: + printVersionTable(versions: versions) + case .json: + try printVersionJSON(versions: versions) + } + } + + private func printVersionTable(versions: [VersionInfo]) { + let header = ["COMPONENT", "VERSION", "BUILD", "COMMIT"] + let rows = [header] + versions.map { [$0.appName, $0.version, $0.buildType, $0.commit] } + + let table = TableOutput(rows: rows) + print(table.format()) + } + + private func printVersionJSON(versions: [VersionInfo]) throws { + let data = try JSONEncoder().encode(versions) + print(String(data: data, encoding: .utf8) ?? "[]") + } + } + + public struct VersionInfo: Codable { + let version: String + let buildType: String + let commit: String + let appName: String + } +} diff --git a/Sources/ContainerCommands/Volume/VolumeCommand.swift b/Sources/ContainerCommands/Volume/VolumeCommand.swift index af01a65d8..54f585dee 100644 --- a/Sources/ContainerCommands/Volume/VolumeCommand.swift +++ b/Sources/ContainerCommands/Volume/VolumeCommand.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerCommands/Volume/VolumeCreate.swift b/Sources/ContainerCommands/Volume/VolumeCreate.swift index f939f3399..c0d1e28a3 100644 --- a/Sources/ContainerCommands/Volume/VolumeCreate.swift +++ b/Sources/ContainerCommands/Volume/VolumeCreate.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import Foundation extension Application.VolumeCommand { diff --git a/Sources/ContainerCommands/Volume/VolumeDelete.swift b/Sources/ContainerCommands/Volume/VolumeDelete.swift index 14447f6ec..7b152d969 100644 --- a/Sources/ContainerCommands/Volume/VolumeDelete.swift +++ b/Sources/ContainerCommands/Volume/VolumeDelete.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,8 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient +import ContainerResource import ContainerizationError import Foundation diff --git a/Sources/ContainerCommands/Volume/VolumeInspect.swift b/Sources/ContainerCommands/Volume/VolumeInspect.swift index 9a922f293..da65c4cfe 100644 --- a/Sources/ContainerCommands/Volume/VolumeInspect.swift +++ b/Sources/ContainerCommands/Volume/VolumeInspect.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,8 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient +import ContainerResource import Foundation extension Application.VolumeCommand { diff --git a/Sources/ContainerCommands/Volume/VolumeList.swift b/Sources/ContainerCommands/Volume/VolumeList.swift index 9f3aacf00..60cf861bf 100644 --- a/Sources/ContainerCommands/Volume/VolumeList.swift +++ b/Sources/ContainerCommands/Volume/VolumeList.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,8 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient +import ContainerResource import ContainerizationExtras import Foundation diff --git a/Sources/ContainerCommands/Volume/VolumePrune.swift b/Sources/ContainerCommands/Volume/VolumePrune.swift index 34ae74770..5d7ebe77b 100644 --- a/Sources/ContainerCommands/Volume/VolumePrune.swift +++ b/Sources/ContainerCommands/Volume/VolumePrune.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient +import ContainerAPIClient import Foundation extension Application.VolumeCommand { @@ -29,19 +29,43 @@ extension Application.VolumeCommand { var global: Flags.Global public func run() async throws { - let (volumeNames, size) = try await ClientVolume.prune() - let formatter = ByteCountFormatter() - let freed = formatter.string(fromByteCount: Int64(size)) - - if volumeNames.isEmpty { - print("No volumes to prune") - } else { - print("Pruned volumes:") - for name in volumeNames { - print(name) + let allVolumes = try await ClientVolume.list() + + // Find all volumes not used by any container + let containers = try await ClientContainer.list() + var volumesInUse = Set() + for container in containers { + for mount in container.configuration.mounts { + if mount.isVolume, let volumeName = mount.volumeName { + volumesInUse.insert(volumeName) + } + } + } + + let volumesToPrune = allVolumes.filter { volume in + !volumesInUse.contains(volume.name) + } + + var prunedVolumes = [String]() + var totalSize: UInt64 = 0 + + for volume in volumesToPrune { + do { + let actualSize = try await ClientVolume.volumeDiskUsage(name: volume.name) + totalSize += actualSize + try await ClientVolume.delete(name: volume.name) + prunedVolumes.append(volume.name) + } catch { + log.error("Failed to prune volume \(volume.name): \(error)") } - print() } + + for name in prunedVolumes { + print(name) + } + + let formatter = ByteCountFormatter() + let freed = formatter.string(fromByteCount: Int64(totalSize)) print("Reclaimed \(freed) in disk space") } } diff --git a/Sources/ContainerLog/OSLogHandler.swift b/Sources/ContainerLog/OSLogHandler.swift index 12508187a..a9eb9a899 100644 --- a/Sources/ContainerLog/OSLogHandler.swift +++ b/Sources/ContainerLog/OSLogHandler.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerLog/StderrLogHandler.swift b/Sources/ContainerLog/StderrLogHandler.swift index e50b346a3..a87a71e40 100644 --- a/Sources/ContainerLog/StderrLogHandler.swift +++ b/Sources/ContainerLog/StderrLogHandler.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerPersistence/DefaultsStore.swift b/Sources/ContainerPersistence/DefaultsStore.swift index 45281469b..2725d17d3 100644 --- a/Sources/ContainerPersistence/DefaultsStore.swift +++ b/Sources/ContainerPersistence/DefaultsStore.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -30,6 +30,7 @@ public enum DefaultsStore { case defaultKernelBinaryPath = "kernel.binaryPath" case defaultKernelURL = "kernel.url" case defaultSubnet = "network.subnet" + case defaultIPv6Subnet = "network.subnetv6" case defaultRegistryDomain = "registry.domain" } @@ -73,6 +74,7 @@ public enum DefaultsStore { (.defaultKernelBinaryPath, { Self.get(key: $0) }), (.defaultKernelURL, { Self.get(key: $0) }), (.defaultSubnet, { Self.getOptional(key: $0) }), + (.defaultIPv6Subnet, { Self.getOptional(key: $0) }), (.defaultDNSDomain, { Self.getOptional(key: $0) }), (.defaultRegistryDomain, { Self.get(key: $0) }), ] @@ -84,7 +86,7 @@ public enum DefaultsStore { private static var udSuite: UserDefaults { guard let ud = UserDefaults.init(suiteName: self.userDefaultDomain) else { - fatalError("Failed to initialize UserDefaults for domain \(self.userDefaultDomain)") + fatalError("failed to initialize UserDefaults for domain \(self.userDefaultDomain)") } return ud } @@ -131,7 +133,9 @@ extension DefaultsStore.Keys { case .defaultKernelURL: return "The URL for the kernel file to install, or the URL for an archive containing the kernel file." case .defaultSubnet: - return "Default subnet for IP allocation (used on macOS 15 only)." + return "Default subnet for IPv4 allocation." + case .defaultIPv6Subnet: + return "Default IPv6 network prefix." case .defaultRegistryDomain: return "The default registry to use for image references that do not specify a registry." } @@ -153,6 +157,8 @@ extension DefaultsStore.Keys { return String.self case .defaultSubnet: return String.self + case .defaultIPv6Subnet: + return String.self case .defaultRegistryDomain: return String.self } @@ -180,6 +186,8 @@ extension DefaultsStore.Keys { return "https://github.com/kata-containers/kata-containers/releases/download/3.17.0/kata-static-3.17.0-arm64.tar.xz" case .defaultSubnet: return "192.168.64.1/24" + case .defaultIPv6Subnet: + return "fd00::/64" case .defaultRegistryDomain: return "docker.io" } diff --git a/Sources/ContainerPersistence/EntityStore.swift b/Sources/ContainerPersistence/EntityStore.swift index e12d34b1e..fefdf8c26 100644 --- a/Sources/ContainerPersistence/EntityStore.swift +++ b/Sources/ContainerPersistence/EntityStore.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerPlugin/ApplicationRoot.swift b/Sources/ContainerPlugin/ApplicationRoot.swift index dbe8d5f27..94db22c38 100644 --- a/Sources/ContainerPlugin/ApplicationRoot.swift +++ b/Sources/ContainerPlugin/ApplicationRoot.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerPlugin/InstallRoot.swift b/Sources/ContainerPlugin/InstallRoot.swift index f0217b552..8f39c790b 100644 --- a/Sources/ContainerPlugin/InstallRoot.swift +++ b/Sources/ContainerPlugin/InstallRoot.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerPlugin/LaunchPlist.swift b/Sources/ContainerPlugin/LaunchPlist.swift index 32857f6e4..ed996b46b 100644 --- a/Sources/ContainerPlugin/LaunchPlist.swift +++ b/Sources/ContainerPlugin/LaunchPlist.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerPlugin/Plugin.swift b/Sources/ContainerPlugin/Plugin.swift index 2d2571b56..2e969b53f 100644 --- a/Sources/ContainerPlugin/Plugin.swift +++ b/Sources/ContainerPlugin/Plugin.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerPlugin/PluginConfig.swift b/Sources/ContainerPlugin/PluginConfig.swift index c4efcfb10..5fdf2a997 100644 --- a/Sources/ContainerPlugin/PluginConfig.swift +++ b/Sources/ContainerPlugin/PluginConfig.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerPlugin/PluginFactory.swift b/Sources/ContainerPlugin/PluginFactory.swift index 8d1eb7015..2123a5ed4 100644 --- a/Sources/ContainerPlugin/PluginFactory.swift +++ b/Sources/ContainerPlugin/PluginFactory.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerPlugin/PluginLoader.swift b/Sources/ContainerPlugin/PluginLoader.swift index 5dd7d8b63..08a0293d3 100644 --- a/Sources/ContainerPlugin/PluginLoader.swift +++ b/Sources/ContainerPlugin/PluginLoader.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -133,7 +133,7 @@ extension PluginLoader { }.first) else { log?.warning( - "Not installing plugin with missing configuration", + "not installing plugin with missing configuration", metadata: [ "path": "\(installURL.path)" ] @@ -144,7 +144,7 @@ extension PluginLoader { // Warn and skip if this plugin name has been encountered already guard !pluginNames.contains(plugin.name) else { log?.warning( - "Not installing shadowed plugin", + "not installing shadowed plugin", metadata: [ "path": "\(installURL.path)", "name": "\(plugin.name)", @@ -157,7 +157,7 @@ extension PluginLoader { pluginNames.insert(plugin.name) } catch { log?.warning( - "Not installing plugin with invalid configuration", + "not installing plugin with invalid configuration", metadata: [ "path": "\(installURL.path)", "error": "\(error)", @@ -183,7 +183,7 @@ extension PluginLoader { } } catch { log?.warning( - "Not installing plugin with invalid configuration", + "not installing plugin with invalid configuration", metadata: [ "name": "\(name)", "error": "\(error)", diff --git a/Sources/ContainerPlugin/ServiceManager.swift b/Sources/ContainerPlugin/ServiceManager.swift index 3cee04f7c..0b2d58a2f 100644 --- a/Sources/ContainerPlugin/ServiceManager.swift +++ b/Sources/ContainerPlugin/ServiceManager.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -72,12 +72,12 @@ public struct ServiceManager { let status = launchctl.terminationStatus guard status == 0 else { throw ContainerizationError( - .internalError, message: "command `launchctl list` failed with status \(status). Message: \(String(data: stderrData, encoding: .utf8) ?? "No error message")") + .internalError, message: "command `launchctl list` failed with status \(status), message: \(String(data: stderrData, encoding: .utf8) ?? "no error message")") } guard let outputText = String(data: outputData, encoding: .utf8) else { throw ContainerizationError( - .internalError, message: "could not decode output of command `launchctl list`. Message: \(String(data: stderrData, encoding: .utf8) ?? "No error message")") + .internalError, message: "could not decode output of command `launchctl list`, message: \(String(data: stderrData, encoding: .utf8) ?? "no error message")") } // The third field of each line of launchctl list output is the label diff --git a/Sources/ContainerClient/Core/Bundle.swift b/Sources/ContainerResource/Container/Bundle.swift similarity index 93% rename from Sources/ContainerClient/Core/Bundle.swift rename to Sources/ContainerResource/Container/Bundle.swift index 34758128b..999b13dae 100644 --- a/Sources/ContainerClient/Core/Bundle.swift +++ b/Sources/ContainerResource/Container/Bundle.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -122,8 +122,12 @@ extension Bundle { path.appendingPathComponent(name) } - public func setContainerRootFs(cloning fs: Filesystem) throws { - let cloned = try fs.clone(to: self.containerRootfsBlock.absolutePath()) + public func setContainerRootFs(cloning fs: Filesystem, readonly: Bool = false) throws { + var mutableFs = fs + if readonly && !mutableFs.options.contains("ro") { + mutableFs.options.append("ro") + } + let cloned = try mutableFs.clone(to: self.containerRootfsBlock.absolutePath()) let fsData = try JSONEncoder().encode(cloned) try fsData.write(to: self.containerRootfsConfig) } diff --git a/Sources/ContainerClient/Core/ContainerConfiguration.swift b/Sources/ContainerResource/Container/ContainerConfiguration.swift similarity index 88% rename from Sources/ContainerClient/Core/ContainerConfiguration.swift rename to Sources/ContainerResource/Container/ContainerConfiguration.swift index 468ca050d..e81d09dbc 100644 --- a/Sources/ContainerClient/Core/ContainerConfiguration.swift +++ b/Sources/ContainerResource/Container/ContainerConfiguration.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,7 +14,6 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerNetworkService import ContainerizationOCI public struct ContainerConfiguration: Sendable, Codable { @@ -50,6 +49,8 @@ public struct ContainerConfiguration: Sendable, Codable { public var virtualization: Bool = false /// Enable SSH agent socket forwarding from host to container. public var ssh: Bool = false + /// Whether to mount the rootfs as read-only. + public var readOnly: Bool = false enum CodingKeys: String, CodingKey { case id @@ -68,6 +69,7 @@ public struct ContainerConfiguration: Sendable, Codable { case runtimeHandler case virtualization case ssh + case readOnly } /// Create a configuration from the supplied Decoder, initializing missing @@ -83,16 +85,8 @@ public struct ContainerConfiguration: Sendable, Codable { labels = try container.decodeIfPresent([String: String].self, forKey: .labels) ?? [:] sysctls = try container.decodeIfPresent([String: String].self, forKey: .sysctls) ?? [:] - // NOTE: migrates [String] to [AttachmentConfiguration]; remove [String] support in a later release if container.contains(.networks) { - do { - networks = try container.decode([AttachmentConfiguration].self, forKey: .networks) - } catch { - let networkIds = try container.decode([String].self, forKey: .networks) - // Parse old network IDs as simple network names without properties - let parsedNetworks = networkIds.map { Parser.ParsedNetwork(name: $0, macAddress: nil) } - networks = try Utility.getAttachmentConfigurations(containerId: id, networks: parsedNetworks) - } + networks = try container.decode([AttachmentConfiguration].self, forKey: .networks) } else { networks = [] } @@ -105,6 +99,7 @@ public struct ContainerConfiguration: Sendable, Codable { runtimeHandler = try container.decodeIfPresent(String.self, forKey: .runtimeHandler) ?? "container-runtime-linux" virtualization = try container.decodeIfPresent(Bool.self, forKey: .virtualization) ?? false ssh = try container.decodeIfPresent(Bool.self, forKey: .ssh) ?? false + readOnly = try container.decodeIfPresent(Bool.self, forKey: .readOnly) ?? false } public struct DNSConfiguration: Sendable, Codable { diff --git a/Sources/ContainerClient/Core/ContainerCreateOptions.swift b/Sources/ContainerResource/Container/ContainerCreateOptions.swift similarity index 93% rename from Sources/ContainerClient/Core/ContainerCreateOptions.swift rename to Sources/ContainerResource/Container/ContainerCreateOptions.swift index 287e52cc3..dd9da217a 100644 --- a/Sources/ContainerClient/Core/ContainerCreateOptions.swift +++ b/Sources/ContainerResource/Container/ContainerCreateOptions.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/ContainerClient/Core/ContainerSnapshot.swift b/Sources/ContainerResource/Container/ContainerSnapshot.swift similarity index 84% rename from Sources/ContainerClient/Core/ContainerSnapshot.swift rename to Sources/ContainerResource/Container/ContainerSnapshot.swift index 21aaa8f53..fc9dbcbf4 100644 --- a/Sources/ContainerClient/Core/ContainerSnapshot.swift +++ b/Sources/ContainerResource/Container/ContainerSnapshot.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,7 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerNetworkService +import Foundation /// A snapshot of a container along with its configuration /// and any runtime state information. @@ -25,14 +25,18 @@ public struct ContainerSnapshot: Codable, Sendable { public var status: RuntimeStatus /// Network interfaces attached to the sandbox that are provided to the container. public var networks: [Attachment] + /// When the container was started. + public var startedDate: Date? public init( configuration: ContainerConfiguration, status: RuntimeStatus, - networks: [Attachment] + networks: [Attachment], + startedDate: Date? = nil ) { self.configuration = configuration self.status = status self.networks = networks + self.startedDate = startedDate } } diff --git a/Sources/ContainerClient/Core/ContainerStats.swift b/Sources/ContainerResource/Container/ContainerStats.swift similarity index 72% rename from Sources/ContainerClient/Core/ContainerStats.swift rename to Sources/ContainerResource/Container/ContainerStats.swift index 6c9abe3fc..e0f9eab94 100644 --- a/Sources/ContainerClient/Core/ContainerStats.swift +++ b/Sources/ContainerResource/Container/ContainerStats.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -21,32 +21,32 @@ public struct ContainerStats: Sendable, Codable { /// Container ID public var id: String /// Physical memory usage in bytes - public var memoryUsageBytes: UInt64 + public var memoryUsageBytes: UInt64? /// Memory limit in bytes - public var memoryLimitBytes: UInt64 + public var memoryLimitBytes: UInt64? /// CPU usage in microseconds - public var cpuUsageUsec: UInt64 + public var cpuUsageUsec: UInt64? /// Network received bytes (sum of all interfaces) - public var networkRxBytes: UInt64 + public var networkRxBytes: UInt64? /// Network transmitted bytes (sum of all interfaces) - public var networkTxBytes: UInt64 + public var networkTxBytes: UInt64? /// Block I/O read bytes (sum of all devices) - public var blockReadBytes: UInt64 + public var blockReadBytes: UInt64? /// Block I/O write bytes (sum of all devices) - public var blockWriteBytes: UInt64 + public var blockWriteBytes: UInt64? /// Number of processes in the container - public var numProcesses: UInt64 + public var numProcesses: UInt64? public init( id: String, - memoryUsageBytes: UInt64, - memoryLimitBytes: UInt64, - cpuUsageUsec: UInt64, - networkRxBytes: UInt64, - networkTxBytes: UInt64, - blockReadBytes: UInt64, - blockWriteBytes: UInt64, - numProcesses: UInt64 + memoryUsageBytes: UInt64?, + memoryLimitBytes: UInt64?, + cpuUsageUsec: UInt64?, + networkRxBytes: UInt64?, + networkTxBytes: UInt64?, + blockReadBytes: UInt64?, + blockWriteBytes: UInt64?, + numProcesses: UInt64? ) { self.id = id self.memoryUsageBytes = memoryUsageBytes diff --git a/Sources/ContainerClient/Core/ContainerStopOptions.swift b/Sources/ContainerResource/Container/ContainerStopOptions.swift similarity index 94% rename from Sources/ContainerClient/Core/ContainerStopOptions.swift rename to Sources/ContainerResource/Container/ContainerStopOptions.swift index c6e8f73fa..290e66ba6 100644 --- a/Sources/ContainerClient/Core/ContainerStopOptions.swift +++ b/Sources/ContainerResource/Container/ContainerStopOptions.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/ContainerClient/Core/Filesystem.swift b/Sources/ContainerResource/Container/Filesystem.swift similarity index 93% rename from Sources/ContainerClient/Core/Filesystem.swift rename to Sources/ContainerResource/Container/Filesystem.swift index b912fc58b..7522a515f 100644 --- a/Sources/ContainerClient/Core/Filesystem.swift +++ b/Sources/ContainerResource/Container/Filesystem.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -84,10 +84,14 @@ public struct Filesystem: Sendable, Codable { self.options = options } + // Defaulting to CachedMode = .on (i.e., cached mode) to fix Linux FS issue when using Virtualization + // * https://github.com/apple/container/issues/614 + // * https://github.com/utmapp/UTM/pull/5919 + /// A block based filesystem. public static func block( - format: String, source: String, destination: String, options: MountOptions, cache: CacheMode = .auto, - sync: SyncMode = .full + format: String, source: String, destination: String, options: MountOptions, cache: CacheMode = .on, + sync: SyncMode = .fsync ) -> Filesystem { .init( type: .block(format: format, cache: cache, sync: sync), @@ -100,7 +104,7 @@ public struct Filesystem: Sendable, Codable { /// A named volume filesystem. public static func volume( name: String, format: String, source: String, destination: String, options: MountOptions, - cache: CacheMode = .auto, sync: SyncMode = .full + cache: CacheMode = .on, sync: SyncMode = .fsync ) -> Filesystem { .init( type: .volume(name: name, format: format, cache: cache, sync: sync), diff --git a/Sources/ContainerClient/Core/ProcessConfiguration.swift b/Sources/ContainerResource/Container/ProcessConfiguration.swift similarity index 98% rename from Sources/ContainerClient/Core/ProcessConfiguration.swift rename to Sources/ContainerResource/Container/ProcessConfiguration.swift index cbc1fc8d0..73bf7c530 100644 --- a/Sources/ContainerClient/Core/ProcessConfiguration.swift +++ b/Sources/ContainerResource/Container/ProcessConfiguration.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/ContainerClient/Core/PublishPort.swift b/Sources/ContainerResource/Container/PublishPort.swift similarity index 74% rename from Sources/ContainerClient/Core/PublishPort.swift rename to Sources/ContainerResource/Container/PublishPort.swift index e36906593..6895a924c 100644 --- a/Sources/ContainerClient/Core/PublishPort.swift +++ b/Sources/ContainerResource/Container/PublishPort.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,6 +14,8 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerizationExtras + /// The network protocols available for port forwarding. public enum PublishProtocol: String, Sendable, Codable { case tcp = "tcp" @@ -37,7 +39,7 @@ public enum PublishProtocol: String, Sendable, Codable { /// Specifies internet port forwarding from host to container. public struct PublishPort: Sendable, Codable { /// The IP address of the proxy listener on the host - public let hostAddress: String + public let hostAddress: IPAddress /// The port number of the proxy listener on the host public let hostPort: UInt16 @@ -52,7 +54,7 @@ public struct PublishPort: Sendable, Codable { public let count: UInt16 /// Creates a new port forwarding specification. - public init(hostAddress: String, hostPort: UInt16, containerPort: UInt16, proto: PublishProtocol, count: UInt16) { + public init(hostAddress: IPAddress, hostPort: UInt16, containerPort: UInt16, proto: PublishProtocol, count: UInt16) { self.hostAddress = hostAddress self.hostPort = hostPort self.containerPort = containerPort @@ -65,10 +67,26 @@ public struct PublishPort: Sendable, Codable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - hostAddress = try container.decode(String.self, forKey: .hostAddress) + hostAddress = try container.decode(IPAddress.self, forKey: .hostAddress) hostPort = try container.decode(UInt16.self, forKey: .hostPort) containerPort = try container.decode(UInt16.self, forKey: .containerPort) proto = try container.decode(PublishProtocol.self, forKey: .proto) count = try container.decodeIfPresent(UInt16.self, forKey: .count) ?? 1 } } + +extension [PublishPort] { + public func hasOverlaps() -> Bool { + var hostPorts = Set() + for publishPort in self { + for index in publishPort.hostPort..<(publishPort.hostPort + publishPort.count) { + let hostPortKey = "\(index)/\(publishPort.proto.rawValue)" + guard !hostPorts.contains(hostPortKey) else { + return true + } + hostPorts.insert(hostPortKey) + } + } + return false + } +} diff --git a/Sources/ContainerClient/Core/PublishSocket.swift b/Sources/ContainerResource/Container/PublishSocket.swift similarity index 95% rename from Sources/ContainerClient/Core/PublishSocket.swift rename to Sources/ContainerResource/Container/PublishSocket.swift index fdba20040..08ea752bf 100644 --- a/Sources/ContainerClient/Core/PublishSocket.swift +++ b/Sources/ContainerResource/Container/PublishSocket.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/ContainerClient/Core/RuntimeStatus.swift b/Sources/ContainerResource/Container/RuntimeStatus.swift similarity index 94% rename from Sources/ContainerClient/Core/RuntimeStatus.swift rename to Sources/ContainerResource/Container/RuntimeStatus.swift index e075a18ad..88900735f 100644 --- a/Sources/ContainerClient/Core/RuntimeStatus.swift +++ b/Sources/ContainerResource/Container/RuntimeStatus.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/ContainerClient/Core/ImageDescription.swift b/Sources/ContainerResource/Image/ImageDescription.swift similarity index 95% rename from Sources/ContainerClient/Core/ImageDescription.swift rename to Sources/ContainerResource/Image/ImageDescription.swift index 3393178ba..a54f73a16 100644 --- a/Sources/ContainerClient/Core/ImageDescription.swift +++ b/Sources/ContainerResource/Image/ImageDescription.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/ContainerClient/Core/ImageDetail.swift b/Sources/ContainerResource/Image/ImageDetail.swift similarity index 51% rename from Sources/ContainerClient/Core/ImageDetail.swift rename to Sources/ContainerResource/Image/ImageDetail.swift index f18a8968d..cf2d672d1 100644 --- a/Sources/ContainerClient/Core/ImageDetail.swift +++ b/Sources/ContainerResource/Image/ImageDetail.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -27,40 +27,16 @@ public struct ImageDetail: Codable { public let config: ContainerizationOCI.Image public let size: Int64 - init(platform: Platform, size: Int64, config: ContainerizationOCI.Image) { + public init(platform: Platform, size: Int64, config: ContainerizationOCI.Image) { self.platform = platform self.config = config self.size = size } } - init(name: String, index: Descriptor, variants: [Variants]) { + public init(name: String, index: Descriptor, variants: [Variants]) { self.name = name self.index = index self.variants = variants } } - -extension ClientImage { - public func details() async throws -> ImageDetail { - let descriptor = try await self.resolved() - let reference = self.reference - var variants: [ImageDetail.Variants] = [] - for desc in try await self.index().manifests { - guard let platform = desc.platform else { - continue - } - let config: ContainerizationOCI.Image - let manifest: ContainerizationOCI.Manifest - do { - config = try await self.config(for: platform) - manifest = try await self.manifest(for: platform) - } catch { - continue - } - let size = desc.size + manifest.config.size + manifest.layers.reduce(0, { (l, r) in l + r.size }) - variants.append(.init(platform: platform, size: size, config: config)) - } - return ImageDetail(name: reference, index: descriptor, variants: variants) - } -} diff --git a/Sources/Services/ContainerNetworkService/Attachment.swift b/Sources/ContainerResource/Network/Attachment.swift similarity index 58% rename from Sources/Services/ContainerNetworkService/Attachment.swift rename to Sources/ContainerResource/Network/Attachment.swift index d2d06e132..f7178f2d9 100644 --- a/Sources/Services/ContainerNetworkService/Attachment.swift +++ b/Sources/ContainerResource/Network/Attachment.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,24 +14,37 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerizationExtras + /// A snapshot of a network interface allocated to a sandbox. public struct Attachment: Codable, Sendable { /// The network ID associated with the attachment. public let network: String /// The hostname associated with the attachment. public let hostname: String - /// The subnet CIDR, where the address is the container interface IPv4 address. - public let address: String + /// The CIDR address describing the interface IPv4 address, with the prefix length of the subnet. + public let ipv4Address: CIDRv4 /// The IPv4 gateway address. - public let gateway: String + public let ipv4Gateway: IPv4Address + /// The CIDR address describing the interface IPv6 address, with the prefix length of the subnet. + /// The address is nil if the IPv6 subnet could not be determined at network creation time. + public let ipv6Address: CIDRv6? /// The MAC address associated with the attachment (optional). - public let macAddress: String? + public let macAddress: MACAddress? - public init(network: String, hostname: String, address: String, gateway: String, macAddress: String? = nil) { + public init( + network: String, + hostname: String, + ipv4Address: CIDRv4, + ipv4Gateway: IPv4Address, + ipv6Address: CIDRv6?, + macAddress: MACAddress? + ) { self.network = network self.hostname = hostname - self.address = address - self.gateway = gateway + self.ipv4Address = ipv4Address + self.ipv4Gateway = ipv4Gateway + self.ipv6Address = ipv6Address self.macAddress = macAddress } } diff --git a/Sources/Services/ContainerNetworkService/AttachmentConfiguration.swift b/Sources/ContainerResource/Network/AttachmentConfiguration.swift similarity index 88% rename from Sources/Services/ContainerNetworkService/AttachmentConfiguration.swift rename to Sources/ContainerResource/Network/AttachmentConfiguration.swift index 7194856c4..7deb9e16c 100644 --- a/Sources/Services/ContainerNetworkService/AttachmentConfiguration.swift +++ b/Sources/ContainerResource/Network/AttachmentConfiguration.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,6 +14,8 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerizationExtras + /// Configuration information for attaching a container network interface to a network. public struct AttachmentConfiguration: Codable, Sendable { /// The network ID associated with the attachment. @@ -34,9 +36,9 @@ public struct AttachmentOptions: Codable, Sendable { public let hostname: String /// The MAC address associated with the attachment (optional). - public let macAddress: String? + public let macAddress: MACAddress? - public init(hostname: String, macAddress: String? = nil) { + public init(hostname: String, macAddress: MACAddress? = nil) { self.hostname = hostname self.macAddress = macAddress } diff --git a/Sources/Services/ContainerNetworkService/NetworkConfiguration.swift b/Sources/ContainerResource/Network/NetworkConfiguration.swift similarity index 74% rename from Sources/Services/ContainerNetworkService/NetworkConfiguration.swift rename to Sources/ContainerResource/Network/NetworkConfiguration.swift index 217a78485..234a08098 100644 --- a/Sources/Services/ContainerNetworkService/NetworkConfiguration.swift +++ b/Sources/ContainerResource/Network/NetworkConfiguration.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -29,8 +29,11 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable { /// When the network was created. public let creationDate: Date - /// The preferred CIDR address for the subnet, if specified - public let subnet: String? + /// The preferred CIDR address for the IPv4 subnet, if specified + public let ipv4Subnet: CIDRv4? + + /// The preferred CIDR address for the IPv6 subnet, if specified + public let ipv6Subnet: CIDRv6? /// Key-value labels for the network. public var labels: [String: String] = [:] @@ -39,13 +42,15 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable { public init( id: String, mode: NetworkMode, - subnet: String? = nil, + ipv4Subnet: CIDRv4? = nil, + ipv6Subnet: CIDRv6? = nil, labels: [String: String] = [:] ) throws { self.id = id self.creationDate = Date() self.mode = mode - self.subnet = subnet + self.ipv4Subnet = ipv4Subnet + self.ipv6Subnet = ipv6Subnet self.labels = labels try validate() } @@ -54,8 +59,11 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable { case id case creationDate case mode - case subnet + case ipv4Subnet + case ipv6Subnet case labels + // TODO: retain for deserialization compatability for now, remove later + case subnet } /// Create a configuration from the supplied Decoder, initializing missing @@ -66,20 +74,33 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable { id = try container.decode(String.self, forKey: .id) creationDate = try container.decodeIfPresent(Date.self, forKey: .creationDate) ?? Date(timeIntervalSince1970: 0) mode = try container.decode(NetworkMode.self, forKey: .mode) - subnet = try container.decodeIfPresent(String.self, forKey: .subnet) + let subnetText = + try container.decodeIfPresent(String.self, forKey: .ipv4Subnet) + ?? container.decodeIfPresent(String.self, forKey: .subnet) + ipv4Subnet = try subnetText.map { try CIDRv4($0) } + ipv6Subnet = try container.decodeIfPresent(String.self, forKey: .ipv6Subnet) + .map { try CIDRv6($0) } labels = try container.decodeIfPresent([String: String].self, forKey: .labels) ?? [:] try validate() } + /// Encode the configuration to the supplied Encoder. + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(id, forKey: .id) + try container.encode(creationDate, forKey: .creationDate) + try container.encode(mode, forKey: .mode) + try container.encodeIfPresent(ipv4Subnet, forKey: .ipv4Subnet) + try container.encodeIfPresent(ipv6Subnet, forKey: .ipv6Subnet) + try container.encode(labels, forKey: .labels) + } + private func validate() throws { guard id.isValidNetworkID() else { throw ContainerizationError(.invalidArgument, message: "invalid network ID: \(id)") } - if let subnet { - _ = try CIDRAddress(subnet) - } - for (key, value) in labels { try validateLabel(key: key, value: value) } diff --git a/Sources/Services/ContainerNetworkService/NetworkMode.swift b/Sources/ContainerResource/Network/NetworkMode.swift similarity index 95% rename from Sources/Services/ContainerNetworkService/NetworkMode.swift rename to Sources/ContainerResource/Network/NetworkMode.swift index 99d1eba12..4b7a4a6eb 100644 --- a/Sources/Services/ContainerNetworkService/NetworkMode.swift +++ b/Sources/ContainerResource/Network/NetworkMode.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/Services/ContainerNetworkService/NetworkState.swift b/Sources/ContainerResource/Network/NetworkState.swift similarity index 73% rename from Sources/Services/ContainerNetworkService/NetworkState.swift rename to Sources/ContainerResource/Network/NetworkState.swift index 0734afa5d..244041485 100644 --- a/Sources/Services/ContainerNetworkService/NetworkState.swift +++ b/Sources/ContainerResource/Network/NetworkState.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,23 +14,31 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerizationExtras import Foundation public struct NetworkStatus: Codable, Sendable { /// The address allocated for the network if no subnet was specified at /// creation time; otherwise, the subnet from the configuration. - public let address: String + public let ipv4Subnet: CIDRv4 + /// The gateway IPv4 address. - public let gateway: String + public let ipv4Gateway: IPv4Address + + /// The address allocated for the IPv6 network if no subnet was specified at + /// creation time; otherwise, the IPv6 subnet from the configuration. + /// The value is nil if the IPv6 subnet cannot be determined at creation time. + public let ipv6Subnet: CIDRv6? public init( - address: String, - gateway: String + ipv4Subnet: CIDRv4, + ipv4Gateway: IPv4Address, + ipv6Subnet: CIDRv6?, ) { - self.address = address - self.gateway = gateway + self.ipv4Subnet = ipv4Subnet + self.ipv4Gateway = ipv4Gateway + self.ipv6Subnet = ipv6Subnet } - } /// The configuration and runtime attributes for a network. diff --git a/Sources/ContainerClient/Core/Volume.swift b/Sources/ContainerResource/Volume/Volume.swift similarity index 89% rename from Sources/ContainerClient/Core/Volume.swift rename to Sources/ContainerResource/Volume/Volume.swift index 08da7e037..fe4de996a 100644 --- a/Sources/ContainerClient/Core/Volume.swift +++ b/Sources/ContainerResource/Volume/Volume.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -80,17 +80,17 @@ public enum VolumeError: Error, LocalizedError { public var errorDescription: String? { switch self { case .volumeNotFound(let name): - return "Volume '\(name)' not found" + return "volume '\(name)' not found" case .volumeAlreadyExists(let name): - return "Volume '\(name)' already exists" + return "volume '\(name)' already exists" case .volumeInUse(let name): - return "Volume '\(name)' is currently in use and cannot be accessed by another container, or deleted." + return "volume '\(name)' is currently in use and cannot be accessed by another container, or deleted" case .invalidVolumeName(let name): - return "Invalid volume name '\(name)'" + return "invalid volume name '\(name)'" case .driverNotSupported(let driver): - return "Volume driver '\(driver)' is not supported" + return "volume driver '\(driver)' is not supported" case .storageError(let message): - return "Storage error: \(message)" + return "storage error: \(message)" } } } diff --git a/Sources/ContainerVersion/Bundle+AppBundle.swift b/Sources/ContainerVersion/Bundle+AppBundle.swift index 077e016cd..5028897e1 100644 --- a/Sources/ContainerVersion/Bundle+AppBundle.swift +++ b/Sources/ContainerVersion/Bundle+AppBundle.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerVersion/CommandLine+Executable.swift b/Sources/ContainerVersion/CommandLine+Executable.swift index ed0a94eae..c48cbe573 100644 --- a/Sources/ContainerVersion/CommandLine+Executable.swift +++ b/Sources/ContainerVersion/CommandLine+Executable.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -26,7 +26,7 @@ extension CommandLine { /// Create the buffer and get the path buffer = [CChar](repeating: 0, count: Int(bufferSize)) guard _NSGetExecutablePath(&buffer, &bufferSize) == 0 else { - fatalError("UNEXPECTED: failed to get executable path") + fatalError("unexpected: failed to get executable path") } /// Return the path with the executable file component removed the last component and diff --git a/Sources/ContainerVersion/ReleaseVersion.swift b/Sources/ContainerVersion/ReleaseVersion.swift index c5a07cf80..4ae98d6a4 100644 --- a/Sources/ContainerVersion/ReleaseVersion.swift +++ b/Sources/ContainerVersion/ReleaseVersion.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -19,16 +19,21 @@ import Foundation public struct ReleaseVersion { public static func singleLine(appName: String) -> String { - var versionDetails: [String: String] = ["build": "release"] - #if DEBUG - versionDetails["build"] = "debug" - #endif + var versionDetails: [String: String] = ["build": buildType()] versionDetails["commit"] = gitCommit().map { String($0.prefix(7)) } ?? "unspecified" let extras: String = versionDetails.map { "\($0): \($1)" }.sorted().joined(separator: ", ") return "\(appName) version \(version()) (\(extras))" } + public static func buildType() -> String { + #if DEBUG + return "debug" + #else + return "release" + #endif + } + public static func version() -> String { let appBundle = Bundle.appBundle(executableURL: CommandLine.executablePathUrl) let bundleVersion = appBundle?.infoDictionary?["CFBundleShortVersionString"] as? String diff --git a/Sources/ContainerXPC/XPCClient.swift b/Sources/ContainerXPC/XPCClient.swift index 72affeceb..9fd1891ce 100644 --- a/Sources/ContainerXPC/XPCClient.swift +++ b/Sources/ContainerXPC/XPCClient.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerXPC/XPCMessage.swift b/Sources/ContainerXPC/XPCMessage.swift index d14658a8f..18b3e98a4 100644 --- a/Sources/ContainerXPC/XPCMessage.swift +++ b/Sources/ContainerXPC/XPCMessage.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerXPC/XPCServer.swift b/Sources/ContainerXPC/XPCServer.swift index 7eb814770..3dd6b557a 100644 --- a/Sources/ContainerXPC/XPCServer.swift +++ b/Sources/ContainerXPC/XPCServer.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -239,12 +239,12 @@ extension xpc_object_t { } var connectionError: Bool { - precondition(isError, "Not an error") + precondition(isError, "not an error") return xpc_equal(self, XPC_ERROR_CONNECTION_INVALID) || xpc_equal(self, XPC_ERROR_CONNECTION_INTERRUPTED) } var connectionClosed: Bool { - precondition(isError, "Not an error") + precondition(isError, "not an error") return xpc_equal(self, XPC_ERROR_CONNECTION_INVALID) } @@ -253,7 +253,7 @@ extension xpc_object_t { } var errorDescription: String? { - precondition(isError, "Not an error") + precondition(isError, "not an error") let cstring = xpc_dictionary_get_string(self, XPC_ERROR_KEY_DESCRIPTION) guard let cstring else { return nil diff --git a/Sources/DNSServer/DNSHandler.swift b/Sources/DNSServer/DNSHandler.swift index 594e6f271..4a5008c62 100644 --- a/Sources/DNSServer/DNSHandler.swift +++ b/Sources/DNSServer/DNSHandler.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/DNSServer/DNSServer+Handle.swift b/Sources/DNSServer/DNSServer+Handle.swift index 2748f0d94..e258fb482 100644 --- a/Sources/DNSServer/DNSServer+Handle.swift +++ b/Sources/DNSServer/DNSServer+Handle.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/DNSServer/DNSServer.swift b/Sources/DNSServer/DNSServer.swift index 100a6a2e8..d38e55459 100644 --- a/Sources/DNSServer/DNSServer.swift +++ b/Sources/DNSServer/DNSServer.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/DNSServer/Handlers/CompositeResolver.swift b/Sources/DNSServer/Handlers/CompositeResolver.swift index 324494843..e090ce1fb 100644 --- a/Sources/DNSServer/Handlers/CompositeResolver.swift +++ b/Sources/DNSServer/Handlers/CompositeResolver.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/DNSServer/Handlers/HostTableResolver.swift b/Sources/DNSServer/Handlers/HostTableResolver.swift index 9806b5ca2..0bc247609 100644 --- a/Sources/DNSServer/Handlers/HostTableResolver.swift +++ b/Sources/DNSServer/Handlers/HostTableResolver.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/DNSServer/Handlers/NxDomainResolver.swift b/Sources/DNSServer/Handlers/NxDomainResolver.swift index 1fe9de058..68b36bf1d 100644 --- a/Sources/DNSServer/Handlers/NxDomainResolver.swift +++ b/Sources/DNSServer/Handlers/NxDomainResolver.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/DNSServer/Handlers/StandardQueryValidator.swift b/Sources/DNSServer/Handlers/StandardQueryValidator.swift index 600e56ac9..fb0d74ae7 100644 --- a/Sources/DNSServer/Handlers/StandardQueryValidator.swift +++ b/Sources/DNSServer/Handlers/StandardQueryValidator.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/DNSServer/Types.swift b/Sources/DNSServer/Types.swift index a9884237a..60e5cd6e0 100644 --- a/Sources/DNSServer/Types.swift +++ b/Sources/DNSServer/Types.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/Helpers/APIServer/APIServer+Start.swift b/Sources/Helpers/APIServer/APIServer+Start.swift index a048d6550..8366a04ce 100644 --- a/Sources/Helpers/APIServer/APIServer+Start.swift +++ b/Sources/Helpers/APIServer/APIServer+Start.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,10 +15,11 @@ //===----------------------------------------------------------------------===// import ArgumentParser +import ContainerAPIClient import ContainerAPIService -import ContainerClient import ContainerNetworkService import ContainerPlugin +import ContainerResource import ContainerXPC import DNSServer import Foundation @@ -271,7 +272,7 @@ extension APIServer { routes[XPCRoute.volumeDelete] = harness.delete routes[XPCRoute.volumeList] = harness.list routes[XPCRoute.volumeInspect] = harness.inspect - routes[XPCRoute.volumePrune] = harness.prune + routes[XPCRoute.volumeDiskUsage] = harness.diskUsage return service } diff --git a/Sources/Helpers/APIServer/APIServer.swift b/Sources/Helpers/APIServer/APIServer.swift index 16cd8501f..2d83370ee 100644 --- a/Sources/Helpers/APIServer/APIServer.swift +++ b/Sources/Helpers/APIServer/APIServer.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/Helpers/APIServer/ContainerDNSHandler.swift b/Sources/Helpers/APIServer/ContainerDNSHandler.swift index 0104c1351..4fcf8a2c4 100644 --- a/Sources/Helpers/APIServer/ContainerDNSHandler.swift +++ b/Sources/Helpers/APIServer/ContainerDNSHandler.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -35,11 +35,12 @@ struct ContainerDNSHandler: DNSHandler { case ResourceRecordType.host: record = try await answerHost(question: question) case ResourceRecordType.host6: - // Return NODATA (noError with empty answers) for AAAA queries ONLY if A record exists. - // This is required because musl libc has issues when A record exists but AAAA returns NXDOMAIN. - // musl treats NXDOMAIN on AAAA as "domain doesn't exist" and fails DNS resolution entirely. - // NODATA correctly indicates "no IPv6 address available, but domain exists". - if try await networkService.lookup(hostname: question.name) != nil { + let result = try await answerHost6(question: question) + if result.record == nil && result.hostnameExists { + // Return NODATA (noError with empty answers) when hostname exists but has no IPv6. + // This is required because musl libc has issues when A record exists but AAAA returns NXDOMAIN. + // musl treats NXDOMAIN on AAAA as "domain doesn't exist" and fails DNS resolution entirely. + // NODATA correctly indicates "no IPv6 address available, but domain exists". return Message( id: query.id, type: .response, @@ -48,8 +49,7 @@ struct ContainerDNSHandler: DNSHandler { answers: [] ) } - // If hostname doesn't exist, return nil which will become NXDOMAIN - return nil + record = result.record case ResourceRecordType.nameServer, ResourceRecordType.alias, ResourceRecordType.startOfAuthority, @@ -94,17 +94,26 @@ struct ContainerDNSHandler: DNSHandler { guard let ipAllocation = try await networkService.lookup(hostname: question.name) else { return nil } - - let components = ipAllocation.address.split(separator: "/") - guard !components.isEmpty else { - throw DNSResolverError.serverError("Invalid IP format: empty address") + let ipv4 = ipAllocation.ipv4Address.address.description + guard let ip = IPv4(ipv4) else { + throw DNSResolverError.serverError("failed to parse IP address: \(ipv4)") } - let ipString = String(components[0]) - guard let ip = IPv4(ipString) else { - throw DNSResolverError.serverError("Failed to parse IP address: \(ipString)") + return HostRecord(name: question.name, ttl: ttl, ip: ip) + } + + private func answerHost6(question: Question) async throws -> (record: ResourceRecord?, hostnameExists: Bool) { + guard let ipAllocation = try await networkService.lookup(hostname: question.name) else { + return (nil, false) + } + guard let ipv6Address = ipAllocation.ipv6Address else { + return (nil, true) + } + let ipv6 = ipv6Address.address.description + guard let ip = IPv6(ipv6) else { + throw DNSResolverError.serverError("failed to parse IPv6 address: \(ipv6)") } - return HostRecord(name: question.name, ttl: ttl, ip: ip) + return (HostRecord(name: question.name, ttl: ttl, ip: ip), true) } } diff --git a/Sources/Helpers/Images/ImagesHelper.swift b/Sources/Helpers/Images/ImagesHelper.swift index d7383f4bc..9a9be3149 100644 --- a/Sources/Helpers/Images/ImagesHelper.swift +++ b/Sources/Helpers/Images/ImagesHelper.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/Helpers/NetworkVmnet/NetworkVmnetHelper+Start.swift b/Sources/Helpers/NetworkVmnet/NetworkVmnetHelper+Start.swift index 3945b8888..f82629fd1 100644 --- a/Sources/Helpers/NetworkVmnet/NetworkVmnetHelper+Start.swift +++ b/Sources/Helpers/NetworkVmnet/NetworkVmnetHelper+Start.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -16,6 +16,8 @@ import ArgumentParser import ContainerNetworkService +import ContainerNetworkServiceClient +import ContainerResource import ContainerXPC import ContainerizationExtras import Foundation @@ -37,8 +39,11 @@ extension NetworkVmnetHelper { @Option(name: .shortAndLong, help: "Network identifier") var id: String - @Option(name: .shortAndLong, help: "CIDR address for the subnet") - var subnet: String? + @Option(name: .customLong("subnet"), help: "CIDR address for the IPv4 subnet") + var ipv4Subnet: String? + + @Option(name: .customLong("subnet-v6"), help: "CIDR address for the IPv6 prefix") + var ipv6Subnet: String? func run() async throws { let commandName = NetworkVmnetHelper._commandName @@ -50,8 +55,14 @@ extension NetworkVmnetHelper { do { log.info("configuring XPC server") - let subnet = try self.subnet.map { try CIDRAddress($0) } - let configuration = try NetworkConfiguration(id: id, mode: .nat, subnet: subnet?.description) + let ipv4Subnet = try self.ipv4Subnet.map { try CIDRv4($0) } + let ipv6Subnet = try self.ipv6Subnet.map { try CIDRv6($0) } + let configuration = try NetworkConfiguration( + id: id, + mode: .nat, + ipv4Subnet: ipv4Subnet, + ipv6Subnet: ipv6Subnet, + ) let network = try Self.createNetwork(configuration: configuration, log: log) try await network.start() let server = try await NetworkService(network: network, log: log) diff --git a/Sources/Helpers/NetworkVmnet/NetworkVmnetHelper.swift b/Sources/Helpers/NetworkVmnet/NetworkVmnetHelper.swift index 983c62cd7..c924f0635 100644 --- a/Sources/Helpers/NetworkVmnet/NetworkVmnetHelper.swift +++ b/Sources/Helpers/NetworkVmnet/NetworkVmnetHelper.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/Helpers/RuntimeLinux/IsolatedInterfaceStrategy.swift b/Sources/Helpers/RuntimeLinux/IsolatedInterfaceStrategy.swift index ebccd01dd..9f4c493ce 100644 --- a/Sources/Helpers/RuntimeLinux/IsolatedInterfaceStrategy.swift +++ b/Sources/Helpers/RuntimeLinux/IsolatedInterfaceStrategy.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -14,7 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerNetworkService +import ContainerResource import ContainerSandboxService import ContainerXPC import Containerization @@ -24,7 +24,7 @@ import Containerization /// works for macOS Sequoia. struct IsolatedInterfaceStrategy: InterfaceStrategy { public func toInterface(attachment: Attachment, interfaceIndex: Int, additionalData: XPCMessage?) -> Interface { - let gateway = interfaceIndex == 0 ? attachment.gateway : nil - return NATInterface(address: attachment.address, gateway: gateway, macAddress: attachment.macAddress) + let ipv4Gateway = interfaceIndex == 0 ? attachment.ipv4Gateway : nil + return NATInterface(ipv4Address: attachment.ipv4Address, ipv4Gateway: ipv4Gateway, macAddress: attachment.macAddress) } } diff --git a/Sources/Helpers/RuntimeLinux/NonisolatedInterfaceStrategy.swift b/Sources/Helpers/RuntimeLinux/NonisolatedInterfaceStrategy.swift index e447ab91e..787ff31d1 100644 --- a/Sources/Helpers/RuntimeLinux/NonisolatedInterfaceStrategy.swift +++ b/Sources/Helpers/RuntimeLinux/NonisolatedInterfaceStrategy.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -14,7 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerNetworkService +import ContainerResource import ContainerSandboxService import ContainerXPC import Containerization @@ -43,7 +43,7 @@ struct NonisolatedInterfaceStrategy: InterfaceStrategy { } log.info("creating NATNetworkInterface with network reference") - let gateway = interfaceIndex == 0 ? attachment.gateway : nil - return NATNetworkInterface(address: attachment.address, gateway: gateway, reference: networkRef, macAddress: attachment.macAddress) + let ipv4Gateway = interfaceIndex == 0 ? attachment.ipv4Gateway : nil + return NATNetworkInterface(ipv4Address: attachment.ipv4Address, ipv4Gateway: ipv4Gateway, reference: networkRef, macAddress: attachment.macAddress) } } diff --git a/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper+Start.swift b/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper+Start.swift index c660ea475..55ac54206 100644 --- a/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper+Start.swift +++ b/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper+Start.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,9 +15,10 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerClient import ContainerLog +import ContainerResource import ContainerSandboxService +import ContainerSandboxServiceClient import ContainerXPC import Foundation import Logging diff --git a/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper.swift b/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper.swift index 6e4072001..3d4e62d86 100644 --- a/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper.swift +++ b/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/ContainerClient/Arch.swift b/Sources/Services/ContainerAPIService/Client/Arch.swift similarity index 74% rename from Sources/ContainerClient/Arch.swift rename to Sources/Services/ContainerAPIService/Client/Arch.swift index 12ae5fcf7..bc90dea29 100644 --- a/Sources/ContainerClient/Arch.swift +++ b/Sources/Services/ContainerAPIService/Client/Arch.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -17,6 +17,17 @@ public enum Arch: String { case arm64, amd64 + public init?(rawValue: String) { + switch rawValue.lowercased() { + case "arm64", "aarch64": + self = .arm64 + case "amd64", "x86_64", "x86-64": + self = .amd64 + default: + return nil + } + } + public static func hostArchitecture() -> Arch { #if arch(arm64) return .arm64 diff --git a/Sources/ContainerClient/Archiver.swift b/Sources/Services/ContainerAPIService/Client/Archiver.swift similarity index 99% rename from Sources/ContainerClient/Archiver.swift rename to Sources/Services/ContainerAPIService/Client/Archiver.swift index eea80108b..d4b03972b 100644 --- a/Sources/ContainerClient/Archiver.swift +++ b/Sources/Services/ContainerAPIService/Client/Archiver.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/ContainerClient/Array+Dedupe.swift b/Sources/Services/ContainerAPIService/Client/Array+Dedupe.swift similarity index 93% rename from Sources/ContainerClient/Array+Dedupe.swift rename to Sources/Services/ContainerAPIService/Client/Array+Dedupe.swift index 6a48ac482..160a395a8 100644 --- a/Sources/ContainerClient/Array+Dedupe.swift +++ b/Sources/Services/ContainerAPIService/Client/Array+Dedupe.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/ContainerClient/Core/ClientContainer.swift b/Sources/Services/ContainerAPIService/Client/ClientContainer.swift similarity index 97% rename from Sources/ContainerClient/Core/ClientContainer.swift rename to Sources/Services/ContainerAPIService/Client/ClientContainer.swift index cf4cc6561..0eb3dc6e2 100644 --- a/Sources/ContainerClient/Core/ClientContainer.swift +++ b/Sources/Services/ContainerAPIService/Client/ClientContainer.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,7 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerNetworkService +import ContainerResource import ContainerXPC import Containerization import ContainerizationError @@ -43,16 +43,21 @@ public struct ClientContainer: Sendable, Codable { /// Network allocated to the container. public let networks: [Attachment] + /// When the container was started. + public let startedDate: Date? + package init(configuration: ContainerConfiguration) { self.configuration = configuration self.status = .stopped self.networks = [] + self.startedDate = nil } init(snapshot: ContainerSnapshot) { self.configuration = snapshot.configuration self.status = snapshot.status self.networks = snapshot.networks + self.startedDate = snapshot.startedDate } } diff --git a/Sources/ContainerClient/Core/ClientDiskUsage.swift b/Sources/Services/ContainerAPIService/Client/ClientDiskUsage.swift similarity index 92% rename from Sources/ContainerClient/Core/ClientDiskUsage.swift rename to Sources/Services/ContainerAPIService/Client/ClientDiskUsage.swift index ff92c15ef..9d0a0040a 100644 --- a/Sources/ContainerClient/Core/ClientDiskUsage.swift +++ b/Sources/Services/ContainerAPIService/Client/ClientDiskUsage.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -31,7 +31,7 @@ public struct ClientDiskUsage { guard let responseData = reply.dataNoCopy(key: .diskUsageStats) else { throw ContainerizationError( .internalError, - message: "Invalid response from server: missing disk usage data" + message: "invalid response from server: missing disk usage data" ) } diff --git a/Sources/ContainerClient/Core/ClientHealthCheck.swift b/Sources/Services/ContainerAPIService/Client/ClientHealthCheck.swift similarity index 74% rename from Sources/ContainerClient/Core/ClientHealthCheck.swift rename to Sources/Services/ContainerAPIService/Client/ClientHealthCheck.swift index 90f006aa8..f20445009 100644 --- a/Sources/ContainerClient/Core/ClientHealthCheck.swift +++ b/Sources/Services/ContainerAPIService/Client/ClientHealthCheck.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -43,6 +43,19 @@ extension ClientHealthCheck { guard let apiServerCommit = reply.string(key: .apiServerCommit) else { throw ContainerizationError(.internalError, message: "failed to decode apiServerCommit in health check") } - return .init(appRoot: appRoot, installRoot: installRoot, apiServerVersion: apiServerVersion, apiServerCommit: apiServerCommit) + guard let apiServerBuild = reply.string(key: .apiServerBuild) else { + throw ContainerizationError(.internalError, message: "failed to decode apiServerBuild in health check") + } + guard let apiServerAppName = reply.string(key: .apiServerAppName) else { + throw ContainerizationError(.internalError, message: "failed to decode apiServerAppName in health check") + } + return .init( + appRoot: appRoot, + installRoot: installRoot, + apiServerVersion: apiServerVersion, + apiServerCommit: apiServerCommit, + apiServerBuild: apiServerBuild, + apiServerAppName: apiServerAppName + ) } } diff --git a/Sources/ContainerClient/Core/ClientImage.swift b/Sources/Services/ContainerAPIService/Client/ClientImage.swift similarity index 85% rename from Sources/ContainerClient/Core/ClientImage.swift rename to Sources/Services/ContainerAPIService/Client/ClientImage.swift index fa6da0731..eafa2e468 100644 --- a/Sources/ContainerClient/Core/ClientImage.swift +++ b/Sources/Services/ContainerAPIService/Client/ClientImage.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -16,6 +16,7 @@ import ContainerImagesServiceClient import ContainerPersistence +import ContainerResource import ContainerXPC import Containerization import ContainerizationError @@ -220,7 +221,13 @@ extension ClientImage { }) } - public static func pull(reference: String, platform: Platform? = nil, scheme: RequestScheme = .auto, progressUpdate: ProgressUpdateHandler? = nil) async throws -> ClientImage { + public static func pull( + reference: String, platform: Platform? = nil, scheme: RequestScheme = .auto, progressUpdate: ProgressUpdateHandler? = nil, maxConcurrentDownloads: Int = 3 + ) async throws -> ClientImage { + guard maxConcurrentDownloads > 0 else { + throw ContainerizationError(.invalidArgument, message: "maximum number of concurrent downloads must be greater than 0, got \(maxConcurrentDownloads)") + } + let client = newXPCClient() let request = newRequest(.imagePull) @@ -234,6 +241,7 @@ extension ClientImage { let insecure = try scheme.schemeFor(host: host) == .http request.set(key: .insecureFlag, value: insecure) + request.set(key: .maxConcurrentDownloads, value: Int64(maxConcurrentDownloads)) var progressUpdateClient: ProgressUpdateClient? if let progressUpdate { @@ -272,16 +280,18 @@ extension ClientImage { let _ = try await client.send(request) } - public static func load(from tarFile: String) async throws -> [ClientImage] { + public static func load(from tarFile: String, force: Bool = false) async throws -> ImageLoadResult { let client = newXPCClient() let request = newRequest(.imageLoad) request.set(key: .filePath, value: tarFile) + request.set(key: .forceLoad, value: force) let reply = try await client.send(request) - let loaded = try reply.imageDescriptions() - return loaded.map { desc in + let (descriptions, rejectedMembers) = try reply.loadResults() + let images = descriptions.map { desc in ClientImage(description: desc) } + return ImageLoadResult(images: images, rejectedMembers: rejectedMembers) } public static func cleanupOrphanedBlobs() async throws -> ([String], UInt64) { @@ -313,8 +323,9 @@ extension ClientImage { return (totalCount: total, activeCount: active, totalSize: size, reclaimableSize: reclaimable) } - public static func fetch(reference: String, platform: Platform? = nil, scheme: RequestScheme = .auto, progressUpdate: ProgressUpdateHandler? = nil) async throws -> ClientImage - { + public static func fetch( + reference: String, platform: Platform? = nil, scheme: RequestScheme = .auto, progressUpdate: ProgressUpdateHandler? = nil, maxConcurrentDownloads: Int = 3 + ) async throws -> ClientImage { do { let match = try await self.get(reference: reference) if let platform { @@ -327,7 +338,7 @@ extension ClientImage { guard err.isCode(.notFound) else { throw err } - return try await Self.pull(reference: reference, platform: platform, scheme: scheme, progressUpdate: progressUpdate) + return try await Self.pull(reference: reference, platform: platform, scheme: scheme, progressUpdate: progressUpdate, maxConcurrentDownloads: maxConcurrentDownloads) } } } @@ -452,14 +463,28 @@ extension XPCMessage { } fileprivate func imageDescriptions() throws -> [ImageDescription] { - let responseData = self.dataNoCopy(key: .imageDescriptions) - guard let responseData else { + let imagesData = self.dataNoCopy(key: .imageDescriptions) + guard let imagesData else { throw ContainerizationError(.empty, message: "imageDescriptions not received") } - let descriptions = try JSONDecoder().decode([ImageDescription].self, from: responseData) + let descriptions = try JSONDecoder().decode([ImageDescription].self, from: imagesData) return descriptions } + fileprivate func loadResults() throws -> ([ImageDescription], [String]) { + let imagesData = self.dataNoCopy(key: .imageDescriptions) + guard let imagesData else { + throw ContainerizationError(.empty, message: "imageDescriptions not received") + } + let descriptions = try JSONDecoder().decode([ImageDescription].self, from: imagesData) + let rejectedMembersData = self.dataNoCopy(key: .rejectedMembers) + guard let rejectedMembersData else { + throw ContainerizationError(.empty, message: "rejectedMembers not received") + } + let rejectedMembers = try JSONDecoder().decode([String].self, from: rejectedMembersData) + return (descriptions, rejectedMembers) + } + fileprivate func filesystem() throws -> Filesystem { let responseData = self.dataNoCopy(key: .filesystem) guard let responseData else { @@ -490,3 +515,27 @@ extension ImageDescription { return name } } + +extension ClientImage { + public func details() async throws -> ImageDetail { + let descriptor = try await self.resolved() + let reference = self.reference + var variants: [ImageDetail.Variants] = [] + for desc in try await self.index().manifests { + guard let platform = desc.platform else { + continue + } + let config: ContainerizationOCI.Image + let manifest: ContainerizationOCI.Manifest + do { + config = try await self.config(for: platform) + manifest = try await self.manifest(for: platform) + } catch { + continue + } + let size = desc.size + manifest.config.size + manifest.layers.reduce(0, { (l, r) in l + r.size }) + variants.append(.init(platform: platform, size: size, config: config)) + } + return ImageDetail(name: reference, index: descriptor, variants: variants) + } +} diff --git a/Sources/ContainerClient/Core/ClientKernel.swift b/Sources/Services/ContainerAPIService/Client/ClientKernel.swift similarity index 93% rename from Sources/ContainerClient/Core/ClientKernel.swift rename to Sources/Services/ContainerAPIService/Client/ClientKernel.swift index 3ef2e4e81..3cac4693c 100644 --- a/Sources/ContainerClient/Core/ClientKernel.swift +++ b/Sources/Services/ContainerAPIService/Client/ClientKernel.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -84,7 +84,7 @@ extension ClientKernel { throw err } throw ContainerizationError( - .notFound, message: "Default kernel not configured for architecture \(platform.architecture). Please use the `container system kernel set` command to configure it") + .notFound, message: "default kernel not configured for architecture \(platform.architecture), please use the `container system kernel set` command to configure it") } } } @@ -97,7 +97,7 @@ extension SystemPlatform { case "amd64": return .linuxAmd default: - fatalError("Unknown architecture") + fatalError("unknown architecture") } } } diff --git a/Sources/ContainerClient/Core/ClientNetwork.swift b/Sources/Services/ContainerAPIService/Client/ClientNetwork.swift similarity index 97% rename from Sources/ContainerClient/Core/ClientNetwork.swift rename to Sources/Services/ContainerAPIService/Client/ClientNetwork.swift index 801068656..d2521314a 100644 --- a/Sources/ContainerClient/Core/ClientNetwork.swift +++ b/Sources/Services/ContainerAPIService/Client/ClientNetwork.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,7 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerNetworkService +import ContainerResource import ContainerXPC import ContainerizationError import ContainerizationOS diff --git a/Sources/ContainerClient/Core/ClientProcess.swift b/Sources/Services/ContainerAPIService/Client/ClientProcess.swift similarity index 97% rename from Sources/ContainerClient/Core/ClientProcess.swift rename to Sources/Services/ContainerAPIService/Client/ClientProcess.swift index b77dde88c..5a5f8543e 100644 --- a/Sources/ContainerClient/Core/ClientProcess.swift +++ b/Sources/Services/ContainerAPIService/Client/ClientProcess.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,7 +14,6 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerNetworkService import ContainerXPC import Containerization import ContainerizationError diff --git a/Sources/ContainerClient/Core/ClientVolume.swift b/Sources/Services/ContainerAPIService/Client/ClientVolume.swift similarity index 86% rename from Sources/ContainerClient/Core/ClientVolume.swift rename to Sources/Services/ContainerAPIService/Client/ClientVolume.swift index 5483ef4c7..8100c4120 100644 --- a/Sources/ContainerClient/Core/ClientVolume.swift +++ b/Sources/Services/ContainerAPIService/Client/ClientVolume.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,6 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerResource import ContainerXPC import Containerization import Foundation @@ -41,7 +42,7 @@ public struct ClientVolume { let reply = try await client.send(message) guard let responseData = reply.dataNoCopy(key: .volume) else { - throw VolumeError.storageError("Invalid response from server") + throw VolumeError.storageError("invalid response from server") } return try JSONDecoder().decode(Volume.self, from: responseData) @@ -81,18 +82,13 @@ public struct ClientVolume { return try JSONDecoder().decode(Volume.self, from: responseData) } - public static func prune() async throws -> ([String], UInt64) { + public static func volumeDiskUsage(name: String) async throws -> UInt64 { let client = XPCClient(service: serviceIdentifier) - let message = XPCMessage(route: .volumePrune) + let message = XPCMessage(route: .volumeDiskUsage) + message.set(key: .volumeName, value: name) let reply = try await client.send(message) - guard let responseData = reply.dataNoCopy(key: .volumes) else { - return ([], 0) - } - - let volumeNames = try JSONDecoder().decode([String].self, from: responseData) let size = reply.uint64(key: .volumeSize) - return (volumeNames, size) + return size } - } diff --git a/Sources/ContainerClient/Core/Constants.swift b/Sources/Services/ContainerAPIService/Client/Constants.swift similarity index 92% rename from Sources/ContainerClient/Core/Constants.swift rename to Sources/Services/ContainerAPIService/Client/Constants.swift index ce2ef811b..2fa2404c4 100644 --- a/Sources/ContainerClient/Core/Constants.swift +++ b/Sources/Services/ContainerAPIService/Client/Constants.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/ContainerClient/ContainerizationProgressAdapter.swift b/Sources/Services/ContainerAPIService/Client/ContainerizationProgressAdapter.swift similarity index 96% rename from Sources/ContainerClient/ContainerizationProgressAdapter.swift rename to Sources/Services/ContainerAPIService/Client/ContainerizationProgressAdapter.swift index 99f472a5d..ce407d520 100644 --- a/Sources/ContainerClient/ContainerizationProgressAdapter.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerizationProgressAdapter.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/ContainerClient/Core/DiskUsage.swift b/Sources/Services/ContainerAPIService/Client/DiskUsage.swift similarity index 96% rename from Sources/ContainerClient/Core/DiskUsage.swift rename to Sources/Services/ContainerAPIService/Client/DiskUsage.swift index e187390c1..6a9c86c02 100644 --- a/Sources/ContainerClient/Core/DiskUsage.swift +++ b/Sources/Services/ContainerAPIService/Client/DiskUsage.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/ContainerClient/FileDownloader.swift b/Sources/Services/ContainerAPIService/Client/FileDownloader.swift similarity index 97% rename from Sources/ContainerClient/FileDownloader.swift rename to Sources/Services/ContainerAPIService/Client/FileDownloader.swift index 854230461..83aabfc51 100644 --- a/Sources/ContainerClient/FileDownloader.swift +++ b/Sources/Services/ContainerAPIService/Client/FileDownloader.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/ContainerClient/Flags.swift b/Sources/Services/ContainerAPIService/Client/Flags.swift similarity index 94% rename from Sources/ContainerClient/Flags.swift rename to Sources/Services/ContainerAPIService/Client/Flags.swift index 4879db711..919b4a2ff 100644 --- a/Sources/ContainerClient/Flags.swift +++ b/Sources/Services/ContainerAPIService/Client/Flags.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -32,7 +32,7 @@ public struct Flags { public struct Process: ParsableArguments { public init() {} - @Option(name: .shortAndLong, help: "Set environment variables (format: key=value)") + @Option(name: .shortAndLong, help: "Set environment variables (key=value, or just key to inherit from host)") public var env: [String] = [] @Option( @@ -208,6 +208,9 @@ public struct Flags { "Expose virtualization capabilities to the container (requires host and guest support)" ) public var virtualization: Bool = false + + @Flag(name: .long, help: "Mount the container's root filesystem as read-only") + public var readOnly = false } @OptionGroupPassthrough @@ -222,4 +225,11 @@ public struct Flags { @Option(name: .long, help: ArgumentHelp("Progress type (format: none|ansi)", valueName: "type")) public var progress: ProgressType = .ansi } + + public struct ImageFetch: ParsableArguments { + public init() {} + + @Option(name: .long, help: "Maximum number of concurrent downloads (default: 3)") + public var maxConcurrentDownloads: Int = 3 + } } diff --git a/Sources/ContainerClient/HostDNSResolver.swift b/Sources/Services/ContainerAPIService/Client/HostDNSResolver.swift similarity index 98% rename from Sources/ContainerClient/HostDNSResolver.swift rename to Sources/Services/ContainerAPIService/Client/HostDNSResolver.swift index 4b0965133..fd3fdf5f2 100644 --- a/Sources/ContainerClient/HostDNSResolver.swift +++ b/Sources/Services/ContainerAPIService/Client/HostDNSResolver.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/Services/ContainerAPIService/Client/ImageLoadResult.swift b/Sources/Services/ContainerAPIService/Client/ImageLoadResult.swift new file mode 100644 index 000000000..a0cccf278 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/ImageLoadResult.swift @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +/// The result of loading an archive file into the image store. +public struct ImageLoadResult { + /// The successfully loaded images + public let images: [ClientImage] + + /// The archive member files that were not extracted due + /// to invalid paths or attempted symlink traversal. + public let rejectedMembers: [String] +} diff --git a/Sources/ContainerClient/Measurement+Parse.swift b/Sources/Services/ContainerAPIService/Client/Measurement+Parse.swift similarity index 97% rename from Sources/ContainerClient/Measurement+Parse.swift rename to Sources/Services/ContainerAPIService/Client/Measurement+Parse.swift index 58bc3ffb4..ae94c3dfc 100644 --- a/Sources/ContainerClient/Measurement+Parse.swift +++ b/Sources/Services/ContainerAPIService/Client/Measurement+Parse.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/ContainerClient/Parser.swift b/Sources/Services/ContainerAPIService/Client/Parser.swift similarity index 93% rename from Sources/ContainerClient/Parser.swift rename to Sources/Services/ContainerAPIService/Client/Parser.swift index 4415b9335..74319cd75 100644 --- a/Sources/ContainerClient/Parser.swift +++ b/Sources/Services/ContainerAPIService/Client/Parser.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,8 +14,10 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerResource import Containerization import ContainerizationError +import ContainerizationExtras import ContainerizationOCI import ContainerizationOS import Foundation @@ -110,17 +112,19 @@ public struct Parser { // This is a somewhat faithful Go->Swift port of Moby's envfile // parsing in the cli: // https://github.com/docker/cli/blob/f5a7a3c72eb35fc5ba9c4d65a2a0e2e1bd216bf2/pkg/kvfile/kvfile.go#L81 - guard FileManager.default.fileExists(atPath: path) else { - throw ContainerizationError( - .notFound, - message: "envfile at \(path) not found" - ) - } - guard let data = FileManager.default.contents(atPath: path) else { + let data: Data + do { + // Use FileHandle to support named pipes (FIFOs) and process substitutions + // like --env-file <(echo "KEY=value") + let fileHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: path)) + defer { try? fileHandle.close() } + data = try fileHandle.readToEnd() ?? Data() + } catch { throw ContainerizationError( .invalidArgument, - message: "failed to read envfile at \(path)" + message: "failed to read envfile at \(path)", + cause: error ) } @@ -190,8 +194,9 @@ public struct Parser { var envVar: [String] = [] for env in envList { var env = env - let parts = env.split(separator: "=", maxSplits: 2) - if parts.count == 1 { + // Only inherit from host if no "=" is present (e.g., "--env VAR") + // "VAR=" should set an explicit empty value, not inherit. + if !env.contains("=") { guard let val = ProcessInfo.processInfo.environment[env] else { continue } @@ -262,7 +267,7 @@ public struct Parser { }() guard let commandToRun = processArguments, commandToRun.count > 0 else { - throw ContainerizationError(.invalidArgument, message: "Command/Entrypoint not specified for container process") + throw ContainerizationError(.invalidArgument, message: "command/entrypoint not specified for container process") } let defaultUser: ProcessConfiguration.User = { @@ -484,8 +489,8 @@ public struct Parser { let src = String(parts[0]) let dst = String(parts[1]) - // Check if it's an absolute directory path first - guard src.hasPrefix("/") else { + // Check if it's a filesystem path + guard src.contains("/") else { // Named volume - validate name syntax only guard VolumeStorage.isValidVolumeName(src) else { throw ContainerizationError(.invalidArgument, message: "invalid volume name '\(src)': must match \(VolumeStorage.volumeNamePattern)") @@ -572,41 +577,39 @@ public struct Parser { // Parse a single `--publish-port` argument into a `PublishPort`. public static func publishPort(_ portText: String) throws -> PublishPort { - let protoSplit = portText.split(separator: "/") + let publishPortRegex = #/((\[(?[^\]]*)\]|(?[^:].*)):)?(?[^:].*):(?[^:/]*)(/(?.*))?/# + guard let match = try publishPortRegex.wholeMatch(in: portText) else { + throw ContainerizationError(.invalidArgument, message: "invalid publish value: \(portText)") + } + let proto: PublishProtocol - let addressAndPortText: String - switch protoSplit.count { - case 1: - addressAndPortText = String(protoSplit[0]) + let protoText = match.proto?.lowercased() ?? "tcp" + switch protoText { + case "tcp": proto = .tcp - case 2: - addressAndPortText = String(protoSplit[0]) - let protoText = String(protoSplit[1]) - guard let parsedProto = PublishProtocol(protoText) else { - throw ContainerizationError(.invalidArgument, message: "invalid publish protocol: \(protoText)") - } - proto = parsedProto + case "udp": + proto = .udp default: - throw ContainerizationError(.invalidArgument, message: "invalid publish value: \(portText)") + throw ContainerizationError(.invalidArgument, message: "invalid publish protocol: \(protoText)") } - let hostAddress: String - let hostPortText: String - let containerPortText: String - let parts = addressAndPortText.split(separator: ":") - switch parts.count { - case 2: - hostAddress = "0.0.0.0" - hostPortText = String(parts[0]) - containerPortText = String(parts[1]) - case 3: - hostAddress = String(parts[0]) - hostPortText = String(parts[1]) - containerPortText = String(parts[2]) - default: - throw ContainerizationError(.invalidArgument, message: "invalid publish address: \(portText)") + let hostAddress: IPAddress + if let ipv6 = match.ipv6, !ipv6.isEmpty { + guard let address = try? IPAddress(String(ipv6)), case .v6 = address else { + throw ContainerizationError(.invalidArgument, message: "invalid publish IPv6 address: \(portText)") + } + hostAddress = address + } else if let ipv4 = match.ipv4, !ipv4.isEmpty { + guard let address = try? IPAddress(String(ipv4)), case .v4 = address else { + throw ContainerizationError(.invalidArgument, message: "invalid publish IPv4 address: \(portText)") + } + hostAddress = address + } else { + hostAddress = try IPAddress("0.0.0.0") } + let hostPortText = match.hostPort + let containerPortText = match.containerPort let hostPortRangeStart: UInt16 let hostPortRangeEnd: UInt16 let containerPortRangeStart: UInt16 @@ -675,7 +678,7 @@ public struct Parser { let containerCount = containerPortRangeEnd - containerPortRangeStart + 1 guard hostCount == containerCount else { - throw ContainerizationError(.invalidArgument, message: "publish host and container port counts are not equal: \(addressAndPortText)") + throw ContainerizationError(.invalidArgument, message: "publish host and container port counts are not equal: \(hostPortText):\(containerPortText)") } return PublishPort( diff --git a/Sources/ContainerClient/ProcessIO.swift b/Sources/Services/ContainerAPIService/Client/ProcessIO.swift similarity index 97% rename from Sources/ContainerClient/ProcessIO.swift rename to Sources/Services/ContainerAPIService/Client/ProcessIO.swift index 697941bc4..0916bee6f 100644 --- a/Sources/ContainerClient/ProcessIO.swift +++ b/Sources/Services/ContainerAPIService/Client/ProcessIO.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -48,7 +48,7 @@ public struct ProcessIO: Sendable { if !tty || !interactive { return nil } - let current = try Terminal.current + let current = try Terminal(descriptor: STDIN_FILENO) try current.setraw() return current }() @@ -94,13 +94,9 @@ public struct ProcessIO: Sendable { let (stream, cc) = AsyncStream.makeStream() if let stdout { configuredStreams += 1 - let pout: FileHandle = { - if let current { - return current.handle - } - return .standardOutput - }() + stdio[1] = stdout.fileHandleForWriting + let pout = FileHandle.standardOutput let rout = stdout.fileHandleForReading rout.readabilityHandler = { handle in let data = handle.availableData @@ -111,7 +107,6 @@ public struct ProcessIO: Sendable { } try! pout.write(contentsOf: data) } - stdio[1] = stdout.fileHandleForWriting } let stderr: Pipe? = { diff --git a/Sources/ContainerClient/ProgressUpdateClient.swift b/Sources/Services/ContainerAPIService/Client/ProgressUpdateClient.swift similarity index 99% rename from Sources/ContainerClient/ProgressUpdateClient.swift rename to Sources/Services/ContainerAPIService/Client/ProgressUpdateClient.swift index c7940fa35..458e318ed 100644 --- a/Sources/ContainerClient/ProgressUpdateClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ProgressUpdateClient.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/ContainerClient/ProgressUpdateService.swift b/Sources/Services/ContainerAPIService/Client/ProgressUpdateService.swift similarity index 98% rename from Sources/ContainerClient/ProgressUpdateService.swift rename to Sources/Services/ContainerAPIService/Client/ProgressUpdateService.swift index a50af72ce..863d4de66 100644 --- a/Sources/ContainerClient/ProgressUpdateService.swift +++ b/Sources/Services/ContainerAPIService/Client/ProgressUpdateService.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/ContainerClient/RequestScheme.swift b/Sources/Services/ContainerAPIService/Client/RequestScheme.swift similarity index 97% rename from Sources/ContainerClient/RequestScheme.swift rename to Sources/Services/ContainerAPIService/Client/RequestScheme.swift index 8dd628544..b9634ae02 100644 --- a/Sources/ContainerClient/RequestScheme.swift +++ b/Sources/Services/ContainerAPIService/Client/RequestScheme.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/ContainerClient/SignalThreshold.swift b/Sources/Services/ContainerAPIService/Client/SignalThreshold.swift similarity index 97% rename from Sources/ContainerClient/SignalThreshold.swift rename to Sources/Services/ContainerAPIService/Client/SignalThreshold.swift index b40296a8f..f1db36b80 100644 --- a/Sources/ContainerClient/SignalThreshold.swift +++ b/Sources/Services/ContainerAPIService/Client/SignalThreshold.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/ContainerClient/String+Extensions.swift b/Sources/Services/ContainerAPIService/Client/String+Extensions.swift similarity index 96% rename from Sources/ContainerClient/String+Extensions.swift rename to Sources/Services/ContainerAPIService/Client/String+Extensions.swift index 4df63149f..e2adf7587 100644 --- a/Sources/ContainerClient/String+Extensions.swift +++ b/Sources/Services/ContainerAPIService/Client/String+Extensions.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/ContainerClient/Core/SystemHealth.swift b/Sources/Services/ContainerAPIService/Client/SystemHealth.swift similarity index 82% rename from Sources/ContainerClient/Core/SystemHealth.swift rename to Sources/Services/ContainerAPIService/Client/SystemHealth.swift index 48fd1f799..11a145264 100644 --- a/Sources/ContainerClient/Core/SystemHealth.swift +++ b/Sources/Services/ContainerAPIService/Client/SystemHealth.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -29,4 +29,10 @@ public struct SystemHealth: Sendable, Codable { /// The Git commit ID for the container services. public let apiServerCommit: String + + /// The build type of the API server (debug|release). + public let apiServerBuild: String + + /// The app name label returned by the server. + public let apiServerAppName: String } diff --git a/Sources/ContainerClient/TableOutput.swift b/Sources/Services/ContainerAPIService/Client/TableOutput.swift similarity index 97% rename from Sources/ContainerClient/TableOutput.swift rename to Sources/Services/ContainerAPIService/Client/TableOutput.swift index a4474976a..7dc5b20cd 100644 --- a/Sources/ContainerClient/TableOutput.swift +++ b/Sources/Services/ContainerAPIService/Client/TableOutput.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/ContainerClient/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift similarity index 93% rename from Sources/ContainerClient/Utility.swift rename to Sources/Services/ContainerAPIService/Client/Utility.swift index 16f43398f..5cb85cab5 100644 --- a/Sources/ContainerClient/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,8 +14,8 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerNetworkService import ContainerPersistence +import ContainerResource import Containerization import ContainerizationError import ContainerizationExtras @@ -72,19 +72,6 @@ public struct Utility { } } - public static func validPublishPorts(_ publishPorts: [PublishPort]) throws { - var hostPorts = Set() - for publishPort in publishPorts { - for index in 0.. (ContainerConfiguration, Kernel) { var requestedPlatform = Parser.platform(os: management.os, arch: management.arch) @@ -112,7 +100,8 @@ public struct Utility { reference: image, platform: requestedPlatform, scheme: scheme, - progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progressUpdate) + progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progressUpdate), + maxConcurrentDownloads: imageFetch.maxConcurrentDownloads ) // Unpack a fetched image before use @@ -140,7 +129,8 @@ public struct Utility { let fetchInitTask = await taskManager.startTask() let initImage = try await ClientImage.fetch( reference: ClientImage.initImageRef, platform: .current, scheme: scheme, - progressUpdate: ProgressTaskCoordinator.handler(for: fetchInitTask, from: progressUpdate)) + progressUpdate: ProgressTaskCoordinator.handler(for: fetchInitTask, from: progressUpdate), + maxConcurrentDownloads: imageFetch.maxConcurrentDownloads) await progressUpdate([ .setDescription("Unpacking init image"), @@ -240,13 +230,16 @@ public struct Utility { guard config.publishedPorts.count <= publishedPortCountLimit else { throw ContainerizationError(.invalidArgument, message: "cannot exceed more than \(publishedPortCountLimit) port publish descriptors") } - try validPublishPorts(config.publishedPorts) + guard !config.publishedPorts.hasOverlaps() else { + throw ContainerizationError(.invalidArgument, message: "host ports for different publish port specs may not overlap") + } // Parse --publish-socket arguments and add to container configuration // to enable socket forwarding from container to host. config.publishedSockets = try Parser.publishSockets(management.publishSockets) config.ssh = management.ssh + config.readOnly = management.readOnly return (config, kernel) } @@ -285,16 +278,17 @@ public struct Utility { } // attach the first network using the fqdn, and the rest using just the container ID - return networks.enumerated().map { item in + return try networks.enumerated().map { item in + let macAddress = try item.element.macAddress.map { try MACAddress($0) } guard item.offset == 0 else { return AttachmentConfiguration( network: item.element.name, - options: AttachmentOptions(hostname: containerId, macAddress: item.element.macAddress) + options: AttachmentOptions(hostname: containerId, macAddress: macAddress) ) } return AttachmentConfiguration( network: item.element.name, - options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: item.element.macAddress) + options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: macAddress) ) } } diff --git a/Sources/ContainerClient/Core/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift similarity index 97% rename from Sources/ContainerClient/Core/XPC+.swift rename to Sources/Services/ContainerAPIService/Client/XPC+.swift index dade31ca5..6a196ffc8 100644 --- a/Sources/ContainerClient/Core/XPC+.swift +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -50,8 +50,6 @@ public enum XPCKeys: String { case stopOptions /// Whether to force stop a container when deleting. case forceDelete - /// An endpoint to talk to a sandbox service. - case sandboxServiceEndpoint /// Plugins case pluginName case plugins @@ -63,6 +61,8 @@ public enum XPCKeys: String { case installRoot case apiServerVersion case apiServerCommit + case apiServerBuild + case apiServerAppName /// Process request keys. case signal @@ -155,8 +155,8 @@ public enum XPCRoute: String { case volumeDelete case volumeList case volumeInspect - case volumePrune + case volumeDiskUsage case systemDiskUsage case ping diff --git a/Sources/Services/ContainerAPIService/Containers/ContainersHarness.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift similarity index 99% rename from Sources/Services/ContainerAPIService/Containers/ContainersHarness.swift rename to Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift index 0b1beec25..7aa5f96d8 100644 --- a/Sources/Services/ContainerAPIService/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,7 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerClient +import ContainerResource import ContainerXPC import Containerization import ContainerizationError diff --git a/Sources/Services/ContainerAPIService/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift similarity index 90% rename from Sources/Services/ContainerAPIService/Containers/ContainersService.swift rename to Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index d12d50726..64037384c 100644 --- a/Sources/Services/ContainerAPIService/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -15,9 +15,10 @@ //===----------------------------------------------------------------------===// import CVersion -import ContainerClient +import ContainerAPIClient import ContainerPlugin -import ContainerSandboxService +import ContainerResource +import ContainerSandboxServiceClient import ContainerXPC import Containerization import ContainerizationError @@ -80,29 +81,23 @@ public actor ContainersService { var results = [String: ContainerState]() for dir in directories { do { - let bundle = ContainerClient.Bundle(path: dir) + let bundle = ContainerResource.Bundle(path: dir) let config = try bundle.configuration let state = ContainerState( snapshot: .init( configuration: config, status: .stopped, - networks: [] + networks: [], + startedDate: nil ) ) results[config.id] = state - let plugin = runtimePlugins.first { $0.name == config.runtimeHandler } - guard let plugin else { + guard runtimePlugins.first(where: { $0.name == config.runtimeHandler }) != nil else { throw ContainerizationError( .internalError, message: "failed to find runtime plugin \(config.runtimeHandler)" ) } - try Self.registerService( - plugin: plugin, - loader: loader, - configuration: config, - path: dir - ) } catch { try? FileManager.default.removeItem(at: dir) log.warning("failed to load container bundle at \(dir.path)") @@ -229,10 +224,7 @@ public actor ContainersService { ) } - let runtimePlugin = self.runtimePlugins.filter { - $0.name == configuration.runtimeHandler - }.first - guard let runtimePlugin else { + guard self.runtimePlugins.first(where: { $0.name == configuration.runtimeHandler }) != nil else { throw ContainerizationError( .notFound, message: "unable to locate runtime plugin \(configuration.runtimeHandler)" @@ -243,7 +235,7 @@ public actor ContainersService { let systemPlatform = kernel.platform let initFs = try await self.getInitBlock(for: systemPlatform.ociPlatform()) - let bundle = try ContainerClient.Bundle.create( + let bundle = try ContainerResource.Bundle.create( path: path, initialFilesystem: initFs, kernel: kernel, @@ -252,20 +244,14 @@ public actor ContainersService { do { let containerImage = ClientImage(description: configuration.image) let imageFs = try await containerImage.getCreateSnapshot(platform: configuration.platform) - try bundle.setContainerRootFs(cloning: imageFs) + try bundle.setContainerRootFs(cloning: imageFs, readonly: configuration.readOnly) try bundle.write(filename: "options.json", value: options) - try Self.registerService( - plugin: runtimePlugin, - loader: self.pluginLoader, - configuration: configuration, - path: path - ) - let snapshot = ContainerSnapshot( configuration: configuration, status: .stopped, - networks: [] + networks: [], + startedDate: nil ) await self.setContainerState(configuration.id, ContainerState(snapshot: snapshot), context: context) } catch { @@ -293,6 +279,16 @@ public actor ContainersService { return } + let path = self.containerRoot.appendingPathComponent(id) + let bundle = ContainerResource.Bundle(path: path) + let config = try bundle.configuration + try Self.registerService( + plugin: self.runtimePlugins.first { $0.name == config.runtimeHandler }!, + loader: self.pluginLoader, + configuration: config, + path: path + ) + let runtime = state.snapshot.configuration.runtimeHandler let sandboxClient = try await SandboxClient.create( id: id, @@ -368,7 +364,7 @@ public actor ContainersService { let waitFunc: ExitMonitor.WaitHandler = { log.info("registering container \(id) with exit monitor") let code = try await client.wait(id) - log.info("container \(id) finished in exit monitor") + log.info("container \(id) finished in exit monitor, exit code \(code)") return code } @@ -377,6 +373,7 @@ public actor ContainersService { let sandboxSnapshot = try await client.state() state.snapshot.status = .running state.snapshot.networks = sandboxSnapshot.networks + state.snapshot.startedDate = Date() await self.setContainerState(id, state, context: context) } } @@ -457,7 +454,7 @@ public actor ContainersService { // the bundle is there, and that the files actually exist. do { let path = self.containerRoot.appendingPathComponent(id) - let bundle = ContainerClient.Bundle(path: path) + let bundle = ContainerResource.Bundle(path: path) return [ try FileHandle(forReadingFrom: bundle.containerLog), try FileHandle(forReadingFrom: bundle.bootlog), @@ -536,14 +533,36 @@ public actor ContainersService { await self.exitMonitor.stopTracking(id: id) - // Try and shutdown the runtime helper. - do { - self.log.info("Shutting down sandbox service for \(id)") + // Shutdown and deregister the sandbox service + self.log.info("Shutting down sandbox service for \(id)") - let client = try state.getClient() - try await client.shutdown() + let path = self.containerRoot.appendingPathComponent(id) + let bundle = ContainerResource.Bundle(path: path) + let config = try bundle.configuration + let label = Self.fullLaunchdServiceLabel( + runtimeName: config.runtimeHandler, + instanceId: id + ) + + // Try to shutdown the client gracefully, but if the sandbox service + // is already dead (e.g., killed externally), we should still continue + // with state cleanup. + if let client = state.client { + do { + try await client.shutdown() + } catch { + self.log.error("Failed to shutdown sandbox service for \(id): \(error)") + } + } + + // Deregister the service, launchd will terminate the process. + // This may also fail if the service was already deregistered or + // the process was killed externally. + do { + try ServiceManager.deregister(fullServiceLabel: label) + self.log.info("Deregistered sandbox service for \(id)") } catch { - self.log.error("failed to shutdown sandbox service for \(id): \(error)") + self.log.error("Failed to deregister sandbox service for \(id): \(error)") } state.snapshot.status = .stopped @@ -574,7 +593,7 @@ public actor ContainersService { // the OCI runtime. await self.exitMonitor.stopTracking(id: id) let path = self.containerRoot.appendingPathComponent(id) - let bundle = ContainerClient.Bundle(path: path) + let bundle = ContainerResource.Bundle(path: path) let config = try bundle.configuration let label = Self.fullLaunchdServiceLabel( @@ -592,7 +611,7 @@ public actor ContainersService { private func getContainerCreationOptions(id: String) throws -> ContainerCreateOptions { let path = self.containerRoot.appendingPathComponent(id) - let bundle = ContainerClient.Bundle(path: path) + let bundle = ContainerResource.Bundle(path: path) let options: ContainerCreateOptions = try bundle.load(filename: "options.json") return options } diff --git a/Sources/Services/ContainerAPIService/DiskUsage/DiskUsageHarness.swift b/Sources/Services/ContainerAPIService/Server/DiskUsage/DiskUsageHarness.swift similarity index 94% rename from Sources/Services/ContainerAPIService/DiskUsage/DiskUsageHarness.swift rename to Sources/Services/ContainerAPIService/Server/DiskUsage/DiskUsageHarness.swift index ddcf5508a..b111e57a2 100644 --- a/Sources/Services/ContainerAPIService/DiskUsage/DiskUsageHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/DiskUsage/DiskUsageHarness.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,7 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerClient +import ContainerAPIClient import ContainerXPC import ContainerizationError import Foundation diff --git a/Sources/Services/ContainerAPIService/DiskUsage/DiskUsageService.swift b/Sources/Services/ContainerAPIService/Server/DiskUsage/DiskUsageService.swift similarity index 97% rename from Sources/Services/ContainerAPIService/DiskUsage/DiskUsageService.swift rename to Sources/Services/ContainerAPIService/Server/DiskUsage/DiskUsageService.swift index 312fe95ec..39d43b8d0 100644 --- a/Sources/Services/ContainerAPIService/DiskUsage/DiskUsageService.swift +++ b/Sources/Services/ContainerAPIService/Server/DiskUsage/DiskUsageService.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,7 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerClient +import ContainerAPIClient import Logging /// Service for calculating disk usage across all resource types diff --git a/Sources/Services/ContainerAPIService/HealthCheck/HealthCheckHarness.swift b/Sources/Services/ContainerAPIService/Server/HealthCheck/HealthCheckHarness.swift similarity index 84% rename from Sources/Services/ContainerAPIService/HealthCheck/HealthCheckHarness.swift rename to Sources/Services/ContainerAPIService/Server/HealthCheck/HealthCheckHarness.swift index ca85c876b..fdc9232a5 100644 --- a/Sources/Services/ContainerAPIService/HealthCheck/HealthCheckHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/HealthCheck/HealthCheckHarness.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import CVersion -import ContainerClient +import ContainerAPIClient import ContainerVersion import ContainerXPC import Containerization @@ -40,6 +40,9 @@ public actor HealthCheckHarness { reply.set(key: .installRoot, value: installRoot.absoluteString) reply.set(key: .apiServerVersion, value: ReleaseVersion.singleLine(appName: "container-apiserver")) reply.set(key: .apiServerCommit, value: get_git_commit().map { String(cString: $0) } ?? "unspecified") + // Extra optional fields for richer client display + reply.set(key: .apiServerBuild, value: ReleaseVersion.buildType()) + reply.set(key: .apiServerAppName, value: "container API Server") return reply } } diff --git a/Sources/Services/ContainerAPIService/Kernel/KernelHarness.swift b/Sources/Services/ContainerAPIService/Server/Kernel/KernelHarness.swift similarity index 97% rename from Sources/Services/ContainerAPIService/Kernel/KernelHarness.swift rename to Sources/Services/ContainerAPIService/Server/Kernel/KernelHarness.swift index 0f6197363..d12905778 100644 --- a/Sources/Services/ContainerAPIService/Kernel/KernelHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Kernel/KernelHarness.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,7 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerClient +import ContainerAPIClient import ContainerXPC import Containerization import ContainerizationError diff --git a/Sources/Services/ContainerAPIService/Kernel/KernelService.swift b/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift similarity index 97% rename from Sources/Services/ContainerAPIService/Kernel/KernelService.swift rename to Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift index 0ba4645e3..a152a53a5 100644 --- a/Sources/Services/ContainerAPIService/Kernel/KernelService.swift +++ b/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,7 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerClient +import ContainerAPIClient import Containerization import ContainerizationArchive import ContainerizationError @@ -84,7 +84,7 @@ public actor KernelService { if let progressUpdate { downloadProgressUpdate = ProgressTaskCoordinator.handler(for: downloadTask, from: progressUpdate) } - try await ContainerClient.FileDownloader.downloadFile(url: tar, to: tarFile, progressUpdate: downloadProgressUpdate) + try await ContainerAPIClient.FileDownloader.downloadFile(url: tar, to: tarFile, progressUpdate: downloadProgressUpdate) } await taskManager.finish() diff --git a/Sources/Services/ContainerAPIService/Networks/NetworksHarness.swift b/Sources/Services/ContainerAPIService/Server/Networks/NetworksHarness.swift similarity index 96% rename from Sources/Services/ContainerAPIService/Networks/NetworksHarness.swift rename to Sources/Services/ContainerAPIService/Server/Networks/NetworksHarness.swift index e3094dabd..00b273dd8 100644 --- a/Sources/Services/ContainerAPIService/Networks/NetworksHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Networks/NetworksHarness.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,7 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerNetworkService +import ContainerResource import ContainerXPC import ContainerizationError import ContainerizationOS diff --git a/Sources/Services/ContainerAPIService/Networks/NetworksService.swift b/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift similarity index 87% rename from Sources/Services/ContainerAPIService/Networks/NetworksService.swift rename to Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift index 69180dea7..1784a16f4 100644 --- a/Sources/Services/ContainerAPIService/Networks/NetworksService.swift +++ b/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,10 +14,11 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerClient -import ContainerNetworkService +import ContainerAPIClient +import ContainerNetworkServiceClient import ContainerPersistence import ContainerPlugin +import ContainerResource import Containerization import ContainerizationError import ContainerizationExtras @@ -283,19 +284,44 @@ public actor NetworksService { serviceIdentifier, ] - if let subnet = (try configuration.subnet.map { try CIDRAddress($0) }) { - var existingCidrs: [CIDRAddress] = [] + if let ipv4Subnet = configuration.ipv4Subnet { + var existingCidrs: [CIDRv4] = [] for networkState in networkStates.values { if case .running(_, let status) = networkState { - existingCidrs.append(try CIDRAddress(status.address)) + existingCidrs.append(status.ipv4Subnet) } } - let overlap = existingCidrs.first { $0.overlaps(cidr: subnet) } + let overlap = existingCidrs.first { + $0.contains(ipv4Subnet.lower) + || $0.contains(ipv4Subnet.upper) + || ipv4Subnet.contains($0.lower) + || ipv4Subnet.contains($0.upper) + } + if let overlap { + throw ContainerizationError(.exists, message: "IPv4 subnet \(ipv4Subnet) overlaps an existing network with subnet \(overlap)") + } + + args += ["--subnet", ipv4Subnet.description] + } + + if let ipv6Subnet = configuration.ipv6Subnet { + var existingCidrs: [CIDRv6] = [] + for networkState in networkStates.values { + if case .running(_, let status) = networkState, let otherIPv6Subnet = status.ipv6Subnet { + existingCidrs.append(otherIPv6Subnet) + } + } + let overlap = existingCidrs.first { + $0.contains(ipv6Subnet.lower) + || $0.contains(ipv6Subnet.upper) + || ipv6Subnet.contains($0.lower) + || ipv6Subnet.contains($0.upper) + } if let overlap { - throw ContainerizationError(.exists, message: "subnet \(subnet) overlaps an existing network with subnet \(overlap)") + throw ContainerizationError(.exists, message: "IPv6 subnet \(ipv6Subnet) overlaps an existing network with subnet \(overlap)") } - args += ["--subnet", subnet.description] + args += ["--subnet-v6", ipv6Subnet.description] } try await pluginLoader.registerWithLaunchd( diff --git a/Sources/Services/ContainerAPIService/Plugin/PluginsHarness.swift b/Sources/Services/ContainerAPIService/Server/Plugin/PluginsHarness.swift similarity index 97% rename from Sources/Services/ContainerAPIService/Plugin/PluginsHarness.swift rename to Sources/Services/ContainerAPIService/Server/Plugin/PluginsHarness.swift index 7e8725e7d..a811d568c 100644 --- a/Sources/Services/ContainerAPIService/Plugin/PluginsHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Plugin/PluginsHarness.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/Services/ContainerAPIService/Plugin/PluginsService.swift b/Sources/Services/ContainerAPIService/Server/Plugin/PluginsService.swift similarity index 98% rename from Sources/Services/ContainerAPIService/Plugin/PluginsService.swift rename to Sources/Services/ContainerAPIService/Server/Plugin/PluginsService.swift index 1995a396d..726ca56d2 100644 --- a/Sources/Services/ContainerAPIService/Plugin/PluginsService.swift +++ b/Sources/Services/ContainerAPIService/Server/Plugin/PluginsService.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/Services/ContainerAPIService/Volumes/VolumesHarness.swift b/Sources/Services/ContainerAPIService/Server/Volumes/VolumesHarness.swift similarity index 89% rename from Sources/Services/ContainerAPIService/Volumes/VolumesHarness.swift rename to Sources/Services/ContainerAPIService/Server/Volumes/VolumesHarness.swift index e145b18a3..a0b2c0e8f 100644 --- a/Sources/Services/ContainerAPIService/Volumes/VolumesHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Volumes/VolumesHarness.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,7 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerClient +import ContainerAPIClient import ContainerXPC import ContainerizationError import Foundation @@ -94,12 +94,13 @@ public struct VolumesHarness: Sendable { } @Sendable - public func prune(_ message: XPCMessage) async throws -> XPCMessage { - let (volumeNames, size) = try await service.prune() - let data = try JSONEncoder().encode(volumeNames) + public func diskUsage(_ message: XPCMessage) async throws -> XPCMessage { + guard let name = message.string(key: .volumeName) else { + throw ContainerizationError(.invalidArgument, message: "volume name cannot be empty") + } + let size = try await service.volumeDiskUsage(name: name) let reply = message.reply() - reply.set(key: .volumes, value: data) reply.set(key: .volumeSize, value: size) return reply } diff --git a/Sources/Services/ContainerAPIService/Volumes/VolumesService.swift b/Sources/Services/ContainerAPIService/Server/Volumes/VolumesService.swift similarity index 81% rename from Sources/Services/ContainerAPIService/Volumes/VolumesService.swift rename to Sources/Services/ContainerAPIService/Server/Volumes/VolumesService.swift index 3c09784e5..b03afb659 100644 --- a/Sources/Services/ContainerAPIService/Volumes/VolumesService.swift +++ b/Sources/Services/ContainerAPIService/Server/Volumes/VolumesService.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,8 +14,8 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerClient import ContainerPersistence +import ContainerResource import Containerization import ContainerizationEXT4 import ContainerizationError @@ -72,48 +72,10 @@ public actor VolumesService { } } - public func prune() async throws -> ([String], UInt64) { - try await lock.withLock { _ in - let allVolumes = try await self.store.list() - - // do entire prune operation atomically with container list - return try await self.containersService.withContainerList { containers in - var inUseSet = Set() - for container in containers { - for mount in container.configuration.mounts { - if mount.isVolume, let volumeName = mount.volumeName { - inUseSet.insert(volumeName) - } - } - } - - let volumesToPrune = allVolumes.filter { volume in - !inUseSet.contains(volume.name) - } - - var prunedNames = [String]() - var totalSize: UInt64 = 0 - - for volume in volumesToPrune { - do { - // calculate actual disk usage before deletion - let volumePath = self.volumePath(for: volume.name) - let actualSize = self.calculateDirectorySize(at: volumePath) - - try await self.store.delete(volume.name) - try self.removeVolumeDirectory(for: volume.name) - - prunedNames.append(volume.name) - totalSize += actualSize - self.log.info("Pruned volume", metadata: ["name": "\(volume.name)", "size": "\(actualSize)"]) - } catch { - self.log.error("failed to prune volume \(volume.name): \(error)") - } - } - - return (prunedNames, totalSize) - } - } + /// Calculate disk usage for a single volume + public func volumeDiskUsage(name: String) async throws -> UInt64 { + let volumePath = self.volumePath(for: name) + return self.calculateDirectorySize(at: volumePath) } /// Calculate disk usage for volumes @@ -191,7 +153,7 @@ public actor VolumesService { let sizeInBytes = UInt64(bytes) guard sizeInBytes >= minSize else { - throw VolumeError.storageError("Volume size too small: minimum 1MiB") + throw VolumeError.storageError("volume size too small: minimum 1MiB") } return sizeInBytes @@ -244,7 +206,7 @@ public actor VolumesService { labels: [String: String] ) async throws -> Volume { guard VolumeStorage.isValidVolumeName(name) else { - throw VolumeError.invalidVolumeName("Invalid volume name '\(name)': must match \(VolumeStorage.volumeNamePattern)") + throw VolumeError.invalidVolumeName("invalid volume name '\(name)': must match \(VolumeStorage.volumeNamePattern)") } // Check if volume already exists by trying to list and finding it @@ -283,7 +245,7 @@ public actor VolumesService { private func _delete(name: String) async throws { guard VolumeStorage.isValidVolumeName(name) else { - throw VolumeError.invalidVolumeName("Invalid volume name '\(name)': must match \(VolumeStorage.volumeNamePattern)") + throw VolumeError.invalidVolumeName("invalid volume name '\(name)': must match \(VolumeStorage.volumeNamePattern)") } // Check if volume exists by trying to list and finding it @@ -311,7 +273,7 @@ public actor VolumesService { private func _inspect(_ name: String) async throws -> Volume { guard VolumeStorage.isValidVolumeName(name) else { - throw VolumeError.invalidVolumeName("Invalid volume name '\(name)': must match \(VolumeStorage.volumeNamePattern)") + throw VolumeError.invalidVolumeName("invalid volume name '\(name)': must match \(VolumeStorage.volumeNamePattern)") } let volumes = try await store.list() diff --git a/Sources/Services/ContainerImagesService/Client/ImageServiceXPCKeys.swift b/Sources/Services/ContainerImagesService/Client/ImageServiceXPCKeys.swift index c81c76dc3..c087f99f1 100644 --- a/Sources/Services/ContainerImagesService/Client/ImageServiceXPCKeys.swift +++ b/Sources/Services/ContainerImagesService/Client/ImageServiceXPCKeys.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -35,6 +35,9 @@ public enum ImagesServiceXPCKeys: String { case ociPlatform case insecureFlag case garbageCollect + case maxConcurrentDownloads + case forceLoad + case rejectedMembers /// ContentStore case digest diff --git a/Sources/Services/ContainerImagesService/Client/ImageServiceXPCRoutes.swift b/Sources/Services/ContainerImagesService/Client/ImageServiceXPCRoutes.swift index f383e0d17..b53ed54c0 100644 --- a/Sources/Services/ContainerImagesService/Client/ImageServiceXPCRoutes.swift +++ b/Sources/Services/ContainerImagesService/Client/ImageServiceXPCRoutes.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/Services/ContainerImagesService/Client/RemoteContentStoreClient.swift b/Sources/Services/ContainerImagesService/Client/RemoteContentStoreClient.swift index 270ad1142..9c92c4d0e 100644 --- a/Sources/Services/ContainerImagesService/Client/RemoteContentStoreClient.swift +++ b/Sources/Services/ContainerImagesService/Client/RemoteContentStoreClient.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/Services/ContainerImagesService/Server/ContentServiceHarness.swift b/Sources/Services/ContainerImagesService/Server/ContentServiceHarness.swift index 544659a89..f9a3ea377 100644 --- a/Sources/Services/ContainerImagesService/Server/ContentServiceHarness.swift +++ b/Sources/Services/ContainerImagesService/Server/ContentServiceHarness.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/Services/ContainerImagesService/Server/ContentStoreService.swift b/Sources/Services/ContainerImagesService/Server/ContentStoreService.swift index e8d6d4a65..1a6d1d1d8 100644 --- a/Sources/Services/ContainerImagesService/Server/ContentStoreService.swift +++ b/Sources/Services/ContainerImagesService/Server/ContentStoreService.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/Services/ContainerImagesService/Server/ImageService.swift b/Sources/Services/ContainerImagesService/Server/ImageService.swift index e7cc1baf7..a172d0775 100644 --- a/Sources/Services/ContainerImagesService/Server/ImageService.swift +++ b/Sources/Services/ContainerImagesService/Server/ImageService.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -14,8 +14,9 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerClient +import ContainerAPIClient import ContainerImagesServiceClient +import ContainerResource import Containerization import ContainerizationArchive import ContainerizationError @@ -59,11 +60,15 @@ public actor ImagesService { return try await imageStore.list().map { $0.description.fromCZ } } - public func pull(reference: String, platform: Platform?, insecure: Bool, progressUpdate: ProgressUpdateHandler?) async throws -> ImageDescription { - self.log.info("ImagesService: \(#function) - ref: \(reference), platform: \(String(describing: platform)), insecure: \(insecure)") + public func pull(reference: String, platform: Platform?, insecure: Bool, progressUpdate: ProgressUpdateHandler?, maxConcurrentDownloads: Int = 3) async throws + -> ImageDescription + { + self.log.info( + "ImagesService: \(#function) - ref: \(reference), platform: \(String(describing: platform)), insecure: \(insecure), maxConcurrentDownloads: \(maxConcurrentDownloads)") let img = try await Self.withAuthentication(ref: reference) { auth in try await self.imageStore.pull( - reference: reference, platform: platform, insecure: insecure, auth: auth, progress: ContainerizationProgressAdapter.handler(from: progressUpdate)) + reference: reference, platform: platform, insecure: insecure, auth: auth, progress: ContainerizationProgressAdapter.handler(from: progressUpdate), + maxConcurrentDownloads: maxConcurrentDownloads) } guard let img else { throw ContainerizationError(.internalError, message: "failed to pull image \(reference)") @@ -102,20 +107,25 @@ public actor ImagesService { try writer.finishEncoding() } - public func load(from tarFile: URL) async throws -> [ImageDescription] { - self.log.info("ImagesService: \(#function) from: \(tarFile.absolutePath())") + public func load(from tarFile: URL, force: Bool) async throws -> ([ImageDescription], [String]) { + let archivePathname = tarFile.absolutePath() + self.log.info("ImagesService: \(#function) from: \(archivePathname)") let reader = try ArchiveReader(file: tarFile) let tempDir = FileManager.default.uniqueTemporaryDirectory() defer { try? FileManager.default.removeItem(at: tempDir) } - try reader.extractContents(to: tempDir) + let rejectedMembers = try reader.extractContents(to: tempDir) + guard rejectedMembers.isEmpty || force else { + throw ContainerizationError(.invalidArgument, message: "cannot load tar image with rejected paths: \(rejectedMembers)") + } + let loaded = try await self.imageStore.load(from: tempDir) var images: [ImageDescription] = [] for image in loaded { images.append(image.description.fromCZ) } - return images + return (images, rejectedMembers) } public func cleanupOrphanedBlobs() async throws -> ([String], UInt64) { @@ -231,7 +241,7 @@ extension ImagesService { throw err } guard authentication != nil else { - throw ContainerizationError(.internalError, message: "\(String(describing: err)). No credentials found for host \(host)") + throw ContainerizationError(.internalError, message: "\(String(describing: err)), no credentials found for host \(host)") } throw err } diff --git a/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift b/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift index cc1e70455..8118dee96 100644 --- a/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift +++ b/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -14,8 +14,9 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerClient +import ContainerAPIClient import ContainerImagesServiceClient +import ContainerResource import ContainerXPC import Containerization import ContainerizationError @@ -47,9 +48,11 @@ public struct ImagesServiceHarness: Sendable { platform = try JSONDecoder().decode(ContainerizationOCI.Platform.self, from: platformData) } let insecure = message.bool(key: .insecureFlag) + let maxConcurrentDownloads = message.int64(key: .maxConcurrentDownloads) let progressUpdateService = ProgressUpdateService(message: message) - let imageDescription = try await service.pull(reference: ref, platform: platform, insecure: insecure, progressUpdate: progressUpdateService?.handler) + let imageDescription = try await service.pull( + reference: ref, platform: platform, insecure: insecure, progressUpdate: progressUpdateService?.handler, maxConcurrentDownloads: Int(maxConcurrentDownloads)) let imageData = try JSONEncoder().encode(imageDescription) let reply = message.reply() @@ -159,16 +162,22 @@ public struct ImagesServiceHarness: Sendable { @Sendable public func load(_ message: XPCMessage) async throws -> XPCMessage { let input = message.string(key: .filePath) + let force = message.bool(key: .forceLoad) guard let input else { throw ContainerizationError( .invalidArgument, message: "missing input file path" ) } - let images = try await service.load(from: URL(filePath: input)) - let data = try JSONEncoder().encode(images) + let (images, rejectedMembers) = try await service.load( + from: URL(filePath: input), + force: force + ) let reply = message.reply() - reply.set(key: .imageDescriptions, value: data) + let imagesData = try JSONEncoder().encode(images) + reply.set(key: .imageDescriptions, value: imagesData) + let rejectedData = try JSONEncoder().encode(rejectedMembers) + reply.set(key: .rejectedMembers, value: rejectedData) return reply } diff --git a/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift b/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift index 08fff5dc0..fd0369453 100644 --- a/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift +++ b/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -14,8 +14,9 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerClient +import ContainerAPIClient import ContainerPersistence +import ContainerResource import Containerization import ContainerizationError import ContainerizationExtras @@ -84,7 +85,7 @@ public actor SnapshotStore { throw ContainerizationError(.internalError, message: "missing platform for descriptor \(desc.digest)") } guard let unpacker = try await self.unpackStrategy(image, platform) else { - self.log?.warning("Skipping unpack for \(image.reference) for platform \(platform.description). No unpacker configured.") + self.log?.warning("no unpacker configured, skipping unpack for \(image.reference) for platform \(platform.description)") continue } let currentSubTask = await taskManager.startTask() diff --git a/Sources/Services/ContainerNetworkService/NetworkClient.swift b/Sources/Services/ContainerNetworkService/Client/NetworkClient.swift similarity index 88% rename from Sources/Services/ContainerNetworkService/NetworkClient.swift rename to Sources/Services/ContainerNetworkService/Client/NetworkClient.swift index e579f3e05..69b8207e0 100644 --- a/Sources/Services/ContainerNetworkService/NetworkClient.swift +++ b/Sources/Services/ContainerNetworkService/Client/NetworkClient.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,8 +14,10 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerResource import ContainerXPC import ContainerizationError +import ContainerizationExtras import Foundation /// A client for interacting with a single network. @@ -46,11 +48,14 @@ extension NetworkClient { return state } - public func allocate(hostname: String, macAddress: String? = nil) async throws -> (attachment: Attachment, additionalData: XPCMessage?) { + public func allocate( + hostname: String, + macAddress: MACAddress? = nil + ) async throws -> (attachment: Attachment, additionalData: XPCMessage?) { let request = XPCMessage(route: NetworkRoutes.allocate.rawValue) request.set(key: NetworkKeys.hostname.rawValue, value: hostname) if let macAddress = macAddress { - request.set(key: NetworkKeys.macAddress.rawValue, value: macAddress) + request.set(key: NetworkKeys.macAddress.rawValue, value: macAddress.description) } let client = createClient() @@ -96,18 +101,18 @@ extension NetworkClient { } extension XPCMessage { - func additionalData() -> XPCMessage? { + public func additionalData() -> XPCMessage? { guard let additionalData = xpc_dictionary_get_dictionary(self.underlying, NetworkKeys.additionalData.rawValue) else { return nil } return XPCMessage(object: additionalData) } - func allocatorDisabled() throws -> Bool { + public func allocatorDisabled() throws -> Bool { self.bool(key: NetworkKeys.allocatorDisabled.rawValue) } - func attachment() throws -> Attachment { + public func attachment() throws -> Attachment { let data = self.dataNoCopy(key: NetworkKeys.attachment.rawValue) guard let data else { throw ContainerizationError(.invalidArgument, message: "no network attachment snapshot data in message") @@ -115,7 +120,7 @@ extension XPCMessage { return try JSONDecoder().decode(Attachment.self, from: data) } - func hostname() throws -> String { + public func hostname() throws -> String { let hostname = self.string(key: NetworkKeys.hostname.rawValue) guard let hostname else { throw ContainerizationError(.invalidArgument, message: "no hostname data in message") @@ -123,7 +128,7 @@ extension XPCMessage { return hostname } - func state() throws -> NetworkState { + public func state() throws -> NetworkState { let data = self.dataNoCopy(key: NetworkKeys.state.rawValue) guard let data else { throw ContainerizationError(.invalidArgument, message: "no network snapshot data in message") diff --git a/Sources/Services/ContainerNetworkService/NetworkKeys.swift b/Sources/Services/ContainerNetworkService/Client/NetworkKeys.swift similarity index 93% rename from Sources/Services/ContainerNetworkService/NetworkKeys.swift rename to Sources/Services/ContainerNetworkService/Client/NetworkKeys.swift index 13def7abd..cc7715a30 100644 --- a/Sources/Services/ContainerNetworkService/NetworkKeys.swift +++ b/Sources/Services/ContainerNetworkService/Client/NetworkKeys.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/Services/ContainerNetworkService/NetworkRoutes.swift b/Sources/Services/ContainerNetworkService/Client/NetworkRoutes.swift similarity index 95% rename from Sources/Services/ContainerNetworkService/NetworkRoutes.swift rename to Sources/Services/ContainerNetworkService/Client/NetworkRoutes.swift index 877fbfb82..edfbec4aa 100644 --- a/Sources/Services/ContainerNetworkService/NetworkRoutes.swift +++ b/Sources/Services/ContainerNetworkService/Client/NetworkRoutes.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/Services/ContainerNetworkService/AllocationOnlyVmnetNetwork.swift b/Sources/Services/ContainerNetworkService/Server/AllocationOnlyVmnetNetwork.swift similarity index 83% rename from Sources/Services/ContainerNetworkService/AllocationOnlyVmnetNetwork.swift rename to Sources/Services/ContainerNetworkService/Server/AllocationOnlyVmnetNetwork.swift index 9612549c2..94ea426d7 100644 --- a/Sources/Services/ContainerNetworkService/AllocationOnlyVmnetNetwork.swift +++ b/Sources/Services/ContainerNetworkService/Server/AllocationOnlyVmnetNetwork.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -15,6 +15,7 @@ //===----------------------------------------------------------------------===// import ContainerPersistence +import ContainerResource import ContainerXPC import ContainerizationError import ContainerizationExtras @@ -35,8 +36,8 @@ public actor AllocationOnlyVmnetNetwork: Network { throw ContainerizationError(.unsupported, message: "invalid network mode \(configuration.mode)") } - guard configuration.subnet == nil else { - throw ContainerizationError(.unsupported, message: "subnet assignment is not yet implemented") + guard configuration.ipv4Subnet == nil else { + throw ContainerizationError(.unsupported, message: "IPv4 subnet assignment is not yet implemented") } self.log = log @@ -65,9 +66,14 @@ public actor AllocationOnlyVmnetNetwork: Network { ) let subnet = DefaultsStore.get(key: .defaultSubnet) - let subnetCIDR = try CIDRAddress(subnet) - let gateway = IPv4Address(fromValue: subnetCIDR.lower.value + 1) - self._state = .running(configuration, NetworkStatus(address: subnetCIDR.description, gateway: gateway.description)) + let subnetCIDR = try CIDRv4(subnet) + let gateway = IPv4Address(subnetCIDR.lower.value + 1) + let status = NetworkStatus( + ipv4Subnet: subnetCIDR, + ipv4Gateway: gateway, + ipv6Subnet: nil, + ) + self._state = .running(configuration, status) log.info( "started allocation-only network", metadata: [ diff --git a/Sources/Services/ContainerNetworkService/AttachmentAllocator.swift b/Sources/Services/ContainerNetworkService/Server/AttachmentAllocator.swift similarity index 86% rename from Sources/Services/ContainerNetworkService/AttachmentAllocator.swift rename to Sources/Services/ContainerNetworkService/Server/AttachmentAllocator.swift index ede611b20..fb1f537c3 100644 --- a/Sources/Services/ContainerNetworkService/AttachmentAllocator.swift +++ b/Sources/Services/ContainerNetworkService/Server/AttachmentAllocator.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -42,10 +42,14 @@ actor AttachmentAllocator { } /// Free an allocated network address by hostname. - func deallocate(hostname: String) async throws { - if let index = hostnames.removeValue(forKey: hostname) { - try allocator.release(index) + @discardableResult + func deallocate(hostname: String) async throws -> UInt32? { + guard let index = hostnames.removeValue(forKey: hostname) else { + return nil } + + try allocator.release(index) + return index } /// If no addresses are allocated, prevent future allocations and return true. diff --git a/Sources/Services/ContainerNetworkService/Network.swift b/Sources/Services/ContainerNetworkService/Server/Network.swift similarity index 92% rename from Sources/Services/ContainerNetworkService/Network.swift rename to Sources/Services/ContainerNetworkService/Server/Network.swift index b6ebedfb1..9be7e0985 100644 --- a/Sources/Services/ContainerNetworkService/Network.swift +++ b/Sources/Services/ContainerNetworkService/Server/Network.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,6 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerResource import ContainerXPC /// Defines common characteristics and operations for a network. diff --git a/Sources/Services/ContainerNetworkService/NetworkService.swift b/Sources/Services/ContainerNetworkService/Server/NetworkService.swift similarity index 73% rename from Sources/Services/ContainerNetworkService/NetworkService.swift rename to Sources/Services/ContainerNetworkService/Server/NetworkService.swift index a6bc6f8ef..f40bdf772 100644 --- a/Sources/Services/ContainerNetworkService/NetworkService.swift +++ b/Sources/Services/ContainerNetworkService/Server/NetworkService.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,6 +14,8 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerNetworkServiceClient +import ContainerResource import ContainerXPC import ContainerizationError import ContainerizationExtras @@ -24,6 +26,7 @@ public actor NetworkService: Sendable { private let network: any Network private let log: Logger? private var allocator: AttachmentAllocator + private var macAddresses: [UInt32: MACAddress] /// Set up a network service for the specified network. public init( @@ -35,10 +38,11 @@ public actor NetworkService: Sendable { throw ContainerizationError(.invalidState, message: "invalid network state - network \(state.id) must be running") } - let subnet = try CIDRAddress(status.address) + let subnet = status.ipv4Subnet let size = Int(subnet.upper.value - subnet.lower.value - 3) self.allocator = try AttachmentAllocator(lower: subnet.lower.value + 2, size: size) + self.macAddresses = [:] self.network = network self.log = log } @@ -59,24 +63,30 @@ public actor NetworkService: Sendable { } let hostname = try message.hostname() - let macAddress = message.string(key: NetworkKeys.macAddress.rawValue) + let macAddress = + try message.string(key: NetworkKeys.macAddress.rawValue) + .map { try MACAddress($0) } + ?? MACAddress((UInt64.random(in: 0...UInt64.max) & 0x0cff_ffff_ffff) | 0xf200_0000_0000) let index = try await allocator.allocate(hostname: hostname) - let subnet = try CIDRAddress(status.address) - let ip = IPv4Address(fromValue: index) + let ipv6Address = try status.ipv6Subnet + .map { try CIDRv6(macAddress.ipv6Address(network: $0.lower), prefix: $0.prefix) } + let ip = IPv4Address(index) let attachment = Attachment( network: state.id, hostname: hostname, - address: try CIDRAddress(ip, prefixLength: subnet.prefixLength).description, - gateway: status.gateway, + ipv4Address: try CIDRv4(ip, prefix: status.ipv4Subnet.prefix), + ipv4Gateway: status.ipv4Gateway, + ipv6Address: ipv6Address, macAddress: macAddress ) log?.info( "allocated attachment", metadata: [ "hostname": "\(hostname)", - "address": "\(attachment.address)", - "gateway": "\(attachment.gateway)", - "macAddress": "\(macAddress ?? "auto")", + "ipv4Address": "\(attachment.ipv4Address)", + "ipv4Gateway": "\(attachment.ipv4Gateway)", + "ipv6Address": "\(attachment.ipv6Address?.description ?? "unavailable")", + "macAddress": "\(attachment.macAddress?.description ?? "unspecified")", ]) let reply = message.reply() try reply.setAttachment(attachment) @@ -85,13 +95,16 @@ public actor NetworkService: Sendable { try reply.setAdditionalData(additionalData.underlying) } } + macAddresses[index] = macAddress return reply } @Sendable public func deallocate(_ message: XPCMessage) async throws -> XPCMessage { let hostname = try message.hostname() - try await allocator.deallocate(hostname: hostname) + if let index = try await allocator.deallocate(hostname: hostname) { + macAddresses.removeValue(forKey: index) + } log?.info("released attachments", metadata: ["hostname": "\(hostname)"]) return message.reply() } @@ -109,14 +122,21 @@ public actor NetworkService: Sendable { guard let index else { return reply } - - let address = IPv4Address(fromValue: index) - let subnet = try CIDRAddress(status.address) + guard let macAddress = macAddresses[index] else { + return reply + } + let address = IPv4Address(index) + let subnet = status.ipv4Subnet + let ipv4Address = try CIDRv4(address, prefix: subnet.prefix) + let ipv6Address = try status.ipv6Subnet + .map { try CIDRv6(macAddress.ipv6Address(network: $0.lower), prefix: $0.prefix) } let attachment = Attachment( network: state.id, hostname: hostname, - address: try CIDRAddress(address, prefixLength: subnet.prefixLength).description, - gateway: status.gateway + ipv4Address: ipv4Address, + ipv4Gateway: status.ipv4Gateway, + ipv6Address: ipv6Address, + macAddress: macAddress ) log?.debug( "lookup attachment", diff --git a/Sources/Services/ContainerNetworkService/ReservedVmnetNetwork.swift b/Sources/Services/ContainerNetworkService/Server/ReservedVmnetNetwork.swift similarity index 63% rename from Sources/Services/ContainerNetworkService/ReservedVmnetNetwork.swift rename to Sources/Services/ContainerNetworkService/Server/ReservedVmnetNetwork.swift index 79808cd53..ff130bbe0 100644 --- a/Sources/Services/ContainerNetworkService/ReservedVmnetNetwork.swift +++ b/Sources/Services/ContainerNetworkService/Server/ReservedVmnetNetwork.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -15,6 +15,7 @@ //===----------------------------------------------------------------------===// import ContainerPersistence +import ContainerResource import ContainerXPC import Containerization import ContainerizationError @@ -37,8 +38,9 @@ public final class ReservedVmnetNetwork: Network { private struct NetworkInfo { let network: vmnet_network_ref - let subnet: CIDRAddress - let gateway: IPv4Address + let ipv4Subnet: CIDRv4 + let ipv4Gateway: IPv4Address + let ipv6Subnet: CIDRv6 } private let stateMutex: Mutex @@ -79,7 +81,11 @@ public final class ReservedVmnetNetwork: Network { let networkInfo = try startNetwork(configuration: configuration, log: log) - let networkStatus = NetworkStatus(address: networkInfo.subnet.description, gateway: networkInfo.gateway.description) + let networkStatus = NetworkStatus( + ipv4Subnet: networkInfo.ipv4Subnet, + ipv4Gateway: networkInfo.ipv4Gateway, + ipv6Subnet: networkInfo.ipv6Subnet, + ) state.networkState = NetworkState.running(configuration, networkStatus) state.network = networkInfo.network } @@ -101,10 +107,6 @@ public final class ReservedVmnetNetwork: Network { "mode": "\(configuration.mode)", ] ) - let subnetText = configuration.subnet ?? DefaultsStore.getOptional(key: .defaultSubnet) - - // with the reservation API, subnet priority is CLI argument, UserDefault, auto - let subnet = try subnetText.map { try CIDRAddress($0) } // set up the vmnet configuration var status: vmnet_return_t = .VMNET_SUCCESS @@ -114,21 +116,42 @@ public final class ReservedVmnetNetwork: Network { vmnet_network_configuration_disable_dhcp(vmnetConfiguration) - // set the subnet if the caller provided one - if let subnet { - let gateway = IPv4Address(fromValue: subnet.lower.value + 1) + // subnet priority is CLI argument, UserDefault, auto + let defaultIpv4Subnet = try DefaultsStore.getOptional(key: .defaultSubnet).map { try CIDRv4($0) } + let ipv4Subnet = configuration.ipv4Subnet ?? defaultIpv4Subnet + let defaultIpv6Subnet = try DefaultsStore.getOptional(key: .defaultIPv6Subnet).map { try CIDRv6($0) } + let ipv6Subnet = configuration.ipv6Subnet ?? defaultIpv6Subnet + + // set the IPv4 subnet if the caller provided one + if let ipv4Subnet { + let gateway = IPv4Address(ipv4Subnet.lower.value + 1) var gatewayAddr = in_addr() inet_pton(AF_INET, gateway.description, &gatewayAddr) - let mask = IPv4Address(fromValue: subnet.prefixLength.prefixMask32) + let mask = IPv4Address(ipv4Subnet.prefix.prefixMask32) var maskAddr = in_addr() inet_pton(AF_INET, mask.description, &maskAddr) log.info( - "configuring vmnet subnet", - metadata: ["cidr": "\(subnet)"] + "configuring vmnet IPv4 subnet", + metadata: ["cidr": "\(ipv4Subnet)"] ) let status = vmnet_network_configuration_set_ipv4_subnet(vmnetConfiguration, &gatewayAddr, &maskAddr) guard status == .VMNET_SUCCESS else { - throw ContainerizationError(.internalError, message: "failed to set subnet \(subnet) for network \(configuration.id)") + throw ContainerizationError(.internalError, message: "failed to set subnet \(ipv4Subnet) for IPv4 network \(configuration.id)") + } + } + + // set the IPv6 network prefix if the caller provided one + if let ipv6Subnet { + let gateway = IPv6Address(ipv6Subnet.lower.value + 1) + var gatewayAddr = in6_addr() + inet_pton(AF_INET6, gateway.description, &gatewayAddr) + log.info( + "configuring vmnet IPv6 prefix", + metadata: ["cidr": "\(ipv6Subnet)"] + ) + let status = vmnet_network_configuration_set_ipv6_prefix(vmnetConfiguration, &gatewayAddr, ipv6Subnet.prefix.length) + guard status == .VMNET_SUCCESS else { + throw ContainerizationError(.internalError, message: "failed to set prefix \(ipv6Subnet) for IPv6 network \(configuration.id)") } } @@ -143,10 +166,22 @@ public final class ReservedVmnetNetwork: Network { vmnet_network_get_ipv4_subnet(network, &subnetAddr, &maskAddr) let subnetValue = UInt32(bigEndian: subnetAddr.s_addr) let maskValue = UInt32(bigEndian: maskAddr.s_addr) - let lower = IPv4Address(fromValue: subnetValue & maskValue) - let upper = IPv4Address(fromValue: lower.value + ~maskValue) - let runningSubnet = try CIDRAddress(lower: lower, upper: upper) - let runningGateway = IPv4Address(fromValue: runningSubnet.lower.value + 1) + let lower = IPv4Address(subnetValue & maskValue) + let upper = IPv4Address(lower.value + ~maskValue) + let runningSubnet = try CIDRv4(lower: lower, upper: upper) + let runningGateway = IPv4Address(runningSubnet.lower.value + 1) + + var prefixAddr = in6_addr() + var prefixLength = UInt8(0) + vmnet_network_get_ipv6_prefix(network, &prefixAddr, &prefixLength) + guard let prefix = Prefix(length: prefixLength) else { + throw ContainerizationError(.internalError, message: "invalid IPv6 prefix length \(prefixLength) for network \(configuration.id)") + } + let prefixIpv6Bytes = withUnsafeBytes(of: prefixAddr.__u6_addr.__u6_addr8) { + Array($0) + } + let prefixIpv6Addr = IPv6Address(prefixIpv6Bytes) + let runningV6Subnet = try CIDRv6(prefixIpv6Addr, prefix: prefix) log.info( "started vmnet network", @@ -154,9 +189,15 @@ public final class ReservedVmnetNetwork: Network { "id": "\(configuration.id)", "mode": "\(configuration.mode)", "cidr": "\(runningSubnet)", + "cidrv6": "\(runningV6Subnet)", ] ) - return NetworkInfo(network: network, subnet: runningSubnet, gateway: runningGateway) + return NetworkInfo( + network: network, + ipv4Subnet: runningSubnet, + ipv4Gateway: runningGateway, + ipv6Subnet: runningV6Subnet, + ) } } diff --git a/Sources/Services/ContainerSandboxService/Client/Bundle+Log.swift b/Sources/Services/ContainerSandboxService/Client/Bundle+Log.swift new file mode 100644 index 000000000..72cc27d84 --- /dev/null +++ b/Sources/Services/ContainerSandboxService/Client/Bundle+Log.swift @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerResource +import Foundation + +extension ContainerResource.Bundle { + /// The pathname for the workload log file. + public var containerLog: URL { + path.appendingPathComponent("stdio.log") + } +} diff --git a/Sources/ContainerClient/ExitMonitor.swift b/Sources/Services/ContainerSandboxService/Client/ExitMonitor.swift similarity index 98% rename from Sources/ContainerClient/ExitMonitor.swift rename to Sources/Services/ContainerSandboxService/Client/ExitMonitor.swift index 25ebb51fe..6ac2fa9e6 100644 --- a/Sources/ContainerClient/ExitMonitor.swift +++ b/Sources/Services/ContainerSandboxService/Client/ExitMonitor.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/ContainerClient/SandboxClient.swift b/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift similarity index 85% rename from Sources/ContainerClient/SandboxClient.swift rename to Sources/Services/ContainerSandboxService/Client/SandboxClient.swift index 722650b25..ca61eee4c 100644 --- a/Sources/ContainerClient/SandboxClient.swift +++ b/Sources/Services/ContainerSandboxService/Client/SandboxClient.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,6 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerResource import ContainerXPC import Containerization import ContainerizationError @@ -60,7 +61,7 @@ public struct SandboxClient: Sendable { cause: error ) } - guard let endpoint = response.endpoint(key: .sandboxServiceEndpoint) else { + guard let endpoint = response.endpoint(key: SandboxKeys.sandboxServiceEndpoint.rawValue) else { throw ContainerizationError( .internalError, message: "failed to get endpoint for sandbox service" @@ -79,7 +80,7 @@ extension SandboxClient { let request = XPCMessage(route: SandboxRoutes.bootstrap.rawValue) for (i, h) in stdio.enumerated() { - let key: XPCKeys = try { + let key: SandboxKeys = try { switch i { case 0: .stdin case 1: .stdout @@ -90,7 +91,7 @@ extension SandboxClient { }() if let h { - request.set(key: key, value: h) + request.set(key: key.rawValue, value: h) } } @@ -122,12 +123,12 @@ extension SandboxClient { public func createProcess(_ id: String, config: ProcessConfiguration, stdio: [FileHandle?]) async throws { let request = XPCMessage(route: SandboxRoutes.createProcess.rawValue) - request.set(key: .id, value: id) + request.set(key: SandboxKeys.id.rawValue, value: id) let data = try JSONEncoder().encode(config) - request.set(key: .processConfig, value: data) + request.set(key: SandboxKeys.processConfig.rawValue, value: data) for (i, h) in stdio.enumerated() { - let key: XPCKeys = try { + let key: SandboxKeys = try { switch i { case 0: .stdin case 1: .stdout @@ -138,7 +139,7 @@ extension SandboxClient { }() if let h { - request.set(key: key, value: h) + request.set(key: key.rawValue, value: h) } } @@ -155,7 +156,7 @@ extension SandboxClient { public func startProcess(_ id: String) async throws { let request = XPCMessage(route: SandboxRoutes.start.rawValue) - request.set(key: .id, value: id) + request.set(key: SandboxKeys.id.rawValue, value: id) do { try await self.client.send(request) } catch { @@ -171,7 +172,7 @@ extension SandboxClient { let request = XPCMessage(route: SandboxRoutes.stop.rawValue) let data = try JSONEncoder().encode(options) - request.set(key: .stopOptions, value: data) + request.set(key: SandboxKeys.stopOptions.rawValue, value: data) let responseTimeout = Duration(.seconds(Int64(options.timeoutInSeconds + 1))) do { @@ -187,8 +188,8 @@ extension SandboxClient { public func kill(_ id: String, signal: Int64) async throws { let request = XPCMessage(route: SandboxRoutes.kill.rawValue) - request.set(key: .id, value: id) - request.set(key: .signal, value: signal) + request.set(key: SandboxKeys.id.rawValue, value: id) + request.set(key: SandboxKeys.signal.rawValue, value: signal) do { try await self.client.send(request) @@ -203,9 +204,9 @@ extension SandboxClient { public func resize(_ id: String, size: Terminal.Size) async throws { let request = XPCMessage(route: SandboxRoutes.resize.rawValue) - request.set(key: .id, value: id) - request.set(key: .width, value: UInt64(size.width)) - request.set(key: .height, value: UInt64(size.height)) + request.set(key: SandboxKeys.id.rawValue, value: id) + request.set(key: SandboxKeys.width.rawValue, value: UInt64(size.width)) + request.set(key: SandboxKeys.height.rawValue, value: UInt64(size.height)) do { try await self.client.send(request) @@ -220,7 +221,7 @@ extension SandboxClient { public func wait(_ id: String) async throws -> ExitStatus { let request = XPCMessage(route: SandboxRoutes.wait.rawValue) - request.set(key: .id, value: id) + request.set(key: SandboxKeys.id.rawValue, value: id) let response: XPCMessage do { @@ -232,14 +233,14 @@ extension SandboxClient { cause: error ) } - let code = response.int64(key: .exitCode) - let date = response.date(key: .exitedAt) + let code = response.int64(key: SandboxKeys.exitCode.rawValue) + let date = response.date(key: SandboxKeys.exitedAt.rawValue) return ExitStatus(exitCode: Int32(code), exitedAt: date) } public func dial(_ port: UInt32) async throws -> FileHandle { let request = XPCMessage(route: SandboxRoutes.dial.rawValue) - request.set(key: .port, value: UInt64(port)) + request.set(key: SandboxKeys.port.rawValue, value: UInt64(port)) let response: XPCMessage do { @@ -251,7 +252,7 @@ extension SandboxClient { cause: error ) } - guard let fh = response.fileHandle(key: .fd) else { + guard let fh = response.fileHandle(key: SandboxKeys.fd.rawValue) else { throw ContainerizationError( .internalError, message: "failed to get fd for vsock port \(port)" @@ -288,7 +289,7 @@ extension SandboxClient { ) } - guard let data = response.dataNoCopy(key: .statistics) else { + guard let data = response.dataNoCopy(key: SandboxKeys.statistics.rawValue) else { throw ContainerizationError( .internalError, message: "no statistics data returned" @@ -301,7 +302,7 @@ extension SandboxClient { extension XPCMessage { public func id() throws -> String { - let id = self.string(key: .id) + let id = self.string(key: SandboxKeys.id.rawValue) guard let id else { throw ContainerizationError( .invalidArgument, @@ -312,7 +313,7 @@ extension XPCMessage { } func sandboxSnapshot() throws -> SandboxSnapshot { - let data = self.dataNoCopy(key: .snapshot) + let data = self.dataNoCopy(key: SandboxKeys.snapshot.rawValue) guard let data else { throw ContainerizationError( .invalidArgument, diff --git a/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift b/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift new file mode 100644 index 000000000..2cb5b5ff7 --- /dev/null +++ b/Sources/Services/ContainerSandboxService/Client/SandboxKeys.swift @@ -0,0 +1,45 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +public enum SandboxKeys: String { + /// ID key. + case id + /// Vsock port number key. + case port + /// Exit code for a process + case exitCode + /// Exit timestamp for a process + case exitedAt + /// FD to a container resource key. + case fd + /// Options for stopping a container key. + case stopOptions + /// An endpoint to talk to a sandbox service. + case sandboxServiceEndpoint + + /// Process request keys. + case signal + case snapshot + case stdin + case stdout + case stderr + case width + case height + case processConfig + + /// Container statistics + case statistics +} diff --git a/Sources/ContainerClient/SandboxRoutes.swift b/Sources/Services/ContainerSandboxService/Client/SandboxRoutes.swift similarity index 97% rename from Sources/ContainerClient/SandboxRoutes.swift rename to Sources/Services/ContainerSandboxService/Client/SandboxRoutes.swift index e6fef77d2..79af080f9 100644 --- a/Sources/ContainerClient/SandboxRoutes.swift +++ b/Sources/Services/ContainerSandboxService/Client/SandboxRoutes.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. diff --git a/Sources/ContainerClient/SandboxSnapshot.swift b/Sources/Services/ContainerSandboxService/Client/SandboxSnapshot.swift similarity index 93% rename from Sources/ContainerClient/SandboxSnapshot.swift rename to Sources/Services/ContainerSandboxService/Client/SandboxSnapshot.swift index 7666ee9b6..1ba312b8c 100644 --- a/Sources/ContainerClient/SandboxSnapshot.swift +++ b/Sources/Services/ContainerSandboxService/Client/SandboxSnapshot.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,7 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerNetworkService +import ContainerResource /// A snapshot of a sandbox and its resources. public struct SandboxSnapshot: Codable, Sendable { diff --git a/Sources/Services/ContainerSandboxService/InterfaceStrategy.swift b/Sources/Services/ContainerSandboxService/Server/InterfaceStrategy.swift similarity index 94% rename from Sources/Services/ContainerSandboxService/InterfaceStrategy.swift rename to Sources/Services/ContainerSandboxService/Server/InterfaceStrategy.swift index 198c1fc09..1fae0a783 100644 --- a/Sources/Services/ContainerSandboxService/InterfaceStrategy.swift +++ b/Sources/Services/ContainerSandboxService/Server/InterfaceStrategy.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,7 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerNetworkService +import ContainerResource import ContainerXPC import Containerization diff --git a/Sources/Services/ContainerSandboxService/SandboxService.swift b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift similarity index 87% rename from Sources/Services/ContainerSandboxService/SandboxService.swift rename to Sources/Services/ContainerSandboxService/Server/SandboxService.swift index 867747364..1d15c661c 100644 --- a/Sources/Services/ContainerSandboxService/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,9 +14,10 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerClient -import ContainerNetworkService +import ContainerNetworkServiceClient import ContainerPersistence +import ContainerResource +import ContainerSandboxServiceClient import ContainerXPC import Containerization import ContainerizationError @@ -94,7 +95,7 @@ public actor SandboxService { self.log.info("`createEndpoint` xpc handler") let endpoint = xpc_endpoint_create(self.connection) let reply = message.reply() - reply.set(key: .sandboxServiceEndpoint, value: endpoint) + reply.set(key: SandboxKeys.sandboxServiceEndpoint.rawValue, value: endpoint) return reply } @@ -115,12 +116,15 @@ public actor SandboxService { ) } - let bundle = ContainerClient.Bundle(path: self.root) + let bundle = ContainerResource.Bundle(path: self.root) try bundle.createLogFile() var config = try bundle.configuration + + var kernel = try bundle.kernel + kernel.commandLine.kernelArgs.append("oops=panic") let vmm = VZVirtualMachineManager( - kernel: try bundle.kernel, + kernel: kernel, initialFilesystem: bundle.initialFilesystem.asMount, rosetta: config.rosetta, logger: self.log @@ -128,9 +132,10 @@ public actor SandboxService { // Dynamically configure the DNS nameserver from a network if no explicit configuration if let dns = config.dns, dns.nameservers.isEmpty { - if let nameserver = try await self.getDefaultNameserver(attachmentConfigurations: config.networks) { + let defaultNameservers = try await self.getDefaultNameservers(attachmentConfigurations: config.networks) + if !defaultNameservers.isEmpty { config.dns = ContainerConfiguration.DNSConfiguration( - nameservers: [nameserver], + nameservers: defaultNameservers, domain: dns.domain, searchDomains: dns.searchDomains, options: dns.options @@ -189,11 +194,10 @@ public actor SandboxService { // a default /etc/hosts. var hostsEntries = [Hosts.Entry.localHostIPV4()] if !interfaces.isEmpty { - let primaryIfaceAddr = interfaces[0].address - let ip = primaryIfaceAddr.split(separator: "/") + let primaryIfaceAddr = interfaces[0].ipv4Address hostsEntries.append( Hosts.Entry( - ipAddress: String(ip[0]), + ipAddress: primaryIfaceAddr.address.description, hostnames: [czConfig.hostname], )) } @@ -201,27 +205,25 @@ public actor SandboxService { czConfig.bootLog = BootLog.file(path: bundle.bootlog, append: true) } - await self.setContainer( - ContainerInfo( - container: container, - config: config, - attachments: attachments, - bundle: bundle, - io: (in: stdin, out: stdout, err: stderr) - )) + let ctrInfo = ContainerInfo( + container: container, + config: config, + attachments: attachments, + bundle: bundle, + io: (in: stdin, out: stdout, err: stderr) + ) + await self.setContainer(ctrInfo) do { try await container.create() try await self.monitor.registerProcess(id: config.id, onExit: self.onContainerExit) if !container.interfaces.isEmpty { - let firstCidr = try CIDRAddress(container.interfaces[0].address) - let ipAddress = firstCidr.address.description - try await self.startSocketForwarders(containerIpAddress: ipAddress, publishedPorts: config.publishedPorts) + try await self.startSocketForwarders(attachment: attachments[0], publishedPorts: config.publishedPorts) } await self.setState(.booted) } catch { do { - try await self.cleanupContainer() + try await self.cleanupContainer(containerInfo: ctrInfo) await self.setState(.created) } catch { self.log.error("failed to cleanup container: \(error)") @@ -275,19 +277,19 @@ public actor SandboxService { let containerStats = ContainerStats( id: stats.id, - memoryUsageBytes: stats.memory.usageBytes, - memoryLimitBytes: stats.memory.limitBytes, - cpuUsageUsec: stats.cpu.usageUsec, - networkRxBytes: stats.networks.reduce(0) { $0 + $1.receivedBytes }, - networkTxBytes: stats.networks.reduce(0) { $0 + $1.transmittedBytes }, - blockReadBytes: stats.blockIO.devices.reduce(0) { $0 + $1.readBytes }, - blockWriteBytes: stats.blockIO.devices.reduce(0) { $0 + $1.writeBytes }, - numProcesses: stats.process.current + memoryUsageBytes: stats.memory?.usageBytes, + memoryLimitBytes: stats.memory?.limitBytes, + cpuUsageUsec: stats.cpu?.usageUsec, + networkRxBytes: stats.networks?.reduce(0) { $0 + $1.receivedBytes }, + networkTxBytes: stats.networks?.reduce(0) { $0 + $1.transmittedBytes }, + blockReadBytes: stats.blockIO?.devices.reduce(0) { $0 + $1.readBytes }, + blockWriteBytes: stats.blockIO?.devices.reduce(0) { $0 + $1.writeBytes }, + numProcesses: stats.process?.current ) let reply = message.reply() let data = try JSONEncoder().encode(containerStats) - reply.set(key: .statistics, value: data) + reply.set(key: SandboxKeys.statistics.rawValue, value: data) return reply } } @@ -307,15 +309,6 @@ public actor SandboxService { case .created, .stopped(_), .stopping: await self.setState(.shuttingDown) - Task { - do { - try await Task.sleep(for: .seconds(5)) - } catch { - self.log.error("failed to sleep before shutting down SandboxService: \(error)") - } - self.log.info("Shutting down SandboxService") - exit(0) - } default: throw ContainerizationError( .invalidState, @@ -450,7 +443,7 @@ public actor SandboxService { if case .stopped(_) = await self.state { return message.reply() } - try await self.cleanupContainer() + try await self.cleanupContainer(containerInfo: ctr, exitStatus: exitStatus) } catch { self.log.error("failed to cleanup container: \(error)") } @@ -518,8 +511,8 @@ public actor SandboxService { case .running: let id = try message.id() let ctr = try getContainer() - let width = message.uint64(key: .width) - let height = message.uint64(key: .height) + let width = message.uint64(key: SandboxKeys.width.rawValue) + let height = message.uint64(key: SandboxKeys.height.rawValue) if id != ctr.container.id { guard let processInfo = self.processes[id] else { @@ -569,7 +562,7 @@ public actor SandboxService { @Sendable public func wait(_ message: XPCMessage) async throws -> XPCMessage { self.log.info("`wait` xpc handler") - guard let id = message.string(key: .id) else { + guard let id = message.string(key: SandboxKeys.id.rawValue) else { throw ContainerizationError(.invalidArgument, message: "missing id in wait xpc message") } @@ -598,7 +591,7 @@ public actor SandboxService { } if let cachedCode { let reply = message.reply() - reply.set(key: .exitCode, value: Int64(cachedCode)) + reply.set(key: SandboxKeys.exitCode.rawValue, value: Int64(cachedCode)) return reply } @@ -607,8 +600,8 @@ public actor SandboxService { self.addWaiter(id: id, cont: cc) } let reply = message.reply() - reply.set(key: .exitCode, value: Int64(exitStatus.exitCode)) - reply.set(key: .exitedAt, value: exitStatus.exitedAt) + reply.set(key: SandboxKeys.exitCode.rawValue, value: Int64(exitStatus.exitCode)) + reply.set(key: SandboxKeys.exitedAt.rawValue, value: exitStatus.exitedAt) return reply } @@ -625,7 +618,7 @@ public actor SandboxService { self.log.info("`dial` xpc handler") switch self.state { case .running, .booted: - let port = message.uint64(key: .port) + let port = message.uint64(key: SandboxKeys.port.rawValue) guard port > 0 else { throw ContainerizationError( .invalidArgument, @@ -637,7 +630,7 @@ public actor SandboxService { let fh = try await ctr.container.dialVsock(port: UInt32(port)) let reply = message.reply() - reply.set(key: .fd, value: fh) + reply.set(key: SandboxKeys.fd.rawValue, value: fh) return reply default: throw ContainerizationError( @@ -675,7 +668,7 @@ public actor SandboxService { } try await self.monitor.track(id: id, waitingOn: waitFunc) } catch { - try? await self.cleanupContainer() + try? await self.cleanupContainer(containerInfo: info) self.setState(.created) throw error } @@ -709,14 +702,27 @@ public actor SandboxService { try await self.monitor.track(id: id, waitingOn: waitFunc) } - private func startSocketForwarders(containerIpAddress: String, publishedPorts: [PublishPort]) async throws { + private func startSocketForwarders(attachment: Attachment, publishedPorts: [PublishPort]) async throws { var forwarders: [SocketForwarderResult] = [] - try Utility.validPublishPorts(publishedPorts) + guard !publishedPorts.hasOverlaps() else { + throw ContainerizationError(.invalidArgument, message: "host ports for different publish port specs may not overlap") + } + try await withThrowingTaskGroup(of: SocketForwarderResult.self) { group in for publishedPort in publishedPorts { for index in 0.. String? { + private func getDefaultNameservers(attachmentConfigurations: [AttachmentConfiguration]) async throws -> [String] { for attachmentConfiguration in attachmentConfigurations { let client = NetworkClient(id: attachmentConfiguration.network) let state = try await client.state() guard case .running(_, let status) = state else { continue } - return status.gateway + return [status.ipv4Gateway.description] } - return nil + return [] } private static func configureInitialProcess( @@ -1003,31 +1010,47 @@ public actor SandboxService { // Now actually bring down the vm. try await lc.stop() + return code } - private func cleanupContainer() async throws { + private func cleanupContainer(containerInfo: ContainerInfo, exitStatus: ExitStatus? = nil) async throws { + let container = containerInfo.container + let id = container.id + + do { + try await container.stop() + } catch { + self.log.error("failed to stop container during cleanup: \(error)") + } + // Give back our lovely IP(s) await self.stopSocketForwarders() - let containerInfo = try self.getContainer() for attachment in containerInfo.attachments { let client = NetworkClient(id: attachment.network) do { try await client.deallocate(hostname: attachment.hostname) } catch { - self.log.error("failed to deallocate hostname \(attachment.hostname) on network \(attachment.network): \(error)") + self.log.error("failed to deallocate hostname \(attachment.hostname) on network \(attachment.network) during cleanup: \(error)") } } + + let status = exitStatus ?? ExitStatus(exitCode: 255) + let waiters = self.waiters[id] ?? [] + for cc in waiters { + cc.resume(returning: status) + } + self.removeWaiters(for: id) } } extension XPCMessage { fileprivate func signal() throws -> Int64 { - self.int64(key: .signal) + self.int64(key: SandboxKeys.signal.rawValue) } fileprivate func stopOptions() throws -> ContainerStopOptions { - guard let data = self.dataNoCopy(key: .stopOptions) else { + guard let data = self.dataNoCopy(key: SandboxKeys.stopOptions.rawValue) else { throw ContainerizationError(.invalidArgument, message: "empty StopOptions") } return try JSONDecoder().decode(ContainerStopOptions.self, from: data) @@ -1035,41 +1058,36 @@ extension XPCMessage { fileprivate func setState(_ state: SandboxSnapshot) throws { let data = try JSONEncoder().encode(state) - self.set(key: .snapshot, value: data) + self.set(key: SandboxKeys.snapshot.rawValue, value: data) } fileprivate func stdio() -> [FileHandle?] { var handles = [FileHandle?](repeating: nil, count: 3) - if let stdin = self.fileHandle(key: .stdin) { + if let stdin = self.fileHandle(key: SandboxKeys.stdin.rawValue) { handles[0] = stdin } - if let stdout = self.fileHandle(key: .stdout) { + if let stdout = self.fileHandle(key: SandboxKeys.stdout.rawValue) { handles[1] = stdout } - if let stderr = self.fileHandle(key: .stderr) { + if let stderr = self.fileHandle(key: SandboxKeys.stderr.rawValue) { handles[2] = stderr } return handles } fileprivate func setFileHandle(_ handle: FileHandle) { - self.set(key: .fd, value: handle) + self.set(key: SandboxKeys.fd.rawValue, value: handle) } fileprivate func processConfig() throws -> ProcessConfiguration { - guard let data = self.dataNoCopy(key: .processConfig) else { + guard let data = self.dataNoCopy(key: SandboxKeys.processConfig.rawValue) else { throw ContainerizationError(.invalidArgument, message: "empty process configuration") } return try JSONDecoder().decode(ProcessConfiguration.self, from: data) } } -extension ContainerClient.Bundle { - /// The pathname for the workload log file. - public var containerLog: URL { - path.appendingPathComponent("stdio.log") - } - +extension ContainerResource.Bundle { func createLogFile() throws { // Create the log file we'll write stdio to. // O_TRUNC resolves a log delay issue on restarted containers by force-updating internal state @@ -1246,7 +1264,7 @@ extension SandboxService { let container: LinuxContainer let config: ContainerConfiguration let attachments: [Attachment] - let bundle: ContainerClient.Bundle + let bundle: ContainerResource.Bundle let io: (in: FileHandle?, out: MultiWriter?, err: MultiWriter?) } diff --git a/Sources/SocketForwarder/ConnectHandler.swift b/Sources/SocketForwarder/ConnectHandler.swift index c4e9c61e1..5f98e805b 100644 --- a/Sources/SocketForwarder/ConnectHandler.swift +++ b/Sources/SocketForwarder/ConnectHandler.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -81,6 +81,11 @@ extension ConnectHandler { .whenComplete { result in switch result { case .success(let channel): + guard context.channel.isActive else { + self.log?.trace("backend - frontend channel closed, closing backend connection") + context.channel.close(promise: nil) + return + } self.log?.trace("backend - connected") self.glue(channel, context: context) case .failure(let error): diff --git a/Sources/SocketForwarder/GlueHandler.swift b/Sources/SocketForwarder/GlueHandler.swift index 59e3e169b..c24d7bc88 100644 --- a/Sources/SocketForwarder/GlueHandler.swift +++ b/Sources/SocketForwarder/GlueHandler.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/SocketForwarder/LRUCache.swift b/Sources/SocketForwarder/LRUCache.swift index 310b351c7..82cb9f6d8 100644 --- a/Sources/SocketForwarder/LRUCache.swift +++ b/Sources/SocketForwarder/LRUCache.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/SocketForwarder/SocketForwarder.swift b/Sources/SocketForwarder/SocketForwarder.swift index a63a0c9a3..ac67497f4 100644 --- a/Sources/SocketForwarder/SocketForwarder.swift +++ b/Sources/SocketForwarder/SocketForwarder.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/SocketForwarder/SocketForwarderResult.swift b/Sources/SocketForwarder/SocketForwarderResult.swift index 9247047a9..c28b54d0a 100644 --- a/Sources/SocketForwarder/SocketForwarderResult.swift +++ b/Sources/SocketForwarder/SocketForwarderResult.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/SocketForwarder/TCPForwarder.swift b/Sources/SocketForwarder/TCPForwarder.swift index f4b99bf52..e5103360b 100644 --- a/Sources/SocketForwarder/TCPForwarder.swift +++ b/Sources/SocketForwarder/TCPForwarder.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/SocketForwarder/UDPForwarder.swift b/Sources/SocketForwarder/UDPForwarder.swift index b3c8252a3..54d472e1f 100644 --- a/Sources/SocketForwarder/UDPForwarder.swift +++ b/Sources/SocketForwarder/UDPForwarder.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/TerminalProgress/Int+Formatted.swift b/Sources/TerminalProgress/Int+Formatted.swift index 87ca58e9a..f320b3ab9 100644 --- a/Sources/TerminalProgress/Int+Formatted.swift +++ b/Sources/TerminalProgress/Int+Formatted.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/TerminalProgress/Int64+Formatted.swift b/Sources/TerminalProgress/Int64+Formatted.swift index e99f63895..4abc7a909 100644 --- a/Sources/TerminalProgress/Int64+Formatted.swift +++ b/Sources/TerminalProgress/Int64+Formatted.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/TerminalProgress/ProgressBar+Add.swift b/Sources/TerminalProgress/ProgressBar+Add.swift index 9c13d6445..29c299b1e 100644 --- a/Sources/TerminalProgress/ProgressBar+Add.swift +++ b/Sources/TerminalProgress/ProgressBar+Add.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/TerminalProgress/ProgressBar+State.swift b/Sources/TerminalProgress/ProgressBar+State.swift index baecbeab2..ecf032e15 100644 --- a/Sources/TerminalProgress/ProgressBar+State.swift +++ b/Sources/TerminalProgress/ProgressBar+State.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -17,10 +17,10 @@ import Foundation extension ProgressBar { - /// A configuration struct for the progress bar. - public struct State { + /// State for the progress bar. + struct State { /// A flag indicating whether the progress bar is finished. - public var finished = false + var finished = false var iteration = 0 private let speedInterval: DispatchTimeInterval = .seconds(1) @@ -41,6 +41,7 @@ extension ProgressBar { calculateSizeSpeed() } } + var totalSize: Int64? private var sizeUpdateSpeed: String? var sizeSpeed: String? { @@ -66,6 +67,7 @@ extension ProgressBar { var startTime: DispatchTime var output = "" + var renderTask: Task? init( description: String = "", subDescription: String = "", itemsName: String = "", tasks: Int = 0, totalTasks: Int? = nil, items: Int = 0, totalItems: Int? = nil, diff --git a/Sources/TerminalProgress/ProgressBar+Terminal.swift b/Sources/TerminalProgress/ProgressBar+Terminal.swift index ab997fea4..9c8c02ff5 100644 --- a/Sources/TerminalProgress/ProgressBar+Terminal.swift +++ b/Sources/TerminalProgress/ProgressBar+Terminal.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -21,10 +21,11 @@ enum EscapeSequence { static let hideCursor = "\u{001B}[?25l" static let showCursor = "\u{001B}[?25h" static let moveUp = "\u{001B}[1A" + static let clearToEndOfLine = "\u{001B}[K" } extension ProgressBar { - private var terminalWidth: Int { + var termWidth: Int { guard let terminalHandle = term, let terminal = try? Terminal(descriptor: terminalHandle.fileDescriptor) @@ -32,19 +33,27 @@ extension ProgressBar { return 0 } - let terminalWidth = (try? Int(terminal.size.width)) ?? 0 - return terminalWidth + return (try? Int(terminal.size.width)) ?? 0 } /// Clears the progress bar and resets the cursor. public func clearAndResetCursor() { - clear() - resetCursor() + state.withLock { s in + clear(state: &s) + resetCursor() + } } /// Clears the progress bar. public func clear() { - displayText("") + state.withLock { s in + clear(state: &s) + } + } + + /// Clears the progress bar (caller must hold state lock). + func clear(state: inout State) { + displayText("", state: &state) } /// Resets the cursor. @@ -63,27 +72,24 @@ extension ProgressBar { } func displayText(_ text: String, terminating: String = "\r") { - var text = text - - // Clears previously printed characters if the new string is shorter. - printedWidth.withLock { - text += String(repeating: " ", count: max($0 - text.count, 0)) - $0 = text.count - } - state.withLock { - $0.output = text + state.withLock { s in + displayText(text, state: &s, terminating: terminating) } + } + + func displayText(_ text: String, state: inout State, terminating: String = "\r") { + state.output = text // Clears previously printed lines. var lines = "" - if terminating.hasSuffix("\r") && terminalWidth > 0 { - let lineCount = (text.count - 1) / terminalWidth + if terminating.hasSuffix("\r") && termWidth > 0 { + let lineCount = (text.count - 1) / termWidth for _ in 0.. - let printedWidth = Mutex(0) let term: FileHandle? let termQueue = DispatchQueue(label: "com.apple.container.ProgressBar") - private let standardError = StandardError() /// Returns `true` if the progress bar has finished. public var isFinished: Bool { @@ -44,10 +42,6 @@ public final class ProgressBar: Sendable { display(EscapeSequence.hideCursor) } - deinit { - clear() - } - /// Allows resetting the progress state. public func reset() { state.withLock { @@ -83,11 +77,22 @@ public final class ProgressBar: Sendable { } private func start(intervalSeconds: TimeInterval) async { - while !state.withLock({ $0.finished }) { + while true { + let done = state.withLock { s -> Bool in + guard !s.finished else { + return true + } + render(state: &s) + s.iteration += 1 + return false + } + + if done { + return + } + let intervalNanoseconds = UInt64(intervalSeconds * 1_000_000_000) - render() - state.withLock { $0.iteration += 1 } - if (try? await Task.sleep(nanoseconds: intervalNanoseconds)) == nil { + guard (try? await Task.sleep(nanoseconds: intervalNanoseconds)) != nil else { return } } @@ -96,55 +101,102 @@ public final class ProgressBar: Sendable { /// Starts an animation of the progress bar. /// - Parameter intervalSeconds: The time interval between updates in seconds. public func start(intervalSeconds: TimeInterval = 0.04) { - Task(priority: .utility) { - await start(intervalSeconds: intervalSeconds) + state.withLock { + if $0.renderTask != nil { + return + } + $0.renderTask = Task(priority: .utility) { + await start(intervalSeconds: intervalSeconds) + } } } /// Finishes the progress bar. - public func finish() { - guard !state.withLock({ $0.finished }) else { - return - } - - state.withLock { $0.finished = true } - - // The last render. - render(force: true) - - if !config.disableProgressUpdates && !config.clearOnFinish { - displayText(state.withLock { $0.output }, terminating: "\n") - } + /// - Parameter clearScreen: If true, clears the progress bar from the screen. + public func finish(clearScreen: Bool = false) { + state.withLock { s in + guard !s.finished else { return } + + s.finished = true + s.renderTask?.cancel() + + let shouldClear = clearScreen || config.clearOnFinish + if !config.disableProgressUpdates && !shouldClear { + let output = draw(state: s) + displayText(output, state: &s, terminating: "\n") + } - if config.clearOnFinish { - clearAndResetCursor() - } else { + if shouldClear { + clear(state: &s) + } resetCursor() } - // Allow printed output to flush. - usleep(100_000) } } extension ProgressBar { - private func secondsSinceStart() -> Int { - let timeDifferenceNanoseconds = DispatchTime.now().uptimeNanoseconds - state.withLock { $0.startTime.uptimeNanoseconds } + private func secondsSinceStart(from startTime: DispatchTime) -> Int { + let timeDifferenceNanoseconds = DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds let timeDifferenceSeconds = Int(floor(Double(timeDifferenceNanoseconds) / 1_000_000_000)) return timeDifferenceSeconds } func render(force: Bool = false) { - guard term != nil && !config.disableProgressUpdates && (force || !state.withLock { $0.finished }) else { + guard term != nil && !config.disableProgressUpdates else { return } - let output = draw() - displayText(output) + state.withLock { s in + render(state: &s, force: force) + } } - func draw() -> String { - let state = self.state.withLock { $0 } + func render(state: inout State, force: Bool = false) { + guard term != nil && !config.disableProgressUpdates else { + return + } + guard force || !state.finished else { + return + } + let output = draw(state: state) + displayText(output, state: &state) + } + /// Detail levels for progressive truncation. + enum DetailLevel: Int, CaseIterable { + case full = 0 // Everything shown + case noSpeed // Drop speed from parens + case noSize // Drop size from parens + case noParens // Drop parens entirely (items, size, speed) + case noTime // Drop time + case noDescription // Drop description/subdescription + case minimal // Just spinner, tasks, percent + } + + func draw(state: State) -> String { + let width = termWidth + // If no terminal or width unknown, use full detail + guard width > 0 else { + return draw(state: state, detail: .full) + } + + // Add a small buffer to prevent wrapping issues during resize + let bufferChars = 4 + let targetWidth = max(1, width - bufferChars) + + for detail in DetailLevel.allCases { + let output = draw(state: state, detail: detail) + if output.count <= targetWidth { + return output + } + } + + return draw(state: state, detail: .minimal) + } + + func draw(state: State, detail: DetailLevel) -> String { var components = [String]() + + // Spinner - always shown if configured (unless using progress bar) if config.showSpinner && !config.showProgressBar { if !state.finished { let spinnerIcon = config.theme.getSpinnerIcon(state.iteration) @@ -154,103 +206,119 @@ extension ProgressBar { } } + // Tasks [x/y] - always shown if configured if config.showTasks, let totalTasks = state.totalTasks { let tasks = min(state.tasks, totalTasks) components.append("[\(tasks)/\(totalTasks)]") } - if config.showDescription && !state.description.isEmpty { - components.append("\(state.description)") - if !state.subDescription.isEmpty { - components.append("\(state.subDescription)") + // Description - dropped at noDescription level + if detail.rawValue < DetailLevel.noDescription.rawValue { + if config.showDescription && !state.description.isEmpty { + components.append("\(state.description)") + if !state.subDescription.isEmpty { + components.append("\(state.subDescription)") + } } } let allowProgress = !config.ignoreSmallSize || state.totalSize == nil || state.totalSize! > Int64(1024 * 1024) - let value = state.totalSize != nil ? state.size : Int64(state.items) let total = state.totalSize ?? Int64(state.totalItems ?? 0) + // Percent - always shown if configured if config.showPercent && total > 0 && allowProgress { components.append("\(state.finished ? "100%" : state.percent)") } + // Progress bar - always shown if configured if config.showProgressBar, total > 0, allowProgress { - let usedWidth = components.joined(separator: " ").count + 45 /* the maximum number of characters we may need */ - let remainingWidth = max(config.width - usedWidth, 1 /* the minimum width of a progress bar */) + let usedWidth = components.joined(separator: " ").count + 45 + let remainingWidth = max(config.width - usedWidth, 1) let barLength = state.finished ? remainingWidth : Int(Int64(remainingWidth) * value / total) let barPaddingLength = remainingWidth - barLength let bar = "\(String(repeating: config.theme.bar, count: barLength))\(String(repeating: " ", count: barPaddingLength))" components.append("|\(bar)|") } - var additionalComponents = [String]() + // Additional components in parens - progressively dropped + if detail.rawValue < DetailLevel.noParens.rawValue { + var additionalComponents = [String]() - if config.showItems, state.items > 0 { - var itemsName = "" - if !state.itemsName.isEmpty { - itemsName = " \(state.itemsName)" - } - if state.finished { - if let totalItems = state.totalItems { - additionalComponents.append("\(totalItems.formattedNumber())\(itemsName)") + // Items - dropped at noParens level + if config.showItems, state.items > 0 { + var itemsName = "" + if !state.itemsName.isEmpty { + itemsName = " \(state.itemsName)" } - } else { - if let totalItems = state.totalItems { - additionalComponents.append("\(state.items.formattedNumber()) of \(totalItems.formattedNumber())\(itemsName)") + if state.finished { + if let totalItems = state.totalItems { + additionalComponents.append("\(totalItems.formattedNumber())\(itemsName)") + } } else { - additionalComponents.append("\(state.items.formattedNumber())\(itemsName)") + if let totalItems = state.totalItems { + additionalComponents.append("\(state.items.formattedNumber()) of \(totalItems.formattedNumber())\(itemsName)") + } else { + additionalComponents.append("\(state.items.formattedNumber())\(itemsName)") + } } } - } - if state.size > 0 && allowProgress { - if state.finished { - if config.showSize { - if let totalSize = state.totalSize { - var formattedTotalSize = totalSize.formattedSize() - formattedTotalSize = adjustFormattedSize(formattedTotalSize) - additionalComponents.append(formattedTotalSize) + // Size and speed - progressively dropped + if state.size > 0 && allowProgress { + if state.finished { + // Size - dropped at noSize level + if detail.rawValue < DetailLevel.noSize.rawValue { + if config.showSize { + if let totalSize = state.totalSize { + var formattedTotalSize = totalSize.formattedSize() + formattedTotalSize = adjustFormattedSize(formattedTotalSize) + additionalComponents.append(formattedTotalSize) + } + } } - } - } else { - var formattedCombinedSize = "" - if config.showSize { - var formattedSize = state.size.formattedSize() - formattedSize = adjustFormattedSize(formattedSize) - if let totalSize = state.totalSize { - var formattedTotalSize = totalSize.formattedSize() - formattedTotalSize = adjustFormattedSize(formattedTotalSize) - formattedCombinedSize = combineSize(size: formattedSize, totalSize: formattedTotalSize) - } else { - formattedCombinedSize = formattedSize + } else { + // Size - dropped at noSize level + var formattedCombinedSize = "" + if detail.rawValue < DetailLevel.noSize.rawValue && config.showSize { + var formattedSize = state.size.formattedSize() + formattedSize = adjustFormattedSize(formattedSize) + if let totalSize = state.totalSize { + var formattedTotalSize = totalSize.formattedSize() + formattedTotalSize = adjustFormattedSize(formattedTotalSize) + formattedCombinedSize = combineSize(size: formattedSize, totalSize: formattedTotalSize) + } else { + formattedCombinedSize = formattedSize + } } - } - var formattedSpeed = "" - if config.showSpeed { - formattedSpeed = "\(state.sizeSpeed ?? state.averageSizeSpeed)" - formattedSpeed = adjustFormattedSize(formattedSpeed) - } + // Speed - dropped at noSpeed level + var formattedSpeed = "" + if detail.rawValue < DetailLevel.noSpeed.rawValue && config.showSpeed { + formattedSpeed = "\(state.sizeSpeed ?? state.averageSizeSpeed)" + formattedSpeed = adjustFormattedSize(formattedSpeed) + } - if config.showSize && config.showSpeed { - additionalComponents.append(formattedCombinedSize) - additionalComponents.append(formattedSpeed) - } else if config.showSize { - additionalComponents.append(formattedCombinedSize) - } else if config.showSpeed { - additionalComponents.append(formattedSpeed) + if !formattedCombinedSize.isEmpty && !formattedSpeed.isEmpty { + additionalComponents.append(formattedCombinedSize) + additionalComponents.append(formattedSpeed) + } else if !formattedCombinedSize.isEmpty { + additionalComponents.append(formattedCombinedSize) + } else if !formattedSpeed.isEmpty { + additionalComponents.append(formattedSpeed) + } } } - } - if additionalComponents.count > 0 { - let joinedAdditionalComponents = additionalComponents.joined(separator: ", ") - components.append("(\(joinedAdditionalComponents))") + if additionalComponents.count > 0 { + let joinedAdditionalComponents = additionalComponents.joined(separator: ", ") + components.append("(\(joinedAdditionalComponents))") + } } - if config.showTime { - let timeDifferenceSeconds = secondsSinceStart() + // Time - dropped at noTime level + if detail.rawValue < DetailLevel.noTime.rawValue && config.showTime { + let timeDifferenceSeconds = secondsSinceStart(from: state.startTime) let formattedTime = timeDifferenceSeconds.formattedTime() components.append("[\(formattedTime)]") } @@ -261,12 +329,13 @@ extension ProgressBar { private func adjustFormattedSize(_ size: String) -> String { // Ensure we always have one digit after the decimal point to prevent flickering. let zero = Int64(0).formattedSize() - guard !size.contains("."), let first = size.first, first.isNumber || !size.contains(zero) else { + let decimalSep = Locale.current.decimalSeparator ?? "." + guard !size.contains(decimalSep), let first = size.first, first.isNumber || !size.contains(zero) else { return size } var size = size for unit in ["MB", "GB", "TB"] { - size = size.replacingOccurrences(of: " \(unit)", with: ".0 \(unit)") + size = size.replacingOccurrences(of: " \(unit)", with: "\(decimalSep)0 \(unit)") } return size } @@ -286,4 +355,8 @@ extension ProgressBar { } return "\(sizeNumber)/\(totalSizeNumber) \(totalSizeUnit)" } + + func draw() -> String { + state.withLock { draw(state: $0) } + } } diff --git a/Sources/TerminalProgress/ProgressConfig.swift b/Sources/TerminalProgress/ProgressConfig.swift index 71c26c786..5a2f836a6 100644 --- a/Sources/TerminalProgress/ProgressConfig.swift +++ b/Sources/TerminalProgress/ProgressConfig.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -163,7 +163,7 @@ extension ProgressConfig { public var description: String { switch self { case .invalid(let reason): - return "Failed to validate config (\(reason))" + return "failed to validate config (\(reason))" } } } diff --git a/Sources/TerminalProgress/ProgressTaskCoordinator.swift b/Sources/TerminalProgress/ProgressTaskCoordinator.swift index 987e3c688..88cc73a7b 100644 --- a/Sources/TerminalProgress/ProgressTaskCoordinator.swift +++ b/Sources/TerminalProgress/ProgressTaskCoordinator.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/TerminalProgress/ProgressTheme.swift b/Sources/TerminalProgress/ProgressTheme.swift index 273b668a5..406b552f0 100644 --- a/Sources/TerminalProgress/ProgressTheme.swift +++ b/Sources/TerminalProgress/ProgressTheme.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/TerminalProgress/ProgressUpdate.swift b/Sources/TerminalProgress/ProgressUpdate.swift index 136e7888f..395dc7b90 100644 --- a/Sources/TerminalProgress/ProgressUpdate.swift +++ b/Sources/TerminalProgress/ProgressUpdate.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Sources/TerminalProgress/StandardError.swift b/Sources/TerminalProgress/StandardError.swift index 666e6123a..c27b5a950 100644 --- a/Sources/TerminalProgress/StandardError.swift +++ b/Sources/TerminalProgress/StandardError.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/CLITests/Subcommands/Build/CLIBuildBase.swift b/Tests/CLITests/Subcommands/Build/CLIBuildBase.swift index 3b1726c24..eb5ec30c5 100644 --- a/Tests/CLITests/Subcommands/Build/CLIBuildBase.swift +++ b/Tests/CLITests/Subcommands/Build/CLIBuildBase.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/CLITests/Subcommands/Build/CLIBuilderEnvOnlyTest.swift b/Tests/CLITests/Subcommands/Build/CLIBuilderEnvOnlyTest.swift index 952181b7b..77d103af2 100644 --- a/Tests/CLITests/Subcommands/Build/CLIBuilderEnvOnlyTest.swift +++ b/Tests/CLITests/Subcommands/Build/CLIBuilderEnvOnlyTest.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/CLITests/Subcommands/Build/CLIBuilderLifecycleTest.swift b/Tests/CLITests/Subcommands/Build/CLIBuilderLifecycleTest.swift index 22d064b33..1f87ba993 100644 --- a/Tests/CLITests/Subcommands/Build/CLIBuilderLifecycleTest.swift +++ b/Tests/CLITests/Subcommands/Build/CLIBuilderLifecycleTest.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -33,5 +33,52 @@ extension TestCLIBuildBase { #expect(status == "stopped", "BuildKit container is not stopped") } } + + @Test func testBuilderEnvironmentColors() throws { + let testColors = "run=green:warning=yellow:error=red:cancel=cyan" + let testNoColor = "true" + + let originalColors = ProcessInfo.processInfo.environment["BUILDKIT_COLORS"] + let originalNoColor = ProcessInfo.processInfo.environment["NO_COLOR"] + + defer { + if let originalColors { + setenv("BUILDKIT_COLORS", originalColors, 1) + } else { + unsetenv("BUILDKIT_COLORS") + } + if let originalNoColor { + setenv("NO_COLOR", originalNoColor, 1) + } else { + unsetenv("NO_COLOR") + } + + try? builderStop() + try? builderDelete(force: true) + } + + setenv("BUILDKIT_COLORS", testColors, 1) + setenv("NO_COLOR", testNoColor, 1) + + try? builderStop() + try? builderDelete(force: true) + + let (_, _, err, status) = try run(arguments: ["builder", "start"]) + try #require(status == 0, "builder start failed: \(err)") + + try waitForBuilderRunning() + + let container = try inspectContainer("buildkit") + let envVars = container.configuration.initProcess.environment + + #expect( + envVars.contains("BUILDKIT_COLORS=\(testColors)"), + "Expected BUILDKIT_COLORS to be passed to container, but it was missing from env: \(envVars)" + ) + #expect( + envVars.contains("NO_COLOR=\(testNoColor)"), + "Expected NO_COLOR to be passed to container, but it was missing from env: \(envVars)" + ) + } } } diff --git a/Tests/CLITests/Subcommands/Build/CLIBuilderLocalOutputTest.swift b/Tests/CLITests/Subcommands/Build/CLIBuilderLocalOutputTest.swift index 12bbb1984..bf7b3795b 100644 --- a/Tests/CLITests/Subcommands/Build/CLIBuilderLocalOutputTest.swift +++ b/Tests/CLITests/Subcommands/Build/CLIBuilderLocalOutputTest.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/CLITests/Subcommands/Build/CLIBuilderTarExportTest.swift b/Tests/CLITests/Subcommands/Build/CLIBuilderTarExportTest.swift index 97c35b312..a47998fd2 100644 --- a/Tests/CLITests/Subcommands/Build/CLIBuilderTarExportTest.swift +++ b/Tests/CLITests/Subcommands/Build/CLIBuilderTarExportTest.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift b/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift index 37ecbec3c..779ce3f12 100644 --- a/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift +++ b/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/CLITests/Subcommands/Build/CLIRunBase.swift b/Tests/CLITests/Subcommands/Build/CLIRunBase.swift index f794e5165..f2e3f61b9 100644 --- a/Tests/CLITests/Subcommands/Build/CLIRunBase.swift +++ b/Tests/CLITests/Subcommands/Build/CLIRunBase.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/CLITests/Subcommands/Build/TestCLITermIO.swift b/Tests/CLITests/Subcommands/Build/TestCLITermIO.swift index 21ec51e3a..1cc844a6c 100644 --- a/Tests/CLITests/Subcommands/Build/TestCLITermIO.swift +++ b/Tests/CLITests/Subcommands/Build/TestCLITermIO.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/CLITests/Subcommands/Containers/TestCLICreate.swift b/Tests/CLITests/Subcommands/Containers/TestCLICreate.swift index abee7a4af..e2d04eb10 100644 --- a/Tests/CLITests/Subcommands/Containers/TestCLICreate.swift +++ b/Tests/CLITests/Subcommands/Containers/TestCLICreate.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -14,6 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerizationExtras import Foundation import Testing @@ -32,7 +33,7 @@ class TestCLICreateCommand: CLITest { @Test func testCreateWithMACAddress() throws { let name = getTestName() - let expectedMAC = "02:42:ac:11:00:03" + let expectedMAC = try MACAddress("02:42:ac:11:00:03") #expect(throws: Never.self, "expected container create with MAC address to succeed") { try doCreate(name: name, networks: ["default,mac=\(expectedMAC)"]) try doStart(name: name) @@ -43,7 +44,10 @@ class TestCLICreateCommand: CLITest { try waitForContainerRunning(name) let inspectResp = try inspectContainer(name) #expect(inspectResp.networks.count > 0, "expected at least one network attachment") - #expect(inspectResp.networks[0].macAddress == expectedMAC, "expected MAC address \(expectedMAC), got \(inspectResp.networks[0].macAddress ?? "nil")") + let actualMAC = inspectResp.networks[0].macAddress?.description ?? "nil" + #expect( + actualMAC == expectedMAC.description, "expected MAC address \(expectedMAC), got \(actualMAC)" + ) } } @@ -93,4 +97,29 @@ class TestCLICreateCommand: CLITest { } } + @Test func testCreateWithFQDNName() throws { + let name = "test.example.com" + let expectedHostname = "test" + #expect(throws: Never.self, "expected container create with FQDN name to succeed") { + try doCreate(name: name) + try doStart(name: name) + defer { + try? doStop(name: name) + try? doRemove(name: name) + } + try waitForContainerRunning(name) + let inspectResp = try inspectContainer(name) + let attachmentHostname = inspectResp.networks.first?.hostname ?? "" + let gotHostname = + attachmentHostname + .split(separator: ".", maxSplits: 1, omittingEmptySubsequences: true) + .first + .map { String($0) } ?? attachmentHostname + #expect( + gotHostname == expectedHostname, + "expected hostname to be extracted as '\(expectedHostname)' from FQDN '\(name)', got '\(gotHostname)' (attachment hostname: '\(attachmentHostname)')" + ) + } + } + } diff --git a/Tests/CLITests/Subcommands/Containers/TestCLIExec.swift b/Tests/CLITests/Subcommands/Containers/TestCLIExec.swift index 17af30d79..e175f5df2 100644 --- a/Tests/CLITests/Subcommands/Containers/TestCLIExec.swift +++ b/Tests/CLITests/Subcommands/Containers/TestCLIExec.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/CLITests/Subcommands/Containers/TestCLIRmRace.swift b/Tests/CLITests/Subcommands/Containers/TestCLIRmRace.swift index fbab21ffc..b189c4d22 100644 --- a/Tests/CLITests/Subcommands/Containers/TestCLIRmRace.swift +++ b/Tests/CLITests/Subcommands/Containers/TestCLIRmRace.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/CLITests/Subcommands/Containers/TestCLIStats.swift b/Tests/CLITests/Subcommands/Containers/TestCLIStats.swift index 8c054fdc6..575f67fd5 100644 --- a/Tests/CLITests/Subcommands/Containers/TestCLIStats.swift +++ b/Tests/CLITests/Subcommands/Containers/TestCLIStats.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -14,7 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerClient +import ContainerResource import Foundation import Testing @@ -47,8 +47,10 @@ class TestCLIStatsCommand: CLITest { #expect(stats.count == 1, "expected stats for one container") #expect(stats[0].id == name, "container ID should match") - #expect(stats[0].memoryUsageBytes > 0, "memory usage should be non-zero") - #expect(stats[0].numProcesses >= 1, "should have at least one process") + let memoryUsageBytes = try #require(stats[0].memoryUsageBytes) + let numProcesses = try #require(stats[0].numProcesses) + #expect(memoryUsageBytes > 0, "memory usage should be non-zero") + #expect(numProcesses >= 1, "should have at least one process") } } diff --git a/Tests/CLITests/Subcommands/Images/TestCLIImages.swift b/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift similarity index 63% rename from Tests/CLITests/Subcommands/Images/TestCLIImages.swift rename to Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift index 5f7800e88..a33c66f32 100644 --- a/Tests/CLITests/Subcommands/Images/TestCLIImages.swift +++ b/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -14,77 +14,13 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerClient +import ContainerAPIClient +import ContainerizationArchive import ContainerizationOCI import Foundation import Testing class TestCLIImagesCommand: CLITest { - func doRemoveImages(images: [String]? = nil) throws { - var args = [ - "image", - "rm", - ] - - if let images { - args.append(contentsOf: images) - } else { - args.append("--all") - } - - let (_, _, error, status) = try run(arguments: args) - if status != 0 { - throw CLIError.executionFailed("command failed: \(error)") - } - } - - func isImagePresent(targetImage: String) throws -> Bool { - let images = try doListImages() - return images.contains(where: { image in - if image.reference == targetImage { - return true - } - return false - }) - } - - func doListImages() throws -> [Image] { - let (_, output, error, status) = try run(arguments: [ - "image", - "list", - "--format", - "json", - ]) - if status != 0 { - throw CLIError.executionFailed("command failed: \(error)") - } - - guard let jsonData = output.data(using: .utf8) else { - throw CLIError.invalidOutput("image list output invalid \(output)") - } - - let decoder = JSONDecoder() - return try decoder.decode([Image].self, from: jsonData) - } - - func doImageTag(image: String, newName: String) throws { - let tagArgs = [ - "image", - "tag", - image, - newName, - ] - - let (_, _, error, status) = try run(arguments: tagArgs) - if status != 0 { - throw CLIError.executionFailed("command failed: \(error)") - } - } - -} - -extension TestCLIImagesCommand { - @Test func testPull() throws { do { try doPull(imageName: alpine) @@ -360,6 +296,154 @@ extension TestCLIImagesCommand { } } + @Test func testMaxConcurrentDownloadsValidation() throws { + // Test that invalid maxConcurrentDownloads value is rejected + let (_, _, error, status) = try run(arguments: [ + "image", + "pull", + "--max-concurrent-downloads", "0", + "alpine:latest", + ]) + + #expect(status != 0, "Expected command to fail with maxConcurrentDownloads=0") + #expect( + error.contains("maximum number of concurrent downloads must be greater than 0"), + "Expected validation error message in output") + } + + @Test func testImageLoadRejectsInvalidMembersWithoutForce() throws { + do { + // 0. Generate unique malicious filename for this test run + let maliciousFilename = "pwned-\(UUID().uuidString).txt" + let maliciousPath = "/tmp/\(maliciousFilename)" + + // 1. Pull image + try doPull(imageName: alpine) + + // 2. Tag image so we can safely remove later + let alpineRef: Reference = try Reference.parse(alpine) + let alpineTagged = "\(alpineRef.name):testImageLoadRejectsInvalidMembers" + try doImageTag(image: alpine, newName: alpineTagged) + let taggedImagePresent = try isImagePresent(targetImage: alpineTagged) + #expect(taggedImagePresent, "expected to see image \(alpineTagged) tagged") + + // 3. Save the image as a tarball + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDir) + } + let tempFile = tempDir.appendingPathComponent(UUID().uuidString) + let saveArgs = [ + "image", + "save", + alpineTagged, + "--output", + tempFile.path(), + ] + let (_, _, saveError, saveStatus) = try run(arguments: saveArgs) + if saveStatus != 0 { + throw CLIError.executionFailed("save command failed: \(saveError)") + } + + // 4. Add malicious member to the tar + try addInvalidMemberToTar(tarPath: tempFile.path(), maliciousFilename: maliciousFilename) + + // 5. Remove the image + try doRemoveImages(images: [alpineTagged]) + let imageRemoved = try !isImagePresent(targetImage: alpineTagged) + #expect(imageRemoved, "expected image \(alpineTagged) to be removed") + + // 6. Try to load the modified tar without force - should fail + let loadArgs = [ + "image", + "load", + "-i", + tempFile.path(), + ] + let (_, _, loadError, loadStatus) = try run(arguments: loadArgs) + #expect(loadStatus != 0, "expected load to fail without force flag") + #expect(loadError.contains("rejected paths") || loadError.contains(maliciousFilename), "expected error about invalid member path") + + // 7. Verify that malicious file was NOT created + let maliciousFileExists = FileManager.default.fileExists(atPath: maliciousPath) + #expect(!maliciousFileExists, "malicious file should not have been created at \(maliciousPath)") + } catch { + Issue.record("failed to test image load with invalid members: \(error)") + return + } + } + + @Test func testImageLoadAcceptsInvalidMembersWithForce() throws { + do { + // 0. Generate unique malicious filename for this test run + let maliciousFilename = "pwned-\(UUID().uuidString).txt" + let maliciousPath = "/tmp/\(maliciousFilename)" + + // 1. Pull image + try doPull(imageName: alpine) + + // 2. Tag image so we can safely remove later + let alpineRef: Reference = try Reference.parse(alpine) + let alpineTagged = "\(alpineRef.name):testImageLoadAcceptsInvalidMembers" + try doImageTag(image: alpine, newName: alpineTagged) + let taggedImagePresent = try isImagePresent(targetImage: alpineTagged) + #expect(taggedImagePresent, "expected to see image \(alpineTagged) tagged") + + // 3. Save the image as a tarball + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDir) + } + let tempFile = tempDir.appendingPathComponent(UUID().uuidString) + let saveArgs = [ + "image", + "save", + alpineTagged, + "--output", + tempFile.path(), + ] + let (_, _, saveError, saveStatus) = try run(arguments: saveArgs) + if saveStatus != 0 { + throw CLIError.executionFailed("save command failed: \(saveError)") + } + + // 4. Add malicious member to the tar + try addInvalidMemberToTar(tarPath: tempFile.path(), maliciousFilename: maliciousFilename) + + // 5. Remove the image + try doRemoveImages(images: [alpineTagged]) + let imageRemoved = try !isImagePresent(targetImage: alpineTagged) + #expect(imageRemoved, "expected image \(alpineTagged) to be removed") + + // 6. Try to load the modified tar with force - should succeed with warning + let loadArgs = [ + "image", + "load", + "-i", + tempFile.path(), + "--force", + ] + let (_, _, loadError, loadStatus) = try run(arguments: loadArgs) + #expect(loadStatus == 0, "expected load to succeed with force flag") + + // Check that warning was logged about rejected member + #expect(loadError.contains("invalid members") || loadError.contains(maliciousFilename), "expected warning about rejected member path") + + // 7. Verify image is loaded + let imageLoaded = try isImagePresent(targetImage: alpineTagged) + #expect(imageLoaded, "expected image \(alpineTagged) to be loaded") + + // 8. Verify that malicious file was NOT created + let maliciousFileExists = FileManager.default.fileExists(atPath: maliciousPath) + #expect(!maliciousFileExists, "malicious file should not have been created at \(maliciousPath)") + } catch { + Issue.record("failed to test image load with force and invalid members: \(error)") + return + } + } + @Test func testImageSaveAndLoadStdinStdout() throws { do { // 1. pull image @@ -420,4 +504,41 @@ extension TestCLIImagesCommand { return } } + + private func addInvalidMemberToTar(tarPath: String, maliciousFilename: String) throws { + // Create a malicious entry with path traversal + let evilEntryName = "../../../../../../../../../../../tmp/\(maliciousFilename)" + let evilEntryContent = "pwned\n".data(using: .utf8)! + + // Create a temporary file for the modified tar + let tempModifiedTar = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).tar") + + // Open the modified tar for writing + let writer = try ArchiveWriter(format: .pax, filter: .none, file: tempModifiedTar) + + // First, copy all existing members from the input tar + let reader = try ArchiveReader(file: URL(fileURLWithPath: tarPath)) + for (entry, data) in reader { + if entry.fileType == .regular { + try writer.writeEntry(entry: entry, data: data) + } else { + try writer.writeEntry(entry: entry, data: nil) + } + } + + // Now add the evil entry + let evilEntry = WriteEntry() + evilEntry.path = evilEntryName + evilEntry.size = Int64(evilEntryContent.count) + evilEntry.modificationDate = Date() + evilEntry.fileType = .regular + evilEntry.permissions = 0o644 + + try writer.writeEntry(entry: evilEntry, data: evilEntryContent) + try writer.finishEncoding() + + // Replace the original tar with the modified one + try FileManager.default.removeItem(atPath: tarPath) + try FileManager.default.moveItem(at: tempModifiedTar, to: URL(fileURLWithPath: tarPath)) + } } diff --git a/Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift b/Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift index 70c94c107..894ec9312 100644 --- a/Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift +++ b/Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,7 +15,7 @@ //===----------------------------------------------------------------------===// import AsyncHTTPClient -import ContainerClient +import ContainerAPIClient import ContainerizationError import ContainerizationExtras import ContainerizationOS @@ -61,7 +61,7 @@ class TestCLINetwork: CLITest { let container = try inspectContainer(name) #expect(container.networks.count > 0) - let cidrAddress = try CIDRAddress(container.networks[0].address) + let cidrAddress = container.networks[0].ipv4Address let url = "http://\(cidrAddress.address):\(port)" var request = HTTPClientRequest(url: url) request.method = .GET diff --git a/Tests/CLITests/Subcommands/Plugins/TestCLIPluginErrors.swift b/Tests/CLITests/Subcommands/Plugins/TestCLIPluginErrors.swift index 4d6694b5a..901c15a61 100644 --- a/Tests/CLITests/Subcommands/Plugins/TestCLIPluginErrors.swift +++ b/Tests/CLITests/Subcommands/Plugins/TestCLIPluginErrors.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/CLITests/Subcommands/Run/TestCLIRunOptions.swift b/Tests/CLITests/Subcommands/Run/TestCLIRunCommand.swift similarity index 77% rename from Tests/CLITests/Subcommands/Run/TestCLIRunOptions.swift rename to Tests/CLITests/Subcommands/Run/TestCLIRunCommand.swift index 500b88f8f..9fb9cd8e6 100644 --- a/Tests/CLITests/Subcommands/Run/TestCLIRunOptions.swift +++ b/Tests/CLITests/Subcommands/Run/TestCLIRunCommand.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,18 +15,25 @@ //===----------------------------------------------------------------------===// import AsyncHTTPClient -import ContainerClient +import ContainerAPIClient import ContainerizationExtras import ContainerizationOS import Foundation import Testing -class TestCLIRunCommand: CLITest { - private func getTestName() -> String { +// FIXME: We've split the tests into two suites to prevent swamping +// the API server with so many run commands that all wind up pulling +// images. +// +// When https://github.com/swiftlang/swift-testing/pull/1390 lands +// and is available on the CI runners, we can try setting the +// environment variable to limit concurrency and rejoin these suites. +class TestCLIRunCommand1: CLITest { + func getTestName() -> String { Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() } - private func getLowercasedTestName() -> String { + func getLowercasedTestName() -> String { getTestName().lowercased() } @@ -194,6 +201,16 @@ class TestCLIRunCommand: CLITest { return } } +} + +class TestCLIRunCommand2: CLITest { + func getTestName() -> String { + Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() + } + + func getLowercasedTestName() -> String { + getTestName().lowercased() + } @Test func testRunCommandMount() throws { do { @@ -384,6 +401,16 @@ class TestCLIRunCommand: CLITest { return } } +} + +class TestCLIRunCommand3: CLITest { + func getTestName() -> String { + Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() + } + + func getLowercasedTestName() -> String { + getTestName().lowercased() + } @Test func testRunCommandDefaultResolvConf() throws { do { @@ -400,9 +427,8 @@ class TestCLIRunCommand: CLITest { .map { $0.joined(separator: " ") } let inspectOutput = try inspectContainer(name) - let ip = String(inspectOutput.networks[0].address.split(separator: "/")[0]) - let ipv4Address = try IPv4Address(ip) - let expectedNameserver = IPv4Address(fromValue: ipv4Address.prefix(prefixLength: 24).value + 1).description + let ip = inspectOutput.networks[0].ipv4Address.address + let expectedNameserver = IPv4Address((ip.value & Prefix(length: 24)!.prefixMask32) + 1).description let defaultDomain = try getDefaultDomain() let expectedLines: [String] = [ "nameserver \(expectedNameserver)", @@ -463,12 +489,12 @@ class TestCLIRunCommand: CLITest { } let inspectOutput = try inspectContainer(name) - let ip = String(inspectOutput.networks[0].address.split(separator: "/")[0]) + let ip = inspectOutput.networks[0].ipv4Address.address let output = try doExec(name: name, cmd: ["cat", "/etc/hosts"]) let lines = output.split(separator: "\n") - let expectedEntries = [("127.0.0.1", "localhost"), (ip, name)] + let expectedEntries = [("127.0.0.1", "localhost"), (ip.description, name)] for (i, line) in lines.enumerated() { let words = line.split(separator: " ").map { String($0) } @@ -513,13 +539,14 @@ class TestCLIRunCommand: CLITest { let response = try await client.execute(request, timeout: .seconds(retryDelaySeconds)) try #require(response.status == .ok) success = true + print("request to \(url) succeeded") } catch { print("request to \(url) failed, error \(error)") try await Task.sleep(for: .seconds(retryDelaySeconds)) } retriesRemaining -= 1 } - #expect(success, "Request to \(url) failed after \(retries - retriesRemaining) retries") + try #require(success, "Request to \(url) failed after \(retries - retriesRemaining) retries") try doStop(name: name) } catch { Issue.record("failed to run container \(error)") @@ -561,13 +588,14 @@ class TestCLIRunCommand: CLITest { let response = try await client.execute(request, timeout: .seconds(retryDelaySeconds)) try #require(response.status == .ok) success = true + print("request to \(url) succeeded") } catch { print("request to \(url) failed, error: \(error)") try await Task.sleep(for: .seconds(retryDelaySeconds)) } retriesRemaining -= 1 } - #expect(success, "Request to \(url) failed after \(retries - retriesRemaining) retries") + try #require(success, "Request to \(url) failed after \(retries - retriesRemaining) retries") try doStop(name: name) } catch { Issue.record("failed to run container \(error)") @@ -576,6 +604,131 @@ class TestCLIRunCommand: CLITest { } } + @available(macOS 26, *) + @Test func testForwardTCPv6() async throws { + let retries = 10 + let retryDelaySeconds = Int64(3) + do { + let name = getLowercasedTestName() + let proxyIp = "[::1]" + let proxyPort = UInt16.random(in: 50000..<55000) + let serverPort = UInt16.random(in: 55000..<60000) + try doLongRun( + name: name, + image: "docker.io/library/node:alpine", + args: ["--publish", "\(proxyIp):\(proxyPort):\(serverPort)/tcp"], + containerArgs: ["npx", "http-server", "-a", "::", "-p", "\(serverPort)"]) + defer { + try? doStop(name: name) + } + + let url = "http://\(proxyIp):\(proxyPort)" + var request = HTTPClientRequest(url: url) + request.method = .GET + let config = HTTPClient.Configuration(proxy: nil) + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { _ = client.shutdown() } + var retriesRemaining = retries + var success = false + while !success && retriesRemaining > 0 { + do { + let response = try await client.execute(request, timeout: .seconds(retryDelaySeconds)) + try #require(response.status == .ok) + success = true + print("request to \(url) succeeded") + } catch { + print("request to \(url) failed, error \(error)") + try await Task.sleep(for: .seconds(retryDelaySeconds)) + } + retriesRemaining -= 1 + } + try #require(success, "Request to \(url) failed after \(retries - retriesRemaining) retries") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandEnvFileFromNamedPipe() throws { + do { + let name = getTestName() + let pipePath = FileManager.default.temporaryDirectory.appendingPathComponent("envfile-pipe\(UUID().uuidString)") + + // create pipe + let result = mkfifo(pipePath.path(), 0o600) + guard result == 0 else { + Issue.record("failed to create named pipe: \(String(cString: strerror(errno)))") + return + } + + defer { + try? FileManager.default.removeItem(at: pipePath) + } + + let content = """ + FOO=bar + BAR=baz + """ + + let group = DispatchGroup() + + group.enter() + DispatchQueue.global().async { + do { + let handle = try FileHandle(forWritingTo: pipePath) + try handle.write(contentsOf: Data(content.utf8)) + try handle.close() + } catch { + Issue.record(error) + return + } + + group.leave() + } + + try doLongRun(name: name, args: ["--env-file", pipePath.path()]) + defer { + try? doStop(name: name) + } + + group.wait() + + let inspectResult = try inspectContainer(name) + let expected = [ + "FOO=bar", + "BAR=baz", + ] + + for item in expected { + #expect( + inspectResult.configuration.initProcess.environment.contains(item), + "expected environment variable \(item) not found" + ) + } + try doStop(name: name) + } catch { + Issue.record(error) + } + } + + @Test func testRunCommandReadOnly() throws { + do { + let name = getTestName() + try doLongRun(name: name, args: ["--read-only"]) + defer { + try? doStop(name: name) + } + // Attempt to touch a file on the read-only rootfs should fail + #expect(throws: (any Error).self) { + try doExec(name: name, cmd: ["touch", "/testfile"]) + } + } catch { + Issue.record("failed to run container \(error)") + return + } + } + func getDefaultDomain() throws -> String? { let (_, output, err, status) = try run(arguments: ["system", "property", "get", "dns.domain"]) try #require(status == 0, "default DNS domain retrieval returned status \(status): \(err)") @@ -586,4 +739,29 @@ class TestCLIRunCommand: CLITest { return trimmedOutput } + + @Test func testPrivilegedPortError() throws { + try #require(geteuid() != 0) + + let name = getTestName() + let privilegedPort = 80 + let (_, _, error, status) = try run(arguments: [ + "run", + "--name", name, + "--publish", "127.0.0.1:\(privilegedPort):80", + alpine, + ]) + defer { + try? doRemove(name: name, force: true) + } + #expect(status != 0, "Command should have failed") + #expect( + error.contains("Permission denied while binding to host port \(privilegedPort)"), + "Error message should mention permission denied for the port. Got: \(error)" + ) + #expect( + error.contains("root privileges"), + "Error message should mention root privileges requirement. Got: \(error)" + ) + } } diff --git a/Tests/CLITests/Subcommands/Run/TestCLIRunLifecycle.swift b/Tests/CLITests/Subcommands/Run/TestCLIRunLifecycle.swift index 1a4128b3d..7a5fc9719 100644 --- a/Tests/CLITests/Subcommands/Run/TestCLIRunLifecycle.swift +++ b/Tests/CLITests/Subcommands/Run/TestCLIRunLifecycle.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/CLITests/Subcommands/System/TestCLIVersion.swift b/Tests/CLITests/Subcommands/System/TestCLIVersion.swift new file mode 100644 index 000000000..e9592a9be --- /dev/null +++ b/Tests/CLITests/Subcommands/System/TestCLIVersion.swift @@ -0,0 +1,102 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025-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 Foundation +import Testing + +/// Tests for `container system version` output formats and build type detection. +final class TestCLIVersion: CLITest { + struct VersionInfo: Codable { + let version: String + let buildType: String + let commit: String + let appName: String + } + + struct VersionJSON: Codable { + let version: String + let buildType: String + let commit: String + let appName: String + let server: VersionInfo? + } + + private func expectedBuildType() throws -> String { + let path = try executablePath + if path.path.contains("/debug/") { + return "debug" + } else if path.path.contains("/release/") { + return "release" + } + // Fallback: prefer debug when ambiguous (matches SwiftPM default for tests) + return "debug" + } + + @Test func defaultDisplaysTable() throws { + let (data, out, err, status) = try run(arguments: ["system", "version"]) // default is table + #expect(status == 0, "system version should succeed, stderr: \(err)") + #expect(!out.isEmpty) + + // Validate table structure + let lines = out.trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: .newlines) + #expect(lines.count >= 2) // header + at least CLI row + #expect(lines[0].contains("COMPONENT") && lines[0].contains("VERSION") && lines[0].contains("BUILD") && lines[0].contains("COMMIT")) + #expect(lines[1].hasPrefix("CLI ")) + + // Build should reflect the binary we are running (debug/release) + let expected = try expectedBuildType() + #expect(lines.joined(separator: "\n").contains(" CLI ")) + #expect(lines.joined(separator: "\n").contains(" \(expected) ")) + _ = data // silence unused warning if assertions short-circuit + } + + @Test func jsonFormat() throws { + let (data, out, err, status) = try run(arguments: ["system", "version", "--format", "json"]) + #expect(status == 0, "system version --format json should succeed, stderr: \(err)") + #expect(!out.isEmpty) + + let decoded = try JSONDecoder().decode(VersionJSON.self, from: data) + #expect(decoded.appName == "container CLI") + #expect(!decoded.version.isEmpty) + #expect(!decoded.commit.isEmpty) + + let expected = try expectedBuildType() + #expect(decoded.buildType == expected) + } + + @Test func explicitTableFormat() throws { + let (_, out, err, status) = try run(arguments: ["system", "version", "--format", "table"]) + #expect(status == 0, "system version --format table should succeed, stderr: \(err)") + #expect(!out.isEmpty) + + let lines = out.trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: .newlines) + #expect(lines.count >= 2) + #expect(lines[0].contains("COMPONENT") && lines[0].contains("VERSION") && lines[0].contains("BUILD") && lines[0].contains("COMMIT")) + #expect(lines[1].hasPrefix("CLI ")) + } + + @Test func buildTypeMatchesBinary() throws { + // Validate build type via JSON to avoid parsing table text loosely + let (data, _, err, status) = try run(arguments: ["system", "version", "--format", "json"]) + #expect(status == 0, "version --format json should succeed, stderr: \(err)") + let decoded = try JSONDecoder().decode(VersionJSON.self, from: data) + + let expected = try expectedBuildType() + #expect(decoded.buildType == expected, "Expected build type \(expected) but got \(decoded.buildType)") + } +} diff --git a/Tests/CLITests/Subcommands/System/TestKernelSet.swift b/Tests/CLITests/Subcommands/System/TestKernelSet.swift index 522d0d9cc..c7e90cb62 100644 --- a/Tests/CLITests/Subcommands/System/TestKernelSet.swift +++ b/Tests/CLITests/Subcommands/System/TestKernelSet.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -14,7 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerClient +import ContainerAPIClient import ContainerPersistence import ContainerizationArchive import Foundation @@ -81,7 +81,7 @@ class TestCLIKernelSet: CLITest { try await withTempDir { tempDir in // manually download the tar file let localTarPath = tempDir.appending(path: remoteTar.lastPathComponent) - try await ContainerClient.FileDownloader.downloadFile(url: remoteTar, to: localTarPath) + try await ContainerAPIClient.FileDownloader.downloadFile(url: remoteTar, to: localTarPath) let extraArgs: [String] = [ "--tar", @@ -113,7 +113,7 @@ class TestCLIKernelSet: CLITest { try await withTempDir { tempDir in // manually download the tar file let localTarPath = tempDir.appending(path: remoteTar.lastPathComponent) - try await ContainerClient.FileDownloader.downloadFile(url: remoteTar, to: localTarPath) + try await ContainerAPIClient.FileDownloader.downloadFile(url: remoteTar, to: localTarPath) // extract just the file we want let targetPath = tempDir.appending(path: URL(string: defaultBinaryPath)!.lastPathComponent) diff --git a/Tests/CLITests/Subcommands/Volumes/TestCLIAnonymousVolumes.swift b/Tests/CLITests/Subcommands/Volumes/TestCLIAnonymousVolumes.swift index 0c2343352..b16b97396 100644 --- a/Tests/CLITests/Subcommands/Volumes/TestCLIAnonymousVolumes.swift +++ b/Tests/CLITests/Subcommands/Volumes/TestCLIAnonymousVolumes.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -14,7 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerClient +import ContainerResource import Foundation import Testing diff --git a/Tests/CLITests/Subcommands/Volumes/TestCLIVolumes.swift b/Tests/CLITests/Subcommands/Volumes/TestCLIVolumes.swift index ad423195c..c46a594d0 100644 --- a/Tests/CLITests/Subcommands/Volumes/TestCLIVolumes.swift +++ b/Tests/CLITests/Subcommands/Volumes/TestCLIVolumes.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -14,7 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerClient +import ContainerAPIClient import Foundation import Testing @@ -332,7 +332,7 @@ class TestCLIVolumes: CLITest { throw CLIError.executionFailed("volume prune failed: \(error)") } - #expect(output.contains("0 B") || output.contains("No volumes to prune"), "should show no space reclaimed or no volumes message") + #expect(output.contains("Zero KB"), "should show no space reclaimed") } @Test func testVolumePruneUnusedVolumes() throws { diff --git a/Tests/CLITests/TestCLINoParallelCases.swift b/Tests/CLITests/TestCLINoParallelCases.swift new file mode 100644 index 000000000..5638ae612 --- /dev/null +++ b/Tests/CLITests/TestCLINoParallelCases.swift @@ -0,0 +1,306 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025-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 ContainerAPIClient +import ContainerizationOCI +import Foundation +import Testing + +/// Tests that need total control over environment to avoid conflicts. +@Suite(.serialized) +class TestCLINoParallelCases: CLITest { + func getTestName() -> String { + Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() + } + + func getLowercasedTestName() -> String { + getTestName().lowercased() + } + + @Test func testImageSingleConcurrentDownload() throws { + // removing this image during parallel tests breaks stuff! + _ = try? run(arguments: ["image", "rm", alpine]) + defer { _ = try? run(arguments: ["image", "rm", "--all"]) } + do { + try doPull(imageName: alpine, args: ["--max-concurrent-downloads", "1"]) + let imagePresent = try isImagePresent(targetImage: alpine) + #expect(imagePresent, "Expected image to be pulled with maxConcurrentDownloads=1") + } catch { + Issue.record("failed to pull image with maxConcurrentDownloads flag: \(error)") + return + } + } + + @Test func testImageManyConcurrentDownloads() throws { + // removing this image during parallel tests breaks stuff! + _ = try? run(arguments: ["image", "rm", alpine]) + defer { _ = try? run(arguments: ["image", "rm", "--all"]) } + do { + try doPull(imageName: alpine, args: ["--max-concurrent-downloads", "64"]) + let imagePresent = try isImagePresent(targetImage: alpine) + #expect(imagePresent, "Expected image to be pulled with maxConcurrentDownloads=64") + } catch { + Issue.record("failed to pull image with maxConcurrentDownloads flag: \(error)") + return + } + } + + @Test func testImagePruneNoImages() throws { + // Prune with no images should succeed + _ = try? run(arguments: ["image", "rm", "--all"]) + let (_, output, error, status) = try run(arguments: ["image", "prune"]) + if status != 0 { + throw CLIError.executionFailed("image prune failed: \(error)") + } + + #expect(output.contains("Zero KB"), "should show no space reclaimed") + } + + @Test func testImagePruneUnusedImages() throws { + // 1. Pull the images + _ = try? run(arguments: ["image", "rm", "--all"]) + defer { _ = try? run(arguments: ["image", "rm", "--all"]) } + try doPull(imageName: alpine) + try doPull(imageName: busybox) + + // 2. Verify the images are present + let alpinePresent = try isImagePresent(targetImage: alpine) + #expect(alpinePresent, "expected to see image \(alpine) pulled") + let busyBoxPresent = try isImagePresent(targetImage: busybox) + #expect(busyBoxPresent, "expected to see image \(busybox) pulled") + + // 3. Prune with the -a flag should remove all unused images + let (_, output, error, status) = try run(arguments: ["image", "prune", "-a"]) + if status != 0 { + throw CLIError.executionFailed("image prune failed: \(error)") + } + #expect(output.contains(alpine), "should prune alpine image") + #expect(output.contains(busybox), "should prune busybox image") + + // 4. Verify the images are gone + let alpineRemoved = try !isImagePresent(targetImage: alpine) + #expect(alpineRemoved, "expected image \(alpine) to be removed") + let busyboxRemoved = try !isImagePresent(targetImage: busybox) + #expect(busyboxRemoved, "expected image \(busybox) to be removed") + } + + @Test func testImagePruneDanglingImages() throws { + let name = getTestName() + let containerName = "\(name)_container" + + // 1. Pull the images + _ = try? run(arguments: ["image", "rm", "--all"]) + defer { _ = try? run(arguments: ["image", "rm", "--all"]) } + _ = try? run(arguments: ["rm", "--all", "--force"]) + defer { _ = try? run(arguments: ["rm", "--all", "--force"]) } + try doPull(imageName: alpine) + try doPull(imageName: busybox) + + // 2. Verify the images are present + let alpinePresent = try isImagePresent(targetImage: alpine) + #expect(alpinePresent, "expected to see image \(alpine) pulled") + let busyBoxPresent = try isImagePresent(targetImage: busybox) + #expect(busyBoxPresent, "expected to see image \(busybox) pulled") + + // 3. Create a running container based on alpine + try doLongRun( + name: containerName, + image: alpine + ) + try waitForContainerRunning(containerName) + + // 4. Prune should only remove the dangling image + let (_, output, error, status) = try run(arguments: ["image", "prune", "-a"]) + if status != 0 { + throw CLIError.executionFailed("image prune failed: \(error)") + } + #expect(output.contains(busybox), "should prune busybox image") + + // 5. Verify the busybox image is gone + let busyboxRemoved = try !isImagePresent(targetImage: busybox) + #expect(busyboxRemoved, "expected image \(busybox) to be removed") + + // 6. Verify the alpine image still exists + let alpineStillPresent = try isImagePresent(targetImage: alpine) + #expect(alpineStillPresent, "expected image \(alpine) to remain") + } + + @available(macOS 26, *) + @Test func testNetworkPruneNoNetworks() throws { + // Ensure the testnetworkcreateanduse network is deleted + // Clean up is necessary for testing prune with no networks + doNetworkDeleteIfExists(name: "testnetworkcreateanduse") + + // Prune with no networks should succeed + let (_, _, _, statusBefore) = try run(arguments: ["network", "list", "--quiet"]) + #expect(statusBefore == 0) + let (_, output, error, status) = try run(arguments: ["network", "prune"]) + if status != 0 { + throw CLIError.executionFailed("network prune failed: \(error)") + } + + #expect(output.isEmpty, "should show no networks pruned") + } + + @available(macOS 26, *) + @Test func testNetworkPruneUnusedNetworks() throws { + let name = getTestName() + let network1 = "\(name)_1" + let network2 = "\(name)_2" + + // Clean up any existing resources from previous runs + doNetworkDeleteIfExists(name: network1) + doNetworkDeleteIfExists(name: network2) + + defer { + doNetworkDeleteIfExists(name: network1) + doNetworkDeleteIfExists(name: network2) + } + + try doNetworkCreate(name: network1) + try doNetworkCreate(name: network2) + + // Verify networks are created + let (_, listBefore, _, statusBefore) = try run(arguments: ["network", "list", "--quiet"]) + #expect(statusBefore == 0) + #expect(listBefore.contains(network1)) + #expect(listBefore.contains(network2)) + + // Prune should remove both + let (_, output, error, status) = try run(arguments: ["network", "prune"]) + if status != 0 { + throw CLIError.executionFailed("network prune failed: \(error)") + } + + #expect(output.contains(network1), "should prune network1") + #expect(output.contains(network2), "should prune network2") + + // Verify networks are gone + let (_, listAfter, _, statusAfter) = try run(arguments: ["network", "list", "--quiet"]) + #expect(statusAfter == 0) + #expect(!listAfter.contains(network1), "network1 should be pruned") + #expect(!listAfter.contains(network2), "network2 should be pruned") + } + + @available(macOS 26, *) + @Test(.disabled("https://github.com/apple/container/issues/953")) + func testNetworkPruneSkipsNetworksInUse() throws { + let name = getTestName() + let containerName = "\(name)_c1" + let networkInUse = "\(name)_inuse" + let networkUnused = "\(name)_unused" + + // Clean up any existing resources from previous runs + try? doStop(name: containerName) + try? doRemove(name: containerName) + doNetworkDeleteIfExists(name: networkInUse) + doNetworkDeleteIfExists(name: networkUnused) + + defer { + try? doStop(name: containerName) + try? doRemove(name: containerName) + doNetworkDeleteIfExists(name: networkInUse) + doNetworkDeleteIfExists(name: networkUnused) + } + + try doNetworkCreate(name: networkInUse) + try doNetworkCreate(name: networkUnused) + + // Verify networks are created + let (_, listBefore, _, statusBefore) = try run(arguments: ["network", "list", "--quiet"]) + #expect(statusBefore == 0) + #expect(listBefore.contains(networkInUse)) + #expect(listBefore.contains(networkUnused)) + + // Creation of container with network connection + let port = UInt16.random(in: 50000..<60000) + try doLongRun( + name: containerName, + image: "docker.io/library/python:alpine", + args: ["--network", networkInUse], + containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"] + ) + try waitForContainerRunning(containerName) + let container = try inspectContainer(containerName) + #expect(container.networks.count > 0) + + // Prune should only remove the unused network + let (_, _, error, status) = try run(arguments: ["network", "prune"]) + if status != 0 { + throw CLIError.executionFailed("network prune failed: \(error)") + } + + // Verify in-use network still exists + let (_, listAfter, _, statusAfter) = try run(arguments: ["network", "list", "--quiet"]) + #expect(statusAfter == 0) + #expect(listAfter.contains(networkInUse), "network in use should NOT be pruned") + #expect(!listAfter.contains(networkUnused), "unused network should be pruned") + } + + @available(macOS 26, *) + @Test(.disabled("https://github.com/apple/container/issues/953")) + func testNetworkPruneSkipsNetworkAttachedToStoppedContainer() async throws { + let name = getTestName() + let containerName = "\(name)_c1" + let networkName = "\(name)" + + // Clean up any existing resources from previous runs + try? doStop(name: containerName) + try? doRemove(name: containerName) + doNetworkDeleteIfExists(name: networkName) + + defer { + try? doStop(name: containerName) + try? doRemove(name: containerName) + doNetworkDeleteIfExists(name: networkName) + } + + try doNetworkCreate(name: networkName) + + // Creation of container with network connection + let port = UInt16.random(in: 50000..<60000) + try doLongRun( + name: containerName, + image: "docker.io/library/python:alpine", + args: ["--network", networkName], + containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"] + ) + try await Task.sleep(for: .seconds(1)) + + // Prune should NOT remove the network (container exists, even if stopped) + let (_, _, error, status) = try run(arguments: ["network", "prune"]) + if status != 0 { + throw CLIError.executionFailed("network prune failed: \(error)") + } + + let (_, listAfter, _, statusAfter) = try run(arguments: ["network", "list", "--quiet"]) + #expect(statusAfter == 0) + #expect(listAfter.contains(networkName), "network attached to stopped container should NOT be pruned") + + try? doStop(name: containerName) + try? doRemove(name: containerName) + + let (_, _, error2, status2) = try run(arguments: ["network", "prune"]) + if status2 != 0 { + throw CLIError.executionFailed("network prune failed: \(error2)") + } + + // Verify network is gone + let (_, listFinal, _, statusFinal) = try run(arguments: ["network", "list", "--quiet"]) + #expect(statusFinal == 0) + #expect(!listFinal.contains(networkName), "network should be pruned after container is deleted") + } +} diff --git a/Tests/CLITests/Utilities/CLITest.swift b/Tests/CLITests/Utilities/CLITest.swift index 83f83b02f..4ccd71a5b 100644 --- a/Tests/CLITests/Utilities/CLITest.swift +++ b/Tests/CLITests/Utilities/CLITest.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. @@ -15,8 +15,7 @@ //===----------------------------------------------------------------------===// import AsyncHTTPClient -import ContainerClient -import ContainerNetworkService +import ContainerResource import Containerization import ContainerizationOS import Foundation @@ -305,7 +304,7 @@ class CLITest { struct inspectOutput: Codable { let status: String let configuration: ContainerConfiguration - let networks: [ContainerNetworkService.Attachment] + let networks: [ContainerResource.Attachment] } func getContainerStatus(_ name: String) throws -> String { @@ -484,4 +483,76 @@ class CLITest { return try await body(tempDir) } + + func doRemoveImages(images: [String]? = nil) throws { + var args = [ + "image", + "rm", + ] + + if let images { + args.append(contentsOf: images) + } else { + args.append("--all") + } + + let (_, _, error, status) = try run(arguments: args) + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + } + + func isImagePresent(targetImage: String) throws -> Bool { + let images = try doListImages() + return images.contains(where: { image in + if image.reference == targetImage { + return true + } + return false + }) + } + + func doListImages() throws -> [Image] { + let (_, output, error, status) = try run(arguments: [ + "image", + "list", + "--format", + "json", + ]) + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + + guard let jsonData = output.data(using: .utf8) else { + throw CLIError.invalidOutput("image list output invalid \(output)") + } + + let decoder = JSONDecoder() + return try decoder.decode([Image].self, from: jsonData) + } + + func doImageTag(image: String, newName: String) throws { + let tagArgs = [ + "image", + "tag", + image, + newName, + ] + + let (_, _, error, status) = try run(arguments: tagArgs) + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + } + + func doNetworkCreate(name: String) throws { + let (_, _, error, status) = try run(arguments: ["network", "create", name]) + if status != 0 { + throw CLIError.executionFailed("network create failed: \(error)") + } + } + + func doNetworkDeleteIfExists(name: String) { + let (_, _, _, _) = (try? run(arguments: ["network", "rm", name])) ?? (nil, "", "", 1) + } } diff --git a/Tests/ContainerAPIClientTests/ArchTests.swift b/Tests/ContainerAPIClientTests/ArchTests.swift new file mode 100644 index 000000000..f4236b43e --- /dev/null +++ b/Tests/ContainerAPIClientTests/ArchTests.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@testable import ContainerAPIClient + +struct ArchTests { + + @Test func testAmd64Initialization() throws { + let arch = Arch(rawValue: "amd64") + #expect(arch != nil) + #expect(arch == .amd64) + } + + @Test func testX86_64Alias() throws { + let arch = Arch(rawValue: "x86_64") + #expect(arch != nil) + #expect(arch == .amd64) + } + + @Test func testX86_64WithDashAlias() throws { + let arch = Arch(rawValue: "x86-64") + #expect(arch != nil) + #expect(arch == .amd64) + } + + @Test func testArm64Initialization() throws { + let arch = Arch(rawValue: "arm64") + #expect(arch != nil) + #expect(arch == .arm64) + } + + @Test func testAarch64Alias() throws { + let arch = Arch(rawValue: "aarch64") + #expect(arch != nil) + #expect(arch == .arm64) + } + + @Test func testCaseInsensitive() throws { + #expect(Arch(rawValue: "AMD64") == .amd64) + #expect(Arch(rawValue: "X86_64") == .amd64) + #expect(Arch(rawValue: "ARM64") == .arm64) + #expect(Arch(rawValue: "AARCH64") == .arm64) + #expect(Arch(rawValue: "Amd64") == .amd64) + } + + @Test func testInvalidArchitecture() throws { + #expect(Arch(rawValue: "invalid") == nil) + #expect(Arch(rawValue: "i386") == nil) + #expect(Arch(rawValue: "powerpc") == nil) + #expect(Arch(rawValue: "") == nil) + } + + @Test func testRawValueRoundTrip() throws { + #expect(Arch.amd64.rawValue == "amd64") + #expect(Arch.arm64.rawValue == "arm64") + } +} diff --git a/Tests/ContainerClientTests/DiskUsageTests.swift b/Tests/ContainerAPIClientTests/DiskUsageTests.swift similarity index 97% rename from Tests/ContainerClientTests/DiskUsageTests.swift rename to Tests/ContainerAPIClientTests/DiskUsageTests.swift index ca445c120..793ad9da1 100644 --- a/Tests/ContainerClientTests/DiskUsageTests.swift +++ b/Tests/ContainerAPIClientTests/DiskUsageTests.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -17,7 +17,7 @@ import Foundation import Testing -@testable import ContainerClient +@testable import ContainerAPIClient struct DiskUsageTests { diff --git a/Tests/ContainerClientTests/HostDNSResolverTest.swift b/Tests/ContainerAPIClientTests/HostDNSResolverTest.swift similarity index 97% rename from Tests/ContainerClientTests/HostDNSResolverTest.swift rename to Tests/ContainerAPIClientTests/HostDNSResolverTest.swift index 2d6741fe9..7f72d7417 100644 --- a/Tests/ContainerClientTests/HostDNSResolverTest.swift +++ b/Tests/ContainerAPIClientTests/HostDNSResolverTest.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -18,7 +18,7 @@ import ContainerizationError import Foundation import Testing -@testable import ContainerClient +@testable import ContainerAPIClient struct HostDNSResolverTest { @Test diff --git a/Tests/ContainerClientTests/Measurement+ParseTests.swift b/Tests/ContainerAPIClientTests/Measurement+ParseTests.swift similarity index 98% rename from Tests/ContainerClientTests/Measurement+ParseTests.swift rename to Tests/ContainerAPIClientTests/Measurement+ParseTests.swift index 3066f0956..4214a7d9d 100644 --- a/Tests/ContainerClientTests/Measurement+ParseTests.swift +++ b/Tests/ContainerAPIClientTests/Measurement+ParseTests.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -17,7 +17,7 @@ import Foundation import Testing -@testable import ContainerClient +@testable import ContainerAPIClient struct MeasurementParseTests { diff --git a/Tests/ContainerClientTests/ParserTest.swift b/Tests/ContainerAPIClientTests/ParserTest.swift similarity index 69% rename from Tests/ContainerClientTests/ParserTest.swift rename to Tests/ContainerAPIClientTests/ParserTest.swift index 8868e1c14..9596b516b 100644 --- a/Tests/ContainerClientTests/ParserTest.swift +++ b/Tests/ContainerAPIClientTests/ParserTest.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -15,17 +15,19 @@ //===----------------------------------------------------------------------===// import ContainerizationError +import ContainerizationExtras import Foundation import Testing -@testable import ContainerClient +@testable import ContainerAPIClient struct ParserTest { @Test func testPublishPortParserTcp() throws { let result = try Parser.publishPorts(["127.0.0.1:8080:8000/tcp"]) #expect(result.count == 1) - #expect(result[0].hostAddress == "127.0.0.1") + let expectedAddress = try IPAddress("127.0.0.1") + #expect(result[0].hostAddress == expectedAddress) #expect(result[0].hostPort == UInt16(8080)) #expect(result[0].containerPort == UInt16(8000)) #expect(result[0].proto == .tcp) @@ -36,7 +38,8 @@ struct ParserTest { func testPublishPortParserUdp() throws { let result = try Parser.publishPorts(["192.168.32.36:8000:8080/UDP"]) #expect(result.count == 1) - #expect(result[0].hostAddress == "192.168.32.36") + let expectedAddress = try IPAddress("192.168.32.36") + #expect(result[0].hostAddress == expectedAddress) #expect(result[0].hostPort == UInt16(8000)) #expect(result[0].containerPort == UInt16(8080)) #expect(result[0].proto == .udp) @@ -47,7 +50,8 @@ struct ParserTest { func testPublishPortRange() throws { let result = try Parser.publishPorts(["127.0.0.1:8080-8179:9000-9099/tcp"]) #expect(result.count == 1) - #expect(result[0].hostAddress == "127.0.0.1") + let expectedAddress = try IPAddress("127.0.0.1") + #expect(result[0].hostAddress == expectedAddress) #expect(result[0].hostPort == UInt16(8080)) #expect(result[0].containerPort == UInt16(9000)) #expect(result[0].proto == .tcp) @@ -58,7 +62,8 @@ struct ParserTest { func testPublishPortRangeSingle() throws { let result = try Parser.publishPorts(["127.0.0.1:8080-8080:9000-9000/tcp"]) #expect(result.count == 1) - #expect(result[0].hostAddress == "127.0.0.1") + let expectedAddress = try IPAddress("127.0.0.1") + #expect(result[0].hostAddress == expectedAddress) #expect(result[0].hostPort == UInt16(8080)) #expect(result[0].containerPort == UInt16(9000)) #expect(result[0].proto == .tcp) @@ -69,7 +74,8 @@ struct ParserTest { func testPublishPortNoHostAddress() throws { let result = try Parser.publishPorts(["8080:8000/tcp"]) #expect(result.count == 1) - #expect(result[0].hostAddress == "0.0.0.0") + let expectedAddress = try IPAddress("0.0.0.0") + #expect(result[0].hostAddress == expectedAddress) #expect(result[0].hostPort == UInt16(8080)) #expect(result[0].containerPort == UInt16(8000)) #expect(result[0].proto == .tcp) @@ -80,7 +86,20 @@ struct ParserTest { func testPublishPortNoProtocol() throws { let result = try Parser.publishPorts(["8080:8000"]) #expect(result.count == 1) - #expect(result[0].hostAddress == "0.0.0.0") + let expectedAddress = try IPAddress("0.0.0.0") + #expect(result[0].hostAddress == expectedAddress) + #expect(result[0].hostPort == UInt16(8080)) + #expect(result[0].containerPort == UInt16(8000)) + #expect(result[0].proto == .tcp) + #expect(result[0].count == 1) + } + + @Test + func testPublishPortParserIPv6() throws { + let result = try Parser.publishPorts(["[fe80::36f3:5e50:ed71:1bb]:8080:8000/tcp"]) + #expect(result.count == 1) + let expectedAddress = try IPAddress("fe80::36f3:5e50:ed71:1bb") + #expect(result[0].hostAddress == expectedAddress) #expect(result[0].hostPort == UInt16(8080)) #expect(result[0].containerPort == UInt16(8000)) #expect(result[0].proto == .tcp) @@ -112,14 +131,43 @@ struct ParserTest { } @Test - func testPublishPortInvalidAddress() throws { + func testPublishPortMissingPort() throws { #expect { _ = try Parser.publishPorts(["1234"]) } throws: { error in guard let error = error as? ContainerizationError else { return false } - return error.description.contains("invalid publish address") + return error.description.contains("invalid publish value") + } + } + + @Test + func testPublishInvalidIPv4Address() throws { + #expect { + _ = try Parser.publishPorts(["1234:8080:8000"]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid publish IPv4 address") + } + } + + @Test + func testPublishInvalidIPv6Address() throws { + #expect { + _ = try Parser.publishPorts([ + "[1234:5678]:8080:8000", + "[2001::db8::1]:8080:8080", + "[2001:db8:85a3::8a2e:370g:7334]:8080:8080", + "[2001:db8:85a3::][8a2e::7334]:8080:8080", + ]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid publish IPv6 address") } } @@ -280,30 +328,85 @@ struct ParserTest { } @Test - func testMountBindRelativePath() throws { - - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent("test-bind-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: tempDir) - } - + func testRelativePaths() throws { let originalDir = FileManager.default.currentDirectoryPath - FileManager.default.changeCurrentDirectoryPath(tempDir.path) defer { FileManager.default.changeCurrentDirectoryPath(originalDir) } - let result = try Parser.mount("type=bind,src=.,dst=/foo") + func realpath(_ path: String) -> String { + let resolved = UnsafeMutablePointer.allocate(capacity: Int(PATH_MAX)) + defer { resolved.deallocate() } + guard let result = Darwin.realpath(path, resolved) else { + return path + } + return String(cString: result) + } - switch result { - case .filesystem(let fs): - let expectedPath = URL(filePath: ".").absoluteURL.path - #expect(fs.source == expectedPath) - #expect(fs.destination == "/foo") - #expect(!fs.isVolume) - case .volume: - #expect(Bool(false), "Expected filesystem mount, got volume") + // Test bind mount with relative path "." + do { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent("test-bind-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + FileManager.default.changeCurrentDirectoryPath(tempDir.path) + let result = try Parser.mount("type=bind,src=.,dst=/foo") + + switch result { + case .filesystem(let fs): + #expect(fs.source == realpath(tempDir.path)) + #expect(fs.destination == "/foo") + #expect(!fs.isVolume) + case .volume: + #expect(Bool(false), "Expected filesystem mount, got volume") + } + } + + // Test volume with relative path "./" + do { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent("test-volume-rel-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + FileManager.default.changeCurrentDirectoryPath(tempDir.path) + let result = try Parser.volume("./:/foo") + + switch result { + case .filesystem(let fs): + let expectedPath = realpath(tempDir.path) + // Normalize trailing slashes for comparison + #expect(fs.source.trimmingCharacters(in: CharacterSet(charactersIn: "/")) == expectedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))) + #expect(fs.destination == "/foo") + case .volume: + #expect(Bool(false), "Expected filesystem mount, got volume") + } + } + + // Test volume with nested relative path "./subdir" + do { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent("test-volume-rel-nested-\(UUID().uuidString)") + let nestedDir = tempDir.appendingPathComponent("subdir") + try FileManager.default.createDirectory(at: nestedDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + FileManager.default.changeCurrentDirectoryPath(tempDir.path) + let result = try Parser.volume("./subdir:/foo") + + switch result { + case .filesystem(let fs): + let expectedPath = realpath(nestedDir.path) + // Normalize trailing slashes for comparison + #expect(fs.source.trimmingCharacters(in: CharacterSet(charactersIn: "/")) == expectedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))) + #expect(fs.destination == "/foo") + case .volume: + #expect(Bool(false), "Expected filesystem mount, got volume") + } } } @@ -423,6 +526,50 @@ struct ParserTest { } } + // MARK: - Environment Variable Tests + + @Test + func testEnvExplicitValue() throws { + let result = Parser.env(envList: ["FOO=bar", "BAZ=qux"]) + #expect(result == ["FOO=bar", "BAZ=qux"]) + } + + @Test + func testEnvImplicitInheritance() throws { + guard let homeValue = ProcessInfo.processInfo.environment["PATH"] else { + Issue.record("PATH environment variable not set") + return + } + + let result = Parser.env(envList: ["PATH"]) + #expect(result == ["PATH=\(homeValue)"]) + } + + @Test + func testEnvImplicitUndefinedVariable() throws { + // A variable that doesn't exist should be silently skipped + let result = Parser.env(envList: ["THIS_VAR_DEFINITELY_DOES_NOT_EXIST_12345"]) + #expect(result.isEmpty) + } + + @Test + func testEnvMixedExplicitAndImplicit() throws { + guard let homeValue = ProcessInfo.processInfo.environment["HOME"] else { + Issue.record("HOME environment variable not set") + return + } + + let result = Parser.env(envList: ["FOO=bar", "HOME", "BAZ=qux"]) + #expect(result == ["FOO=bar", "HOME=\(homeValue)", "BAZ=qux"]) + } + + @Test + func testEnvEmptyValue() throws { + // Explicit empty value should be preserved + let result = Parser.env(envList: ["EMPTY="]) + #expect(result == ["EMPTY="]) + } + private func tmpFileWithContent(_ content: String) throws -> URL { let tempDir = FileManager.default.temporaryDirectory let tempFile = tempDir.appendingPathComponent("envfile-test-\(UUID().uuidString)") @@ -493,10 +640,12 @@ struct ParserTest { #expect { _ = try Parser.envFile(path: "/nonexistent/foo_bar_baz") } throws: { error in - guard let error = error as? ContainerizationError else { + guard let error = error as? ContainerizationError, + let cause = error.cause + else { return false } - return error.description.contains("not found") + return String(describing: cause).contains("No such file or directory") } } @@ -579,6 +728,41 @@ struct ParserTest { } } + @Test + func testParseEnvFileFromNamedPipe() throws { + let pipePath = FileManager.default.temporaryDirectory + .appendingPathComponent("envfile-pipe-\(UUID().uuidString)") + + // Create a named pipe (FIFO) + let result = mkfifo(pipePath.path, 0o600) + guard result == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EPERM) + } + defer { try? FileManager.default.removeItem(at: pipePath) } + + let group = DispatchGroup() + + group.enter() + DispatchQueue.global().async { + do { + let handle = try FileHandle(forWritingTo: pipePath) + try handle.write(contentsOf: "SECRET_KEY=value123\n".data(using: .utf8)!) + try handle.close() + } catch { + Issue.record(error) + } + group.leave() + } + + // Read from pipe (blocks until writer connects) + let lines = try Parser.envFile(path: pipePath.path) + + // Wait for write to complete + group.wait() + + #expect(lines == ["SECRET_KEY=value123"]) + } + // MARK: Network Parser Tests @Test @@ -661,4 +845,22 @@ struct ParserTest { return error.description.contains("invalid property format") } } + + // MARK: - Relative Path Passthrough Tests + + @Test + func testProcessEntrypointRelativePathPassthrough() throws { + let processFlags = try Flags.Process.parse(["--cwd", "/bin"]) + let managementFlags = try Flags.Management.parse(["--entrypoint", "./uname"]) + + let result = try Parser.process( + arguments: [], + processFlags: processFlags, + managementFlags: managementFlags, + config: nil + ) + + #expect(result.executable == "./uname") + #expect(result.workingDirectory == "/bin") + } } diff --git a/Tests/ContainerClientTests/RequestSchemeTests.swift b/Tests/ContainerAPIClientTests/RequestSchemeTests.swift similarity index 96% rename from Tests/ContainerClientTests/RequestSchemeTests.swift rename to Tests/ContainerAPIClientTests/RequestSchemeTests.swift index d87ac8825..0219335b5 100644 --- a/Tests/ContainerClientTests/RequestSchemeTests.swift +++ b/Tests/ContainerAPIClientTests/RequestSchemeTests.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -19,7 +19,7 @@ import ContainerizationError import Foundation import Testing -@testable import ContainerClient +@testable import ContainerAPIClient struct RequestSchemeTests { static let defaultDnsDomain = DefaultsStore.get(key: .defaultDNSDomain) diff --git a/Tests/ContainerClientTests/UtilityTests.swift b/Tests/ContainerAPIClientTests/UtilityTests.swift similarity index 77% rename from Tests/ContainerClientTests/UtilityTests.swift rename to Tests/ContainerAPIClientTests/UtilityTests.swift index 312525028..bc48df975 100644 --- a/Tests/ContainerClientTests/UtilityTests.swift +++ b/Tests/ContainerAPIClientTests/UtilityTests.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -14,11 +14,12 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerResource import ContainerizationError import Foundation import Testing -@testable import ContainerClient +@testable import ContainerAPIClient struct UtilityTests { @@ -90,30 +91,21 @@ struct UtilityTests { } @Test - func testPublishPortsNonOverlapping() throws { - let result = try Parser.publishPorts([ - "8080-8179:9000-9099/tcp", - "8180-8279:9100-9199/tcp", + func testPublishPortParser() throws { + let ports = try Parser.publishPorts([ + "127.0.0.1:8000:9080", + "8080-8179:9000-9099/udp", ]) - #expect(result.count == 2) - try Utility.validPublishPorts(result) - } - - @Test - func testPublishPortsOverlapping() throws { - let result = try Parser.publishPorts([ - "9000-9100:8080-8180/tcp", - "9100-9199:8180-8279/tcp", - ]) - #expect(result.count == 2) - #expect { - try Utility.validPublishPorts(result) - } throws: { error in - guard let error = error as? ContainerizationError else { - return false - } - return error.description.contains("port specs may not overlap") - } - + #expect(ports.count == 2) + #expect(ports[0].hostAddress.description == "127.0.0.1") + #expect(ports[0].hostPort == 8000) + #expect(ports[0].containerPort == 9080) + #expect(ports[0].proto == .tcp) + #expect(ports[0].count == 1) + #expect(ports[1].hostAddress.description == "0.0.0.0") + #expect(ports[1].hostPort == 8080) + #expect(ports[1].containerPort == 9000) + #expect(ports[1].proto == .udp) + #expect(ports[1].count == 100) } } diff --git a/Tests/ContainerBuildTests/BuildFile.swift b/Tests/ContainerBuildTests/BuildFile.swift index 5f5f17ffd..662b04e20 100644 --- a/Tests/ContainerBuildTests/BuildFile.swift +++ b/Tests/ContainerBuildTests/BuildFile.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/ContainerBuildTests/BuilderExtensionsTests.swift b/Tests/ContainerBuildTests/BuilderExtensionsTests.swift index c627bf701..05789d2ad 100644 --- a/Tests/ContainerBuildTests/BuilderExtensionsTests.swift +++ b/Tests/ContainerBuildTests/BuilderExtensionsTests.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/ContainerBuildTests/GlobberTests.swift b/Tests/ContainerBuildTests/GlobberTests.swift index 36aca4eb7..fff0fc2dd 100644 --- a/Tests/ContainerBuildTests/GlobberTests.swift +++ b/Tests/ContainerBuildTests/GlobberTests.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/ContainerNetworkServiceTests/AttachmentAllocatorTest.swift b/Tests/ContainerNetworkServiceTests/AttachmentAllocatorTest.swift new file mode 100644 index 000000000..9db889763 --- /dev/null +++ b/Tests/ContainerNetworkServiceTests/AttachmentAllocatorTest.swift @@ -0,0 +1,207 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import ContainerizationExtras +import Testing + +@testable import ContainerNetworkService + +struct AttachmentAllocatorTest { + @Test func testAllocateSingleHostname() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let address = try await allocator.allocate(hostname: "test-host") + + #expect(address >= 100) + #expect(address < 110) + } + + @Test func testAllocateSameHostnameTwice() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let address1 = try await allocator.allocate(hostname: "test-host") + let address2 = try await allocator.allocate(hostname: "test-host") + + #expect(address1 == address2) + } + + @Test func testAllocateMultipleHostnames() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let address1 = try await allocator.allocate(hostname: "host1") + let address2 = try await allocator.allocate(hostname: "host2") + let address3 = try await allocator.allocate(hostname: "host3") + + #expect(address1 != address2) + #expect(address2 != address3) + #expect(address1 != address3) + } + + @Test func testLookupAllocatedHostname() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let allocatedAddress = try await allocator.allocate(hostname: "test-host") + let lookedUpAddress = try await allocator.lookup(hostname: "test-host") + + #expect(lookedUpAddress == allocatedAddress) + } + + @Test func testLookupNonExistentHostname() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let address = try await allocator.lookup(hostname: "non-existent") + + #expect(address == nil) + } + + @Test func testDeallocateAllocatedHostname() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let allocatedAddress = try await allocator.allocate(hostname: "test-host") + let deallocatedAddress = try await allocator.deallocate(hostname: "test-host") + + #expect(deallocatedAddress == allocatedAddress) + + // After deallocation, lookup should return nil + let lookedUpAddress = try await allocator.lookup(hostname: "test-host") + #expect(lookedUpAddress == nil) + } + + @Test func testDeallocateNonExistentHostname() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let deallocatedAddress = try await allocator.deallocate(hostname: "non-existent") + + #expect(deallocatedAddress == nil) + } + + @Test func testReallocateAfterDeallocation() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let address1 = try await allocator.allocate(hostname: "test-host") + let released1 = try await allocator.deallocate(hostname: "test-host") + #expect(address1 == released1) + let address2 = try await allocator.allocate(hostname: "test-host") + + // After deallocation, allocating the same hostname should give a new address + #expect(address2 >= 100) + #expect(address2 < 110) + } + + @Test func testAllocateUntilFull() async throws { + let size = 5 + let allocator = try AttachmentAllocator(lower: 100, size: size) + + // Allocate up to the limit + for i in 0..= 100) + #expect(newAddress < 103) + + // The three remaining allocations should all be different + let finalAddress1 = try await allocator.lookup(hostname: "host1") + let finalAddress3 = try await allocator.lookup(hostname: "host3") + let finalAddress4 = try await allocator.lookup(hostname: "host4") + + #expect(finalAddress1 == address1) + #expect(finalAddress3 == address3) + #expect(finalAddress4 == newAddress) + } + + @Test func testDisableAllocatorWhenEmpty() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let disabled = await allocator.disableAllocator() + + #expect(disabled == true) + + // After disabling, allocation should fail + await #expect(throws: Error.self) { + try await allocator.allocate(hostname: "test-host") + } + } + + @Test func testDisableAllocatorWhenNotEmpty() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + _ = try await allocator.allocate(hostname: "test-host") + + let disabled = await allocator.disableAllocator() + + #expect(disabled == false) + + // Since disable failed, should still be able to allocate + let address = try await allocator.allocate(hostname: "another-host") + #expect(address >= 100) + #expect(address < 110) + } + + @Test func testDisableAfterDeallocatingAll() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + _ = try await allocator.allocate(hostname: "host1") + _ = try await allocator.allocate(hostname: "host2") + + try await allocator.deallocate(hostname: "host1") + try await allocator.deallocate(hostname: "host2") + + let disabled = await allocator.disableAllocator() + + #expect(disabled == true) + + // After disabling, allocation should fail + await #expect(throws: Error.self) { + try await allocator.allocate(hostname: "test-host") + } + } + + @Test func testMultipleDeallocationsOfSameHostname() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let address = try await allocator.allocate(hostname: "test-host") + + let firstDeallocate = try await allocator.deallocate(hostname: "test-host") + #expect(firstDeallocate == address) + + // Second deallocation should return nil since it's already deallocated + let secondDeallocate = try await allocator.deallocate(hostname: "test-host") + #expect(secondDeallocate == nil) + } +} diff --git a/Tests/ContainerPluginTests/CommandLine+ExecutableTest.swift b/Tests/ContainerPluginTests/CommandLine+ExecutableTest.swift index 5e439bbcf..d9e913c8d 100644 --- a/Tests/ContainerPluginTests/CommandLine+ExecutableTest.swift +++ b/Tests/ContainerPluginTests/CommandLine+ExecutableTest.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/ContainerPluginTests/MockPluginFactory.swift b/Tests/ContainerPluginTests/MockPluginFactory.swift index 52cb655de..272af8fd1 100644 --- a/Tests/ContainerPluginTests/MockPluginFactory.swift +++ b/Tests/ContainerPluginTests/MockPluginFactory.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/ContainerPluginTests/PluginConfigTest.swift b/Tests/ContainerPluginTests/PluginConfigTest.swift index 6210f2699..b4b150923 100644 --- a/Tests/ContainerPluginTests/PluginConfigTest.swift +++ b/Tests/ContainerPluginTests/PluginConfigTest.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/ContainerPluginTests/PluginFactoryTest.swift b/Tests/ContainerPluginTests/PluginFactoryTest.swift index eb35c2e8c..211b27fe0 100644 --- a/Tests/ContainerPluginTests/PluginFactoryTest.swift +++ b/Tests/ContainerPluginTests/PluginFactoryTest.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/ContainerPluginTests/PluginLoaderTest.swift b/Tests/ContainerPluginTests/PluginLoaderTest.swift index b429f6fac..5c90af56f 100644 --- a/Tests/ContainerPluginTests/PluginLoaderTest.swift +++ b/Tests/ContainerPluginTests/PluginLoaderTest.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/ContainerPluginTests/PluginTest.swift b/Tests/ContainerPluginTests/PluginTest.swift index a55561cb2..a69775b78 100644 --- a/Tests/ContainerPluginTests/PluginTest.swift +++ b/Tests/ContainerPluginTests/PluginTest.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/ContainerNetworkServiceTests/NetworkConfigurationTest.swift b/Tests/ContainerResourceTests/NetworkConfigurationTest.swift similarity index 74% rename from Tests/ContainerNetworkServiceTests/NetworkConfigurationTest.swift rename to Tests/ContainerResourceTests/NetworkConfigurationTest.swift index 72bea9e89..b0802a026 100644 --- a/Tests/ContainerNetworkServiceTests/NetworkConfigurationTest.swift +++ b/Tests/ContainerResourceTests/NetworkConfigurationTest.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -18,7 +18,7 @@ import ContainerizationError import ContainerizationExtras import Testing -@testable import ContainerNetworkService +@testable import ContainerResource struct NetworkConfigurationTest { @Test func testValidationOkDefaults() throws { @@ -33,12 +33,12 @@ struct NetworkConfigurationTest { "0-_.1", ] for id in ids { - let subnet = "192.168.64.1/24" + let ipv4Subnet = try CIDRv4("192.168.64.1/24") let labels = [ "foo": "bar", "baz": String(repeating: "0", count: 4096 - "baz".count - "=".count), ] - _ = try NetworkConfiguration(id: id, mode: .nat, subnet: subnet, labels: labels) + _ = try NetworkConfiguration(id: id, mode: .nat, ipv4Subnet: ipv4Subnet, labels: labels) } } @@ -50,13 +50,13 @@ struct NetworkConfigurationTest { "Foo", ] for id in ids { - let subnet = "192.168.64.1/24" + let ipv4Subnet = try CIDRv4("192.168.64.1/24") let labels = [ "foo": "bar", "baz": String(repeating: "0", count: 4096 - "baz".count - "=".count), ] #expect { - _ = try NetworkConfiguration(id: id, mode: .nat, subnet: subnet, labels: labels) + _ = try NetworkConfiguration(id: id, mode: .nat, ipv4Subnet: ipv4Subnet, labels: labels) } throws: { error in guard let err = error as? ContainerizationError else { return false } #expect(err.code == .invalidArgument) @@ -66,22 +66,6 @@ struct NetworkConfigurationTest { } } - @Test func testValidationBadSubnet() throws { - let id = "foo" - let subnet = "192.168.64.1" - let labels = [ - "foo": "bar", - "baz": String(repeating: "0", count: 4096 - "baz".count - "=".count), - ] - #expect { - _ = try NetworkConfiguration(id: id, mode: .nat, subnet: subnet, labels: labels) - } throws: { error in - guard let err = error as? NetworkAddressError else { return false } - #expect(err.description.starts(with: "invalid CIDR block")) - return true - } - } - @Test func testValidationGoodLabels() throws { let allLabels = [ ["com.example.my-label": "bar"], @@ -91,8 +75,8 @@ struct NetworkConfigurationTest { ] for labels in allLabels { let id = "foo" - let subnet = "192.168.64.1/24" - _ = try NetworkConfiguration(id: id, mode: .nat, subnet: subnet, labels: labels) + let ipv4Subnet = try CIDRv4("192.168.64.1/24") + _ = try NetworkConfiguration(id: id, mode: .nat, ipv4Subnet: ipv4Subnet, labels: labels) } } @@ -106,9 +90,9 @@ struct NetworkConfigurationTest { ] for labels in allLabels { let id = "foo" - let subnet = "192.168.64.1/24" + let ipv4Subnet = try CIDRv4("192.168.64.1/24") #expect { - _ = try NetworkConfiguration(id: id, mode: .nat, subnet: subnet, labels: labels) + _ = try NetworkConfiguration(id: id, mode: .nat, ipv4Subnet: ipv4Subnet, labels: labels) } throws: { error in guard let err = error as? ContainerizationError else { return false } #expect(err.code == .invalidArgument) diff --git a/Tests/ContainerResourceTests/PublishPortTests.swift b/Tests/ContainerResourceTests/PublishPortTests.swift new file mode 100644 index 000000000..3727f9eb3 --- /dev/null +++ b/Tests/ContainerResourceTests/PublishPortTests.swift @@ -0,0 +1,52 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerizationExtras +import Foundation +import Testing + +@testable import ContainerResource + +struct PublishPortTests { + @Test + func testPublishPortsNonOverlapping() throws { + let ports = [ + PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 9000, containerPort: 8080, proto: .tcp, count: 100), + PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 9100, containerPort: 8180, proto: .tcp, count: 100), + ] + #expect(!ports.hasOverlaps()) + } + + @Test + func testPublishPortsOverlapping() throws { + let ports = [ + PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 9000, containerPort: 8080, proto: .tcp, count: 101), + PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 9100, containerPort: 8180, proto: .tcp, count: 100), + ] + #expect(ports.hasOverlaps()) + } + + @Test + func testPublishPortsSamePortDifferentProtocols() throws { + let ports = [ + PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 8080, containerPort: 8080, proto: .tcp, count: 1), + PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 8080, containerPort: 8080, proto: .udp, count: 1), + PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 1024, containerPort: 1024, proto: .tcp, count: 1025), + PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 1024, containerPort: 1024, proto: .udp, count: 1025), + ] + #expect(!ports.hasOverlaps()) + } +} diff --git a/Tests/ContainerClientTests/VolumeValidationTests.swift b/Tests/ContainerResourceTests/VolumeValidationTests.swift similarity index 98% rename from Tests/ContainerClientTests/VolumeValidationTests.swift rename to Tests/ContainerResourceTests/VolumeValidationTests.swift index b666bae20..0e86411bb 100644 --- a/Tests/ContainerClientTests/VolumeValidationTests.swift +++ b/Tests/ContainerResourceTests/VolumeValidationTests.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// 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. @@ -17,7 +17,7 @@ import Foundation import Testing -@testable import ContainerClient +@testable import ContainerResource struct VolumeValidationTests { diff --git a/Tests/DNSServerTests/CompositeResolverTest.swift b/Tests/DNSServerTests/CompositeResolverTest.swift index cc254e7e3..227c6b997 100644 --- a/Tests/DNSServerTests/CompositeResolverTest.swift +++ b/Tests/DNSServerTests/CompositeResolverTest.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/DNSServerTests/HostTableResolverTest.swift b/Tests/DNSServerTests/HostTableResolverTest.swift index b0f77f9a3..1a7aff2cc 100644 --- a/Tests/DNSServerTests/HostTableResolverTest.swift +++ b/Tests/DNSServerTests/HostTableResolverTest.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/DNSServerTests/MockHandlers.swift b/Tests/DNSServerTests/MockHandlers.swift index ba558227f..6496c7e04 100644 --- a/Tests/DNSServerTests/MockHandlers.swift +++ b/Tests/DNSServerTests/MockHandlers.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/DNSServerTests/NxDomainResolverTest.swift b/Tests/DNSServerTests/NxDomainResolverTest.swift index c1dfc0789..db592e56d 100644 --- a/Tests/DNSServerTests/NxDomainResolverTest.swift +++ b/Tests/DNSServerTests/NxDomainResolverTest.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/DNSServerTests/StandardQueryValidatorTest.swift b/Tests/DNSServerTests/StandardQueryValidatorTest.swift index 5798fa49b..185b00b44 100644 --- a/Tests/DNSServerTests/StandardQueryValidatorTest.swift +++ b/Tests/DNSServerTests/StandardQueryValidatorTest.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. +// Copyright © 2025-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. diff --git a/Tests/SocketForwarderTests/ConnectHandlerRaceTest.swift b/Tests/SocketForwarderTests/ConnectHandlerRaceTest.swift new file mode 100644 index 000000000..1d1e32282 --- /dev/null +++ b/Tests/SocketForwarderTests/ConnectHandlerRaceTest.swift @@ -0,0 +1,66 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025-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 NIO +import Testing + +@testable import SocketForwarder + +struct ConnectHandlerRaceTest { + let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + + @Test + func testRapidConnectDisconnect() async throws { + let requestCount = 500 + + let serverAddress = try SocketAddress(ipAddress: "127.0.0.1", port: 0) + let server = TCPEchoServer(serverAddress: serverAddress, eventLoopGroup: eventLoopGroup) + let serverChannel = try await server.run().get() + let actualServerAddress = try #require(serverChannel.localAddress) + + let proxyAddress = try SocketAddress(ipAddress: "127.0.0.1", port: 0) + let forwarder = try TCPForwarder( + proxyAddress: proxyAddress, + serverAddress: actualServerAddress, + eventLoopGroup: eventLoopGroup + ) + let forwarderResult = try await forwarder.run().get() + let actualProxyAddress = try #require(forwarderResult.proxyAddress) + + try await withThrowingTaskGroup(of: Void.self) { group in + for _ in 0.. [--debug] +container image load --input [--force] [--debug] ``` **Options** * `-i, --input `: Path to the image tar archive +* `-f, --force`: Load images even if invalid member files are detected ### `container image tag` @@ -653,7 +654,7 @@ Creates a new network with the given name. **Usage** ```bash -container network create [--label