From 996a6819e35d0b06071fc491ee0c4fd5cfec43f7 Mon Sep 17 00:00:00 2001 From: Morris Richman <81453549+Mcrich23@users.noreply.github.com> Date: Thu, 18 Sep 2025 17:49:48 -0700 Subject: [PATCH 001/491] Rename CLI and ExecutableCLI folders (#635) ## Motivation and Context This is an extension of #603 to cleanup the folder structure and have it match with the new library and target names. --- Package.swift | 4 ++-- Sources/{ExecutableCLI => CLI}/Executable.swift | 0 Sources/{CLI => ContainerCommands}/Application.swift | 0 Sources/{CLI => ContainerCommands}/BuildCommand.swift | 0 Sources/{CLI => ContainerCommands}/Builder/Builder.swift | 0 .../Builder/BuilderDelete.swift | 0 .../{CLI => ContainerCommands}/Builder/BuilderStart.swift | 0 .../Builder/BuilderStatus.swift | 0 .../{CLI => ContainerCommands}/Builder/BuilderStop.swift | 0 Sources/{CLI => ContainerCommands}/Codable+JSON.swift | 0 .../Container/ContainerCreate.swift | 0 .../Container/ContainerDelete.swift | 0 .../Container/ContainerExec.swift | 0 .../Container/ContainerInspect.swift | 0 .../Container/ContainerKill.swift | 0 .../Container/ContainerList.swift | 0 .../Container/ContainerLogs.swift | 0 .../Container/ContainerStart.swift | 0 .../Container/ContainerStop.swift | 0 .../Container/ContainersCommand.swift | 0 .../Container/ProcessUtils.swift | 0 Sources/{CLI => ContainerCommands}/DefaultCommand.swift | 0 .../{CLI => ContainerCommands}/Image/ImageCommand.swift | 0 .../{CLI => ContainerCommands}/Image/ImageInspect.swift | 0 Sources/{CLI => ContainerCommands}/Image/ImageList.swift | 0 Sources/{CLI => ContainerCommands}/Image/ImageLoad.swift | 0 Sources/{CLI => ContainerCommands}/Image/ImagePrune.swift | 0 Sources/{CLI => ContainerCommands}/Image/ImagePull.swift | 0 Sources/{CLI => ContainerCommands}/Image/ImagePush.swift | 0 .../{CLI => ContainerCommands}/Image/ImageRemove.swift | 0 Sources/{CLI => ContainerCommands}/Image/ImageSave.swift | 0 Sources/{CLI => ContainerCommands}/Image/ImageTag.swift | 0 .../Network/NetworkCommand.swift | 0 .../Network/NetworkCreate.swift | 0 .../Network/NetworkDelete.swift | 0 .../Network/NetworkInspect.swift | 0 .../{CLI => ContainerCommands}/Network/NetworkList.swift | 0 Sources/{CLI => ContainerCommands}/Registry/Login.swift | 0 Sources/{CLI => ContainerCommands}/Registry/Logout.swift | 0 .../Registry/RegistryCommand.swift | 0 Sources/{CLI => ContainerCommands}/RunCommand.swift | 0 .../{CLI => ContainerCommands}/System/DNS/DNSCreate.swift | 0 .../{CLI => ContainerCommands}/System/DNS/DNSDelete.swift | 0 .../{CLI => ContainerCommands}/System/DNS/DNSList.swift | 0 .../System/Kernel/KernelSet.swift | 0 .../System/Property/PropertyClear.swift | 8 +++++--- .../System/Property/PropertyGet.swift | 8 +++++--- .../System/Property/PropertyList.swift | 8 +++++--- .../System/Property/PropertySet.swift | 8 +++++--- .../{CLI => ContainerCommands}/System/SystemCommand.swift | 0 Sources/{CLI => ContainerCommands}/System/SystemDNS.swift | 0 .../{CLI => ContainerCommands}/System/SystemKernel.swift | 0 .../{CLI => ContainerCommands}/System/SystemLogs.swift | 0 .../System/SystemProperty.swift | 0 .../{CLI => ContainerCommands}/System/SystemStart.swift | 0 .../{CLI => ContainerCommands}/System/SystemStatus.swift | 0 .../{CLI => ContainerCommands}/System/SystemStop.swift | 0 .../{CLI => ContainerCommands}/Volume/VolumeCommand.swift | 0 .../{CLI => ContainerCommands}/Volume/VolumeCreate.swift | 0 .../{CLI => ContainerCommands}/Volume/VolumeDelete.swift | 0 .../{CLI => ContainerCommands}/Volume/VolumeInspect.swift | 0 .../{CLI => ContainerCommands}/Volume/VolumeList.swift | 0 62 files changed, 22 insertions(+), 14 deletions(-) rename Sources/{ExecutableCLI => CLI}/Executable.swift (100%) rename Sources/{CLI => ContainerCommands}/Application.swift (100%) rename Sources/{CLI => ContainerCommands}/BuildCommand.swift (100%) rename Sources/{CLI => ContainerCommands}/Builder/Builder.swift (100%) rename Sources/{CLI => ContainerCommands}/Builder/BuilderDelete.swift (100%) rename Sources/{CLI => ContainerCommands}/Builder/BuilderStart.swift (100%) rename Sources/{CLI => ContainerCommands}/Builder/BuilderStatus.swift (100%) rename Sources/{CLI => ContainerCommands}/Builder/BuilderStop.swift (100%) rename Sources/{CLI => ContainerCommands}/Codable+JSON.swift (100%) rename Sources/{CLI => ContainerCommands}/Container/ContainerCreate.swift (100%) rename Sources/{CLI => ContainerCommands}/Container/ContainerDelete.swift (100%) rename Sources/{CLI => ContainerCommands}/Container/ContainerExec.swift (100%) rename Sources/{CLI => ContainerCommands}/Container/ContainerInspect.swift (100%) rename Sources/{CLI => ContainerCommands}/Container/ContainerKill.swift (100%) rename Sources/{CLI => ContainerCommands}/Container/ContainerList.swift (100%) rename Sources/{CLI => ContainerCommands}/Container/ContainerLogs.swift (100%) rename Sources/{CLI => ContainerCommands}/Container/ContainerStart.swift (100%) rename Sources/{CLI => ContainerCommands}/Container/ContainerStop.swift (100%) rename Sources/{CLI => ContainerCommands}/Container/ContainersCommand.swift (100%) rename Sources/{CLI => ContainerCommands}/Container/ProcessUtils.swift (100%) rename Sources/{CLI => ContainerCommands}/DefaultCommand.swift (100%) rename Sources/{CLI => ContainerCommands}/Image/ImageCommand.swift (100%) rename Sources/{CLI => ContainerCommands}/Image/ImageInspect.swift (100%) rename Sources/{CLI => ContainerCommands}/Image/ImageList.swift (100%) rename Sources/{CLI => ContainerCommands}/Image/ImageLoad.swift (100%) rename Sources/{CLI => ContainerCommands}/Image/ImagePrune.swift (100%) rename Sources/{CLI => ContainerCommands}/Image/ImagePull.swift (100%) rename Sources/{CLI => ContainerCommands}/Image/ImagePush.swift (100%) rename Sources/{CLI => ContainerCommands}/Image/ImageRemove.swift (100%) rename Sources/{CLI => ContainerCommands}/Image/ImageSave.swift (100%) rename Sources/{CLI => ContainerCommands}/Image/ImageTag.swift (100%) rename Sources/{CLI => ContainerCommands}/Network/NetworkCommand.swift (100%) rename Sources/{CLI => ContainerCommands}/Network/NetworkCreate.swift (100%) rename Sources/{CLI => ContainerCommands}/Network/NetworkDelete.swift (100%) rename Sources/{CLI => ContainerCommands}/Network/NetworkInspect.swift (100%) rename Sources/{CLI => ContainerCommands}/Network/NetworkList.swift (100%) rename Sources/{CLI => ContainerCommands}/Registry/Login.swift (100%) rename Sources/{CLI => ContainerCommands}/Registry/Logout.swift (100%) rename Sources/{CLI => ContainerCommands}/Registry/RegistryCommand.swift (100%) rename Sources/{CLI => ContainerCommands}/RunCommand.swift (100%) rename Sources/{CLI => ContainerCommands}/System/DNS/DNSCreate.swift (100%) rename Sources/{CLI => ContainerCommands}/System/DNS/DNSDelete.swift (100%) rename Sources/{CLI => ContainerCommands}/System/DNS/DNSList.swift (100%) rename Sources/{CLI => ContainerCommands}/System/Kernel/KernelSet.swift (100%) rename Sources/{CLI => ContainerCommands}/System/Property/PropertyClear.swift (88%) rename Sources/{CLI => ContainerCommands}/System/Property/PropertyGet.swift (89%) rename Sources/{CLI => ContainerCommands}/System/Property/PropertyList.swift (93%) rename Sources/{CLI => ContainerCommands}/System/Property/PropertySet.swift (94%) rename Sources/{CLI => ContainerCommands}/System/SystemCommand.swift (100%) rename Sources/{CLI => ContainerCommands}/System/SystemDNS.swift (100%) rename Sources/{CLI => ContainerCommands}/System/SystemKernel.swift (100%) rename Sources/{CLI => ContainerCommands}/System/SystemLogs.swift (100%) rename Sources/{CLI => ContainerCommands}/System/SystemProperty.swift (100%) rename Sources/{CLI => ContainerCommands}/System/SystemStart.swift (100%) rename Sources/{CLI => ContainerCommands}/System/SystemStatus.swift (100%) rename Sources/{CLI => ContainerCommands}/System/SystemStop.swift (100%) rename Sources/{CLI => ContainerCommands}/Volume/VolumeCommand.swift (100%) rename Sources/{CLI => ContainerCommands}/Volume/VolumeCreate.swift (100%) rename Sources/{CLI => ContainerCommands}/Volume/VolumeDelete.swift (100%) rename Sources/{CLI => ContainerCommands}/Volume/VolumeInspect.swift (100%) rename Sources/{CLI => ContainerCommands}/Volume/VolumeList.swift (100%) diff --git a/Package.swift b/Package.swift index ae5b989ac..098be1cf2 100644 --- a/Package.swift +++ b/Package.swift @@ -71,7 +71,7 @@ let package = Package( "ContainerClient", "ContainerCommands", ], - path: "Sources/ExecutableCLI" + path: "Sources/CLI" ), .target( name: "ContainerCommands", @@ -90,7 +90,7 @@ let package = Package( "ContainerVersion", "TerminalProgress", ], - path: "Sources/CLI" + path: "Sources/ContainerCommands" ), .executableTarget( name: "container-apiserver", diff --git a/Sources/ExecutableCLI/Executable.swift b/Sources/CLI/Executable.swift similarity index 100% rename from Sources/ExecutableCLI/Executable.swift rename to Sources/CLI/Executable.swift diff --git a/Sources/CLI/Application.swift b/Sources/ContainerCommands/Application.swift similarity index 100% rename from Sources/CLI/Application.swift rename to Sources/ContainerCommands/Application.swift diff --git a/Sources/CLI/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift similarity index 100% rename from Sources/CLI/BuildCommand.swift rename to Sources/ContainerCommands/BuildCommand.swift diff --git a/Sources/CLI/Builder/Builder.swift b/Sources/ContainerCommands/Builder/Builder.swift similarity index 100% rename from Sources/CLI/Builder/Builder.swift rename to Sources/ContainerCommands/Builder/Builder.swift diff --git a/Sources/CLI/Builder/BuilderDelete.swift b/Sources/ContainerCommands/Builder/BuilderDelete.swift similarity index 100% rename from Sources/CLI/Builder/BuilderDelete.swift rename to Sources/ContainerCommands/Builder/BuilderDelete.swift diff --git a/Sources/CLI/Builder/BuilderStart.swift b/Sources/ContainerCommands/Builder/BuilderStart.swift similarity index 100% rename from Sources/CLI/Builder/BuilderStart.swift rename to Sources/ContainerCommands/Builder/BuilderStart.swift diff --git a/Sources/CLI/Builder/BuilderStatus.swift b/Sources/ContainerCommands/Builder/BuilderStatus.swift similarity index 100% rename from Sources/CLI/Builder/BuilderStatus.swift rename to Sources/ContainerCommands/Builder/BuilderStatus.swift diff --git a/Sources/CLI/Builder/BuilderStop.swift b/Sources/ContainerCommands/Builder/BuilderStop.swift similarity index 100% rename from Sources/CLI/Builder/BuilderStop.swift rename to Sources/ContainerCommands/Builder/BuilderStop.swift diff --git a/Sources/CLI/Codable+JSON.swift b/Sources/ContainerCommands/Codable+JSON.swift similarity index 100% rename from Sources/CLI/Codable+JSON.swift rename to Sources/ContainerCommands/Codable+JSON.swift diff --git a/Sources/CLI/Container/ContainerCreate.swift b/Sources/ContainerCommands/Container/ContainerCreate.swift similarity index 100% rename from Sources/CLI/Container/ContainerCreate.swift rename to Sources/ContainerCommands/Container/ContainerCreate.swift diff --git a/Sources/CLI/Container/ContainerDelete.swift b/Sources/ContainerCommands/Container/ContainerDelete.swift similarity index 100% rename from Sources/CLI/Container/ContainerDelete.swift rename to Sources/ContainerCommands/Container/ContainerDelete.swift diff --git a/Sources/CLI/Container/ContainerExec.swift b/Sources/ContainerCommands/Container/ContainerExec.swift similarity index 100% rename from Sources/CLI/Container/ContainerExec.swift rename to Sources/ContainerCommands/Container/ContainerExec.swift diff --git a/Sources/CLI/Container/ContainerInspect.swift b/Sources/ContainerCommands/Container/ContainerInspect.swift similarity index 100% rename from Sources/CLI/Container/ContainerInspect.swift rename to Sources/ContainerCommands/Container/ContainerInspect.swift diff --git a/Sources/CLI/Container/ContainerKill.swift b/Sources/ContainerCommands/Container/ContainerKill.swift similarity index 100% rename from Sources/CLI/Container/ContainerKill.swift rename to Sources/ContainerCommands/Container/ContainerKill.swift diff --git a/Sources/CLI/Container/ContainerList.swift b/Sources/ContainerCommands/Container/ContainerList.swift similarity index 100% rename from Sources/CLI/Container/ContainerList.swift rename to Sources/ContainerCommands/Container/ContainerList.swift diff --git a/Sources/CLI/Container/ContainerLogs.swift b/Sources/ContainerCommands/Container/ContainerLogs.swift similarity index 100% rename from Sources/CLI/Container/ContainerLogs.swift rename to Sources/ContainerCommands/Container/ContainerLogs.swift diff --git a/Sources/CLI/Container/ContainerStart.swift b/Sources/ContainerCommands/Container/ContainerStart.swift similarity index 100% rename from Sources/CLI/Container/ContainerStart.swift rename to Sources/ContainerCommands/Container/ContainerStart.swift diff --git a/Sources/CLI/Container/ContainerStop.swift b/Sources/ContainerCommands/Container/ContainerStop.swift similarity index 100% rename from Sources/CLI/Container/ContainerStop.swift rename to Sources/ContainerCommands/Container/ContainerStop.swift diff --git a/Sources/CLI/Container/ContainersCommand.swift b/Sources/ContainerCommands/Container/ContainersCommand.swift similarity index 100% rename from Sources/CLI/Container/ContainersCommand.swift rename to Sources/ContainerCommands/Container/ContainersCommand.swift diff --git a/Sources/CLI/Container/ProcessUtils.swift b/Sources/ContainerCommands/Container/ProcessUtils.swift similarity index 100% rename from Sources/CLI/Container/ProcessUtils.swift rename to Sources/ContainerCommands/Container/ProcessUtils.swift diff --git a/Sources/CLI/DefaultCommand.swift b/Sources/ContainerCommands/DefaultCommand.swift similarity index 100% rename from Sources/CLI/DefaultCommand.swift rename to Sources/ContainerCommands/DefaultCommand.swift diff --git a/Sources/CLI/Image/ImageCommand.swift b/Sources/ContainerCommands/Image/ImageCommand.swift similarity index 100% rename from Sources/CLI/Image/ImageCommand.swift rename to Sources/ContainerCommands/Image/ImageCommand.swift diff --git a/Sources/CLI/Image/ImageInspect.swift b/Sources/ContainerCommands/Image/ImageInspect.swift similarity index 100% rename from Sources/CLI/Image/ImageInspect.swift rename to Sources/ContainerCommands/Image/ImageInspect.swift diff --git a/Sources/CLI/Image/ImageList.swift b/Sources/ContainerCommands/Image/ImageList.swift similarity index 100% rename from Sources/CLI/Image/ImageList.swift rename to Sources/ContainerCommands/Image/ImageList.swift diff --git a/Sources/CLI/Image/ImageLoad.swift b/Sources/ContainerCommands/Image/ImageLoad.swift similarity index 100% rename from Sources/CLI/Image/ImageLoad.swift rename to Sources/ContainerCommands/Image/ImageLoad.swift diff --git a/Sources/CLI/Image/ImagePrune.swift b/Sources/ContainerCommands/Image/ImagePrune.swift similarity index 100% rename from Sources/CLI/Image/ImagePrune.swift rename to Sources/ContainerCommands/Image/ImagePrune.swift diff --git a/Sources/CLI/Image/ImagePull.swift b/Sources/ContainerCommands/Image/ImagePull.swift similarity index 100% rename from Sources/CLI/Image/ImagePull.swift rename to Sources/ContainerCommands/Image/ImagePull.swift diff --git a/Sources/CLI/Image/ImagePush.swift b/Sources/ContainerCommands/Image/ImagePush.swift similarity index 100% rename from Sources/CLI/Image/ImagePush.swift rename to Sources/ContainerCommands/Image/ImagePush.swift diff --git a/Sources/CLI/Image/ImageRemove.swift b/Sources/ContainerCommands/Image/ImageRemove.swift similarity index 100% rename from Sources/CLI/Image/ImageRemove.swift rename to Sources/ContainerCommands/Image/ImageRemove.swift diff --git a/Sources/CLI/Image/ImageSave.swift b/Sources/ContainerCommands/Image/ImageSave.swift similarity index 100% rename from Sources/CLI/Image/ImageSave.swift rename to Sources/ContainerCommands/Image/ImageSave.swift diff --git a/Sources/CLI/Image/ImageTag.swift b/Sources/ContainerCommands/Image/ImageTag.swift similarity index 100% rename from Sources/CLI/Image/ImageTag.swift rename to Sources/ContainerCommands/Image/ImageTag.swift diff --git a/Sources/CLI/Network/NetworkCommand.swift b/Sources/ContainerCommands/Network/NetworkCommand.swift similarity index 100% rename from Sources/CLI/Network/NetworkCommand.swift rename to Sources/ContainerCommands/Network/NetworkCommand.swift diff --git a/Sources/CLI/Network/NetworkCreate.swift b/Sources/ContainerCommands/Network/NetworkCreate.swift similarity index 100% rename from Sources/CLI/Network/NetworkCreate.swift rename to Sources/ContainerCommands/Network/NetworkCreate.swift diff --git a/Sources/CLI/Network/NetworkDelete.swift b/Sources/ContainerCommands/Network/NetworkDelete.swift similarity index 100% rename from Sources/CLI/Network/NetworkDelete.swift rename to Sources/ContainerCommands/Network/NetworkDelete.swift diff --git a/Sources/CLI/Network/NetworkInspect.swift b/Sources/ContainerCommands/Network/NetworkInspect.swift similarity index 100% rename from Sources/CLI/Network/NetworkInspect.swift rename to Sources/ContainerCommands/Network/NetworkInspect.swift diff --git a/Sources/CLI/Network/NetworkList.swift b/Sources/ContainerCommands/Network/NetworkList.swift similarity index 100% rename from Sources/CLI/Network/NetworkList.swift rename to Sources/ContainerCommands/Network/NetworkList.swift diff --git a/Sources/CLI/Registry/Login.swift b/Sources/ContainerCommands/Registry/Login.swift similarity index 100% rename from Sources/CLI/Registry/Login.swift rename to Sources/ContainerCommands/Registry/Login.swift diff --git a/Sources/CLI/Registry/Logout.swift b/Sources/ContainerCommands/Registry/Logout.swift similarity index 100% rename from Sources/CLI/Registry/Logout.swift rename to Sources/ContainerCommands/Registry/Logout.swift diff --git a/Sources/CLI/Registry/RegistryCommand.swift b/Sources/ContainerCommands/Registry/RegistryCommand.swift similarity index 100% rename from Sources/CLI/Registry/RegistryCommand.swift rename to Sources/ContainerCommands/Registry/RegistryCommand.swift diff --git a/Sources/CLI/RunCommand.swift b/Sources/ContainerCommands/RunCommand.swift similarity index 100% rename from Sources/CLI/RunCommand.swift rename to Sources/ContainerCommands/RunCommand.swift diff --git a/Sources/CLI/System/DNS/DNSCreate.swift b/Sources/ContainerCommands/System/DNS/DNSCreate.swift similarity index 100% rename from Sources/CLI/System/DNS/DNSCreate.swift rename to Sources/ContainerCommands/System/DNS/DNSCreate.swift diff --git a/Sources/CLI/System/DNS/DNSDelete.swift b/Sources/ContainerCommands/System/DNS/DNSDelete.swift similarity index 100% rename from Sources/CLI/System/DNS/DNSDelete.swift rename to Sources/ContainerCommands/System/DNS/DNSDelete.swift diff --git a/Sources/CLI/System/DNS/DNSList.swift b/Sources/ContainerCommands/System/DNS/DNSList.swift similarity index 100% rename from Sources/CLI/System/DNS/DNSList.swift rename to Sources/ContainerCommands/System/DNS/DNSList.swift diff --git a/Sources/CLI/System/Kernel/KernelSet.swift b/Sources/ContainerCommands/System/Kernel/KernelSet.swift similarity index 100% rename from Sources/CLI/System/Kernel/KernelSet.swift rename to Sources/ContainerCommands/System/Kernel/KernelSet.swift diff --git a/Sources/CLI/System/Property/PropertyClear.swift b/Sources/ContainerCommands/System/Property/PropertyClear.swift similarity index 88% rename from Sources/CLI/System/Property/PropertyClear.swift rename to Sources/ContainerCommands/System/Property/PropertyClear.swift index dff042f30..6c69e3786 100644 --- a/Sources/CLI/System/Property/PropertyClear.swift +++ b/Sources/ContainerCommands/System/Property/PropertyClear.swift @@ -21,8 +21,10 @@ import ContainerizationError import Foundation extension Application { - struct PropertyClear: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct PropertyClear: AsyncParsableCommand { + public init() {} + + public static let configuration = CommandConfiguration( commandName: "clear", abstract: "Clear a property value" ) @@ -33,7 +35,7 @@ extension Application { @Argument(help: "the property ID") var id: String - func run() async throws { + public func run() async throws { guard let key = DefaultsStore.Keys(rawValue: id) else { throw ContainerizationError(.invalidArgument, message: "invalid property ID: \(id)") } diff --git a/Sources/CLI/System/Property/PropertyGet.swift b/Sources/ContainerCommands/System/Property/PropertyGet.swift similarity index 89% rename from Sources/CLI/System/Property/PropertyGet.swift rename to Sources/ContainerCommands/System/Property/PropertyGet.swift index a5bf7c157..909f2cd47 100644 --- a/Sources/CLI/System/Property/PropertyGet.swift +++ b/Sources/ContainerCommands/System/Property/PropertyGet.swift @@ -21,8 +21,10 @@ import ContainerizationError import Foundation extension Application { - struct PropertyGet: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct PropertyGet: AsyncParsableCommand { + public init() {} + + public static let configuration = CommandConfiguration( commandName: "get", abstract: "Retrieve a property value" ) @@ -33,7 +35,7 @@ extension Application { @Argument(help: "the property ID") var id: String - func run() async throws { + public func run() async throws { let value = DefaultsStore.allValues() .filter { id == $0.id } .first diff --git a/Sources/CLI/System/Property/PropertyList.swift b/Sources/ContainerCommands/System/Property/PropertyList.swift similarity index 93% rename from Sources/CLI/System/Property/PropertyList.swift rename to Sources/ContainerCommands/System/Property/PropertyList.swift index 2d834c6c8..312d109f7 100644 --- a/Sources/CLI/System/Property/PropertyList.swift +++ b/Sources/ContainerCommands/System/Property/PropertyList.swift @@ -20,8 +20,10 @@ import ContainerPersistence import Foundation extension Application { - struct PropertyList: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct PropertyList: AsyncParsableCommand { + public init() {} + + public static let configuration = CommandConfiguration( commandName: "list", abstract: "List system properties", aliases: ["ls"] @@ -36,7 +38,7 @@ extension Application { @OptionGroup var global: Flags.Global - func run() async throws { + public func run() async throws { let vals = DefaultsStore.allValues() try printValues(vals, format: format) } diff --git a/Sources/CLI/System/Property/PropertySet.swift b/Sources/ContainerCommands/System/Property/PropertySet.swift similarity index 94% rename from Sources/CLI/System/Property/PropertySet.swift rename to Sources/ContainerCommands/System/Property/PropertySet.swift index 4c3deefa5..490de2215 100644 --- a/Sources/CLI/System/Property/PropertySet.swift +++ b/Sources/ContainerCommands/System/Property/PropertySet.swift @@ -23,8 +23,10 @@ import ContainerizationOCI import Foundation extension Application { - struct PropertySet: AsyncParsableCommand { - static let configuration = CommandConfiguration( + public struct PropertySet: AsyncParsableCommand { + public init() {} + + public static let configuration = CommandConfiguration( commandName: "set", abstract: "Set a property value" ) @@ -38,7 +40,7 @@ extension Application { @Argument(help: "the property value") var value: String - func run() async throws { + public func run() async throws { guard let key = DefaultsStore.Keys(rawValue: id) else { throw ContainerizationError(.invalidArgument, message: "invalid property ID: \(id)") } diff --git a/Sources/CLI/System/SystemCommand.swift b/Sources/ContainerCommands/System/SystemCommand.swift similarity index 100% rename from Sources/CLI/System/SystemCommand.swift rename to Sources/ContainerCommands/System/SystemCommand.swift diff --git a/Sources/CLI/System/SystemDNS.swift b/Sources/ContainerCommands/System/SystemDNS.swift similarity index 100% rename from Sources/CLI/System/SystemDNS.swift rename to Sources/ContainerCommands/System/SystemDNS.swift diff --git a/Sources/CLI/System/SystemKernel.swift b/Sources/ContainerCommands/System/SystemKernel.swift similarity index 100% rename from Sources/CLI/System/SystemKernel.swift rename to Sources/ContainerCommands/System/SystemKernel.swift diff --git a/Sources/CLI/System/SystemLogs.swift b/Sources/ContainerCommands/System/SystemLogs.swift similarity index 100% rename from Sources/CLI/System/SystemLogs.swift rename to Sources/ContainerCommands/System/SystemLogs.swift diff --git a/Sources/CLI/System/SystemProperty.swift b/Sources/ContainerCommands/System/SystemProperty.swift similarity index 100% rename from Sources/CLI/System/SystemProperty.swift rename to Sources/ContainerCommands/System/SystemProperty.swift diff --git a/Sources/CLI/System/SystemStart.swift b/Sources/ContainerCommands/System/SystemStart.swift similarity index 100% rename from Sources/CLI/System/SystemStart.swift rename to Sources/ContainerCommands/System/SystemStart.swift diff --git a/Sources/CLI/System/SystemStatus.swift b/Sources/ContainerCommands/System/SystemStatus.swift similarity index 100% rename from Sources/CLI/System/SystemStatus.swift rename to Sources/ContainerCommands/System/SystemStatus.swift diff --git a/Sources/CLI/System/SystemStop.swift b/Sources/ContainerCommands/System/SystemStop.swift similarity index 100% rename from Sources/CLI/System/SystemStop.swift rename to Sources/ContainerCommands/System/SystemStop.swift diff --git a/Sources/CLI/Volume/VolumeCommand.swift b/Sources/ContainerCommands/Volume/VolumeCommand.swift similarity index 100% rename from Sources/CLI/Volume/VolumeCommand.swift rename to Sources/ContainerCommands/Volume/VolumeCommand.swift diff --git a/Sources/CLI/Volume/VolumeCreate.swift b/Sources/ContainerCommands/Volume/VolumeCreate.swift similarity index 100% rename from Sources/CLI/Volume/VolumeCreate.swift rename to Sources/ContainerCommands/Volume/VolumeCreate.swift diff --git a/Sources/CLI/Volume/VolumeDelete.swift b/Sources/ContainerCommands/Volume/VolumeDelete.swift similarity index 100% rename from Sources/CLI/Volume/VolumeDelete.swift rename to Sources/ContainerCommands/Volume/VolumeDelete.swift diff --git a/Sources/CLI/Volume/VolumeInspect.swift b/Sources/ContainerCommands/Volume/VolumeInspect.swift similarity index 100% rename from Sources/CLI/Volume/VolumeInspect.swift rename to Sources/ContainerCommands/Volume/VolumeInspect.swift diff --git a/Sources/CLI/Volume/VolumeList.swift b/Sources/ContainerCommands/Volume/VolumeList.swift similarity index 100% rename from Sources/CLI/Volume/VolumeList.swift rename to Sources/ContainerCommands/Volume/VolumeList.swift From 5ddb10f9af657be0722bc9b8195bfc2a26710b42 Mon Sep 17 00:00:00 2001 From: Danny Canter Date: Fri, 19 Sep 2025 08:56:03 -0700 Subject: [PATCH 002/491] Bump CZ to 0.8.0 (#648) Allows stop in LinuxContainer to be idempotent --- Package.resolved | 10 +++++----- Package.swift | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Package.resolved b/Package.resolved index 3328a100d..414623d6c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "143ce50ebf87ab65c66820ce4a20e4fcfd63db3ed4f173029c361078f1e31755", + "originHash" : "23302f085b6239754a405a0a6ec0dcaad1d48086319c3eaa5a5650b4bf2670e5", "pins" : [ { "identity" : "async-http-client", @@ -15,14 +15,14 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/containerization.git", "state" : { - "revision" : "f3641e67cbc934d66f241ceced3a3024565c4d12", - "version" : "0.7.2" + "revision" : "51ef9f81fef574bbd815d4f5560157297b0a4067", + "version" : "0.8.0" } }, { "identity" : "dns", "kind" : "remoteSourceControl", - "location" : "https://github.com/Bouke/DNS.git", + "location" : "https://github.com/Bouke/DNS", "state" : { "revision" : "78bbd1589890a90b202d11d5f9e1297050cf0eb2", "version" : "1.2.0" @@ -31,7 +31,7 @@ { "identity" : "dnsclient", "kind" : "remoteSourceControl", - "location" : "https://github.com/orlandos-nl/DNSClient.git", + "location" : "https://github.com/orlandos-nl/DNSClient", "state" : { "revision" : "551fbddbf4fa728d4cd86f6a5208fe4f925f0549", "version" : "2.4.4" diff --git a/Package.swift b/Package.swift index 098be1cf2..a935294a8 100644 --- a/Package.swift +++ b/Package.swift @@ -23,7 +23,7 @@ import PackageDescription let releaseVersion = ProcessInfo.processInfo.environment["RELEASE_VERSION"] ?? "0.0.0" let gitCommit = ProcessInfo.processInfo.environment["GIT_COMMIT"] ?? "unspecified" let builderShimVersion = "0.6.1" -let scVersion = "0.7.2" +let scVersion = "0.8.0" let package = Package( name: "container", From 465edb372daee63fcf79032c28db3df9a873719e Mon Sep 17 00:00:00 2001 From: Danny Canter Date: Fri, 19 Sep 2025 08:56:12 -0700 Subject: [PATCH 003/491] SandboxService: Turn off RunAtLoad for the default plugin (#649) For the default Linux runtime plugin we shouldn't have RunAtLoad on as it causes the process to be spawned the second we register it with launchd. This means that for a `container create` we'll have the runtime plugin process running afterwards. We already register a mach service for the plugin so on the first rpc it will spawn the process anyways which is plenty. --- config/container-runtime-linux-config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/container-runtime-linux-config.json b/config/container-runtime-linux-config.json index 624c622b9..2b25aa48f 100644 --- a/config/container-runtime-linux-config.json +++ b/config/container-runtime-linux-config.json @@ -4,7 +4,7 @@ "author": "Apple", "servicesConfig" : { "loadAtBoot" : false, - "runAtLoad" : true, + "runAtLoad" : false, "services" : [ { "type" : "runtime" From 2ef1d8fdbb5363e9d8bb422b74531ffa85a0d114 Mon Sep 17 00:00:00 2001 From: Danny Canter Date: Fri, 19 Sep 2025 08:56:23 -0700 Subject: [PATCH 004/491] CLI: Skip stopping containers in system stop if APIServer is down (#650) Today if you do system stop twice (or if you forgot the server isn't running) you get bombarded with annoying (and confusing) XPC interrupted messages. We should skip the container stop and list dance if we can't talk to the APIServer with a simple ping. --- .../ContainerCommands/System/SystemStop.swift | 55 +++++++++++-------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/Sources/ContainerCommands/System/SystemStop.swift b/Sources/ContainerCommands/System/SystemStop.swift index b3179b3be..bd60893d0 100644 --- a/Sources/ContainerCommands/System/SystemStop.swift +++ b/Sources/ContainerCommands/System/SystemStop.swift @@ -47,36 +47,47 @@ extension Application { let launchdDomainString = try ServiceManager.getDomainString() let fullLabel = "\(launchdDomainString)/\(prefix)apiserver" - log.info("stopping containers", metadata: ["stopTimeoutSeconds": "\(Self.stopTimeoutSeconds)"]) + var running = true do { - let containers = try await ClientContainer.list() - let signal = try Signals.parseSignal("SIGTERM") - let opts = ContainerStopOptions(timeoutInSeconds: Self.stopTimeoutSeconds, signal: signal) - let failed = try await ContainerStop.stopContainers(containers: containers, stopOptions: opts) - if !failed.isEmpty { - log.warning("some containers could not be stopped gracefully", metadata: ["ids": "\(failed)"]) - } - + log.info("checking if APIServer is alive") + _ = try await ClientHealthCheck.ping(timeout: .seconds(5)) } catch { - log.warning("failed to stop all containers", metadata: ["error": "\(error)"]) + log.info("APIServer health check failed, skipping bootout") + running = false } - log.info("waiting for containers to exit") - do { - for _ in 0.. Date: Fri, 19 Sep 2025 09:41:55 -0700 Subject: [PATCH 005/491] Use com.apple.container.registry as keychain ID. (#652) - Closes #644. ## Type of Change - [ ] Bug fix - [ ] New feature - [x] Breaking change - [ ] Documentation update ## Motivation and Context Avoids conflicts. ## Testing - [x] Tested locally - [ ] Added/updated tests - [ ] Added/updated docs --- Sources/ContainerClient/Core/Constants.swift | 2 +- .../Services/ContainerImagesService/Server/ImageService.swift | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Sources/ContainerClient/Core/Constants.swift b/Sources/ContainerClient/Core/Constants.swift index db321d93d..ca0710cb6 100644 --- a/Sources/ContainerClient/Core/Constants.swift +++ b/Sources/ContainerClient/Core/Constants.swift @@ -15,5 +15,5 @@ //===----------------------------------------------------------------------===// public enum Constants { - public static let keychainID = "com.apple.container" + public static let keychainID = "com.apple.container.registry" } diff --git a/Sources/Services/ContainerImagesService/Server/ImageService.swift b/Sources/Services/ContainerImagesService/Server/ImageService.swift index 59e5ef779..18e5a34bb 100644 --- a/Sources/Services/ContainerImagesService/Server/ImageService.swift +++ b/Sources/Services/ContainerImagesService/Server/ImageService.swift @@ -26,8 +26,6 @@ import Logging import TerminalProgress public actor ImagesService { - public static let keychainID = "com.apple.container" - private let log: Logger private let contentStore: ContentStore private let imageStore: ImageStore @@ -165,7 +163,7 @@ extension ImagesService { if let authentication { return try await body(authentication) } - let keychain = KeychainHelper(id: Self.keychainID) + let keychain = KeychainHelper(id: Constants.keychainID) do { authentication = try keychain.lookup(domain: host) } catch let err as KeychainHelper.Error { From 444064dc35d6eee46b1606354144d16a28ec22a4 Mon Sep 17 00:00:00 2001 From: Danny Canter Date: Fri, 19 Sep 2025 23:09:01 -0700 Subject: [PATCH 006/491] Swap to APIServer for all communications (#628) --- Sources/ContainerClient/ContainerEvents.swift | 20 - .../Core/ClientContainer.swift | 157 +++-- .../ContainerClient/Core/ClientProcess.swift | 91 ++- .../Core/ContainerSnapshot.swift | 6 +- Sources/ContainerClient/{ => Core}/XPC+.swift | 17 +- .../ExitMonitor.swift | 2 - Sources/ContainerClient/SandboxClient.swift | 183 ++++-- Sources/ContainerClient/SandboxRoutes.swift | 4 + Sources/ContainerClient/SandboxSnapshot.swift | 6 +- .../Builder/BuilderStart.swift | 2 +- .../Container/ContainerStop.swift | 5 +- Sources/ContainerCommands/RunCommand.swift | 1 - Sources/ContainerXPC/XPCClient.swift | 21 +- Sources/ContainerXPC/XPCServer.swift | 10 +- Sources/Helpers/APIServer/APIServer.swift | 39 +- .../RuntimeLinux/RuntimeLinuxHelper.swift | 35 +- .../Containers/ContainersHarness.swift | 202 ++++++- .../Containers/ContainersService.swift | 538 +++++++++++++----- .../Networks/NetworksService.swift | 6 +- .../NetworkClient.swift | 5 - .../SandboxService.swift | 472 ++++++++------- 21 files changed, 1254 insertions(+), 568 deletions(-) delete mode 100644 Sources/ContainerClient/ContainerEvents.swift rename Sources/ContainerClient/{ => Core}/XPC+.swift (93%) rename Sources/{Services/ContainerSandboxService => ContainerClient}/ExitMonitor.swift (99%) diff --git a/Sources/ContainerClient/ContainerEvents.swift b/Sources/ContainerClient/ContainerEvents.swift deleted file mode 100644 index 83d06f509..000000000 --- a/Sources/ContainerClient/ContainerEvents.swift +++ /dev/null @@ -1,20 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerEvent: Sendable, Codable { - case containerStart(id: String) - case containerExit(id: String, exitCode: Int64) -} diff --git a/Sources/ContainerClient/Core/ClientContainer.swift b/Sources/ContainerClient/Core/ClientContainer.swift index 696b0dce8..a110fe50c 100644 --- a/Sources/ContainerClient/Core/ClientContainer.swift +++ b/Sources/ContainerClient/Core/ClientContainer.swift @@ -25,10 +25,6 @@ import TerminalProgress public struct ClientContainer: Sendable, Codable { static let serviceIdentifier = "com.apple.container.apiserver" - private var sandboxClient: SandboxClient { - SandboxClient(id: configuration.id, runtime: configuration.runtimeHandler) - } - /// Identifier of the container. public var id: String { configuration.id @@ -58,14 +54,10 @@ public struct ClientContainer: Sendable, Codable { self.status = snapshot.status self.networks = snapshot.networks } - - public var initProcess: ClientProcess { - ClientProcessImpl(containerId: self.id, client: self.sandboxClient) - } } extension ClientContainer { - private static func newClient() -> XPCClient { + private static func newXPCClient() -> XPCClient { XPCClient(service: serviceIdentifier) } @@ -84,8 +76,8 @@ extension ClientContainer { kernel: Kernel ) async throws -> ClientContainer { do { - let client = Self.newClient() - let request = XPCMessage(route: .createContainer) + let client = Self.newXPCClient() + let request = XPCMessage(route: .containerCreate) let data = try JSONEncoder().encode(configuration) let kdata = try JSONEncoder().encode(kernel) @@ -107,8 +99,8 @@ extension ClientContainer { public static func list() async throws -> [ClientContainer] { do { - let client = Self.newClient() - let request = XPCMessage(route: .listContainer) + let client = Self.newXPCClient() + let request = XPCMessage(route: .containerList) let response = try await xpcSend( client: client, @@ -145,16 +137,72 @@ extension ClientContainer { extension ClientContainer { public func bootstrap(stdio: [FileHandle?]) async throws -> ClientProcess { - let client = self.sandboxClient - try await client.bootstrap(stdio: stdio) - return ClientProcessImpl(containerId: self.id, client: self.sandboxClient) + let request = XPCMessage(route: .containerBootstrap) + let client = Self.newXPCClient() + + for (i, h) in stdio.enumerated() { + let key: XPCKeys = try { + switch i { + case 0: .stdin + case 1: .stdout + case 2: .stderr + default: + throw ContainerizationError(.invalidArgument, message: "invalid fd \(i)") + } + }() + + if let h { + request.set(key: key, value: h) + } + } + + do { + request.set(key: .id, value: self.id) + try await client.send(request) + return ClientProcessImpl(containerId: self.id, xpcClient: client) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to bootstrap container", + cause: error + ) + } + } + + public func kill(_ signal: Int32) async throws { + do { + let request = XPCMessage(route: .containerKill) + request.set(key: .id, value: self.id) + request.set(key: .processIdentifier, value: self.id) + request.set(key: .signal, value: Int64(signal)) + + let client = Self.newXPCClient() + try await client.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to kill container", + cause: error + ) + } } /// Stop the container and all processes currently executing inside. public func stop(opts: ContainerStopOptions = ContainerStopOptions.default) async throws { do { - let client = self.sandboxClient - try await client.stop(options: opts) + let client = Self.newXPCClient() + let request = XPCMessage(route: .containerStop) + let data = try JSONEncoder().encode(opts) + request.set(key: .id, value: self.id) + request.set(key: .stopOptions, value: data) + + // Stop is somewhat more prone to hanging than other commands given it + // has quite a bit of `wait()`'s down the chain to make sure the container actually + // exited. To combat a potential hang, lets timeout if we don't return in a small + // time period after the actual stop timeout sent via .stopOptions (the time + // until we send SIGKILL after SIGTERM if the container still hasn't exited). + let responseTimeout = Duration(.seconds(Int64(opts.timeoutInSeconds + 3))) + try await client.send(request, responseTimeout: responseTimeout) } catch { throw ContainerizationError( .internalError, @@ -167,8 +215,8 @@ extension ClientContainer { /// Delete the container along with any resources. public func delete(force: Bool = false) async throws { do { - let client = XPCClient(service: Self.serviceIdentifier) - let request = XPCMessage(route: .deleteContainer) + let client = Self.newXPCClient() + let request = XPCMessage(route: .containerDelete) request.set(key: .id, value: self.id) request.set(key: .forceDelete, value: force) try await client.send(request) @@ -180,38 +228,45 @@ extension ClientContainer { ) } } -} -extension ClientContainer { - /// Execute a new process inside a running container. + /// Create a new process inside a running container. The process is in a + /// created state and must still be started. public func createProcess( id: String, configuration: ProcessConfiguration, stdio: [FileHandle?] ) async throws -> ClientProcess { do { - let client = self.sandboxClient - try await client.createProcess(id, config: configuration, stdio: stdio) - return ClientProcessImpl(containerId: self.id, processId: id, client: client) - } catch { - throw ContainerizationError( - .internalError, - message: "failed to exec in container", - cause: error - ) - } - } + let request = XPCMessage(route: .containerCreateProcess) + request.set(key: .id, value: self.id) + request.set(key: .processIdentifier, value: id) - /// Send or "kill" a signal to the initial process of the container. - /// Kill does not wait for the process to exit, it only delivers the signal. - public func kill(_ signal: Int32) async throws { - do { - let client = self.sandboxClient - try await client.kill(self.id, signal: Int64(signal)) + let data = try JSONEncoder().encode(configuration) + request.set(key: .processConfig, value: data) + + for (i, h) in stdio.enumerated() { + let key: XPCKeys = try { + switch i { + case 0: .stdin + case 1: .stdout + case 2: .stderr + default: + throw ContainerizationError(.invalidArgument, message: "invalid fd \(i)") + } + }() + + if let h { + request.set(key: key, value: h) + } + } + + let client = Self.newXPCClient() + try await client.send(request) + return ClientProcessImpl(containerId: self.id, processId: id, xpcClient: client) } catch { throw ContainerizationError( .internalError, - message: "failed to kill container \(self.id)", + message: "failed to create process in container", cause: error ) } @@ -219,7 +274,7 @@ extension ClientContainer { public func logs() async throws -> [FileHandle] { do { - let client = XPCClient(service: Self.serviceIdentifier) + let client = Self.newXPCClient() let request = XPCMessage(route: .containerLogs) request.set(key: .id, value: self.id) @@ -242,15 +297,27 @@ extension ClientContainer { } public func dial(_ port: UInt32) async throws -> FileHandle { + let request = XPCMessage(route: .containerDial) + request.set(key: .id, value: self.id) + request.set(key: .port, value: UInt64(port)) + + let client = Self.newXPCClient() + let response: XPCMessage do { - let client = self.sandboxClient - return try await client.dial(port) + response = try await client.send(request) } catch { throw ContainerizationError( .internalError, - message: "failed to dial \(port) in container \(self.id)", + message: "failed to dial port \(port) on container", cause: error ) } + guard let fh = response.fileHandle(key: .fd) else { + throw ContainerizationError( + .internalError, + message: "failed to get fd for vsock port \(port)" + ) + } + return fh } } diff --git a/Sources/ContainerClient/Core/ClientProcess.swift b/Sources/ContainerClient/Core/ClientProcess.swift index b3907d91b..cb391ed10 100644 --- a/Sources/ContainerClient/Core/ClientProcess.swift +++ b/Sources/ContainerClient/Core/ClientProcess.swift @@ -35,7 +35,7 @@ public protocol ClientProcess: Sendable { func start() async throws /// Send a terminal resize request to the process `id`. func resize(_ size: Terminal.Size) async throws - /// Send or "kill" a signal to the process `id`. + /// Send a signal to the process `id`. /// Kill does not wait for the process to exit, it only delivers the signal. func kill(_ signal: Int32) async throws /// Wait for the process `id` to complete and return its exit code. @@ -45,79 +45,66 @@ public protocol ClientProcess: Sendable { struct ClientProcessImpl: ClientProcess, Sendable { static let serviceIdentifier = "com.apple.container.apiserver" + + /// ID of the process. + public var id: String { + processId ?? containerId + } + /// Identifier of the container. public let containerId: String - private let client: SandboxClient - /// Identifier of a process. That is running inside of a container. /// This field is nil if the process this objects refers to is the /// init process of the container. public let processId: String? - public var id: String { - processId ?? containerId - } + private let xpcClient: XPCClient - init(containerId: String, processId: String? = nil, client: SandboxClient) { + init(containerId: String, processId: String? = nil, xpcClient: XPCClient) { self.containerId = containerId self.processId = processId - self.client = client + self.xpcClient = xpcClient } - /// Start the container and return the initial process. + /// Start the process. public func start() async throws { - do { - let client = self.client - try await client.startProcess(self.id) - } catch { - throw ContainerizationError( - .internalError, - message: "failed to start container", - cause: error - ) - } + let request = XPCMessage(route: .containerStartProcess) + request.set(key: .id, value: containerId) + request.set(key: .processIdentifier, value: id) + + try await xpcClient.send(request) } + /// Send a signal to the process. public func kill(_ signal: Int32) async throws { - do { - - let client = self.client - try await client.kill(self.id, signal: Int64(signal)) - } catch { - throw ContainerizationError( - .internalError, - message: "failed to kill process", - cause: error - ) - } - } + let request = XPCMessage(route: .containerKill) + request.set(key: .id, value: containerId) + request.set(key: .processIdentifier, value: id) + request.set(key: .signal, value: Int64(signal)) - public func resize(_ size: ContainerizationOS.Terminal.Size) async throws { - do { + try await xpcClient.send(request) + } - let client = self.client - try await client.resize(self.id, size: size) + /// Resize the processes PTY if it has one. + public func resize(_ size: Terminal.Size) async throws { + let request = XPCMessage(route: .containerResize) + request.set(key: .id, value: containerId) + request.set(key: .processIdentifier, value: id) + request.set(key: .width, value: UInt64(size.width)) + request.set(key: .height, value: UInt64(size.height)) - } catch { - throw ContainerizationError( - .internalError, - message: "failed to resize process", - cause: error - ) - } + try await xpcClient.send(request) } + /// Wait for the process to exit. public func wait() async throws -> Int32 { - do { - let client = self.client - return try await client.wait(self.id) - } catch { - throw ContainerizationError( - .internalError, - message: "failed to wait on process", - cause: error - ) - } + let request = XPCMessage(route: .containerWait) + request.set(key: .id, value: containerId) + request.set(key: .processIdentifier, value: id) + + let response = try await xpcClient.send(request) + let code = response.int64(key: .exitCode) + return Int32(code) } } diff --git a/Sources/ContainerClient/Core/ContainerSnapshot.swift b/Sources/ContainerClient/Core/ContainerSnapshot.swift index ebd6f1ca5..b0fcb9436 100644 --- a/Sources/ContainerClient/Core/ContainerSnapshot.swift +++ b/Sources/ContainerClient/Core/ContainerSnapshot.swift @@ -20,11 +20,11 @@ import ContainerNetworkService /// and any runtime state information. public struct ContainerSnapshot: Codable, Sendable { /// The configuration of the container. - public let configuration: ContainerConfiguration + public var configuration: ContainerConfiguration /// The runtime status of the container. - public let status: RuntimeStatus + public var status: RuntimeStatus /// Network interfaces attached to the sandbox that are provided to the container. - public let networks: [Attachment] + public var networks: [Attachment] public init( configuration: ContainerConfiguration, diff --git a/Sources/ContainerClient/XPC+.swift b/Sources/ContainerClient/Core/XPC+.swift similarity index 93% rename from Sources/ContainerClient/XPC+.swift rename to Sources/ContainerClient/Core/XPC+.swift index af0281378..db90eb64a 100644 --- a/Sources/ContainerClient/XPC+.swift +++ b/Sources/ContainerClient/Core/XPC+.swift @@ -48,6 +48,8 @@ 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 @@ -114,9 +116,18 @@ public enum XPCKeys: String { } public enum XPCRoute: String { - case listContainer - case createContainer - case deleteContainer + case containerList + case containerCreate + case containerBootstrap + case containerCreateProcess + case containerStartProcess + case containerWait + case containerDelete + case containerStop + case containerDial + case containerResize + case containerKill + case containerState case containerLogs case containerEvent diff --git a/Sources/Services/ContainerSandboxService/ExitMonitor.swift b/Sources/ContainerClient/ExitMonitor.swift similarity index 99% rename from Sources/Services/ContainerSandboxService/ExitMonitor.swift rename to Sources/ContainerClient/ExitMonitor.swift index 80dfa58ef..4318065a6 100644 --- a/Sources/Services/ContainerSandboxService/ExitMonitor.swift +++ b/Sources/ContainerClient/ExitMonitor.swift @@ -14,8 +14,6 @@ // limitations under the License. //===----------------------------------------------------------------------===// -// - import ContainerizationError import ContainerizationExtras import Foundation diff --git a/Sources/ContainerClient/SandboxClient.swift b/Sources/ContainerClient/SandboxClient.swift index 67815a696..d9a4cdac5 100644 --- a/Sources/ContainerClient/SandboxClient.swift +++ b/Sources/ContainerClient/SandboxClient.swift @@ -21,7 +21,7 @@ import Foundation import TerminalProgress /// A client for interacting with a single sandbox. -public struct SandboxClient: Sendable, Codable { +public struct SandboxClient: Sendable { static let label = "com.apple.container.runtime" public static func machServiceLabel(runtime: String, id: String) -> String { @@ -34,11 +34,41 @@ public struct SandboxClient: Sendable, Codable { let id: String let runtime: String + let client: XPCClient - /// Create a container. - public init(id: String, runtime: String) { + init(id: String, runtime: String, client: XPCClient) { self.id = id self.runtime = runtime + self.client = client + } + + /// Create a SandboxClient by ID and runtime string. The returned client is ready to be used + /// without additional steps. + public static func create(id: String, runtime: String) async throws -> SandboxClient { + let label = Self.machServiceLabel(runtime: runtime, id: id) + let client = XPCClient(service: label) + let request = XPCMessage(route: SandboxRoutes.createEndpoint.rawValue) + + let response: XPCMessage + do { + response = try await client.send(request, responseTimeout: .seconds(3)) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to create container \(id)", + cause: error + ) + } + guard let endpoint = response.endpoint(key: .sandboxServiceEndpoint) else { + throw ContainerizationError( + .internalError, + message: "failed to get endpoint for sandbox service" + ) + } + + let endpointConnection = xpc_connection_create_from_endpoint(endpoint) + let xpcClient = XPCClient(connection: endpointConnection, label: label) + return SandboxClient(id: id, runtime: runtime, client: xpcClient) } } @@ -46,17 +76,15 @@ public struct SandboxClient: Sendable, Codable { extension SandboxClient { public func bootstrap(stdio: [FileHandle?]) async throws { let request = XPCMessage(route: SandboxRoutes.bootstrap.rawValue) - let client = createClient() - defer { client.close() } for (i, h) in stdio.enumerated() { - let key: XPCKeys = { + let key: XPCKeys = try { switch i { case 0: .stdin case 1: .stdout case 2: .stderr default: - fatalError("invalid fd \(i)") + throw ContainerizationError(.invalidArgument, message: "invalid fd \(i)") } }() @@ -65,15 +93,29 @@ extension SandboxClient { } } - try await client.send(request) + do { + try await self.client.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to bootstrap container \(self.id)", + cause: error + ) + } } public func state() async throws -> SandboxSnapshot { let request = XPCMessage(route: SandboxRoutes.state.rawValue) - let client = createClient() - defer { client.close() } - - let response = try await client.send(request) + let response: XPCMessage + do { + response = try await self.client.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to get state for container \(self.id)", + cause: error + ) + } return try response.sandboxSnapshot() } @@ -84,13 +126,13 @@ extension SandboxClient { request.set(key: .processConfig, value: data) for (i, h) in stdio.enumerated() { - let key: XPCKeys = { + let key: XPCKeys = try { switch i { case 0: .stdin case 1: .stdout case 2: .stderr default: - fatalError("invalid fd \(i)") + throw ContainerizationError(.invalidArgument, message: "invalid fd \(i)") } }() @@ -99,19 +141,29 @@ extension SandboxClient { } } - let client = createClient() - defer { client.close() } - try await client.send(request) + do { + try await self.client.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to create process \(id) in container \(self.id)", + cause: error + ) + } } public func startProcess(_ id: String) async throws { let request = XPCMessage(route: SandboxRoutes.start.rawValue) request.set(key: .id, value: id) - - let client = createClient() - defer { client.close() } - - try await client.send(request) + do { + try await self.client.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to start process \(id) in container \(self.id)", + cause: error + ) + } } public func stop(options: ContainerStopOptions) async throws { @@ -120,10 +172,16 @@ extension SandboxClient { let data = try JSONEncoder().encode(options) request.set(key: .stopOptions, value: data) - let client = createClient() - defer { client.close() } let responseTimeout = Duration(.seconds(Int64(options.timeoutInSeconds + 1))) - try await client.send(request, responseTimeout: responseTimeout) + do { + try await self.client.send(request, responseTimeout: responseTimeout) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to stop container \(self.id)", + cause: error + ) + } } public func kill(_ id: String, signal: Int64) async throws { @@ -131,9 +189,15 @@ extension SandboxClient { request.set(key: .id, value: id) request.set(key: .signal, value: signal) - let client = createClient() - defer { client.close() } - try await client.send(request) + do { + try await self.client.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to send signal \(signal) to process \(id) in container \(self.id)", + cause: error + ) + } } public func resize(_ id: String, size: Terminal.Size) async throws { @@ -142,18 +206,31 @@ extension SandboxClient { request.set(key: .width, value: UInt64(size.width)) request.set(key: .height, value: UInt64(size.height)) - let client = createClient() - defer { client.close() } - try await client.send(request) + do { + try await self.client.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to resize pty for process \(id) in container \(self.id)", + cause: error + ) + } } public func wait(_ id: String) async throws -> Int32 { let request = XPCMessage(route: SandboxRoutes.wait.rawValue) request.set(key: .id, value: id) - let client = createClient() - defer { client.close() } - let response = try await client.send(request) + let response: XPCMessage + do { + response = try await self.client.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to wait for process \(id) in container \(self.id)", + cause: error + ) + } let code = response.int64(key: .exitCode) return Int32(code) } @@ -162,10 +239,16 @@ extension SandboxClient { let request = XPCMessage(route: SandboxRoutes.dial.rawValue) request.set(key: .port, value: UInt64(port)) - let client = createClient() - defer { client.close() } - - let response = try await client.send(request) + let response: XPCMessage + do { + response = try await self.client.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to dial \(port) on \(self.id)", + cause: error + ) + } guard let fh = response.fileHandle(key: .fd) else { throw ContainerizationError( .internalError, @@ -175,8 +258,18 @@ extension SandboxClient { return fh } - private func createClient() -> XPCClient { - XPCClient(service: machServiceLabel) + public func shutdown() async throws { + let request = XPCMessage(route: SandboxRoutes.shutdown.rawValue) + + do { + _ = try await self.client.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to shutdown container \(self.id)", + cause: error + ) + } } } @@ -184,7 +277,10 @@ extension XPCMessage { public func id() throws -> String { let id = self.string(key: .id) guard let id else { - throw ContainerizationError(.invalidArgument, message: "No id") + throw ContainerizationError( + .invalidArgument, + message: "No id" + ) } return id } @@ -192,7 +288,10 @@ extension XPCMessage { func sandboxSnapshot() throws -> SandboxSnapshot { let data = self.dataNoCopy(key: .snapshot) guard let data else { - throw ContainerizationError(.invalidArgument, message: "No state data returned") + throw ContainerizationError( + .invalidArgument, + message: "No state data returned" + ) } return try JSONDecoder().decode(SandboxSnapshot.self, from: data) } diff --git a/Sources/ContainerClient/SandboxRoutes.swift b/Sources/ContainerClient/SandboxRoutes.swift index e5296623c..93948207f 100644 --- a/Sources/ContainerClient/SandboxRoutes.swift +++ b/Sources/ContainerClient/SandboxRoutes.swift @@ -15,6 +15,8 @@ //===----------------------------------------------------------------------===// public enum SandboxRoutes: String { + /// Create an xpc endpoint to the sandbox instance. + case createEndpoint = "com.apple.container.sandbox/createEndpoint" /// Bootstrap the sandbox instance and create the init process. case bootstrap = "com.apple.container.sandbox/bootstrap" /// Create a process in the sandbox. @@ -35,4 +37,6 @@ public enum SandboxRoutes: String { case exec = "com.apple.container.sandbox/exec" /// Dial a vsock port in the sandbox. case dial = "com.apple.container.sandbox/dial" + /// Shutdown the sandbox service process. + case shutdown = "com.apple.container.sandbox/shutdown" } diff --git a/Sources/ContainerClient/SandboxSnapshot.swift b/Sources/ContainerClient/SandboxSnapshot.swift index 129780234..7895e94e6 100644 --- a/Sources/ContainerClient/SandboxSnapshot.swift +++ b/Sources/ContainerClient/SandboxSnapshot.swift @@ -19,11 +19,11 @@ import ContainerNetworkService /// A snapshot of a sandbox and its resources. public struct SandboxSnapshot: Codable, Sendable { /// The runtime status of the sandbox. - public let status: RuntimeStatus + public var status: RuntimeStatus /// Network attachments for the sandbox. - public let networks: [Attachment] + public var networks: [Attachment] /// Containers placed in the sandbox. - public let containers: [ContainerSnapshot] + public var containers: [ContainerSnapshot] public init( status: RuntimeStatus, diff --git a/Sources/ContainerCommands/Builder/BuilderStart.swift b/Sources/ContainerCommands/Builder/BuilderStart.swift index e03af8fb1..e14acb025 100644 --- a/Sources/ContainerCommands/Builder/BuilderStart.swift +++ b/Sources/ContainerCommands/Builder/BuilderStart.swift @@ -251,7 +251,7 @@ extension ClientContainer { defer { try? io.close() } let process = try await bootstrap(stdio: io.stdio) - _ = try await process.start() + try await process.start() await taskManager?.finish() try io.closeAfterStart() diff --git a/Sources/ContainerCommands/Container/ContainerStop.swift b/Sources/ContainerCommands/Container/ContainerStop.swift index 3bfbca8c4..7ffa28662 100644 --- a/Sources/ContainerCommands/Container/ContainerStop.swift +++ b/Sources/ContainerCommands/Container/ContainerStop.swift @@ -70,7 +70,10 @@ extension Application { ) let failed = try await Self.stopContainers(containers: containers, stopOptions: opts) if failed.count > 0 { - throw ContainerizationError(.internalError, message: "stop failed for one or more containers \(failed.joined(separator: ","))") + throw ContainerizationError( + .internalError, + message: "stop failed for one or more containers \(failed.joined(separator: ","))" + ) } } diff --git a/Sources/ContainerCommands/RunCommand.swift b/Sources/ContainerCommands/RunCommand.swift index 5fcc47b89..6d5d65031 100644 --- a/Sources/ContainerCommands/RunCommand.swift +++ b/Sources/ContainerCommands/RunCommand.swift @@ -110,7 +110,6 @@ extension Application { ) let detach = self.managementFlags.detach - do { let io = try ProcessIO.create( tty: self.processFlags.tty, diff --git a/Sources/ContainerXPC/XPCClient.swift b/Sources/ContainerXPC/XPCClient.swift index dbe07bce4..ebf9de1f8 100644 --- a/Sources/ContainerXPC/XPCClient.swift +++ b/Sources/ContainerXPC/XPCClient.swift @@ -18,7 +18,7 @@ import ContainerizationError import Foundation -public struct XPCClient: Sendable { +public final class XPCClient: Sendable { private nonisolated(unsafe) let connection: xpc_connection_t private let q: DispatchQueue? private let service: String @@ -33,6 +33,20 @@ public struct XPCClient: Sendable { xpc_connection_set_target_queue(connection, self.q) xpc_connection_activate(connection) } + + public init(connection: xpc_connection_t, label: String, queue: DispatchQueue? = nil) { + self.connection = connection + self.q = queue + self.service = label + + xpc_connection_set_event_handler(connection) { _ in } + xpc_connection_set_target_queue(connection, self.q) + xpc_connection_activate(connection) + } + + deinit { + self.close() + } } extension XPCClient { @@ -56,7 +70,10 @@ extension XPCClient { group.addTask { try await Task.sleep(for: responseTimeout) let route = message.string(key: XPCMessage.routeKey) ?? "nil" - throw ContainerizationError(.internalError, message: "XPC timeout for request to \(self.service)/\(route)") + throw ContainerizationError( + .internalError, + message: "XPC timeout for request to \(self.service)/\(route)" + ) } } diff --git a/Sources/ContainerXPC/XPCServer.swift b/Sources/ContainerXPC/XPCServer.swift index d496e877e..7b02a0f70 100644 --- a/Sources/ContainerXPC/XPCServer.swift +++ b/Sources/ContainerXPC/XPCServer.swift @@ -35,13 +35,20 @@ public struct XPCServer: Sendable { let connection = xpc_connection_create_mach_service( identifier, nil, - UInt64(XPC_CONNECTION_MACH_SERVICE_LISTENER)) + UInt64(XPC_CONNECTION_MACH_SERVICE_LISTENER) + ) self.routes = routes self.connection = connection self.log = log } + public init(connection: xpc_connection_t, routes: [String: RouteHandler], log: Logging.Logger) { + self.routes = routes + self.connection = connection + self.log = log + } + public func listen() async throws { let connections = AsyncStream { cont in lock.withLock { @@ -71,6 +78,7 @@ public struct XPCServer: Sendable { lock.withLock { xpc_connection_activate(self.connection) } + try await withThrowingDiscardingTaskGroup { group in for await conn in connections { // `conn` isn't used concurrently. diff --git a/Sources/Helpers/APIServer/APIServer.swift b/Sources/Helpers/APIServer/APIServer.swift index 844ee1db0..b40221b42 100644 --- a/Sources/Helpers/APIServer/APIServer.swift +++ b/Sources/Helpers/APIServer/APIServer.swift @@ -61,7 +61,11 @@ struct APIServer: AsyncParsableCommand { var routes = [XPCRoute: XPCServer.RouteHandler]() let pluginLoader = try initializePluginLoader(log: log) try await initializePlugins(pluginLoader: pluginLoader, log: log, routes: &routes) - let containersService = try initializeContainerService(pluginLoader: pluginLoader, log: log, routes: &routes) + let containersService = try initializeContainerService( + pluginLoader: pluginLoader, + log: log, + routes: &routes + ) let networkService = try await initializeNetworkService( pluginLoader: pluginLoader, containersService: containersService, @@ -177,6 +181,8 @@ struct APIServer: AsyncParsableCommand { log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler] ) async throws { + log.info("initializing plugins") + let bootPlugins = pluginLoader.findPlugins().filter { $0.shouldBoot } let service = PluginsService(pluginLoader: pluginLoader, log: log) @@ -191,11 +197,15 @@ struct APIServer: AsyncParsableCommand { } private func initializeHealthCheckService(log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) { + log.info("initializing health check service") + let svc = HealthCheckHarness(appRoot: appRoot, installRoot: installRoot, log: log) routes[XPCRoute.ping] = svc.ping } private func initializeKernelService(log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) throws { + log.info("initializing kernel service") + let svc = try KernelService(log: log, appRoot: appRoot) let harness = KernelHarness(service: svc, log: log) routes[XPCRoute.installKernel] = harness.install @@ -203,6 +213,8 @@ struct APIServer: AsyncParsableCommand { } private func initializeContainerService(pluginLoader: PluginLoader, log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) throws -> ContainersService { + log.info("initializing container service") + let service = try ContainersService( appRoot: appRoot, pluginLoader: pluginLoader, @@ -210,11 +222,18 @@ struct APIServer: AsyncParsableCommand { ) let harness = ContainersHarness(service: service, log: log) - routes[XPCRoute.listContainer] = harness.list - routes[XPCRoute.createContainer] = harness.create - routes[XPCRoute.deleteContainer] = harness.delete + routes[XPCRoute.containerList] = harness.list + routes[XPCRoute.containerCreate] = harness.create + routes[XPCRoute.containerDelete] = harness.delete routes[XPCRoute.containerLogs] = harness.logs - routes[XPCRoute.containerEvent] = harness.eventHandler + routes[XPCRoute.containerBootstrap] = harness.bootstrap + routes[XPCRoute.containerDial] = harness.dial + routes[XPCRoute.containerStop] = harness.stop + routes[XPCRoute.containerStartProcess] = harness.startProcess + routes[XPCRoute.containerCreateProcess] = harness.createProcess + routes[XPCRoute.containerResize] = harness.resize + routes[XPCRoute.containerWait] = harness.wait + routes[XPCRoute.containerKill] = harness.kill return service } @@ -225,6 +244,8 @@ struct APIServer: AsyncParsableCommand { log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler] ) async throws -> NetworksService { + log.info("initializing network service") + let resourceRoot = appRoot.appendingPathComponent("networks") let service = try await NetworksService( pluginLoader: pluginLoader, @@ -249,7 +270,13 @@ struct APIServer: AsyncParsableCommand { return service } - private func initializeVolumeService(containersService: ContainersService, log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) throws { + private func initializeVolumeService( + containersService: ContainersService, + log: Logger, + routes: inout [XPCRoute: XPCServer.RouteHandler] + ) throws { + log.info("initializing volume service") + let resourceRoot = appRoot.appendingPathComponent("volumes") let service = try VolumesService(resourceRoot: resourceRoot, containersService: containersService, log: log) let harness = VolumesHarness(service: service, log: log) diff --git a/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper.swift b/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper.swift index 4be614291..9fb5b2d5f 100644 --- a/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper.swift +++ b/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper.swift @@ -64,15 +64,33 @@ struct RuntimeLinuxHelper: AsyncParsableCommand { signal(SIGPIPE, SIG_IGN) log.info("configuring XPC server") + let interfaceStrategy: any InterfaceStrategy if #available(macOS 26, *) { interfaceStrategy = NonisolatedInterfaceStrategy(log: log) } else { interfaceStrategy = IsolatedInterfaceStrategy() } - let server = SandboxService(root: .init(fileURLWithPath: root), interfaceStrategy: interfaceStrategy, eventLoopGroup: eventLoopGroup, log: log) - let xpc = XPCServer( + + nonisolated(unsafe) let anonymousConnection = xpc_connection_create(nil, nil) + let server = SandboxService( + root: .init(fileURLWithPath: root), + interfaceStrategy: interfaceStrategy, + eventLoopGroup: eventLoopGroup, + connection: anonymousConnection, + log: log + ) + + let endpointServer = XPCServer( identifier: machServiceLabel, + routes: [ + SandboxRoutes.createEndpoint.rawValue: server.createEndpoint + ], + log: log + ) + + let mainServer = XPCServer( + connection: anonymousConnection, routes: [ SandboxRoutes.bootstrap.rawValue: server.bootstrap, SandboxRoutes.createProcess.rawValue: server.createProcess, @@ -83,12 +101,23 @@ struct RuntimeLinuxHelper: AsyncParsableCommand { SandboxRoutes.wait.rawValue: server.wait, SandboxRoutes.start.rawValue: server.startProcess, SandboxRoutes.dial.rawValue: server.dial, + SandboxRoutes.shutdown.rawValue: server.shutdown, ], log: log ) log.info("starting XPC server") - try await xpc.listen() + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await endpointServer.listen() + } + group.addTask { + try await mainServer.listen() + } + defer { group.cancelAll() } + + _ = try await group.next() + } } catch { log.error("\(commandName) failed", metadata: ["error": "\(error)"]) try? await eventLoopGroup.shutdownGracefully() diff --git a/Sources/Services/ContainerAPIService/Containers/ContainersHarness.swift b/Sources/Services/ContainerAPIService/Containers/ContainersHarness.swift index 61e92eec5..6e1062da5 100644 --- a/Sources/Services/ContainerAPIService/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Containers/ContainersHarness.swift @@ -41,15 +41,142 @@ public struct ContainersHarness: Sendable { return reply } + @Sendable + public func bootstrap(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + let stdio = message.stdio() + try await service.bootstrap(id: id, stdio: stdio) + return message.reply() + } + + @Sendable + public func stop(_ message: XPCMessage) async throws -> XPCMessage { + let stopOptions = try message.stopOptions() + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + try await service.stop(id: id, options: stopOptions) + return message.reply() + } + + @Sendable + public func dial(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + + let port = message.uint64(key: .port) + let fh = try await service.dial(id: id, port: UInt32(port)) + let reply = message.reply() + reply.setFileHandle(fh) + + return reply + } + + @Sendable + public func wait(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + let processID = message.string(key: .processIdentifier) + guard let processID else { + throw ContainerizationError( + .invalidArgument, + message: "process ID cannot be empty" + ) + } + + let exitCode = try await service.wait(id: id, processID: processID) + let reply = message.reply() + reply.set(key: .exitCode, value: Int64(exitCode)) + return reply + } + + @Sendable + public func resize(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + let processID = message.string(key: .processIdentifier) + guard let processID else { + throw ContainerizationError( + .invalidArgument, + message: "process ID cannot be empty" + ) + } + + let width = message.uint64(key: .width) + let height = message.uint64(key: .height) + try await service.resize( + id: id, + processID: processID, + size: Terminal.Size(width: UInt16(width), height: UInt16(height)) + ) + + return message.reply() + } + + @Sendable + public func kill(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + let processID = message.string(key: .processIdentifier) + guard let processID else { + throw ContainerizationError( + .invalidArgument, + message: "process ID cannot be empty" + ) + } + try await service.kill( + id: id, + processID: processID, + signal: try message.signal() + ) + return message.reply() + } + @Sendable public func create(_ message: XPCMessage) async throws -> XPCMessage { let data = message.dataNoCopy(key: .containerConfig) guard let data else { - throw ContainerizationError(.invalidArgument, message: "container configuration cannot be empty") + throw ContainerizationError( + .invalidArgument, + message: "container configuration cannot be empty" + ) } let kdata = message.dataNoCopy(key: .kernel) guard let kdata else { - throw ContainerizationError(.invalidArgument, message: "kernel cannot be empty") + throw ContainerizationError( + .invalidArgument, + message: "kernel cannot be empty" + ) } let odata = message.dataNoCopy(key: .containerOptions) var options: ContainerCreateOptions = .default @@ -63,6 +190,60 @@ public struct ContainersHarness: Sendable { return message.reply() } + @Sendable + public func createProcess(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + let processID = message.string(key: .processIdentifier) + guard let processID else { + throw ContainerizationError( + .invalidArgument, + message: "process ID cannot be empty" + ) + } + let config = try message.processConfig() + let stdio = message.stdio() + + try await service.createProcess( + id: id, + processID: processID, + config: config, + stdio: stdio + ) + + return message.reply() + } + + @Sendable + public func startProcess(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + let processID = message.string(key: .processIdentifier) + guard let processID else { + throw ContainerizationError( + .invalidArgument, + message: "process ID cannot be empty" + ) + } + + try await service.startProcess( + id: id, + processID: processID, + ) + + return message.reply() + } + @Sendable public func delete(_ message: XPCMessage) async throws -> XPCMessage { let id = message.string(key: .id) @@ -88,21 +269,4 @@ public struct ContainersHarness: Sendable { try reply.set(key: .logs, value: fds) return reply } - - @Sendable - public func eventHandler(_ message: XPCMessage) async throws -> XPCMessage { - let event = try message.containerEvent() - try await service.handleContainerEvents(event: event) - return message.reply() - } -} - -extension XPCMessage { - public func containerEvent() throws -> ContainerEvent { - guard let data = self.dataNoCopy(key: .containerEvent) else { - throw ContainerizationError(.invalidArgument, message: "Missing container event data") - } - let event = try JSONDecoder().decode(ContainerEvent.self, from: data) - return event - } } diff --git a/Sources/Services/ContainerAPIService/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Containers/ContainersService.swift index acb806f1d..86f361d39 100644 --- a/Sources/Services/ContainerAPIService/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Containers/ContainersService.swift @@ -18,6 +18,7 @@ import CVersion import ContainerClient import ContainerPlugin import ContainerSandboxService +import ContainerXPC import Containerization import ContainerizationError import ContainerizationExtras @@ -27,6 +28,22 @@ import Foundation import Logging public actor ContainersService { + struct ContainerState { + var snapshot: ContainerSnapshot + var client: SandboxClient? + + func getClient() throws -> SandboxClient { + guard let client else { + var message = "no sandbox client exists" + if snapshot.status == .stopped { + message += ": container is stopped" + } + throw ContainerizationError(.invalidState, message: message) + } + return client + } + } + private static let machServicePrefix = "com.apple.container" private static let launchdDomainString = try! ServiceManager.getDomainString() @@ -34,13 +51,15 @@ public actor ContainersService { private let containerRoot: URL private let pluginLoader: PluginLoader private let runtimePlugins: [Plugin] + private let exitMonitor: ExitMonitor private let lock = AsyncLock() - private var containers: [String: ContainerSnapshot] + private var containers: [String: ContainerState] public init(appRoot: URL, pluginLoader: PluginLoader, log: Logger) throws { let containerRoot = appRoot.appendingPathComponent("containers") try FileManager.default.createDirectory(at: containerRoot, withIntermediateDirectories: true) + self.exitMonitor = ExitMonitor(log: log) self.containerRoot = containerRoot self.pluginLoader = pluginLoader self.log = log @@ -48,7 +67,7 @@ public actor ContainersService { self.containers = try Self.loadAtBoot(root: containerRoot, loader: pluginLoader, log: log) } - static func loadAtBoot(root: URL, loader: PluginLoader, log: Logger) throws -> [String: ContainerSnapshot] { + static func loadAtBoot(root: URL, loader: PluginLoader, log: Logger) throws -> [String: ContainerState] { var directories = try FileManager.default.contentsOfDirectory( at: root, includingPropertiesForKeys: [.isDirectoryKey] @@ -58,17 +77,32 @@ public actor ContainersService { } let runtimePlugins = loader.findPlugins().filter { $0.hasType(.runtime) } - var results = [String: ContainerSnapshot]() + var results = [String: ContainerState]() for dir in directories { do { let bundle = ContainerClient.Bundle(path: dir) let config = try bundle.configuration - results[config.id] = .init(configuration: config, status: .stopped, networks: []) + let state = ContainerState( + snapshot: .init( + configuration: config, + status: .stopped, + networks: [] + ) + ) + results[config.id] = state let plugin = runtimePlugins.first { $0.name == config.runtimeHandler } guard let plugin else { - throw ContainerizationError(.internalError, message: "Failed to find runtime plugin \(config.runtimeHandler)") + throw ContainerizationError( + .internalError, + message: "Failed to find runtime plugin \(config.runtimeHandler)" + ) } - try Self.registerService(plugin: plugin, loader: loader, configuration: config, path: dir) + 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)") @@ -77,21 +111,17 @@ public actor ContainersService { return results } - private func setContainer(_ id: String, _ item: ContainerSnapshot, context: AsyncLock.Context) async { - self.containers[id] = item - } - /// List all containers registered with the service. public func list() async throws -> [ContainerSnapshot] { self.log.debug("\(#function)") - return Array(self.containers.values) + return self.containers.values.map { $0.snapshot } } /// Execute an operation with the current container list while maintaining atomicity /// This prevents race conditions where containers are created during the operation public func withContainerList(_ operation: @Sendable @escaping ([ContainerSnapshot]) async throws -> T) async throws -> T { try await lock.withLock { context in - let snapshots = Array(await self.containers.values) + let snapshots = await self.containers.values.map { $0.snapshot } return try await operation(snapshots) } } @@ -101,12 +131,15 @@ public actor ContainersService { self.log.debug("\(#function)") guard containers[configuration.id] == nil else { - throw ContainerizationError(.exists, message: "container already exists: \(configuration.id)") + throw ContainerizationError( + .exists, + message: "container already exists: \(configuration.id)" + ) } var allHostnames = Set() for container in containers.values { - for attachmentConfiguration in container.configuration.networks { + for attachmentConfiguration in container.snapshot.configuration.networks { allHostnames.insert(attachmentConfiguration.options.hostname) } } @@ -119,16 +152,20 @@ public actor ContainersService { } guard conflictingHostnames.isEmpty else { - throw ContainerizationError(.exists, message: "hostname(s) already exist: \(conflictingHostnames)") + throw ContainerizationError( + .exists, + message: "hostname(s) already exist: \(conflictingHostnames)" + ) } - self.containers[configuration.id] = ContainerSnapshot(configuration: configuration, status: .stopped, networks: []) - let runtimePlugin = self.runtimePlugins.filter { $0.name == configuration.runtimeHandler }.first guard let runtimePlugin else { - throw ContainerizationError(.notFound, message: "unable to locate runtime plugin \(configuration.runtimeHandler)") + throw ContainerizationError( + .notFound, + message: "unable to locate runtime plugin \(configuration.runtimeHandler)" + ) } let path = self.containerRoot.appendingPathComponent(configuration.id) @@ -153,6 +190,13 @@ public actor ContainersService { configuration: configuration, path: path ) + + let snapshot = ContainerSnapshot( + configuration: configuration, + status: .stopped, + networks: [] + ) + self.containers[configuration.id] = ContainerState(snapshot: snapshot) } catch { do { try bundle.delete() @@ -163,80 +207,264 @@ public actor ContainersService { } } - private func getInitBlock(for platform: Platform) async throws -> Filesystem { - let initImage = try await ClientImage.fetch(reference: ClientImage.initImageRef, platform: platform) - var fs = try await initImage.getCreateSnapshot(platform: platform) - fs.options = ["ro"] - return fs + /// Bootstrap the init process of the container. + public func bootstrap(id: String, stdio: [FileHandle?]) async throws { + self.log.debug("\(#function)") + do { + try await self.lock.withLock { context in + var state = try await self.getContainerState(id: id, context: context) + let runtime = state.snapshot.configuration.runtimeHandler + let sandboxClient = try await SandboxClient.create( + id: id, + runtime: runtime + ) + try await sandboxClient.bootstrap(stdio: stdio) + + try await self.exitMonitor.registerProcess( + id: id, + onExit: self.handleContainerExit + ) + + state.client = sandboxClient + await self.setContainerState(id, state, context: context) + } + } catch { + do { + try await _cleanup(id: id) + } catch { + self.log.error("failed to cleanup container \(id) after bootstrap failure: \(error)") + } + throw error + } } - private static func registerService( - plugin: Plugin, - loader: PluginLoader, - configuration: ContainerConfiguration, - path: URL - ) throws { - let args = [ - "--root", path.path, - "--uuid", configuration.id, - "--debug", - ] - try loader.registerWithLaunchd( - plugin: plugin, - pluginStateRoot: path, - args: args, - instanceId: configuration.id - ) + /// Create a new process in the container. + public func createProcess( + id: String, + processID: String, + config: ProcessConfiguration, + stdio: [FileHandle?] + ) async throws { + self.log.debug("\(#function)") + + let state = try self._getContainerState(id: id) + do { + let client = try state.getClient() + try await client.createProcess( + processID, + config: config, + stdio: stdio + ) + } catch { + do { + try await _cleanup(id: id) + } catch { + self.log.error("failed to cleanup container \(id) after start failure: \(error)") + } + throw error + } + } + + /// Start a process in a container. This can either be a process created via + /// createProcess, or the init process of the container which requires + /// id == processID. + public func startProcess(id: String, processID: String) async throws { + self.log.debug("\(#function)") + + do { + try await self.lock.withLock { context in + var state = try await self.getContainerState(id: id, context: context) + + let isInit = Self.isInitProcess(id: id, processID: processID) + if state.snapshot.status == .running && isInit { + return + } + + let client = try state.getClient() + try await client.startProcess(processID) + + if isInit { + let log = self.log + 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") + + return code + } + try await self.exitMonitor.track(id: id, waitingOn: waitFunc) + + let sandboxSnapshot = try await client.state() + state.snapshot.status = .running + state.snapshot.networks = sandboxSnapshot.networks + await self.setContainerState(id, state, context: context) + } + } + } catch { + do { + try await _cleanup(id: id) + } catch { + self.log.error("failed to cleanup container \(id) after start failure: \(error)") + } + throw error + } } - private func get(id: String, context: AsyncLock.Context) throws -> ContainerSnapshot { - try self._get(id: id) + /// Send a signal to the container. + public func kill(id: String, processID: String, signal: Int64) async throws { + self.log.debug("\(#function)") + + let state = try self._getContainerState(id: id) + let client = try state.getClient() + try await client.kill(processID, signal: signal) } - private func _get(id: String) throws -> ContainerSnapshot { - let item = self.containers[id] - guard let item else { + /// Stop all containers inside the sandbox, aborting any processes currently + /// executing inside the container, before stopping the underlying sandbox. + public func stop(id: String, options: ContainerStopOptions) async throws { + self.log.debug("\(#function)") + + let state = try self._getContainerState(id: id) + + // Stop should be idempotent. + let client: SandboxClient + do { + client = try state.getClient() + } catch { + return + } + + do { + try await client.stop(options: options) + } catch let err as ContainerizationError { + if err.code != .interrupted { + throw err + } + } + try await handleContainerExit(id: id) + } + + public func dial(id: String, port: UInt32) async throws -> FileHandle { + let state = try self._getContainerState(id: id) + let client = try state.getClient() + return try await client.dial(port) + } + + /// Wait waits for the container's init process or exec to exit and returns the + /// exit status. + public func wait(id: String, processID: String) async throws -> Int32 { + self.log.debug("\(#function)") + + let state = try self._getContainerState(id: id) + let client = try state.getClient() + return try await client.wait(processID) + } + + /// Resize resizes the container's PTY if one exists. + public func resize(id: String, processID: String, size: Terminal.Size) async throws { + self.log.debug("\(#function)") + + let state = try self._getContainerState(id: id) + let client = try state.getClient() + try await client.resize(processID, size: size) + } + + // Get the logs for the container. + public func logs(id: String) async throws -> [FileHandle] { + self.log.debug("\(#function)") + + // Logs doesn't care if the container is running or not, just that + // the bundle is there, and that the files actually exist. + do { + let path = self.containerRoot.appendingPathComponent(id) + let bundle = ContainerClient.Bundle(path: path) + return [ + try FileHandle(forReadingFrom: bundle.containerLog), + try FileHandle(forReadingFrom: bundle.bootlog), + ] + } catch { throw ContainerizationError( - .notFound, - message: "container with ID \(id) not found" + .internalError, + message: "failed to open container logs: \(error)" ) } - return item } /// Delete a container and its resources. public func delete(id: String, force: Bool) async throws { self.log.debug("\(#function)") - let item = try self._get(id: id) - switch item.status { + let state = try self._getContainerState(id: id) + switch state.snapshot.status { case .running: if !force { throw ContainerizationError( .invalidState, - message: "container \(id) is \(item.status) and can not be deleted" + message: "container \(id) is \(state.snapshot.status) and can not be deleted" ) } - let autoRemove = try getContainerCreationOptions(id: id).autoRemove let opts = ContainerStopOptions( timeoutInSeconds: 5, signal: SIGKILL ) - try await self._stop( - id: id, - runtimeHandler: item.configuration.runtimeHandler, - options: opts - ) - if autoRemove { - return + let client = try state.getClient() + try await client.stop(options: opts) + try await self.lock.withLock { context in + try await self.cleanup(id: id, context: context) } - try self._cleanup(id: id, item: item) case .stopping: throw ContainerizationError( .invalidState, - message: "container \(id) is \(item.status) and can not be deleted" + message: "container \(id) is \(state.snapshot.status) and can not be deleted" ) default: - try self._cleanup(id: id, item: item) + try await self.lock.withLock { context in + try await self.cleanup(id: id, context: context) + } + } + } + + private func handleContainerExit(id: String, code: Int32? = nil) async throws { + try await self.lock.withLock { [self] context in + try await handleContainerExit(id: id, code: code, context: context) + } + } + + private func handleContainerExit(id: String, code: Int32?, context: AsyncLock.Context) async throws { + if let code { + self.log.info("Handling container \(id) exit. Code \(code)") + } + + var state: ContainerState + do { + state = try self.getContainerState(id: id, context: context) + if state.snapshot.status == .stopped { + return + } + } catch { + // Was auto removed by the background thread, nothing for us to do. + return + } + + await self.exitMonitor.stopTracking(id: id) + + // Try and shutdown the runtime helper. + do { + self.log.info("Shutting down sandbox service for \(id)") + + let client = try state.getClient() + try await client.shutdown() + } catch { + self.log.error("failed to shutdown sandbox service for \(id): \(error)") + } + + state.snapshot.status = .stopped + state.snapshot.networks = [] + state.client = nil + await self.setContainerState(id, state, context: context) + + let options = try getContainerCreationOptions(id: id) + if options.autoRemove { + try await self.cleanup(id: id, context: context) } } @@ -244,30 +472,33 @@ public actor ContainersService { "\(Self.launchdDomainString)/\(Self.machServicePrefix).\(runtimeName).\(instanceId)" } - private func _cleanup(id: String, item: ContainerSnapshot) throws { + private func _cleanup(id: String) async throws { self.log.debug("\(#function)") + // Did the exit container handler win? + if self.containers[id] == nil { + return + } + + // To be pedantic. This is only needed if something in the "launch + // the init process" lifecycle fails before actually fork+exec'ing + // the OCI runtime. + await self.exitMonitor.stopTracking(id: id) let path = self.containerRoot.appendingPathComponent(id) let bundle = ContainerClient.Bundle(path: path) let config = try bundle.configuration - let label = Self.fullLaunchdServiceLabel(runtimeName: config.runtimeHandler, instanceId: id) + let label = Self.fullLaunchdServiceLabel( + runtimeName: config.runtimeHandler, + instanceId: id + ) try ServiceManager.deregister(fullServiceLabel: label) try bundle.delete() self.containers.removeValue(forKey: id) } - private func _shutdown(id: String, item: ContainerSnapshot) throws { - let path = self.containerRoot.appendingPathComponent(id) - let bundle = ContainerClient.Bundle(path: path) - let config = try bundle.configuration - - let label = Self.fullLaunchdServiceLabel(runtimeName: config.runtimeHandler, instanceId: id) - try ServiceManager.kill(fullServiceLabel: label) - } - - private func cleanup(id: String, item: ContainerSnapshot, context: AsyncLock.Context) throws { - try self._cleanup(id: id, item: item) + private func cleanup(id: String, context: AsyncLock.Context) async throws { + try await self._cleanup(id: id) } private func getContainerCreationOptions(id: String) throws -> ContainerCreateOptions { @@ -277,104 +508,95 @@ public actor ContainersService { return options } - private func containerProcessExitHandler(_ id: String, _ exitCode: Int32, context: AsyncLock.Context) async { - self.log.info("Handling container \(id) exit. Code \(exitCode)") - do { - let item = try self.get(id: id, context: context) - let snapshot = ContainerSnapshot(configuration: item.configuration, status: .stopped, networks: []) - await self.setContainer(id, snapshot, context: context) + private func getInitBlock(for platform: Platform) async throws -> Filesystem { + let initImage = try await ClientImage.fetch(reference: ClientImage.initImageRef, platform: platform) + var fs = try await initImage.getCreateSnapshot(platform: platform) + fs.options = ["ro"] + return fs + } - let options = try getContainerCreationOptions(id: id) - if options.autoRemove { - try self.cleanup(id: id, item: item, context: context) - } - } catch { - self.log.error( - "Failed to handle container exit", - metadata: [ - "id": .string(id), - "error": .string(String(describing: error)), - ]) - } + private static func registerService( + plugin: Plugin, + loader: PluginLoader, + configuration: ContainerConfiguration, + path: URL + ) throws { + let args = [ + "--root", path.path, + "--uuid", configuration.id, + "--debug", + ] + try loader.registerWithLaunchd( + plugin: plugin, + pluginStateRoot: path, + args: args, + instanceId: configuration.id + ) } - private func containerStartHandler(_ id: String, context: AsyncLock.Context) async throws { - self.log.debug("\(#function)") - self.log.info("Handling container \(id) Start.") - do { - let currentSnapshot = try self.get(id: id, context: context) - let client = SandboxClient(id: currentSnapshot.configuration.id, runtime: currentSnapshot.configuration.runtimeHandler) - let sandboxSnapshot = try await client.state() - let snapshot = ContainerSnapshot(configuration: currentSnapshot.configuration, status: .running, networks: sandboxSnapshot.networks) - await self.setContainer(id, snapshot, context: context) - } catch { - self.log.error( - "Failed to handle container start", - metadata: [ - "id": .string(id), - "error": .string(String(describing: error)), - ]) + private func setContainerState(_ id: String, _ state: ContainerState, context: AsyncLock.Context) async { + self.containers[id] = state + } + + private func getContainerState(id: String, context: AsyncLock.Context) throws -> ContainerState { + try self._getContainerState(id: id) + } + + private func _getContainerState(id: String) throws -> ContainerState { + let state = self.containers[id] + guard let state else { + throw ContainerizationError( + .notFound, + message: "container with ID \(id) not found" + ) } + return state + } + + private static func isInitProcess(id: String, processID: String) -> Bool { + id == processID } } -extension ContainersService { - public func handleContainerEvents(event: ContainerEvent) async throws { - self.log.debug("\(#function)") - try await self.lock.withLock { context in - switch event { - case .containerExit(let id, let code): - await self.containerProcessExitHandler(id, Int32(code), context: context) - case .containerStart(let id): - try await self.containerStartHandler(id, context: context) - } - } +extension XPCMessage { + func signal() throws -> Int64 { + self.int64(key: .signal) } - /// Stop all containers inside the sandbox, aborting any processes currently - /// executing inside the container, before stopping the underlying sandbox. - public func stop(id: String, options: ContainerStopOptions) async throws { - self.log.debug("\(#function)") - try await lock.withLock { context in - let item = try await self.get(id: id, context: context) - switch item.status { - case .running: - try await self._stop( - id: id, - runtimeHandler: item.configuration.runtimeHandler, - options: options - ) - default: - return - } + func stopOptions() throws -> ContainerStopOptions { + guard let data = self.dataNoCopy(key: .stopOptions) else { + throw ContainerizationError(.invalidArgument, message: "empty StopOptions") } + return try JSONDecoder().decode(ContainerStopOptions.self, from: data) } - private func _stop(id: String, runtimeHandler: String, options: ContainerStopOptions) async throws { - let client = SandboxClient( - id: id, - runtime: runtimeHandler - ) - try await client.stop(options: options) + func setState(_ state: SandboxSnapshot) throws { + let data = try JSONEncoder().encode(state) + self.set(key: .snapshot, value: data) } - public func logs(id: String) async throws -> [FileHandle] { - self.log.debug("\(#function)") - // Logs doesn't care if the container is running or not, just that - // the bundle is there, and that the files actually exist. - do { - let path = self.containerRoot.appendingPathComponent(id) - let bundle = ContainerClient.Bundle(path: path) - return [ - try FileHandle(forReadingFrom: bundle.containerLog), - try FileHandle(forReadingFrom: bundle.bootlog), - ] - } catch { - throw ContainerizationError( - .internalError, - message: "failed to open container logs: \(error)" - ) + func stdio() -> [FileHandle?] { + var handles = [FileHandle?](repeating: nil, count: 3) + if let stdin = self.fileHandle(key: .stdin) { + handles[0] = stdin + } + if let stdout = self.fileHandle(key: .stdout) { + handles[1] = stdout } + if let stderr = self.fileHandle(key: .stderr) { + handles[2] = stderr + } + return handles } + func setFileHandle(_ handle: FileHandle) { + self.set(key: .fd, value: handle) + } + + func processConfig() throws -> ProcessConfiguration { + guard let data = self.dataNoCopy(key: .processConfig) else { + throw ContainerizationError(.invalidArgument, message: "empty process configuration") + } + return try JSONDecoder().decode(ProcessConfiguration.self, from: data) + } } diff --git a/Sources/Services/ContainerAPIService/Networks/NetworksService.swift b/Sources/Services/ContainerAPIService/Networks/NetworksService.swift index 6416782c9..015e04408 100644 --- a/Sources/Services/ContainerAPIService/Networks/NetworksService.swift +++ b/Sources/Services/ContainerAPIService/Networks/NetworksService.swift @@ -48,7 +48,11 @@ public actor NetworksService { self.log = log try FileManager.default.createDirectory(at: resourceRoot, withIntermediateDirectories: true) - self.store = try FilesystemEntityStore(path: resourceRoot, type: "network", log: log) + self.store = try FilesystemEntityStore( + path: resourceRoot, + type: "network", + log: log + ) let networkPlugin = pluginLoader diff --git a/Sources/Services/ContainerNetworkService/NetworkClient.swift b/Sources/Services/ContainerNetworkService/NetworkClient.swift index 80645e568..7be2284f8 100644 --- a/Sources/Services/ContainerNetworkService/NetworkClient.swift +++ b/Sources/Services/ContainerNetworkService/NetworkClient.swift @@ -40,7 +40,6 @@ extension NetworkClient { public func state() async throws -> NetworkState { let request = XPCMessage(route: NetworkRoutes.state.rawValue) let client = createClient() - defer { client.close() } let response = try await client.send(request) let state = try response.state() @@ -52,7 +51,6 @@ extension NetworkClient { request.set(key: NetworkKeys.hostname.rawValue, value: hostname) let client = createClient() - defer { client.close() } let response = try await client.send(request) let attachment = try response.attachment() @@ -65,7 +63,6 @@ extension NetworkClient { request.set(key: NetworkKeys.hostname.rawValue, value: hostname) let client = createClient() - defer { client.close() } try await client.send(request) } @@ -74,7 +71,6 @@ extension NetworkClient { request.set(key: NetworkKeys.hostname.rawValue, value: hostname) let client = createClient() - defer { client.close() } let response = try await client.send(request) return try response.dataNoCopy(key: NetworkKeys.attachment.rawValue).map { @@ -86,7 +82,6 @@ extension NetworkClient { let request = XPCMessage(route: NetworkRoutes.disableAllocator.rawValue) let client = createClient() - defer { client.close() } let response = try await client.send(request) return try response.allocatorDisabled() diff --git a/Sources/Services/ContainerSandboxService/SandboxService.swift b/Sources/Services/ContainerSandboxService/SandboxService.swift index ef9bb9095..555af28a1 100644 --- a/Sources/Services/ContainerSandboxService/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/SandboxService.swift @@ -14,8 +14,6 @@ // limitations under the License. //===----------------------------------------------------------------------===// -// - import ContainerClient import ContainerNetworkService import ContainerPersistence @@ -37,6 +35,7 @@ import struct ContainerizationOCI.Process /// An XPC service that manages the lifecycle of a single VM-backed container. public actor SandboxService { + private let connection: xpc_connection_t private let root: URL private let interfaceStrategy: InterfaceStrategy private var container: ContainerInfo? @@ -66,12 +65,36 @@ public actor SandboxService { /// - interfaceStrategy: The strategy for producing network interface /// objects for each network to which the container attaches. /// - log: The destination for log messages. - public init(root: URL, interfaceStrategy: InterfaceStrategy, eventLoopGroup: any EventLoopGroup, log: Logger) { + public init( + root: URL, + interfaceStrategy: InterfaceStrategy, + eventLoopGroup: any EventLoopGroup, + connection: xpc_connection_t, + log: Logger + ) { self.root = root self.interfaceStrategy = interfaceStrategy self.log = log self.monitor = ExitMonitor(log: log) self.eventLoopGroup = eventLoopGroup + self.connection = connection + } + + /// Returns an endpoint from an anonymous xpc connection. + /// + /// - Parameters: + /// - message: An XPC message with no parameters. + /// + /// - Returns: An XPC message with the following parameters: + /// - endpoint: An XPC endpoint that can be used to communicate + /// with the sandbox service. + @Sendable + public func createEndpoint(_ message: XPCMessage) async throws -> XPCMessage { + self.log.info("`createEndpoint` xpc handler") + let endpoint = xpc_endpoint_create(self.connection) + let reply = message.reply() + reply.set(key: .sandboxServiceEndpoint, value: endpoint) + return reply } /// Start the VM and the guest agent process for a container. @@ -217,7 +240,7 @@ public actor SandboxService { /// - Returns: An XPC message with no parameters. @Sendable public func startProcess(_ message: XPCMessage) async throws -> XPCMessage { - self.log.info("`start` xpc handler") + self.log.info("`startProcess` xpc handler") return try await self.lock.withLock { lock in let id = try message.id() let containerInfo = try await self.getContainer() @@ -225,7 +248,6 @@ public actor SandboxService { if id == containerId { try await self.startInitProcess(lock: lock) await self.setState(.running) - try await self.sendContainerEvent(.containerStart(id: id)) } else { try await self.startExecProcess(processId: id, lock: lock) } @@ -233,119 +255,37 @@ public actor SandboxService { } } - private func startInitProcess(lock: AsyncLock.Context) async throws { - let info = try self.getContainer() - let container = info.container - let id = container.id - - guard self.state == .booted else { - throw ContainerizationError( - .invalidState, - message: "container expected to be in booted state, got: \(self.state)" - ) - } - - self.setState(.starting) - do { - let io = info.io - - try await container.start() - let waitFunc: ExitMonitor.WaitHandler = { - let code = try await container.wait() - if let out = io.out { - try out.close() - } - if let err = io.err { - try err.close() - } - return code - } - try await self.monitor.track(id: id, waitingOn: waitFunc) - } catch { - try? await self.cleanupContainer() - self.setState(.created) - try await self.sendContainerEvent(.containerExit(id: id, exitCode: -1)) - throw error - } - } - - private func startExecProcess(processId id: String, lock: AsyncLock.Context) async throws { - let container = try self.getContainer().container - guard let processInfo = self.processes[id] else { - throw ContainerizationError(.notFound, message: "Process with id \(id)") - } - - let containerInfo = try self.getContainer() - - let czConfig = self.configureProcessConfig(config: processInfo.config, stdio: processInfo.io, containerConfig: containerInfo.config) - - let process = try await container.exec(id, configuration: czConfig) - try self.setUnderlyingProcess(id, process) - - try await process.start() + /// Shutdown the SandboxService. + /// + /// - Parameters: + /// - message: An XPC message with no parameters. + /// + /// - Returns: An XPC message with no parameters. + @Sendable + public func shutdown(_ message: XPCMessage) async throws -> XPCMessage { + self.log.info("`shutdown` xpc handler") - let waitFunc: ExitMonitor.WaitHandler = { - let code = try await process.wait() - if let out = processInfo.io[1] { - try self.closeHandle(out.fileDescriptor) - } - if let err = processInfo.io[2] { - try self.closeHandle(err.fileDescriptor) - } - return code - } - try await self.monitor.track(id: id, waitingOn: waitFunc) - } + switch self.state { + case .created, .stopped(_), .stopping: + self.state = .shuttingDown - private func startSocketForwarders(containerIpAddress: String, publishedPorts: [PublishPort]) async throws { - var forwarders: [SocketForwarderResult] = [] - try await withThrowingTaskGroup(of: SocketForwarderResult.self) { group in - for publishedPort in publishedPorts { - let proxyAddress = try SocketAddress(ipAddress: publishedPort.hostAddress, port: Int(publishedPort.hostPort)) - let serverAddress = try SocketAddress(ipAddress: containerIpAddress, port: Int(publishedPort.containerPort)) - log.info( - "creating forwarder for", - metadata: [ - "proxy": "\(proxyAddress)", - "server": "\(serverAddress)", - "protocol": "\(publishedPort.proto)", - ]) - group.addTask { - let forwarder: SocketForwarder - switch publishedPort.proto { - case .tcp: - forwarder = try TCPForwarder( - proxyAddress: proxyAddress, - serverAddress: serverAddress, - eventLoopGroup: self.eventLoopGroup, - log: self.log - ) - case .udp: - forwarder = try UDPForwarder( - proxyAddress: proxyAddress, - serverAddress: serverAddress, - eventLoopGroup: self.eventLoopGroup, - log: self.log - ) - } - return try await forwarder.run().get() + 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) } - for try await result in group { - forwarders.append(result) - } + default: + throw ContainerizationError( + .invalidState, + message: "cannot shutdown: container is not stopped" + ) } - self.socketForwarders = forwarders - } - - private func stopSocketForwarders() async { - log.info("closing forwarders") - for forwarder in self.socketForwarders { - forwarder.close() - try? await forwarder.wait() - } - log.info("closed forwarders") + return message.reply() } /// Create a process inside the virtual machine for the container. @@ -365,17 +305,12 @@ public actor SandboxService { log.info("`createProcess` xpc handler") return try await self.lock.withLock { [self] _ in switch await self.state { - case .created, .stopped(_), .starting, .stopping: - throw ContainerizationError( - .invalidState, - message: "cannot exec: container is not running" - ) case .running, .booted: let id = try message.id() let config = try message.processConfig() let stdio = message.stdio() - await self.addNewProcess(id, config, stdio) + try await self.addNewProcess(id, config, stdio) try await self.monitor.registerProcess( id: id, @@ -396,6 +331,11 @@ public actor SandboxService { ) return message.reply() + default: + throw ContainerizationError( + .invalidState, + message: "cannot exec: container is not running" + ) } } } @@ -416,7 +356,7 @@ public actor SandboxService { var cs: ContainerSnapshot? switch state { - case .created, .stopped(_), .starting, .booted: + case .created, .stopped(_), .booted, .shuttingDown: status = .stopped case .stopping: status = .stopping @@ -455,36 +395,32 @@ public actor SandboxService { @Sendable public func stop(_ message: XPCMessage) async throws -> XPCMessage { self.log.info("`stop` xpc handler") - let reply = try await self.lock.withLock { [self] _ in - switch await self.state { - case .stopped(_), .created, .stopping: - return message.reply() - case .starting: - throw ContainerizationError( - .invalidState, - message: "cannot stop: container is not running" - ) - case .running, .booted: - let ctr = try await getContainer() - let stopOptions = try message.stopOptions() + switch self.state { + case .running, .booted: + self.state = .stopping + + let ctr = try getContainer() + let stopOptions = try message.stopOptions() + let code = try await gracefulStopContainer( + ctr.container, + stopOpts: stopOptions + ) + + await self.lock.withLock { _ in do { - try await gracefulStopContainer( - ctr.container, - stopOpts: stopOptions - ) + if case .stopped(_) = await self.state { + return + } + try await self.cleanupContainer() } catch { - log.notice("failed to stop sandbox gracefully: \(error)") + self.log.error("failed to cleanup container: \(error)") } - await setState(.stopping) - return message.reply() + await self.setState(.stopped(code)) } + default: + break } - do { - try await cleanupContainer() - } catch { - self.log.error("failed to cleanup container: \(error)") - } - return reply + return message.reply() } /// Signal a process running in the virtual machine. @@ -500,11 +436,6 @@ public actor SandboxService { self.log.info("`kill` xpc handler") return try await self.lock.withLock { [self] _ in switch await self.state { - case .created, .stopped, .starting, .booted, .stopping: - throw ContainerizationError( - .invalidState, - message: "cannot kill: container is not running" - ) case .running: let ctr = try await getContainer() let id = try message.id() @@ -523,6 +454,11 @@ public actor SandboxService { // TODO: fix underlying signal value to int64 try await ctr.container.kill(Int32(try message.signal())) return message.reply() + default: + throw ContainerizationError( + .invalidState, + message: "cannot kill: container is not running" + ) } } } @@ -539,34 +475,47 @@ public actor SandboxService { @Sendable public func resize(_ message: XPCMessage) async throws -> XPCMessage { self.log.info("`resize` xpc handler") - return try await self.lock.withLock { [self] _ in - switch await self.state { - case .created, .stopped, .starting, .booted, .stopping: - throw ContainerizationError( - .invalidState, - message: "cannot resize: container is not running" - ) - case .running: - let id = try message.id() - let ctr = try await getContainer() - let width = message.uint64(key: .width) - let height = message.uint64(key: .height) - if id != ctr.container.id { - guard let processInfo = await self.processes[id] else { - throw ContainerizationError(.invalidState, message: "Process \(id) does not exist") - } - - guard let proc = processInfo.process else { - throw ContainerizationError(.invalidState, message: "Process \(id) not started") - } + switch self.state { + case .running: + let id = try message.id() + let ctr = try getContainer() + let width = message.uint64(key: .width) + let height = message.uint64(key: .height) + + if id != ctr.container.id { + guard let processInfo = self.processes[id] else { + throw ContainerizationError( + .invalidState, + message: "Process \(id) does not exist" + ) + } - try await proc.resize(to: .init(width: UInt16(width), height: UInt16(height))) - return message.reply() + guard let proc = processInfo.process else { + throw ContainerizationError( + .invalidState, + message: "Process \(id) not started" + ) } - try await ctr.container.resize(to: .init(width: UInt16(width), height: UInt16(height))) - return message.reply() + try await proc.resize( + to: .init( + width: UInt16(width), + height: UInt16(height)) + ) + } else { + try await ctr.container.resize( + to: .init( + width: UInt16(width), + height: UInt16(height)) + ) } + + return message.reply() + default: + throw ContainerizationError( + .invalidState, + message: "cannot resize: container is not running" + ) } } @@ -635,11 +584,6 @@ public actor SandboxService { public func dial(_ message: XPCMessage) async throws -> XPCMessage { self.log.info("`dial` xpc handler") switch self.state { - case .starting, .created, .stopped, .stopping: - throw ContainerizationError( - .invalidState, - message: "cannot dial: container is not running" - ) case .running, .booted: let port = message.uint64(key: .port) guard port > 0 else { @@ -655,7 +599,125 @@ public actor SandboxService { let reply = message.reply() reply.set(key: .fd, value: fh) return reply + default: + throw ContainerizationError( + .invalidState, + message: "cannot dial: container is not running" + ) + } + } + + private func startInitProcess(lock: AsyncLock.Context) async throws { + let info = try self.getContainer() + let container = info.container + let id = container.id + + guard self.state == .booted else { + throw ContainerizationError( + .invalidState, + message: "container expected to be in booted state, got: \(self.state)" + ) + } + + do { + let io = info.io + + try await container.start() + let waitFunc: ExitMonitor.WaitHandler = { + let code = try await container.wait() + if let out = io.out { + try out.close() + } + if let err = io.err { + try err.close() + } + return code + } + try await self.monitor.track(id: id, waitingOn: waitFunc) + } catch { + try? await self.cleanupContainer() + self.setState(.created) + throw error + } + } + + private func startExecProcess(processId id: String, lock: AsyncLock.Context) async throws { + let container = try self.getContainer().container + guard let processInfo = self.processes[id] else { + throw ContainerizationError(.notFound, message: "Process with id \(id)") + } + + let containerInfo = try self.getContainer() + + let czConfig = self.configureProcessConfig(config: processInfo.config, stdio: processInfo.io, containerConfig: containerInfo.config) + + let process = try await container.exec(id, configuration: czConfig) + try self.setUnderlyingProcess(id, process) + + try await process.start() + + let waitFunc: ExitMonitor.WaitHandler = { + let code = try await process.wait() + if let out = processInfo.io[1] { + try self.closeHandle(out.fileDescriptor) + } + if let err = processInfo.io[2] { + try self.closeHandle(err.fileDescriptor) + } + return code + } + try await self.monitor.track(id: id, waitingOn: waitFunc) + } + + private func startSocketForwarders(containerIpAddress: String, publishedPorts: [PublishPort]) async throws { + var forwarders: [SocketForwarderResult] = [] + try await withThrowingTaskGroup(of: SocketForwarderResult.self) { group in + for publishedPort in publishedPorts { + let proxyAddress = try SocketAddress(ipAddress: publishedPort.hostAddress, port: Int(publishedPort.hostPort)) + let serverAddress = try SocketAddress(ipAddress: containerIpAddress, port: Int(publishedPort.containerPort)) + log.info( + "creating forwarder for", + metadata: [ + "proxy": "\(proxyAddress)", + "server": "\(serverAddress)", + "protocol": "\(publishedPort.proto)", + ]) + group.addTask { + let forwarder: SocketForwarder + switch publishedPort.proto { + case .tcp: + forwarder = try TCPForwarder( + proxyAddress: proxyAddress, + serverAddress: serverAddress, + eventLoopGroup: self.eventLoopGroup, + log: self.log + ) + case .udp: + forwarder = try UDPForwarder( + proxyAddress: proxyAddress, + serverAddress: serverAddress, + eventLoopGroup: self.eventLoopGroup, + log: self.log + ) + } + return try await forwarder.run().get() + } + } + for try await result in group { + forwarders.append(result) + } + } + + self.socketForwarders = forwarders + } + + private func stopSocketForwarders() async { + log.info("closing forwarders") + for forwarder in self.socketForwarders { + forwarder.close() + try? await forwarder.wait() } + log.info("closed forwarders") } private func onContainerExit(id: String, code: Int32) async throws { @@ -664,10 +726,9 @@ public actor SandboxService { try await self.lock.withLock { [self] _ in let ctrInfo = try await getContainer() let ctr = ctrInfo.container - // Did someone explicitly call stop and we're already - // cleaning up? + switch await self.state { - case .stopped(_): + case .stopped(_), .stopping: return default: break @@ -676,7 +737,7 @@ public actor SandboxService { do { try await ctr.stop() } catch { - log.notice("failed to stop sandbox gracefully: \(error)") + self.log.notice("failed to stop sandbox gracefully: \(error)") } do { @@ -685,13 +746,12 @@ public actor SandboxService { self.log.error("failed to cleanup container: \(error)") } await setState(.stopped(code)) + let waiters = await self.waiters[id] ?? [] for cc in waiters { cc.resume(returning: code) } await self.removeWaiters(for: id) - try await self.sendContainerEvent(.containerExit(id: id, exitCode: Int64(code))) - exit(code) } } @@ -866,11 +926,12 @@ public actor SandboxService { return container } - private func gracefulStopContainer(_ lc: LinuxContainer, stopOpts: ContainerStopOptions) async throws { + private func gracefulStopContainer(_ lc: LinuxContainer, stopOpts: ContainerStopOptions) async throws -> Int32 { // Try and gracefully shut down the process. Even if this succeeds we need to power off // the vm, but we should try this first always. + var code: Int32 = 255 do { - try await withThrowingTaskGroup(of: Void.self) { group in + code = try await withThrowingTaskGroup(of: Int32.self) { group in group.addTask { try await lc.wait() } @@ -878,13 +939,24 @@ public actor SandboxService { try await lc.kill(stopOpts.signal) try await Task.sleep(for: .seconds(stopOpts.timeoutInSeconds)) try await lc.kill(SIGKILL) + + return 137 + } + guard let code = try await group.next() else { + throw ContainerizationError( + .internalError, + message: "failed to get exit code from gracefully stopping container" + ) } - try await group.next() group.cancelAll() + + return code } } catch {} + // Now actually bring down the vm. try await lc.stop() + return code } private func cleanupContainer() async throws { @@ -900,17 +972,6 @@ public actor SandboxService { } } } - - private func sendContainerEvent(_ event: ContainerEvent) async throws { - let serviceIdentifier = "com.apple.container.apiserver" - let client = XPCClient(service: serviceIdentifier) - let message = XPCMessage(route: .containerEvent) - - let data = try JSONEncoder().encode(event) - message.set(key: .containerEvent, value: data) - try await client.send(message) - } - } extension XPCMessage { @@ -1088,7 +1149,10 @@ extension SandboxService { self.container = info } - private func addNewProcess(_ id: String, _ config: ProcessConfiguration, _ io: [FileHandle?]) { + private func addNewProcess(_ id: String, _ config: ProcessConfiguration, _ io: [FileHandle?]) throws { + guard self.processes[id] == nil else { + throw ContainerizationError(.invalidArgument, message: "process \(id) already exists") + } self.processes[id] = ProcessInfo(config: config, process: nil, state: .created, io: io) } @@ -1107,13 +1171,21 @@ extension SandboxService { let io: (in: FileHandle?, out: MultiWriter?, err: MultiWriter?) } + /// States the underlying sandbox can be in. public enum State: Sendable, Equatable { + /// Sandbox is created. This should be what the service starts the sandbox in. case created + /// Bootstrap will transition a .created state to .booted. case booted - case starting + /// startProcess on the init process will transition .booted to .running. case running + /// At the beginning of stop() .running will be transitioned to .stopping. case stopping + /// Once a stop is successful, .stopping will transition to .stopped. case stopped(Int32) + /// .shuttingDown will be the last state the sandbox service will ever be in. Shortly + /// afterwards the process will exit. + case shuttingDown } func setState(_ new: State) { From 6eaf51b8bc8768e96f94bcb2192fd117f18c06d2 Mon Sep 17 00:00:00 2001 From: Dmitry Kovba Date: Sat, 20 Sep 2025 00:07:57 -0700 Subject: [PATCH 007/491] Remove Native Builder from the `main` branch (#634) The work on the Native Builder has been moved to a separate branch `dev/native-builder`. This PR removes it from the `main` branch. --- Package.swift | 78 - .../ContainerBuildCache/BuildCache.swift | 148 -- .../ContainerBuildCache/CacheError.swift | 53 - .../ContainerBuildCache/CacheIndex.swift | 289 ---- .../ContainerBuildCache/CacheManifest.swift | 274 --- .../ContainerBuildCache/CacheTypes.swift | 251 --- .../ContentAddressableCache.swift | 400 ----- .../ContainerBuildDemo/Demo.swift | 113 -- .../ContainerBuildDemo/ErrorPresenter.swift | 72 - .../ContainerBuildDemo/Examples.swift | 327 ---- .../ContainerBuildDemo/GraphVisualizer.swift | 421 ----- .../ContainerBuildDemo/main.swift | 20 - .../BuildExecutor.swift | 169 -- .../ExecutionContext.swift | 483 ------ .../ExecutionDispatcher.swift | 238 --- .../ExecutorError.swift | 52 - .../Executors/ExecOperationExecutor.swift | 109 -- .../FilesystemOperationExecutor.swift | 132 -- .../Executors/ImageOperationExecutor.swift | 186 --- .../Executors/MetadataOperationExecutor.swift | 204 --- .../Executors/UnknownOperationExecutor.swift | 79 - .../OperationExecutor.swift | 158 -- .../ReportingHelpers.swift | 204 --- .../ContainerBuildExecutor/Scheduler.swift | 1477 ----------------- .../ContainerBuildIR/Analysis/Analyzer.swift | 44 - .../Analysis/DependencyAnalyzer.swift | 111 -- .../Analysis/SemanticAnalyzer.swift | 482 ------ .../Analysis/ValidatorAdapter.swift | 76 - .../BuildDefinitionError.swift | 47 - .../ContainerBuildIR/Digest.swift | 173 -- .../ContainerBuildIR/Graph/BuildGraph.swift | 467 ------ .../Graph/GraphBuilder+Arg.swift | 177 -- .../ContainerBuildIR/Graph/GraphBuilder.swift | 483 ------ .../Graph/GraphTraversal.swift | 288 ---- .../ContainerBuildIR/Graph/StageBuilder.swift | 95 -- .../Operations/ExecOperation.swift | 365 ---- .../Operations/FilesystemOperation.swift | 313 ---- .../Operations/ImageOperation.swift | 173 -- .../Operations/MetadataOperation.swift | 454 ----- .../Operations/Operation.swift | 288 ---- .../ContainerBuildIR/Platform.swift | 26 - .../ContainerBuildIR/Reference.swift | 291 ---- .../Serialization/IRCoder.swift | 321 ---- .../Validation/Validator.swift | 411 ----- .../Docker/DockerInstructionVisitor.swift | 207 --- .../Docker/DockerfileParser.swift | 495 ------ .../Docker/DockerfileTokenizer.swift | 121 -- .../Docker/Instructions/ArgInstruction.swift | 40 - .../Docker/Instructions/CopyInstruction.swift | 112 -- .../Instructions/DockerInstruction.swift | 81 - .../Instructions/ExposeInstruction.swift | 69 - .../Docker/Instructions/RunInstruction.swift | 389 ----- .../ContainerBuildParser/Types.swift | 44 - .../BaseProgressConsumer.swift | 179 -- .../ContainerBuildReporting/BuildEvent.swift | 189 --- .../BuildStatistics.swift | 123 -- .../JSONProgressConsumer.swift | 312 ---- .../PlainProgressConsumer.swift | 225 --- .../ProgressConsumer.swift | 44 - .../ReportableError.swift | 258 --- .../ContainerBuildReporting/Reporter.swift | 46 - .../BinaryPath.swift | 319 ---- .../ContainerBuildSnapshotter/DiffKey.swift | 473 ------ .../ContainerBuildSnapshotter/Differ.swift | 198 --- .../FileContentDiffer.swift | 65 - .../Snapshotter.swift | 42 - .../ContainerBuildSnapshotter/Types.swift | 193 --- Sources/NativeBuilder/README.md | 52 - .../docs/ContainerBuildCache/Architecture.md | 313 ---- .../ContainerBuildExecutor/Architecture.md | 177 -- .../docs/ContainerBuildIR/Graph.md | 238 --- .../docs/ContainerBuildIR/Operations.md | 231 --- Sources/NativeBuilder/docs/Design.md | 238 --- Sources/NativeBuilder/docs/README.md | 39 - .../CacheIndexTests.swift | 523 ------ .../CacheTestHelpers.swift | 535 ------ .../ContentAddressableCacheTests.swift | 235 --- .../ExecutionDispatcherTests.swift | 265 --- .../MockSnapshotter.swift | 103 -- .../ReporterTests.swift | 212 --- .../ContainerBuildIRTests/AnalysisTests.swift | 612 ------- .../ContainerBuildIRTests/BasicTests.swift | 315 ---- .../DependencyAnalysisTests.swift | 733 -------- .../DigestAndPlatformTests.swift | 323 ---- .../GraphBuilderTests.swift | 655 -------- .../OperationTests.swift | 843 ---------- .../SerializationTests.swift | 622 ------- .../ValidationTests.swift | 928 ----------- .../ParserTests.swift | 1131 ------------- .../TokenizerTests.swift | 652 -------- .../VisitorTests.swift | 117 -- .../BaseProgressConsumerTests.swift | 679 -------- .../BuildEventTests.swift | 626 ------- .../BuildStatisticsTests.swift | 465 ------ .../JSONProgressConsumerTests.swift | 714 -------- .../PlainProgressConsumerTests.swift | 951 ----------- .../ReportableErrorTests.swift | 521 ------ .../SimpleNewTests.swift | 79 - .../BinaryPathTests.swift | 532 ------ .../DiffKeyUnitTests.swift | 323 ---- .../FileContentDifferTests.swift | 86 - .../TestUtils.swift | 82 - 102 files changed, 30396 deletions(-) delete mode 100644 Sources/NativeBuilder/ContainerBuildCache/BuildCache.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildCache/CacheError.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildCache/CacheIndex.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildCache/CacheManifest.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildCache/CacheTypes.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildCache/ContentAddressableCache.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildDemo/Demo.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildDemo/ErrorPresenter.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildDemo/Examples.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildDemo/GraphVisualizer.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildDemo/main.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildExecutor/BuildExecutor.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildExecutor/ExecutionContext.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildExecutor/ExecutionDispatcher.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildExecutor/ExecutorError.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildExecutor/Executors/ExecOperationExecutor.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildExecutor/Executors/FilesystemOperationExecutor.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildExecutor/Executors/ImageOperationExecutor.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildExecutor/Executors/MetadataOperationExecutor.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildExecutor/Executors/UnknownOperationExecutor.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildExecutor/OperationExecutor.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildExecutor/ReportingHelpers.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildExecutor/Scheduler.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildIR/Analysis/Analyzer.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildIR/Analysis/DependencyAnalyzer.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildIR/Analysis/SemanticAnalyzer.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildIR/Analysis/ValidatorAdapter.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildIR/BuildDefinitionError.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildIR/Digest.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildIR/Graph/BuildGraph.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildIR/Graph/GraphBuilder+Arg.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildIR/Graph/GraphBuilder.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildIR/Graph/GraphTraversal.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildIR/Graph/StageBuilder.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildIR/Operations/ExecOperation.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildIR/Operations/FilesystemOperation.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildIR/Operations/ImageOperation.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildIR/Operations/MetadataOperation.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildIR/Operations/Operation.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildIR/Platform.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildIR/Reference.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildIR/Serialization/IRCoder.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildIR/Validation/Validator.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildParser/Docker/DockerInstructionVisitor.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildParser/Docker/DockerfileParser.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildParser/Docker/DockerfileTokenizer.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildParser/Docker/Instructions/ArgInstruction.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildParser/Docker/Instructions/CopyInstruction.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildParser/Docker/Instructions/DockerInstruction.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildParser/Docker/Instructions/ExposeInstruction.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildParser/Docker/Instructions/RunInstruction.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildParser/Types.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildReporting/BaseProgressConsumer.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildReporting/BuildEvent.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildReporting/BuildStatistics.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildReporting/JSONProgressConsumer.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildReporting/PlainProgressConsumer.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildReporting/ProgressConsumer.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildReporting/ReportableError.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildReporting/Reporter.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildSnapshotter/BinaryPath.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildSnapshotter/DiffKey.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildSnapshotter/Differ.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildSnapshotter/FileContentDiffer.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildSnapshotter/Snapshotter.swift delete mode 100644 Sources/NativeBuilder/ContainerBuildSnapshotter/Types.swift delete mode 100644 Sources/NativeBuilder/README.md delete mode 100644 Sources/NativeBuilder/docs/ContainerBuildCache/Architecture.md delete mode 100644 Sources/NativeBuilder/docs/ContainerBuildExecutor/Architecture.md delete mode 100644 Sources/NativeBuilder/docs/ContainerBuildIR/Graph.md delete mode 100644 Sources/NativeBuilder/docs/ContainerBuildIR/Operations.md delete mode 100644 Sources/NativeBuilder/docs/Design.md delete mode 100644 Sources/NativeBuilder/docs/README.md delete mode 100644 Tests/NativeBuilderTests/ContainerBuildCacheTests/CacheIndexTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildCacheTests/CacheTestHelpers.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildCacheTests/ContentAddressableCacheTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildExecutorTests/ExecutionDispatcherTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildExecutorTests/MockSnapshotter.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildExecutorTests/ReporterTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildIRTests/AnalysisTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildIRTests/BasicTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildIRTests/DependencyAnalysisTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildIRTests/DigestAndPlatformTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildIRTests/GraphBuilderTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildIRTests/OperationTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildIRTests/SerializationTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildIRTests/ValidationTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildParserTests/ParserTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildParserTests/TokenizerTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildParserTests/VisitorTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildReportingTests/BaseProgressConsumerTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildReportingTests/BuildEventTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildReportingTests/BuildStatisticsTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildReportingTests/JSONProgressConsumerTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildReportingTests/PlainProgressConsumerTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildReportingTests/ReportableErrorTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildReportingTests/SimpleNewTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildSnapshotterTests/BinaryPathTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildSnapshotterTests/DiffKeyUnitTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildSnapshotterTests/FileContentDifferTests.swift delete mode 100644 Tests/NativeBuilderTests/ContainerBuildSnapshotterTests/TestUtils.swift diff --git a/Package.swift b/Package.swift index a935294a8..4f866a95e 100644 --- a/Package.swift +++ b/Package.swift @@ -42,18 +42,11 @@ let package = Package( .library(name: "ContainerVersion", targets: ["ContainerVersion"]), .library(name: "ContainerXPC", targets: ["ContainerXPC"]), .library(name: "SocketForwarder", targets: ["SocketForwarder"]), - .library(name: "ContainerBuildReporting", targets: ["ContainerBuildReporting"]), - .library(name: "ContainerBuildIR", targets: ["ContainerBuildIR"]), - .library(name: "ContainerBuildExecutor", targets: ["ContainerBuildExecutor"]), - .library(name: "ContainerBuildCache", targets: ["ContainerBuildCache"]), - .library(name: "ContainerBuildSnapshotter", targets: ["ContainerBuildSnapshotter"]), - .library(name: "ContainerBuildParser", targets: ["ContainerBuildParser"]), ], dependencies: [ .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), .package(url: "https://github.com/apple/swift-collections.git", from: "1.2.0"), - .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"), .package(url: "https://github.com/grpc/grpc-swift.git", from: "1.26.0"), .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.29.0"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.80.0"), @@ -276,77 +269,6 @@ let package = Package( "ContainerPersistence", ] ), - .executableTarget( - name: "native-builder-demo", - dependencies: ["ContainerBuildIR", "ContainerBuildExecutor", "ContainerBuildCache"], - path: "Sources/NativeBuilder/ContainerBuildDemo" - ), - .target( - name: "ContainerBuildReporting", - dependencies: [], - path: "Sources/NativeBuilder/ContainerBuildReporting", - ), - .target( - name: "ContainerBuildIR", - dependencies: [ - "ContainerBuildReporting", - .product(name: "ContainerizationOCI", package: "containerization"), - .product(name: "Crypto", package: "swift-crypto"), - ], - path: "Sources/NativeBuilder/ContainerBuildIR", - ), - .target( - name: "ContainerBuildExecutor", - dependencies: [ - "ContainerBuildReporting", - "ContainerBuildIR", - "ContainerBuildSnapshotter", - "ContainerBuildCache", - .product(name: "ContainerizationOCI", package: "containerization"), - ], - path: "Sources/NativeBuilder/ContainerBuildExecutor", - ), - .target( - name: "ContainerBuildCache", - dependencies: [ - "ContainerBuildIR", - "ContainerBuildSnapshotter", - .product(name: "ContainerizationOCI", package: "containerization"), - .product(name: "ContainerizationExtras", package: "containerization"), - .product(name: "Crypto", package: "swift-crypto"), - ], - path: "Sources/NativeBuilder/ContainerBuildCache", - ), - .target( - name: "ContainerBuildSnapshotter", - dependencies: [ - "ContainerBuildIR", - .product(name: "ContainerizationOCI", package: "containerization"), - .product(name: "ContainerizationArchive", package: "containerization"), - .product(name: "Crypto", package: "swift-crypto"), - "ContainerClient", - ], - path: "Sources/NativeBuilder/ContainerBuildSnapshotter", - ), - .target( - name: "ContainerBuildParser", - dependencies: [ - "ContainerBuildIR" - ], - path: "Sources/NativeBuilder/ContainerBuildParser", - ), - .testTarget( - name: "NativeBuilderTests", - dependencies: [ - "ContainerBuildIR", - "ContainerBuildExecutor", - "ContainerBuildCache", - "ContainerBuildReporting", - "ContainerBuildParser", - "ContainerBuildSnapshotter", - "ContainerClient", - ] - ), .target( name: "ContainerPersistence", dependencies: [ diff --git a/Sources/NativeBuilder/ContainerBuildCache/BuildCache.swift b/Sources/NativeBuilder/ContainerBuildCache/BuildCache.swift deleted file mode 100644 index 51edcf838..000000000 --- a/Sources/NativeBuilder/ContainerBuildCache/BuildCache.swift +++ /dev/null @@ -1,148 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import ContainerBuildSnapshotter -import ContainerizationOCI -import Foundation - -/// Protocol for build cache implementations. -/// -/// The cache stores operation results to avoid redundant execution. -/// -/// PERFORMANCE OPTIMIZATION OPPORTUNITIES (from Design.md): -/// -/// 1. Graph-Level Cache Analysis: -/// - Add a method to check multiple cache keys in parallel before execution starts -/// - Return a map of cache hits/misses for the entire build graph -/// - Example: func checkBatch(_ keys: [CacheKey]) async -> [CacheKey: Bool] -/// -/// 2. Cache-Aware Graph Transformation: -/// - Implement a CachedScheduler that pre-processes the build graph -/// - Replace cached operations with lightweight cache retrieval operations -/// - Skip entire dependency chains if final results are cached -/// -/// 3. Speculative Cache Warming: -/// - Add methods to prefetch likely cache entries based on build patterns -/// - Support background cache population for common operations -/// -/// 4. Parallel Cache Operations: -/// - Batch get/put operations for multiple artifacts -/// - Use Swift concurrency for non-blocking cache access -/// - Example: func getBatch(_ keys: [CacheKey]) async -> [CacheKey: CachedResult?] -/// -/// 5. Cache Metadata for Scheduling: -/// - Expose cache hit probability estimates -/// - Provide operation cost estimates based on historical data -/// - Enable scheduler to make smarter execution decisions -/// -/// Current implementation is functionally correct but checks cache sequentially -/// during execution rather than optimizing the execution graph based on cache state. -public protocol BuildCache: Sendable { - /// Look up a cached result for an operation. - /// - /// - Parameters: - /// - key: The cache key for the operation - /// - operation: The operation being cached - /// - Returns: The cached result if found, nil otherwise - func get(_ key: CacheKey, for operation: ContainerBuildIR.Operation) async -> CachedResult? - - /// Store a result in the cache. - /// - /// - Parameters: - /// - result: The result to cache - /// - key: The cache key - /// - operation: The operation that produced the result - func put(_ result: CachedResult, key: CacheKey, for operation: ContainerBuildIR.Operation) async - - /// Get cache statistics. - /// - /// - Returns: Statistics about cache usage and performance - func statistics() async -> CacheStatistics -} - -/// A key for cache lookups. -public struct CacheKey: Hashable, Sendable { - /// The digest of the operation. - public let operationDigest: ContainerBuildIR.Digest - - /// Input digests from dependencies. - public let inputDigests: [ContainerBuildIR.Digest] - - /// Platform identifier. - public let platform: Platform - - public init( - operationDigest: ContainerBuildIR.Digest, - inputDigests: [ContainerBuildIR.Digest] = [], - platform: Platform - ) { - self.operationDigest = operationDigest - self.inputDigests = inputDigests - self.platform = platform - } -} - -/// A cached execution result. -public struct CachedResult: Sendable { - /// The snapshot produced by the operation. - public let snapshot: Snapshot - - /// Environment changes made by the operation. - public let environmentChanges: [String: EnvironmentValue] - - /// Metadata changes. - public let metadataChanges: [String: String] - - public init( - snapshot: Snapshot, - environmentChanges: [String: EnvironmentValue] = [:], - metadataChanges: [String: String] = [:] - ) { - self.snapshot = snapshot - self.environmentChanges = environmentChanges - self.metadataChanges = metadataChanges - } -} - -/// A no-op cache implementation that never caches. -public struct NoOpBuildCache: BuildCache { - public init() {} - - public func get(_ key: CacheKey, for operation: ContainerBuildIR.Operation) async -> CachedResult? { - nil - } - - public func put(_ result: CachedResult, key: CacheKey, for operation: ContainerBuildIR.Operation) async { - // No-op - } - - public func statistics() async -> CacheStatistics { - CacheStatistics( - entryCount: 0, - totalSize: 0, - hitRate: 0, - oldestEntryAge: 0, - mostRecentEntryAge: 0, - evictionPolicy: "none", - averageEntrySize: 0, - operationMetrics: .empty, - errorCount: 0, - lastGCTime: nil, - shardInfo: nil - ) - } -} diff --git a/Sources/NativeBuilder/ContainerBuildCache/CacheError.swift b/Sources/NativeBuilder/ContainerBuildCache/CacheError.swift deleted file mode 100644 index f04987b07..000000000 --- a/Sources/NativeBuilder/ContainerBuildCache/CacheError.swift +++ /dev/null @@ -1,53 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import Foundation - -/// An error related to cache operations. -public enum CacheError: Error, LocalizedError { - /// A cache entry was expected but not found. This is often a normal cache miss. - case itemNotFound(key: String) - - /// The manifest file for the cache is corrupted or unreadable. - case manifestUnreadable(path: String, underlyingError: Error) - - /// A file was read from the cache, but its content hash did not match the expected digest. - case digestMismatch(expected: Digest, actual: Digest) - - /// An error occurred while trying to write an item to the cache storage. - case storageFailed(path: String, underlyingError: Error) - - /// Failed to encode cache-related data as UTF-8. - case encodingFailed(String) - - // MARK: - LocalizedError Conformance - - public var errorDescription: String? { - switch self { - case .itemNotFound(let key): - return "Item with key '\(key)' not found in cache." - case .manifestUnreadable(let path, _): - return "Failed to read cache manifest at '\(path)'." - case .digestMismatch(let expected, let actual): - return "Cache integrity check failed: content digest \(actual) does not match expected digest \(expected)." - case .storageFailed(let path, _): - return "Failed to write to cache storage at '\(path)'." - case .encodingFailed(let details): - return "Failed to encode cache data: \(details)" - } - } -} diff --git a/Sources/NativeBuilder/ContainerBuildCache/CacheIndex.swift b/Sources/NativeBuilder/ContainerBuildCache/CacheIndex.swift deleted file mode 100644 index f772c2303..000000000 --- a/Sources/NativeBuilder/ContainerBuildCache/CacheIndex.swift +++ /dev/null @@ -1,289 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import ContainerizationExtras -import ContainerizationOCI -import Foundation - -// MARK: - Cache Index - -/// Actor-based cache index that manages cache metadata with atomic updates -public actor CacheIndex: Sendable { - private let path: URL - - /// Cache index state persisted to cache.json - struct State: Codable { - var entries: [String: CacheEntry] - var version: Int - var statistics: Statistics - - struct Statistics: Codable { - var totalSize: UInt64 - var entryCount: Int - var hitCount: Int64 - var missCount: Int64 - var evictionCount: Int64 - var lastModified: Date - var lastGC: Date? - } - - static let empty = State( - entries: [:], - version: 1, - statistics: Statistics( - totalSize: 0, - entryCount: 0, - hitCount: 0, - missCount: 0, - evictionCount: 0, - lastModified: Date(), - lastGC: nil - ) - ) - } - - public init(path: URL) throws { - try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true) - self.path = path - } - - // MARK: - Public Methods - - /// Add or update a cache entry - public func put( - key: String, - descriptor: Descriptor, - metadata: CacheMetadata - ) async throws { - var state = try self.load() - - // Update or create entry - let entry = CacheEntry( - descriptor: descriptor, - metadata: metadata - ) - - // Update statistics - if state.entries[key] == nil { - state.statistics.entryCount += 1 - } - state.entries[key] = entry - state.statistics.totalSize = calculateTotalSize(state.entries) - state.statistics.lastModified = Date() - - try self.save(state) - } - - /// Get a cache entry and update access time - public func get(key: String) async throws -> CacheEntry? { - var state = try self.load() - - guard var entry = state.entries[key] else { - state.statistics.missCount += 1 - try self.save(state) - return nil - } - - // Update access time - entry.metadata.accessedAt = Date() - state.entries[key] = entry - state.statistics.hitCount += 1 - state.statistics.lastModified = Date() - - try self.save(state) - return entry - } - - /// Remove cache entries - public func remove(keys: [String]) async throws { - var state = try self.load() - - for key in keys { - if state.entries.removeValue(forKey: key) != nil { - state.statistics.entryCount -= 1 - state.statistics.evictionCount += 1 - } - } - - state.statistics.totalSize = calculateTotalSize(state.entries) - state.statistics.lastModified = Date() - - try self.save(state) - } - - /// Get all cache entries - public func allEntries() async throws -> [String: CacheEntry] { - let state = try self.load() - return state.entries - } - - /// Get cache statistics - public func statistics() async throws -> CacheStatistics { - let state = try self.load() - - // Calculate derived statistics - let hitRate = - state.statistics.hitCount + state.statistics.missCount > 0 - ? Double(state.statistics.hitCount) / Double(state.statistics.hitCount + state.statistics.missCount) - : 0.0 - - let ages = state.entries.values.map { entry in - Date().timeIntervalSince(entry.metadata.createdAt) - }.sorted() - - let oldestAge = ages.last ?? 0 - let newestAge = ages.first ?? 0 - - let avgSize = - state.statistics.entryCount > 0 - ? state.statistics.totalSize / UInt64(state.statistics.entryCount) - : 0 - - return CacheStatistics( - entryCount: state.statistics.entryCount, - totalSize: state.statistics.totalSize, - hitRate: hitRate, - oldestEntryAge: oldestAge, - mostRecentEntryAge: newestAge, - evictionPolicy: "lru", - averageEntrySize: avgSize, - operationMetrics: .empty, - errorCount: 0, - lastGCTime: state.statistics.lastGC, - shardInfo: nil - ) - } - - // MARK: - File Operations - - /// Load cache index from cache.json - private func load() throws -> State { - let indexPath = self.path.appendingPathComponent("cache.json") - - guard FileManager.default.fileExists(atPath: indexPath.path) else { - return .empty - } - - do { - let data = try Data(contentsOf: indexPath) - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try decoder.decode(State.self, from: data) - } catch { - // Handle corrupted index by starting fresh - print("Warning: Cache index corrupted, starting with empty cache: \(error.localizedDescription)") - - // Try to backup the corrupted file for debugging - let backupPath = indexPath.appendingPathExtension("corrupted") - try? FileManager.default.moveItem(at: indexPath, to: backupPath) - - // Return empty state to start fresh - return .empty - } - } - - /// Save cache index to cache.json atomically - private func save(_ state: State) throws { - let indexPath = self.path.appendingPathComponent("cache.json") - let tempPath = indexPath.appendingPathExtension("tmp") - - do { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - - let data = try encoder.encode(state) - try data.write(to: tempPath, options: .atomic) - - // Atomic rename - _ = try FileManager.default.replaceItem(at: indexPath, withItemAt: tempPath, backupItemName: nil, options: [], resultingItemURL: nil) - } catch { - // Clean up temp file if it exists - try? FileManager.default.removeItem(at: tempPath) - throw CacheError.storageFailed(path: tempPath.path, underlyingError: error) - } - } - - // MARK: - Helper Methods - - private func calculateTotalSize(_ entries: [String: CacheEntry]) -> UInt64 { - entries.values.reduce(0) { total, entry in - total + UInt64(entry.descriptor.size) - } - } -} - -// MARK: - Cache Entry - -/// Individual cache entry containing manifest descriptor and metadata -public struct CacheEntry: Codable, Sendable { - /// OCI descriptor for the cache manifest - public let descriptor: Descriptor - - /// Cache metadata - public var metadata: CacheMetadata - - public init(descriptor: Descriptor, metadata: CacheMetadata) { - self.descriptor = descriptor - self.metadata = metadata - } -} - -// MARK: - Cache Metadata - -/// Metadata associated with a cache entry -public struct CacheMetadata: Codable, Sendable { - /// When the entry was created - public let createdAt: Date - - /// When the entry was last accessed - public var accessedAt: Date - - /// Hash of the operation that created this cache entry - public let operationHash: String - - /// Platform this cache entry is for - public let platform: Platform - - /// Time-to-live in seconds (nil means no expiration) - public let ttl: TimeInterval? - - /// Custom tags for filtering - public let tags: [String: String] - - public init( - createdAt: Date = Date(), - accessedAt: Date = Date(), - operationHash: String, - platform: Platform, - ttl: TimeInterval? = nil, - tags: [String: String] = [:] - ) { - self.createdAt = createdAt - self.accessedAt = accessedAt - self.operationHash = operationHash - self.platform = platform - self.ttl = ttl - self.tags = tags - } - - /// Check if the entry has expired - public var isExpired: Bool { - guard let ttl = ttl else { return false } - return Date().timeIntervalSince(createdAt) > ttl - } -} diff --git a/Sources/NativeBuilder/ContainerBuildCache/CacheManifest.swift b/Sources/NativeBuilder/ContainerBuildCache/CacheManifest.swift deleted file mode 100644 index 4e3ca6401..000000000 --- a/Sources/NativeBuilder/ContainerBuildCache/CacheManifest.swift +++ /dev/null @@ -1,274 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import ContainerBuildSnapshotter -import ContainerizationOCI -import Foundation - -// MARK: - Cache Manifest Types - -/// OCI-compliant cache manifest stored in ContentStore -struct CacheManifest: Codable, Sendable { - let schemaVersion: Int - let mediaType: String - let config: CacheConfig - let annotations: [String: String] - let subject: Descriptor? - - /// Snapshot embedded directly in manifest - let snapshot: Snapshot? - - /// Environment changes embedded directly in manifest - let environmentChanges: [String: EnvironmentValue] - - /// Metadata changes embedded directly in manifest - let metadataChanges: [String: String] - - static let currentSchemaVersion = 5 // Incremented for direct Snapshot storage - static let manifestMediaType = "application/vnd.container-build.cache.manifest.v5+json" - - init( - schemaVersion: Int = CacheManifest.currentSchemaVersion, - mediaType: String = CacheManifest.manifestMediaType, - config: CacheConfig, - annotations: [String: String] = [:], - subject: Descriptor? = nil, - snapshot: Snapshot? = nil, - environmentChanges: [String: EnvironmentValue] = [:], - metadataChanges: [String: String] = [:] - ) { - self.schemaVersion = schemaVersion - self.mediaType = mediaType - self.config = config - self.annotations = annotations - self.subject = subject - self.snapshot = snapshot - self.environmentChanges = environmentChanges - self.metadataChanges = metadataChanges - } -} - -/// Cache configuration embedded in manifest -struct CacheConfig: Codable, Sendable { - let cacheKey: SerializedCacheKey - let operationType: String - let platform: Platform - let buildVersion: String - let createdAt: Date - - init( - cacheKey: SerializedCacheKey, - operationType: String, - platform: Platform, - buildVersion: String, - createdAt: Date = Date() - ) { - self.cacheKey = cacheKey - self.operationType = operationType - self.platform = platform - self.buildVersion = buildVersion - self.createdAt = createdAt - } -} - -/// Serializable version of CacheKey for storage -struct SerializedCacheKey: Codable, Sendable { - let operationDigest: String - let inputDigests: [String] - let platform: PlatformData - - struct PlatformData: Codable, Sendable { - let os: String - let architecture: String - let variant: String? - let osVersion: String? - let osFeatures: [String]? - } - - init(from key: CacheKey) { - self.operationDigest = key.operationDigest.stringValue - self.inputDigests = key.inputDigests.map { $0.stringValue } - self.platform = PlatformData( - os: key.platform.os, - architecture: key.platform.architecture, - variant: key.platform.variant, - osVersion: key.platform.osVersion, - osFeatures: key.platform.osFeatures.map { Array($0) } - ) - } -} - -// MARK: - Manifest Extensions - -extension CacheManifest { - /// Create a manifest with subject reference (for linking to base images) - func withSubject(_ subject: Descriptor) -> CacheManifest { - CacheManifest( - schemaVersion: schemaVersion, - mediaType: mediaType, - config: config, - annotations: annotations, - subject: subject, - snapshot: snapshot, - environmentChanges: environmentChanges, - metadataChanges: metadataChanges - ) - } - - /// Add or update annotation - func withAnnotation(key: String, value: String) -> CacheManifest { - var newAnnotations = annotations - newAnnotations[key] = value - - return CacheManifest( - schemaVersion: schemaVersion, - mediaType: mediaType, - config: config, - annotations: newAnnotations, - subject: subject, - snapshot: snapshot, - environmentChanges: environmentChanges, - metadataChanges: metadataChanges - ) - } - - /// Add or update environment changes - func withEnvironmentChanges(_ changes: [String: EnvironmentValue]) -> CacheManifest { - var newEnvironmentChanges = environmentChanges - for (key, value) in changes { - newEnvironmentChanges[key] = value - } - - return CacheManifest( - schemaVersion: schemaVersion, - mediaType: mediaType, - config: config, - annotations: annotations, - subject: subject, - snapshot: snapshot, - environmentChanges: newEnvironmentChanges, - metadataChanges: metadataChanges - ) - } - - /// Add or update metadata changes - func withMetadataChanges(_ changes: [String: String]) -> CacheManifest { - var newMetadataChanges = metadataChanges - for (key, value) in changes { - newMetadataChanges[key] = value - } - - return CacheManifest( - schemaVersion: schemaVersion, - mediaType: mediaType, - config: config, - annotations: annotations, - subject: subject, - snapshot: snapshot, - environmentChanges: environmentChanges, - metadataChanges: newMetadataChanges - ) - } - - /// Check if manifest has a snapshot - var hasSnapshot: Bool { - snapshot != nil - } - - /// Check if manifest has environment changes - var hasEnvironmentChanges: Bool { - !environmentChanges.isEmpty - } - - /// Check if manifest has metadata changes - var hasMetadataChanges: Bool { - !metadataChanges.isEmpty - } - - /// Set or update the snapshot - func withSnapshot(_ snapshot: Snapshot) -> CacheManifest { - CacheManifest( - schemaVersion: schemaVersion, - mediaType: mediaType, - config: config, - annotations: annotations, - subject: subject, - snapshot: snapshot, - environmentChanges: environmentChanges, - metadataChanges: metadataChanges - ) - } - - /// Create a manifest with combined snapshot, environment and metadata changes - func withChanges( - snapshot: Snapshot? = nil, - environment: [String: EnvironmentValue] = [:], - metadata: [String: String] = [:] - ) -> CacheManifest { - var newEnvironmentChanges = environmentChanges - var newMetadataChanges = metadataChanges - - for (key, value) in environment { - newEnvironmentChanges[key] = value - } - - for (key, value) in metadata { - newMetadataChanges[key] = value - } - - return CacheManifest( - schemaVersion: schemaVersion, - mediaType: mediaType, - config: config, - annotations: annotations, - subject: subject, - snapshot: snapshot ?? self.snapshot, - environmentChanges: newEnvironmentChanges, - metadataChanges: newMetadataChanges - ) - } -} - -// MARK: - Descriptor Extensions - -extension Descriptor { - /// Create a descriptor for cache content - static func forCacheContent( - mediaType: String, - digest: String, - size: Int64, - compressed: Bool = false, - annotations: [String: String]? = nil - ) -> Descriptor { - var finalMediaType = mediaType - if compressed && !mediaType.contains("+") { - // Detect compression from annotations if not in media type - if let compressionType = annotations?["com.apple.container-build.compression"] { - finalMediaType += "+\(compressionType)" - } - } - - return Descriptor( - mediaType: finalMediaType, - digest: digest, - size: size, - urls: nil, - annotations: annotations, - platform: nil - ) - } -} diff --git a/Sources/NativeBuilder/ContainerBuildCache/CacheTypes.swift b/Sources/NativeBuilder/ContainerBuildCache/CacheTypes.swift deleted file mode 100644 index dcffdba48..000000000 --- a/Sources/NativeBuilder/ContainerBuildCache/CacheTypes.swift +++ /dev/null @@ -1,251 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import ContainerizationOCI -import Foundation - -// MARK: - Configuration Types - -/// Cache configuration -public struct CacheConfiguration: Sendable { - /// Maximum cache size in bytes - public let maxSize: UInt64 - - /// Maximum age for cache entries - public let maxAge: TimeInterval - - /// Index database path - public let indexPath: URL - - /// Eviction policy - public let evictionPolicy: EvictionPolicy - - /// Concurrency limits - public let concurrency: ConcurrencyConfiguration - - /// Integrity verification - public let verifyIntegrity: Bool - - /// Shard configuration for distributed caching - public let sharding: ShardingConfiguration? - - /// Garbage collection interval - public let gcInterval: TimeInterval - - /// Cache key version for invalidation - public let cacheKeyVersion: String - - /// Default TTL for cache entries - public let defaultTTL: TimeInterval? - - public init( - maxSize: UInt64 = 10 * 1024 * 1024 * 1024, // 10GB default - maxAge: TimeInterval = 7 * 24 * 60 * 60, // 7 days default - indexPath: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! - .appendingPathComponent("com.apple.container-build.cache.db"), - evictionPolicy: EvictionPolicy = .lru, - concurrency: ConcurrencyConfiguration = .default, - verifyIntegrity: Bool = true, - sharding: ShardingConfiguration? = nil, - gcInterval: TimeInterval = 3600, // 1 hour - cacheKeyVersion: String = "v1", - defaultTTL: TimeInterval? = nil - ) { - self.maxSize = maxSize - self.maxAge = maxAge - self.indexPath = indexPath - self.evictionPolicy = evictionPolicy - self.concurrency = concurrency - self.verifyIntegrity = verifyIntegrity - self.sharding = sharding - self.gcInterval = gcInterval - self.cacheKeyVersion = cacheKeyVersion - self.defaultTTL = defaultTTL - } -} - -public enum EvictionPolicy: String, Sendable { - case lru = "lru" // Least Recently Used - case lfu = "lfu" // Least Frequently Used - case fifo = "fifo" // First In First Out - case ttl = "ttl" // Time To Live based - case arc = "arc" // Adaptive Replacement Cache -} - -public struct ConcurrencyConfiguration: Sendable { - public let maxConcurrentReads: Int - public let maxConcurrentWrites: Int - public let maxConcurrentEvictions: Int - - public static let `default` = ConcurrencyConfiguration( - maxConcurrentReads: 100, - maxConcurrentWrites: 10, - maxConcurrentEvictions: 2 - ) - - public init(maxConcurrentReads: Int, maxConcurrentWrites: Int, maxConcurrentEvictions: Int) { - self.maxConcurrentReads = maxConcurrentReads - self.maxConcurrentWrites = maxConcurrentWrites - self.maxConcurrentEvictions = maxConcurrentEvictions - } -} - -public struct ShardingConfiguration: Sendable { - public let shardCount: Int - public let shardId: Int - public let consistentHashing: Bool - - public init(shardCount: Int, shardId: Int, consistentHashing: Bool = true) { - self.shardCount = shardCount - self.shardId = shardId - self.consistentHashing = consistentHashing - } -} - -// MARK: - Cache Entry Types - -/// Cache index entry for tracking -struct CacheIndexEntry: Sendable, Codable { - let digest: String - let manifestSize: Int64 - let totalSize: UInt64 - let createdAt: Date - var lastAccessedAt: Date - var accessCount: Int64 - let platform: Platform - let operationType: String - let contentDigests: [String] - let transaction: UUID? - - var age: TimeInterval { - Date().timeIntervalSince(createdAt) - } -} - -/// Normalized platform for consistent hashing -struct NormalizedPlatform: Codable { - let os: String - let architecture: String - let variant: String? - let osVersion: String? - let osFeatures: [String]? -} - -// MARK: - Statistics Types - -/// Enhanced cache statistics -public struct CacheStatistics: Sendable { - public let entryCount: Int - public let totalSize: UInt64 - public let hitRate: Double - public let oldestEntryAge: TimeInterval - public let mostRecentEntryAge: TimeInterval - public let evictionPolicy: String - public let averageEntrySize: UInt64 - public let operationMetrics: OperationMetrics - public let errorCount: Int - public let lastGCTime: Date? - public let shardInfo: ShardInfo? - - public static let empty = CacheStatistics( - entryCount: 0, - totalSize: 0, - hitRate: 0, - oldestEntryAge: 0, - mostRecentEntryAge: 0, - evictionPolicy: "none", - averageEntrySize: 0, - operationMetrics: .empty, - errorCount: 0, - lastGCTime: nil, - shardInfo: nil - ) -} - -public struct OperationMetrics: Sendable { - public let totalOperations: Int64 - public let averageGetDuration: TimeInterval - public let averagePutDuration: TimeInterval - public let p95GetDuration: TimeInterval - public let p95PutDuration: TimeInterval - - public static let empty = OperationMetrics( - totalOperations: 0, - averageGetDuration: 0, - averagePutDuration: 0, - p95GetDuration: 0, - p95PutDuration: 0 - ) -} - -public struct ShardInfo: Sendable { - public let shardId: Int - public let totalShards: Int -} - -// MARK: - Operation Types - -/// Clear filter for selective cache clearing -public struct ClearFilter: Sendable { - public let platform: Platform? - public let operationType: String? - public let olderThan: Date? - public let pattern: String? - - public init( - platform: Platform? = nil, - operationType: String? = nil, - olderThan: Date? = nil, - pattern: String? = nil - ) { - self.platform = platform - self.operationType = operationType - self.olderThan = olderThan - self.pattern = pattern - } -} - -// MARK: - Eviction Types - -enum EvictionReason: String { - case sizeLimit = "size_limit" - case expired = "expired" - case manual = "manual" - case lowMemory = "low_memory" -} - -// MARK: - Extensions - -extension Array { - func chunked(into size: Int) -> [[Element]] { - stride(from: 0, to: count, by: size).map { - Array(self[$0.. String { - map { String(format: "%02x", $0) }.joined() - } -} - -extension ContainerBuildIR.Digest { - func hexEncodedString() -> String { - self.bytes.map { String(format: "%02x", $0) }.joined() - } -} diff --git a/Sources/NativeBuilder/ContainerBuildCache/ContentAddressableCache.swift b/Sources/NativeBuilder/ContainerBuildCache/ContentAddressableCache.swift deleted file mode 100644 index cf434688a..000000000 --- a/Sources/NativeBuilder/ContainerBuildCache/ContentAddressableCache.swift +++ /dev/null @@ -1,400 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import ContainerBuildSnapshotter -import ContainerizationOCI -import Crypto -import Foundation - -/// Content-addressable cache implementation using ContentStore as backing storage. -/// -/// This implementation stores cache entries as OCI artifacts with manifests -/// pointing to content layers. It provides atomic operations, deduplication, -/// and efficient eviction policies. -public actor ContentAddressableCache: BuildCache { - private let contentStore: ContentStore - private let index: CacheIndex - private let configuration: CacheConfiguration - private var evictionTask: Task? - - /// Initialize a new content-addressable cache. - /// - /// - Parameters: - /// - contentStore: The content store for storage - /// - configuration: Cache configuration - public init( - contentStore: ContentStore, - configuration: CacheConfiguration = CacheConfiguration() - ) async throws { - self.contentStore = contentStore - self.configuration = configuration - do { - self.index = try CacheIndex(path: configuration.indexPath) - } catch { - throw CacheError.manifestUnreadable(path: configuration.indexPath.path, underlyingError: error) - } - - // Start background eviction task - self.evictionTask = Task { [weak self] in - await self?.runPeriodicEviction() - } - } - - deinit { - evictionTask?.cancel() - } - - // MARK: - BuildCache Protocol Implementation - - public func get(_ key: CacheKey, for operation: ContainerBuildIR.Operation) async -> CachedResult? { - // Generate cache digest - guard let digest = try? generateCacheDigest(from: key) else { - return nil - } - - // Check index - guard let entry = try? await index.get(key: digest) else { - return nil - } - - // Fetch manifest from content store using the manifest digest from the index - guard let manifest: CacheManifest = try? await contentStore.get(digest: entry.descriptor.digest) else { - // Clean up orphaned index entry - try? await index.remove(keys: [digest]) - return nil - } - - // Reconstruct result from manifest - guard let result = try? await reconstructResult(from: manifest) else { - return nil - } - - // Update access time - var updatedMetadata = entry.metadata - updatedMetadata.accessedAt = Date() - try? await index.put(key: digest, descriptor: entry.descriptor, metadata: updatedMetadata) - - return result - } - - public func put(_ result: CachedResult, key: CacheKey, for operation: ContainerBuildIR.Operation) async { - // Generate cache digest - guard let digest = try? generateCacheDigest(from: key) else { - return - } - - // Check if already exists - if let _ = try? await index.get(key: digest) { - return - } - - do { - // Start new ingest session - let (sessionId, ingestDir) = try await contentStore.newIngestSession() - - do { - // Create content writer for this session - let writer = try ContentWriter(for: ingestDir) - - // Create manifest with embedded snapshot, environment and metadata - let manifest = createManifest( - key: key, - operation: operation, - snapshot: result.snapshot, - environmentChanges: result.environmentChanges, - metadataChanges: result.metadataChanges - ) - - // Write manifest - let (manifestSize, manifestDigest) = try writer.create(from: manifest) - - // Complete ingest session - _ = try await contentStore.completeIngestSession(sessionId) - - let descriptor = Descriptor( - mediaType: manifest.mediaType, - digest: manifestDigest.digestString, - size: manifestSize - ) - - let operationHash: String - do { - operationHash = try operation.contentDigest().stringValue - } catch { - // If we can't compute the operation hash, use a fallback - operationHash = "unknown" - } - - let metadata = CacheMetadata( - createdAt: Date(), - accessedAt: Date(), - operationHash: operationHash, - platform: key.platform, - ttl: configuration.defaultTTL, - tags: [:] - ) - - try await index.put(key: digest, descriptor: descriptor, metadata: metadata) - - // Trigger eviction if needed - Task { [weak self] in - await self?.checkAndEvict() - } - - } catch { - // Cancel ingest session on error - try? await contentStore.cancelIngestSession(sessionId) - throw error - } - } catch { - // Log error but don't propagate - caching should not fail builds - print("Cache put failed: \(error)") - } - } - - public func has(key: CacheKey) async -> Bool { - guard let digest = try? generateCacheDigest(from: key) else { - return false - } - return (try? await index.get(key: digest)) != nil - } - - public func evict(keys: [CacheKey]) async { - for key in keys { - guard let digest = try? generateCacheDigest(from: key) else { - continue - } - - if let entry = try? await index.get(key: digest) { - // Only need to delete the manifest itself now (no separate layers) - _ = try? await contentStore.delete(digests: [entry.descriptor.digest]) - - // Remove from index - try? await index.remove(keys: [digest]) - } - } - } - - public func statistics() async -> CacheStatistics { - let stats = (try? await index.statistics()) ?? CacheStatistics.empty - - return CacheStatistics( - entryCount: stats.entryCount, - totalSize: stats.totalSize, - hitRate: stats.hitRate, - oldestEntryAge: stats.oldestEntryAge, - mostRecentEntryAge: stats.mostRecentEntryAge, - evictionPolicy: "lru", - averageEntrySize: stats.entryCount > 0 ? stats.totalSize / UInt64(stats.entryCount) : 0, - operationMetrics: .empty, - errorCount: 0, - lastGCTime: nil, - shardInfo: nil - ) - } - - // MARK: - Private Methods - - /// Generate a deterministic cache digest from the cache key. - /// - Throws: CacheError.encodingFailed if UTF-8 encoding fails - private func generateCacheDigest(from key: CacheKey) throws -> String { - var hasher = SHA256() - - // Version prefix for cache invalidation - guard let versionData = configuration.cacheKeyVersion.data(using: .utf8) else { - // This should never happen as cacheKeyVersion is controlled internally - throw CacheError.encodingFailed("Failed to encode cache key version as UTF-8: \(configuration.cacheKeyVersion)") - } - hasher.update(data: versionData) - - // Operation digest - hasher.update(data: key.operationDigest.bytes) - - // Sorted input digests for determinism - for digest in key.inputDigests.sorted(by: { $0.stringValue < $1.stringValue }) { - hasher.update(data: digest.bytes) - } - - // Platform data - let platformData = encodePlatform(key.platform) - hasher.update(data: platformData) - - let digest = hasher.finalize() - let hexString = digest.map { String(format: "%02x", $0) }.joined() - return "sha256:\(hexString)" - } - - /// Encode platform to canonical form for hashing. - private func encodePlatform(_ platform: Platform) -> Data { - let normalized = NormalizedPlatform( - os: platform.os, - architecture: platform.architecture, - variant: platform.variant, - osVersion: platform.osVersion, - osFeatures: platform.osFeatures?.sorted() - ) - - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - return (try? encoder.encode(normalized)) ?? Data() - } - - /// Create cache manifest with embedded snapshot, environment and metadata. - private func createManifest( - key: CacheKey, - operation: ContainerBuildIR.Operation, - snapshot: Snapshot? = nil, - environmentChanges: [String: EnvironmentValue] = [:], - metadataChanges: [String: String] = [:] - ) -> CacheManifest { - CacheManifest( - schemaVersion: CacheManifest.currentSchemaVersion, - mediaType: CacheManifest.manifestMediaType, - config: CacheConfig( - cacheKey: SerializedCacheKey(from: key), - operationType: String(describing: type(of: operation)), - platform: key.platform, - buildVersion: "1.0.0" - ), - annotations: [ - "com.apple.container-build.created": ISO8601DateFormatter().string(from: Date()), - "com.apple.container-build.cache-version": configuration.cacheKeyVersion, - ], - subject: nil, - snapshot: snapshot, - environmentChanges: environmentChanges, - metadataChanges: metadataChanges - ) - } - - /// Reconstruct cached result from manifest. - private func reconstructResult(from manifest: CacheManifest) async throws -> CachedResult { - // Snapshot is embedded directly in the manifest - guard let snapshot = manifest.snapshot else { - throw CacheError.storageFailed( - path: "Missing snapshot", underlyingError: NSError(domain: "Cache", code: 500, userInfo: [NSLocalizedDescriptionKey: "No snapshot found in manifest"])) - } - - // Environment and metadata are also embedded directly in the manifest - return CachedResult( - snapshot: snapshot, - environmentChanges: manifest.environmentChanges, - metadataChanges: manifest.metadataChanges - ) - } - - /// Check if eviction is needed and trigger it. - private func checkAndEvict() async { - let stats = try? await index.statistics() - - guard let stats = stats else { return } - - // Check if we need to evict based on size - if stats.totalSize > configuration.maxSize { - await performEviction(targetSize: UInt64(Double(configuration.maxSize) * 0.8)) - } - } - - /// Perform cache eviction to reach target size. - private func performEviction(targetSize: UInt64) async { - let stats = try? await index.statistics() - guard let currentSize = stats?.totalSize, currentSize > targetSize else { return } - - let sizeToEvict = currentSize - targetSize - - // Get all entries and sort by access time (LRU) - guard let allEntries = try? await index.allEntries() else { return } - let sortedEntries = allEntries.sorted { $0.value.metadata.accessedAt < $1.value.metadata.accessedAt } - - var evictedSize: UInt64 = 0 - var keysToEvict: [String] = [] - var digestsToDelete: [String] = [] - - for (key, entry) in sortedEntries { - // Only need to delete the manifest itself (no separate layers) - digestsToDelete.append(entry.descriptor.digest) - - keysToEvict.append(key) - evictedSize += UInt64(entry.descriptor.size) - - if evictedSize >= sizeToEvict { - break - } - } - - // Remove from index - try? await index.remove(keys: keysToEvict) - - // Delete content from store - _ = try? await contentStore.delete(digests: digestsToDelete) - } - - /// Run periodic eviction task. - private func runPeriodicEviction() async { - while !Task.isCancelled { - // Sleep for GC interval - try? await Task.sleep(nanoseconds: UInt64(configuration.gcInterval * 1_000_000_000)) - - // Remove expired entries based on TTL - guard let allEntries = try? await index.allEntries() else { return } - var keysToEvict: [String] = [] - var digestsToDelete: [String] = [] - - for (key, entry) in allEntries { - // Check TTL - if let ttl = entry.metadata.ttl { - let expirationDate = entry.metadata.createdAt.addingTimeInterval(ttl) - if Date() > expirationDate { - keysToEvict.append(key) - - // Only need to delete the manifest itself (no separate layers) - digestsToDelete.append(entry.descriptor.digest) - } - } - } - - if !keysToEvict.isEmpty { - try? await index.remove(keys: keysToEvict) - _ = try? await contentStore.delete(digests: digestsToDelete) - } - - // Check size limits - await checkAndEvict() - } - } -} - -// MARK: - Extensions - -extension Platform { - var canonicalString: String { - var parts = ["\(os)/\(architecture)"] - if let variant = variant { - parts.append(variant) - } - return parts.joined(separator: "/") - } - - /// - Throws: CacheError.encodingFailed if UTF-8 encoding fails - func canonicalRepresentation() throws -> Data { - guard let data = canonicalString.data(using: .utf8) else { - // This should never happen as canonicalString contains only valid UTF-8 - throw CacheError.encodingFailed("Failed to encode canonical platform string as UTF-8: \(canonicalString)") - } - return data - } -} diff --git a/Sources/NativeBuilder/ContainerBuildDemo/Demo.swift b/Sources/NativeBuilder/ContainerBuildDemo/Demo.swift deleted file mode 100644 index deaf9400d..000000000 --- a/Sources/NativeBuilder/ContainerBuildDemo/Demo.swift +++ /dev/null @@ -1,113 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildCache -import ContainerBuildExecutor -import ContainerBuildIR -import ContainerBuildReporting -import ContainerBuildSnapshotter -import Foundation - -public actor DummySnapshotter: Snapshotter { - public init() {} - - public func prepare(_ snapshot: Snapshot) async throws -> Snapshot { - if let mount = snapshot.state.mountpoint { - var isDir: ObjCBool = false - if !FileManager.default.fileExists(atPath: mount.path, isDirectory: &isDir) { - try FileManager.default.createDirectory(at: mount, withIntermediateDirectories: true) - } - } - return snapshot - } - - public func commit(_ snapshot: Snapshot) async throws -> Snapshot { - // Produce a minimal committed snapshot; layer fields are optional - Snapshot( - id: snapshot.id, - digest: snapshot.digest, - size: snapshot.size, - parent: snapshot.parent, - createdAt: snapshot.createdAt, - state: .committed() - ) - } - - public func remove(_ snapshot: Snapshot) async throws { - if let mount = snapshot.state.mountpoint { - try? FileManager.default.removeItem(at: mount) - } - } -} - -/// A simple demonstration of the build execution system. -public struct Demo { - public static func runDemo() async throws { - // Set up the build environment first - let snapshotter = DummySnapshotter() - let cache = NoOpBuildCache() - let reporter = Reporter() - - // Create a build graph with parallel operations, passing the reporter - let graph = try IRExample.createParallelBuild(reporter: reporter) - let config = Scheduler.Configuration( - maxConcurrency: ProcessInfo.processInfo.activeProcessorCount, - failFast: true, - enableProgressReporting: true - ) - let executor = Scheduler( - snapshotter: snapshotter, - cache: cache, - reporter: reporter, - configuration: config - ) - - // Create progress consumer - let consumer = PlainProgressConsumer( - configuration: .init() - ) - - // Start progress monitoring task - let progressTask = Task { - try await consumer.consume(reporter: reporter) - } - - // Register completion handler to wait for progress task - executor.onCompletion { - try? await progressTask.value - } - - // Execute the build - this will wait for progress to complete before returning - _ = try await executor.execute(graph) - - // Get build statistics - let stats = consumer.getStatistics() - - // Print build summary based on statistics - if let duration = stats.duration { - if stats.success == true { - print("\nBuild completed successfully in \(String(format: "%.2f", duration))s") - // print(" Total operations: \(stats.totalOperations)") - // print(" Cache hits: \(stats.cacheHits)") - // print(" Executed: \(stats.executedOperations)") - } else { - print("\nBuild failed after \(String(format: "%.2f", duration))s") - // print(" Failed operations: \(stats.failedOperations)") - } - } - } - -} diff --git a/Sources/NativeBuilder/ContainerBuildDemo/ErrorPresenter.swift b/Sources/NativeBuilder/ContainerBuildDemo/ErrorPresenter.swift deleted file mode 100644 index fb831073e..000000000 --- a/Sources/NativeBuilder/ContainerBuildDemo/ErrorPresenter.swift +++ /dev/null @@ -1,72 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildCache -import ContainerBuildExecutor -import ContainerBuildIR -import Foundation - -/// A utility to present various error types in a uniform, user-friendly format. -public struct ErrorPresenter { - /// Renders any error into a formatted string for console output. - public func present(error: Error) -> String { - var output: [String] = [] - output.append("❌ Build failed.") - output.append("----------------------------------------") - appendErrorDetails(error, to: &output, isRoot: true) - output.append("----------------------------------------") - return output.joined(separator: "\n") - } - - /// A recursive helper to print error chains. - private func appendErrorDetails(_ error: Error, to output: inout [String], isRoot: Bool) { - let title: String - var details: String = error.localizedDescription - var underlyingError: (any Error)? - - switch error { - case let err as BuildDefinitionError: - title = "Build Definition Error" - details = err.errorDescription ?? "No details." - - case let err as CacheError: - title = "Cache Error" - details = err.errorDescription ?? "No details." - if case .storageFailed(_, let underlying) = err { - underlyingError = underlying - } else if case .manifestUnreadable(_, let underlying) = err { - underlyingError = underlying - } - - case let err as ExecutorError: - title = "Build Execution Error" - // Extract richer details from the context - details = "Operation '\(String(describing: err.context.operation))' failed." - underlyingError = err.context.underlyingError - - default: - title = "Unexpected Error" - } - - output.append(isRoot ? "Reason: \(title)" : "Caused by: \(title)") - output.append(" ↳ Details: \(details)") - - // If there's a wrapped error, recurse. - if let underlyingError = underlyingError { - appendErrorDetails(underlyingError, to: &output, isRoot: false) - } - } -} diff --git a/Sources/NativeBuilder/ContainerBuildDemo/Examples.swift b/Sources/NativeBuilder/ContainerBuildDemo/Examples.swift deleted file mode 100644 index fcae3eb40..000000000 --- a/Sources/NativeBuilder/ContainerBuildDemo/Examples.swift +++ /dev/null @@ -1,327 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import ContainerBuildReporting -import Foundation - -/// Example demonstrating how to build an IR graph programmatically. -/// -/// This example creates a multi-stage Node.js application build. -public enum IRExample { - - /// Create a simple single-stage build. - public static func createSimpleBuild() throws -> BuildGraph { - guard let baseImage = ImageReference(parsing: "ubuntu:22.04") else { - throw ReferenceError.invalidFormat("ubuntu:22.04") - } - - return try GraphBuilder.singleStage( - from: baseImage, - platform: .linuxAMD64 - ) { builder in - try builder - .run("apt-get update && apt-get install -y curl") - .workdir("/app") - .copyFromContext(paths: ["package.json", "src/"], to: "/app/") - .run("npm install") - .env("NODE_ENV", "production") - .expose([PortSpec(port: 3000)]) - .cmd(Command.exec(["node", "src/index.js"])) - } - } - - /// Create a realistic multi-stage build showing actual parallelism - public static func createParallelBuild(reporter: Reporter? = nil) throws -> BuildGraph { - // In container builds, true parallelism happens between stages or with independent resources - - guard let nodeImage = ImageReference(parsing: "node:18-alpine"), - let goImage = ImageReference(parsing: "golang:1.21-alpine"), - let alpineImage = ImageReference(parsing: "alpine:3.18") - else { - throw ReferenceError.invalidFormat("Invalid image reference") - } - - return try GraphBuilder.multiStage(reporter: reporter) { builder in - // Stage 1: Build frontend assets - try builder - .stage(name: "frontend-builder", from: nodeImage) - .workdir("/frontend") - .copyFromContext(paths: ["frontend/package*.json"], to: "./") - .run("npm ci") - .copyFromContext(paths: ["frontend/"], to: "./") - .run("npm run build") - - // Stage 2: Build backend - try builder - .stage(name: "backend-builder", from: goImage) - .workdir("/backend") - .copyFromContext(paths: ["go.mod", "go.sum"], to: "./") - .run("go mod download") - .copyFromContext(paths: ["*.go", "cmd/", "internal/"], to: "./") - .run("CGO_ENABLED=0 go build -o server ./cmd/server") - - // Stage 3: Runtime - depends on both builders - try builder - .stage(name: "runtime", from: alpineImage) - .copyFromStage(.named("frontend-builder"), paths: ["/frontend/dist"], to: "/app/static") - .copyFromStage(.named("backend-builder"), paths: ["/backend/server"], to: "/app/server") - .run("chmod +x /app/server") - .expose([PortSpec(port: 8080)]) - .cmd(.exec(["/app/server"])) - } - } - - /// Create a multi-stage build for a Go application. - public static func createMultiStageBuild() throws -> BuildGraph { - try GraphBuilder.multiStage { builder in - // Build stage - guard let builderImage = ImageReference(parsing: "golang:1.21-alpine") else { - throw ReferenceError.invalidFormat("golang:1.21-alpine") - } - - try builder - .stage( - name: "builder", - from: builderImage - ) - .workdir("/build") - .copyFromContext(paths: ["go.mod", "go.sum"], to: "./") - .run("go mod download") - .copyFromContext(paths: ["*.go"], to: "./") - .run("CGO_ENABLED=0 go build -o app") - - // Runtime stage - try builder - .scratch(name: "runtime") - .copyFromStage( - .named("builder"), - paths: ["/build/app"], - to: "/app", - chmod: .mode(0o755) - ) - .copyFromStage( - .named("builder"), - paths: ["/etc/ssl/certs/ca-certificates.crt"], - to: "/etc/ssl/certs/" - ) - .user(.uid(1000)) - .entrypoint(.exec(["/app"])) - } - } - - /// Create a Python application with best practices. - public static func createPythonBuild() throws -> BuildGraph { - let graph = try BuildGraph( - stages: [ - // Dependencies stage - BuildStage( - name: "dependencies", - base: { - guard let pythonImage = ImageReference(parsing: "python:3.11-slim") else { - throw ReferenceError.invalidFormat("python:3.11-slim") - } - return ImageOperation( - source: .registry(pythonImage), - platform: .linuxAMD64 - ) - }(), - nodes: [ - BuildNode( - operation: MetadataOperation( - action: .setWorkdir(path: "/app") - ) - ), - BuildNode( - operation: FilesystemOperation( - action: .copy, - source: .context(ContextSource(paths: ["requirements.txt"])), - destination: "/app/" - ) - ), - BuildNode( - operation: ExecOperation( - command: .shell("pip install --user --no-cache-dir -r requirements.txt") - ) - ), - ] - ), - - // Application stage - BuildStage( - name: "app", - base: { - guard let pythonImage = ImageReference(parsing: "python:3.11-slim") else { - throw ReferenceError.invalidFormat("python:3.11-slim") - } - return ImageOperation( - source: .registry(pythonImage), - platform: .linuxAMD64 - ) - }(), - nodes: [ - // Copy dependencies from first stage - BuildNode( - operation: FilesystemOperation( - action: .copy, - source: .stage(.named("dependencies"), paths: ["/root/.local"]), - destination: "/root/.local" - ) - ), - // Set up application - BuildNode( - operation: MetadataOperation( - action: .setWorkdir(path: "/app") - ) - ), - BuildNode( - operation: FilesystemOperation( - action: .copy, - source: .context(ContextSource(paths: ["*.py", "src/"])), - destination: "/app/" - ) - ), - // Configure runtime - BuildNode( - operation: MetadataOperation( - action: .setEnv( - key: "PYTHONPATH", - value: .literal("/root/.local/lib/python3.11/site-packages") - ) - ) - ), - BuildNode( - operation: MetadataOperation( - action: .setUser(user: .uidGid(uid: 1000, gid: 1000)) - ) - ), - BuildNode( - operation: MetadataOperation( - action: .expose(ports: [PortSpec(port: 8000)]) - ) - ), - BuildNode( - operation: MetadataOperation( - action: .setHealthcheck( - healthcheck: Healthcheck( - test: .shell("curl -f http://localhost:8000/health || exit 1"), - interval: 30, - timeout: 3, - retries: 3 - ) - ) - ) - ), - BuildNode( - operation: MetadataOperation( - action: .setCmd(command: .exec(["python", "main.py"])) - ) - ), - ] - ), - ], - targetPlatforms: [.linuxAMD64, .linuxARM64] - ) - - return graph - } - - /// Demonstrate advanced features like cache mounts and secrets. - public static func createAdvancedBuild() throws -> BuildGraph { - guard let nodeImage = ImageReference(parsing: "node:18-alpine") else { - throw ReferenceError.invalidFormat("node:18-alpine") - } - - return try GraphBuilder.singleStage( - from: nodeImage - ) { builder in - try builder - .workdir("/app") - - // Cache mount for package manager - .run( - "npm ci", - mounts: [ - Mount( - type: .cache, - target: "/root/.npm", - options: MountOptions(sharing: .shared) - ) - ] - ) - - // Secret mount for private registry - .run( - "npm install @private/package", - mounts: [ - Mount( - type: .secret, - target: "/root/.npmrc", - source: .secret("npm-token"), - options: MountOptions(readOnly: true, mode: 0o600) - ) - ] - ) - - // Build with tmpfs for temporary files - .run( - "npm run build", - mounts: [ - Mount( - type: .tmpfs, - target: "/tmp", - options: MountOptions(size: 1024 * 1024 * 1024) // 1GB - ) - ] - ) - - // Multi-platform metadata - .label("org.opencontainers.image.source", "https://github.com/example/app") - .label("org.opencontainers.image.version", "${VERSION}") - .label("org.opencontainers.image.created", "${BUILD_DATE}") - } - } - - /// Validate and analyze a build graph. - public static func analyzeGraph(_ graph: BuildGraph) { - // Validate - let validator = StandardValidator() - let validationResult = validator.validate(graph) - - print("Validation Results:") - print(" Errors: \(validationResult.errors.count)") - for error in validationResult.errors { - print(" - \(error)") - } - print(" Warnings: \(validationResult.warnings.count)") - for warning in validationResult.warnings { - print(" - \(warning.message)") - if let suggestion = warning.suggestion { - print(" Suggestion: \(suggestion)") - } - } - - // Analyze with reporter - print("\nSemantic Analysis:") - - // Graph statistics - let stats = graph.analyze() - print("\nGraph Statistics:") - print(" Stages: \(stats.stageCount)") - print(" Operations: \(stats.operationCount)") - print(" Critical Path: \(stats.criticalPathLength) operations") - } -} diff --git a/Sources/NativeBuilder/ContainerBuildDemo/GraphVisualizer.swift b/Sources/NativeBuilder/ContainerBuildDemo/GraphVisualizer.swift deleted file mode 100644 index 765d1cf40..000000000 --- a/Sources/NativeBuilder/ContainerBuildDemo/GraphVisualizer.swift +++ /dev/null @@ -1,421 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildExecutor -import ContainerBuildIR -import ContainerBuildReporting -import Foundation - -/// Visualizes build graphs in various formats -public struct GraphVisualizer { - - // MARK: - ASCII Visualization - - /// Generate an ASCII representation of the build graph - public static func generateASCII(_ graph: BuildGraph) -> String { - var output = "" - let analysis = graph.analyze() - - // Header - output += "Build Graph\n" - output += "===========\n" - output += "Stages: \(analysis.stageCount) | Operations: \(analysis.operationCount) | Critical Path: \(analysis.criticalPathLength)\n" - output += "\n" - - // Process each stage and collect cross-stage dependencies - var crossStageDeps: [(from: String, to: String, desc: String)] = [] - - for (stageIndex, stage) in graph.stages.enumerated() { - let stageName = stage.name ?? "stage-\(stageIndex)" - output += drawStage(stage, name: stageName, in: graph) - - // Check for cross-stage dependencies - for node in stage.nodes { - if let fsOp = node.operation as? FilesystemOperation { - switch fsOp.source { - case .stage(let ref, let paths): - let sourceStage: String - switch ref { - case .named(let name): - sourceStage = name - case .index(let idx): - sourceStage = "stage-\(idx)" - case .previous: - sourceStage = stageIndex > 0 ? (graph.stages[stageIndex - 1].name ?? "stage-\(stageIndex-1)") : "unknown" - } - crossStageDeps.append((from: sourceStage, to: stageName, desc: "COPY \(paths.first ?? "")")) - default: - break - } - } - } - - output += "\n" - } - - // Show cross-stage dependencies - if !crossStageDeps.isEmpty { - output += "Cross-Stage Dependencies:\n" - for dep in crossStageDeps { - output += " \(dep.from) → \(dep.to) [\(dep.desc)]\n" - } - output += "\n" - } - - // Parallelism analysis - let parallelGroups = analysis.parallelismOpportunities.filter { $0.count > 1 } - if !parallelGroups.isEmpty { - output += "Parallelism Analysis\n" - output += "===================\n" - for (index, group) in parallelGroups.enumerated() { - output += "Group \(index + 1): \(group.count) operations can run in parallel\n" - } - } - - return output - } - - private static func drawStage(_ stage: BuildStage, name: String, in graph: BuildGraph) -> String { - var output = "" - - // Stage header - let headerLine = "┌─ Stage: \(name) " - output += headerLine + String(repeating: "─", count: max(50 - headerLine.count, 3)) + "┐\n" - - // First draw the base image operation - let baseDesc = formatNodeDescription(BuildNode(operation: stage.base)) - output += "│ [\(baseDesc)]\n" - - // Draw nodes in order - for (_, node) in stage.nodes.enumerated() { - output += "│\n" - output += "│ ↓\n" - output += drawNodesAtLevel([node], in: stage, showParallel: false) - } - - // Stage footer - output += "└" + String(repeating: "─", count: 51) + "┘\n" - - return output - } - - private static func computeNodeLevels(_ nodes: [BuildNode]) -> [[BuildNode]] { - var levels: [[BuildNode]] = [] - var nodeLevel: [UUID: Int] = [:] - - func computeLevel(for node: BuildNode) -> Int { - if let level = nodeLevel[node.id] { - return level - } - - var maxDepLevel = -1 - for depId in node.dependencies { - if let depNode = nodes.first(where: { $0.id == depId }) { - maxDepLevel = max(maxDepLevel, computeLevel(for: depNode)) - } - } - - let level = maxDepLevel + 1 - nodeLevel[node.id] = level - return level - } - - // Compute level for each node - for node in nodes { - let level = computeLevel(for: node) - while levels.count <= level { - levels.append([]) - } - levels[level].append(node) - } - - return levels - } - - private static func drawConnections(from previousNodes: [BuildNode], to currentNodes: [BuildNode], in stage: BuildStage) -> String { - var output = "│\n" - - // Check if any connections exist - var hasConnections = false - for node in currentNodes { - if !node.dependencies.isEmpty { - hasConnections = true - break - } - } - - if hasConnections { - // Draw connection lines - var connectionLine = "│ " - for (index, node) in currentNodes.enumerated() { - if index > 0 { - connectionLine += " " - } - - let depCount = node.dependencies.count - if depCount > 0 { - connectionLine += "╱" - if depCount > 1 { - connectionLine += "─┴─" - } else { - connectionLine += "───" - } - connectionLine += "╲" - } else { - connectionLine += " " - } - } - output += connectionLine + "\n" - } - - return output - } - - private static func drawNodesAtLevel(_ nodes: [BuildNode], in stage: BuildStage, showParallel: Bool) -> String { - var output = "│ " - - // Draw nodes - for (index, node) in nodes.enumerated() { - if index > 0 { - output += " " - if showParallel { - output += "║ " // Double bar indicates parallel execution - } else { - output += " " - } - } - - let desc = formatNodeDescription(node) - output += "[\(desc)]" - } - - output += "\n" - return output - } - - private static func formatNodeDescription(_ node: BuildNode) -> String { - let fullDesc = ReportContext.describeOperation(node.operation) - let parts = fullDesc.split(separator: " ", maxSplits: 1) - let operation = String(parts[0]) - let args = parts.count > 1 ? String(parts[1]) : "" - - // Format based on operation type - switch operation { - case "FROM": - return "FROM \(truncate(args, to: 20))" - case "RUN": - return "RUN \(truncate(args, to: 25))" - case "COPY", "ADD": - let paths = args.split(separator: " ") - let source = paths.first ?? "" - return "\(operation) \(truncate(String(source), to: 15))" - case "WORKDIR": - return "WORKDIR \(args)" - case "ENV": - return "ENV \(truncate(args, to: 20))" - case "EXPOSE": - return "EXPOSE \(args)" - case "CMD", "ENTRYPOINT": - return "\(operation) \(truncate(args, to: 18))" - case "LABEL": - return "LABEL \(truncate(args, to: 18))" - case "USER": - return "USER \(args)" - case "ARG": - return "ARG \(truncate(args, to: 20))" - case "VOLUME": - return "VOLUME \(args)" - default: - return "\(operation) \(truncate(args, to: 18))" - } - } - - private static func truncate(_ string: String, to length: Int) -> String { - if string.count <= length { - return string - } - return String(string.prefix(length - 3)) + "..." - } - - // MARK: - Graphviz DOT Format - - /// Generate a Graphviz DOT representation of the build graph - public static func generateDOT(_ graph: BuildGraph) -> String { - var output = "digraph BuildGraph {\n" - output += " rankdir=TB;\n" - output += " node [shape=box, style=rounded];\n" - output += " \n" - - // Graph metadata - output += " label=\"Build Graph - \(graph.stages.count) stages, \(graph.targetPlatforms.count) platforms\";\n" - output += " labelloc=t;\n" - output += " \n" - - // Process each stage - for (stageIndex, stage) in graph.stages.enumerated() { - let stageName = stage.name ?? "stage_\(stageIndex)" - - // Create subgraph for stage - output += " subgraph cluster_\(stageIndex) {\n" - output += " label=\"Stage: \(stageName)\";\n" - output += " style=dotted;\n" - output += " color=gray;\n" - output += " \n" - - // Add nodes - for node in stage.nodes { - let nodeId = sanitizeNodeId(node.id.uuidString) - let label = formatNodeLabel(node) - let color = getNodeColor(for: node.operation) - - output += " \(nodeId) [label=\"\(label)\", fillcolor=\"\(color)\", style=filled];\n" - } - - // Add dependencies within stage - for node in stage.nodes { - let nodeId = sanitizeNodeId(node.id.uuidString) - for depId in node.dependencies { - let depNodeId = sanitizeNodeId(depId.uuidString) - output += " \(depNodeId) -> \(nodeId);\n" - } - } - - output += " }\n" - output += " \n" - } - - // Add cross-stage dependencies - output += " // Cross-stage dependencies\n" - for (stageIndex, stage) in graph.stages.enumerated() { - for node in stage.nodes { - if let fsOp = node.operation as? FilesystemOperation { - switch fsOp.source { - case .stage(let ref, _): - if let sourceStage = graph.resolveStage(ref), - let sourceIndex = graph.stages.firstIndex(where: { $0.id == sourceStage.id }), - sourceIndex != stageIndex - { - // Draw edge from last node of source stage to this node - if let lastNode = sourceStage.nodes.last { - let sourceId = sanitizeNodeId(lastNode.id.uuidString) - let targetId = sanitizeNodeId(node.id.uuidString) - output += " \(sourceId) -> \(targetId) [style=dashed, color=blue, label=\"stage copy\"];\n" - } - } - default: - break - } - } - } - } - - output += "}\n" - return output - } - - private static func sanitizeNodeId(_ id: String) -> String { - "node_" + id.replacingOccurrences(of: "-", with: "_") - } - - private static func formatNodeLabel(_ node: BuildNode) -> String { - let desc = ReportContext.describeOperation(node.operation) - let parts = desc.split(separator: " ", maxSplits: 1) - let operation = String(parts[0]) - let args = parts.count > 1 ? String(parts[1]) : "" - - // Escape quotes for DOT format - let escapedArgs = - args - .replacingOccurrences(of: "\"", with: "\\\"") - .replacingOccurrences(of: "\n", with: "\\n") - - switch operation { - case "RUN", "COPY", "ADD", "CMD", "ENTRYPOINT": - return "\(operation)\\n\(truncate(escapedArgs, to: 30))" - default: - return "\(operation)\\n\(escapedArgs)" - } - } - - private static func getNodeColor(for operation: any ContainerBuildIR.Operation) -> String { - switch operation { - case is ImageOperation: - return "#E8F5E9" // Light green - case is ExecOperation: - return "#E3F2FD" // Light blue - case is FilesystemOperation: - return "#FFF3E0" // Light orange - case is MetadataOperation: - return "#F3E5F5" // Light purple - default: - return "#F5F5F5" // Light gray - } - } - - // MARK: - Mermaid Format - - /// Generate a Mermaid diagram representation of the build graph - public static func generateMermaid(_ graph: BuildGraph) -> String { - var output = "graph TB\n" - - // Process each stage - for (stageIndex, stage) in graph.stages.enumerated() { - let stageName = stage.name ?? "stage-\(stageIndex)" - - // Add stage label - output += " subgraph \(stageName)\n" - - // Add nodes - for node in stage.nodes { - let nodeId = "N" + node.id.uuidString.prefix(8) - let label = formatNodeDescription(node) - output += " \(nodeId)[\"\(label)\"]\n" - } - - // Add dependencies - for node in stage.nodes { - let nodeId = "N" + node.id.uuidString.prefix(8) - for depId in node.dependencies { - let depNodeId = "N" + depId.uuidString.prefix(8) - output += " \(depNodeId) --> \(nodeId)\n" - } - } - - output += " end\n" - } - - return output - } -} - -// MARK: - Convenience Extensions - -extension BuildGraph { - /// Generate ASCII visualization of this graph - public var asciiDiagram: String { - GraphVisualizer.generateASCII(self) - } - - /// Generate Graphviz DOT format of this graph - public var dotFormat: String { - GraphVisualizer.generateDOT(self) - } - - /// Generate Mermaid diagram of this graph - public var mermaidDiagram: String { - GraphVisualizer.generateMermaid(self) - } -} diff --git a/Sources/NativeBuilder/ContainerBuildDemo/main.swift b/Sources/NativeBuilder/ContainerBuildDemo/main.swift deleted file mode 100644 index df028a0d9..000000000 --- a/Sources/NativeBuilder/ContainerBuildDemo/main.swift +++ /dev/null @@ -1,20 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import Foundation - -try await Demo.runDemo() diff --git a/Sources/NativeBuilder/ContainerBuildExecutor/BuildExecutor.swift b/Sources/NativeBuilder/ContainerBuildExecutor/BuildExecutor.swift deleted file mode 100644 index 42b642a5b..000000000 --- a/Sources/NativeBuilder/ContainerBuildExecutor/BuildExecutor.swift +++ /dev/null @@ -1,169 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildCache -import ContainerBuildIR -import ContainerizationOCI -import Foundation - -/// The main executor responsible for orchestrating container build execution. -/// -/// This protocol defines the high-level interface for executing build graphs. -/// Implementations coordinate stage execution, handle caching, and manage build state. -public protocol BuildExecutor: Sendable { - /// Execute a complete build graph. - /// - /// - Parameter graph: The build graph to execute - /// - Returns: The result of the build execution - /// - Throws: Any errors encountered during execution - func execute(_ graph: BuildGraph) async throws -> BuildResult - - /// Cancel any ongoing execution. - /// - /// This should gracefully stop execution and clean up resources. - func cancel() async -} - -/// The result of executing a build graph. -public struct BuildResult: Sendable { - /// The final image manifest for each target platform. - public let manifests: [Platform: ImageManifest] - - /// Execution metrics for performance analysis. - public let metrics: ExecutionMetrics - - /// Cache statistics for the build. - public let cacheStats: CacheStatistics - - /// Logs generated during the build. - public let logs: [String]? - - public init( - manifests: [Platform: ImageManifest], - metrics: ExecutionMetrics, - cacheStats: CacheStatistics, - logs: [String]? = nil - ) { - self.manifests = manifests - self.metrics = metrics - self.cacheStats = cacheStats - self.logs = logs - } -} - -/// Represents a built container image manifest. -public struct ImageManifest: Sendable { - /// The content-addressed digest of the image. - public let digest: Digest - - /// The size of the image in bytes. - public let size: Int64 - - /// The configuration digest. - public let configDigest: Digest - - /// Layer digests in order. - public let layers: [LayerDescriptor] - - public init( - digest: Digest, - size: Int64, - configDigest: Digest, - layers: [LayerDescriptor] - ) { - self.digest = digest - self.size = size - self.configDigest = configDigest - self.layers = layers - } -} - -/// Describes a single layer in an image. -public struct LayerDescriptor: Sendable { - /// The digest of the layer. - public let digest: Digest - - /// The size of the layer in bytes. - public let size: Int64 - - /// The media type of the layer. - public let mediaType: String - - public init(digest: Digest, size: Int64, mediaType: String = "application/vnd.oci.image.layer.v1.tar+gzip") { - self.digest = digest - self.size = size - self.mediaType = mediaType - } -} - -/// Metrics collected during build execution. -public struct ExecutionMetrics: Sendable { - /// Total execution time. - public let totalDuration: TimeInterval - - /// Time spent on each stage. - public let stageDurations: [String: TimeInterval] - - /// Number of operations executed. - public let operationCount: Int - - /// Number of operations that were cached. - public let cachedOperationCount: Int - - /// Total bytes transferred. - public let bytesTransferred: Int64 - - public init( - totalDuration: TimeInterval, - stageDurations: [String: TimeInterval], - operationCount: Int, - cachedOperationCount: Int, - bytesTransferred: Int64 - ) { - self.totalDuration = totalDuration - self.stageDurations = stageDurations - self.operationCount = operationCount - self.cachedOperationCount = cachedOperationCount - self.bytesTransferred = bytesTransferred - } -} - -/// Errors that can occur during build execution. -public enum BuildExecutorError: LocalizedError { - case stageNotFound(String) - case cyclicDependency - case operationFailed(ContainerBuildIR.Operation, underlying: Error) - case cancelled - case unsupportedOperation(ContainerBuildIR.Operation) - case internalError(String) - - public var errorDescription: String? { - switch self { - case .stageNotFound(let name): - return "Stage not found: '\(name)'" - case .cyclicDependency: - return "Cyclic dependency detected in build graph" - case .operationFailed(let op, let error): - return "Operation failed: \(op) - \(error.localizedDescription)" - case .cancelled: - return "Build execution was cancelled" - case .unsupportedOperation(let op): - return "Unsupported operation: \(type(of: op))" - case .internalError(let message): - return "Internal error: \(message)" - } - } -} diff --git a/Sources/NativeBuilder/ContainerBuildExecutor/ExecutionContext.swift b/Sources/NativeBuilder/ContainerBuildExecutor/ExecutionContext.swift deleted file mode 100644 index 8ec81d316..000000000 --- a/Sources/NativeBuilder/ContainerBuildExecutor/ExecutionContext.swift +++ /dev/null @@ -1,483 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import ContainerBuildReporting -import ContainerBuildSnapshotter -import ContainerizationOCI -import Foundation - -/// Carries execution state through operation execution. -/// -/// The context maintains the current state of the build, including filesystem -/// snapshots, environment variables, and other mutable state that operations -/// may read or modify. -public final class ExecutionContext: @unchecked Sendable { - /// The current build stage being executed. - public let stage: BuildStage - - /// The complete build graph. - public let graph: BuildGraph - - /// The target platform for this execution. - public let platform: Platform - - /// Progress reporter for build events. - public let reporter: Reporter - - /// The snapshotter for managing filesystem snapshots. - public let snapshotter: any Snapshotter - - /// Current environment variables. - private var _environment: Environment - - /// Current working directory. - private var _workingDirectory: String - - /// Current user. - private var _user: ContainerBuildIR.User? - - /// Image configuration being built. - private var _imageConfig: OCIImageConfig - - /// Snapshots for each executed node. - private var _snapshots: [UUID: Snapshot] - - /// Currently active (prepared but not committed) snapshots for cleanup. - private var _activeSnapshots: [UUID: Snapshot] - - /// Head (most recent committed) snapshot for this context. - private var _headSnapshot: Snapshot? - - /// Lock for thread-safe access. - private let lock = NSLock() - - /// Serialize filesystem mutations within this context. - /// Ensures prepare → body → commit happens one-at-a-time per context to avoid - /// divergent snapshot branches when multiple FS-mutating operations run in parallel. - private let fsSemaphore = AsyncSemaphore(value: 1) - - public init( - stage: BuildStage, - graph: BuildGraph, - platform: Platform, - reporter: Reporter, - snapshotter: any Snapshotter, - baseEnvironment: Environment = .init(), - baseConfig: OCIImageConfig? = nil - ) { - self.stage = stage - self.graph = graph - self.platform = platform - self.reporter = reporter - self.snapshotter = snapshotter - self._environment = baseEnvironment - self._workingDirectory = "/" - self._user = nil - self._imageConfig = baseConfig ?? OCIImageConfig(platform: platform) - self._snapshots = [:] - self._activeSnapshots = [:] - self._headSnapshot = nil - } - - deinit { - // Clean up any remaining active snapshots - // Note: This is a best-effort cleanup since deinit can't be async - // The proper cleanup should happen in the executor error handling - if !_activeSnapshots.isEmpty { - // Log warning about unclean shutdown - print("Warning: ExecutionContext deallocated with \(_activeSnapshots.count) active snapshots") - } - } - - /// Get the current environment. - public var environment: Environment { - lock.withLock { _environment } - } - - /// Update the environment. - public func updateEnvironment(_ updates: [String: EnvironmentValue]) { - lock.withLock { - // Create new environment with updates - var newVars = _environment.variables - for (key, value) in updates { - // Remove existing entries for this key - newVars.removeAll { $0.key == key } - // Add new entry - newVars.append((key: key, value: value)) - } - _environment = Environment(newVars) - } - } - - /// Get the current working directory. - public var workingDirectory: String { - lock.withLock { _workingDirectory } - } - - /// Set the working directory. - public func setWorkingDirectory(_ path: String) { - lock.withLock { _workingDirectory = path } - } - - /// Get the current user. - public var user: ContainerBuildIR.User? { - lock.withLock { _user } - } - - /// Set the current user. - public func setUser(_ user: ContainerBuildIR.User?) { - lock.withLock { _user = user } - } - - /// Get the current image configuration. - public var imageConfig: OCIImageConfig { - lock.withLock { _imageConfig } - } - - /// Update the image configuration. - public func updateImageConfig(_ updates: (inout OCIImageConfig) -> Void) { - lock.withLock { - updates(&_imageConfig) - } - } - - /// Get the snapshot for a node. - public func snapshot(for nodeId: UUID) -> Snapshot? { - lock.withLock { _snapshots[nodeId] } - } - - /// Set the snapshot for a node. - public func setSnapshot(_ snapshot: Snapshot, for nodeId: UUID) { - lock.withLock { - _snapshots[nodeId] = snapshot - _headSnapshot = snapshot - } - } - - /// The current head snapshot (last committed snapshot in this context). - public var headSnapshot: Snapshot? { - lock.withLock { _headSnapshot } - } - - /// Create a child context for a nested execution. - public func childContext(for stage: BuildStage) -> ExecutionContext { - let (environmentVars, imageConfig) = lock.withLock { - (_environment.variables, _imageConfig) - } - - return ExecutionContext( - stage: stage, - graph: graph, - platform: platform, - reporter: reporter, - snapshotter: snapshotter, - baseEnvironment: Environment(environmentVars), - baseConfig: imageConfig - ) - } - - // MARK: - Snapshotter Integration - - /// Prepare a snapshot for modification by an operation. - /// - /// This method handles the snapshotter lifecycle: - /// 1. Takes a parent snapshot (or creates a base snapshot if none) - /// 2. Calls snapshotter.prepare() to make it ready for modification - /// 3. Tracks the prepared snapshot for cleanup if needed - /// - /// - Parameter operationId: The UUID of the operation that will modify this snapshot - /// - Returns: A prepared snapshot ready for modification - /// - Throws: Any errors from the snapshotter - public func prepareSnapshot(for operationId: UUID) async throws -> Snapshot { - let parentCommitted = headSnapshot - - // Always create a new child snapshot that points to the latest committed snapshot (if any). - // Prepare is responsible for ensuring both the child mountpoint and parent materialization (if needed). - let tempMountPoint = FileManager.default.temporaryDirectory - .appendingPathComponent("child-snapshot", isDirectory: true) - .appendingPathComponent(UUID().uuidString, isDirectory: true) - - let snapshotToPrepare: Snapshot - if let parent = parentCommitted { - snapshotToPrepare = Snapshot( - digest: parent.digest, // initial digest equals base; final digest set at commit - size: parent.size, - parent: parent, - state: .prepared(mountpoint: tempMountPoint) - ) - } else { - snapshotToPrepare = Snapshot( - digest: try Digest(algorithm: .sha256, bytes: Data(count: 32)), // Empty digest for scratch - size: 0, - parent: nil, - state: .prepared(mountpoint: tempMountPoint) - ) - } - - // Prepare the snapshot via snapshotter (materializes parent if needed). - let preparedSnapshot = try await snapshotter.prepare(snapshotToPrepare) - - // Track active snapshot for cleanup - lock.withLock { - _activeSnapshots[operationId] = preparedSnapshot - } - - return preparedSnapshot - } - - /// Commit a prepared snapshot after an operation completes successfully. - /// - /// This method: - /// 1. Calls snapshotter.commit() to finalize the snapshot - /// 2. Stores the committed snapshot for the operation - /// 3. Removes it from active snapshots tracking - /// - /// - Parameters: - /// - snapshot: The prepared snapshot to commit - /// - operationId: The UUID of the operation that modified this snapshot - /// - Returns: The committed snapshot with final digest and state - /// - Throws: Any errors from the snapshotter - public func commitSnapshot(_ snapshot: Snapshot, for operationId: UUID) async throws -> Snapshot { - let committedSnapshot = try await snapshotter.commit(snapshot) - - lock.withLock { - // Store committed snapshot - _snapshots[operationId] = committedSnapshot - // Update head pointer - _headSnapshot = committedSnapshot - // Remove from active tracking - _activeSnapshots.removeValue(forKey: operationId) - } - - return committedSnapshot - } - - /// Clean up a prepared snapshot if an operation fails. - /// - /// This method: - /// 1. Calls snapshotter.remove() to clean up the snapshot - /// 2. Removes it from active snapshots tracking - /// - /// - Parameter operationId: The UUID of the operation that failed - public func cleanupSnapshot(for operationId: UUID) async { - let snapshotToCleanup = lock.withLock { - _activeSnapshots.removeValue(forKey: operationId) - } - - if let snapshot = snapshotToCleanup { - do { - try await snapshotter.remove(snapshot) - } catch { - // Log error but don't throw - this is cleanup - let context = ReportContext(description: "Snapshot cleanup warning") - await reporter.report(.operationLog(context: context, message: "Failed to cleanup snapshot \(snapshot.id): \(error)")) - } - } - } - - /// Clean up all active snapshots (for context cleanup). - public func cleanupAllActiveSnapshots() async { - let activeSnapshots = lock.withLock { - let snapshots = Array(_activeSnapshots.values) - _activeSnapshots.removeAll() - return snapshots - } - - for snapshot in activeSnapshots { - do { - try await snapshotter.remove(snapshot) - } catch { - // Log error but continue cleanup - let context = ReportContext(description: "Snapshot cleanup warning") - await reporter.report(.operationLog(context: context, message: "Failed to cleanup snapshot \(snapshot.id): \(error)")) - } - } - } - - /// Get the count of active snapshots (for monitoring/debugging). - public var activeSnapshotCount: Int { - lock.withLock { _activeSnapshots.count } - } - - /// Get the count of committed snapshots (for monitoring/debugging). - public var committedSnapshotCount: Int { - lock.withLock { _snapshots.count } - } - - // MARK: - Snapshot helper - - /// Convenience wrapper to prepare, use, and commit a snapshot around a body of work. - /// - Parameters: - /// - base: Optional starting snapshot to prepare. If nil, a new child of the latest committed snapshot is created. - /// - body: Async body that performs work against the prepared snapshot. - /// - Returns: Tuple of (result returned by body, final committed snapshot) - @discardableResult - public func withSnapshot(startingFrom base: Snapshot? = nil, _ body: @Sendable (Snapshot) async throws -> T) async throws -> (T, Snapshot) { - try await fsSemaphore.withPermit { - let operationId = UUID() - - // Prepare snapshot - let workingSnapshot: Snapshot - if let base = base { - // Prepare provided base snapshot and track it as active for cleanup - let prepared: Snapshot - switch base.state { - case .prepared: - prepared = base - default: - prepared = try await snapshotter.prepare(base) - } - lock.withLock { - _activeSnapshots[operationId] = prepared - } - workingSnapshot = prepared - } else { - workingSnapshot = try await prepareSnapshot(for: operationId) - } - - do { - // Execute body work - let result = try await body(workingSnapshot) - - // Commit and persist - let finalSnapshot = try await commitSnapshot(workingSnapshot, for: operationId) - - return (result, finalSnapshot) - } catch { - // Cleanup on failure then rethrow - await cleanupSnapshot(for: operationId) - throw error - } - } - } - - /// Prepare and commit a snapshot from a provided base without performing any body work. - /// If the base is not already prepared, it will be prepared first. - /// - Parameter base: The base snapshot to prepare and commit. - /// - Returns: The committed snapshot. - public func prepareAndCommit(from base: Snapshot) async throws -> Snapshot { - try await fsSemaphore.withPermit { - let operationId = UUID() - - // Prepare if needed and track as active for cleanup - let working: Snapshot - switch base.state { - case .prepared: - working = base - lock.withLock { _activeSnapshots[operationId] = working } - default: - let prepared = try await snapshotter.prepare(base) - lock.withLock { _activeSnapshots[operationId] = prepared } - working = prepared - } - - do { - let committed = try await commitSnapshot(working, for: operationId) - return committed - } catch { - await cleanupSnapshot(for: operationId) - throw error - } - } - } -} - -/// OCI image configuration. -/// -/// Represents the configuration for an OCI container image. -public struct OCIImageConfig: Sendable { - /// The platform this image is for. - public let platform: Platform - - /// Environment variables. - public var env: [String] - - /// Default command. - public var cmd: [String]? - - /// Entry point. - public var entrypoint: [String]? - - /// Working directory. - public var workingDir: String? - - /// User. - public var user: String? - - /// Exposed ports. - public var exposedPorts: Set - - /// Volumes. - public var volumes: Set - - /// Labels. - public var labels: [String: String] - - /// Stop signal. - public var stopSignal: String? - - /// Health check. - public var healthcheck: Healthcheck? - - public init( - platform: Platform, - env: [String] = [], - cmd: [String]? = nil, - entrypoint: [String]? = nil, - workingDir: String? = nil, - user: String? = nil, - exposedPorts: Set = [], - volumes: Set = [], - labels: [String: String] = [:], - stopSignal: String? = nil, - healthcheck: Healthcheck? = nil - ) { - self.platform = platform - self.env = env - self.cmd = cmd - self.entrypoint = entrypoint - self.workingDir = workingDir - self.user = user - self.exposedPorts = exposedPorts - self.volumes = volumes - self.labels = labels - self.stopSignal = stopSignal - self.healthcheck = healthcheck - } -} - -// Helper extension for thread-safe lock usage -extension NSLock { - func withLock(_ body: () throws -> T) rethrows -> T { - lock() - defer { unlock() } - return try body() - } -} - -// Helper extension for Environment -extension Environment { - /// Get the value for a key. - public func get(_ key: String) -> EnvironmentValue? { - for (k, v) in variables.reversed() { - if k == key { - return v - } - } - return nil - } -} diff --git a/Sources/NativeBuilder/ContainerBuildExecutor/ExecutionDispatcher.swift b/Sources/NativeBuilder/ContainerBuildExecutor/ExecutionDispatcher.swift deleted file mode 100644 index 43c9c6be9..000000000 --- a/Sources/NativeBuilder/ContainerBuildExecutor/ExecutionDispatcher.swift +++ /dev/null @@ -1,238 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import ContainerizationOCI -import Foundation - -/// Routes operations to appropriate executors based on capabilities and constraints. -/// -/// The dispatcher maintains a registry of executors and matches operations to -/// executors based on operation type, platform requirements, and executor capabilities. -public final class ExecutionDispatcher: Sendable { - /// Registered executors. - private let executors: [any OperationExecutor] - - /// Semaphores for concurrency control per executor. - private let semaphores: [ObjectIdentifier: AsyncSemaphore] - - public init(executors: [any OperationExecutor]) { - self.executors = executors - - // Create semaphores based on executor capabilities - var semas: [ObjectIdentifier: AsyncSemaphore] = [:] - for executor in executors { - let id = ObjectIdentifier(type(of: executor)) - semas[id] = AsyncSemaphore(value: executor.capabilities.maxConcurrency) - } - self.semaphores = semas - } - - /// Dispatch an operation to an appropriate executor. - /// - /// - Parameters: - /// - operation: The operation to execute - /// - context: The execution context - /// - constraints: Any additional constraints from the build node - /// - Returns: The execution result - /// - Throws: If no suitable executor is found or execution fails - public func dispatch( - _ operation: ContainerBuildIR.Operation, - context: ExecutionContext, - constraints: NodeConstraints? = nil - ) async throws -> ExecutionResult { - // Find a suitable executor - guard - let executor = findExecutor( - for: operation, - platform: context.platform, - constraints: constraints - ) - else { - throw BuildExecutorError.unsupportedOperation(operation) - } - - // Get semaphore for concurrency control - let executorId = ObjectIdentifier(type(of: executor)) - guard let semaphore = semaphores[executorId] else { - throw BuildExecutorError.internalError("Semaphore not found for executor \(executorId)") - } - - // Execute with concurrency limit - return try await semaphore.withPermit { - try await executor.execute(operation, context: context) - } - } - - /// Find an executor that can handle the given operation. - private func findExecutor( - for operation: ContainerBuildIR.Operation, - platform: Platform, - constraints: NodeConstraints? - ) -> (any OperationExecutor)? { - // Score each executor based on how well it matches - let candidates = executors.compactMap { executor -> (executor: any OperationExecutor, score: Int)? in - guard executor.canExecute(operation) else { return nil } - - let capabilities = executor.capabilities - var score = 0 - - // Check operation kind support - if capabilities.supportedOperations.contains(operation.operationKind) { - score += 100 - } - - // Check platform support - if let supportedPlatforms = capabilities.supportedPlatforms { - guard supportedPlatforms.contains(platform) else { - return nil // Platform not supported - } - score += 50 - } else { - score += 25 // Supports all platforms - } - - // Check privilege requirements - if let constraints = constraints, constraints.requiresPrivileged { - guard capabilities.requiresPrivileged else { - return nil // Cannot satisfy privilege requirement - } - score += 10 - } - - // Check resource requirements - if let constraints = constraints { - if !satisfiesResourceRequirements( - capabilities.resources, - constraints: constraints - ) { - return nil - } - } - - return (executor, score) - } - - // Return the highest scoring executor - return candidates.max(by: { $0.score < $1.score })?.executor - } - - /// Check if executor resources satisfy constraints. - private func satisfiesResourceRequirements( - _ resources: ResourceRequirements, - constraints: NodeConstraints - ) -> Bool { - // Check memory requirements - if let requiredMemory = constraints.minMemory, - let availableMemory = resources.minMemory, - availableMemory < requiredMemory - { - return false - } - - // Check disk requirements - if let requiredDisk = constraints.minDiskSpace, - let availableDisk = resources.minDiskSpace, - availableDisk < requiredDisk - { - return false - } - - // Check CPU architecture - if let requiredArch = constraints.cpuArchitecture, - let availableArch = resources.cpuArchitecture, - availableArch != requiredArch - { - return false - } - - return true - } -} - -/// Constraints that can be applied to node execution. -public struct NodeConstraints: Sendable { - /// Whether privileged execution is required. - public let requiresPrivileged: Bool - - /// Minimum memory required. - public let minMemory: Int64? - - /// Minimum disk space required. - public let minDiskSpace: Int64? - - /// Required CPU architecture. - public let cpuArchitecture: String? - - /// Custom constraints. - public let custom: [String: String] - - public init( - requiresPrivileged: Bool = false, - minMemory: Int64? = nil, - minDiskSpace: Int64? = nil, - cpuArchitecture: String? = nil, - custom: [String: String] = [:] - ) { - self.requiresPrivileged = requiresPrivileged - self.minMemory = minMemory - self.minDiskSpace = minDiskSpace - self.cpuArchitecture = cpuArchitecture - self.custom = custom - } -} - -/// A simple async semaphore for concurrency control. -actor AsyncSemaphore { - private var permits: Int - private var waiters: [CheckedContinuation] = [] - - init(value: Int) { - self.permits = value - } - - func acquire() async { - if permits > 0 { - permits -= 1 - return - } - - await withCheckedContinuation { continuation in - waiters.append(continuation) - } - } - - func release() { - if let waiter = waiters.first { - waiters.removeFirst() - waiter.resume() - } else { - permits += 1 - } - } - - func withPermit(_ body: @Sendable () async throws -> T) async throws -> T { - await acquire() - do { - let result = try await body() - release() - return result - } catch { - release() - throw error - } - } -} diff --git a/Sources/NativeBuilder/ContainerBuildExecutor/ExecutorError.swift b/Sources/NativeBuilder/ContainerBuildExecutor/ExecutorError.swift deleted file mode 100644 index 12535915a..000000000 --- a/Sources/NativeBuilder/ContainerBuildExecutor/ExecutorError.swift +++ /dev/null @@ -1,52 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import Foundation - -/// An error originating from the build executor, enriched with runtime context. -public struct ExecutorError: Error, LocalizedError { - public let type: FailureType - public let context: ErrorContext - - public var errorDescription: String? { - "Execution failed: \(context.underlyingError.localizedDescription)" - } -} - -extension ExecutorError { - /// The general category of the execution failure. - public enum FailureType: Sendable { - case executionFailed - case cancelled - case invalidConfiguration - case unsupportedOperation - } - - /// Represents the detailed context of an error that occurred during a build. - public struct ErrorContext: Sendable { - public let operation: ContainerBuildIR.Operation // The operation that failed - public let underlyingError: any Error - public let diagnostics: Diagnostics - } - - /// Basic diagnostic information captured at failure time. - public struct Diagnostics: Sendable { - public let environment: [String: String] - public let workingDirectory: String - public let recentLogs: [String] - } -} diff --git a/Sources/NativeBuilder/ContainerBuildExecutor/Executors/ExecOperationExecutor.swift b/Sources/NativeBuilder/ContainerBuildExecutor/Executors/ExecOperationExecutor.swift deleted file mode 100644 index b05abe20d..000000000 --- a/Sources/NativeBuilder/ContainerBuildExecutor/Executors/ExecOperationExecutor.swift +++ /dev/null @@ -1,109 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import ContainerBuildSnapshotter -import Foundation - -/// Executes ExecOperation (RUN commands). -public struct ExecOperationExecutor: OperationExecutor { - public let capabilities: ExecutorCapabilities - - public init() { - self.capabilities = ExecutorCapabilities( - supportedOperations: [.exec], - maxConcurrency: 5 - ) - } - - public func execute(_ operation: ContainerBuildIR.Operation, context: ExecutionContext) async throws -> ExecutionResult { - guard let execOp = operation as? ExecOperation else { - throw ExecutorError( - type: .invalidConfiguration, - context: ExecutorError.ErrorContext( - operation: operation, underlyingError: NSError(domain: "Executor", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unsupported operation"]), - diagnostics: ExecutorError.Diagnostics(environment: [:], workingDirectory: "", recentLogs: []))) - } - - let startTime = Date() - - do { - let (output, finalSnapshot) = try await context.withSnapshot { snapshot in - try await executeCommand(execOp, in: snapshot, context: context) - } - - let duration = Date().timeIntervalSince(startTime) - - return ExecutionResult( - environmentChanges: [:], // TODO: Extract environment changes from command execution - metadataChanges: [:], - snapshot: finalSnapshot, - duration: duration, - output: output - ) - } catch { - // Collect diagnostics - let environment = context.environment.effectiveEnvironment - let diagnostics = ExecutorError.Diagnostics( - environment: environment, - workingDirectory: context.workingDirectory, - recentLogs: ["Failed to execute: \(execOp.command.displayString)", "Error: \(error.localizedDescription)"] - ) - - throw ExecutorError( - type: .executionFailed, - context: ExecutorError.ErrorContext( - operation: operation, - underlyingError: error, - diagnostics: diagnostics - ) - ) - } - } - - /// Execute a command in the prepared snapshot environment. - /// - /// This simulates command execution for development and testing purposes. - /// The snapshotter is fully functional and creates real filesystem snapshots, - /// but the actual command execution is simulated to avoid system dependencies. - /// - /// - Parameters: - /// - operation: The exec operation to perform - /// - snapshot: The prepared snapshot with working directory - /// - context: The execution context - /// - Returns: The simulated execution output - private func executeCommand( - _ operation: ExecOperation, - in snapshot: Snapshot, - context: ExecutionContext - ) async throws -> ExecutionOutput { - - let commandString = operation.command.displayString - - // Simulate command execution - // The snapshotter will still properly track any filesystem changes - // that would result from this operation - return ExecutionOutput( - stdout: "[SIMULATED] Executing: \(commandString)\nOutput from command execution...\nDone.", - stderr: "", - exitCode: 0 - ) - } - - public func canExecute(_ operation: ContainerBuildIR.Operation) -> Bool { - operation is ExecOperation - } -} diff --git a/Sources/NativeBuilder/ContainerBuildExecutor/Executors/FilesystemOperationExecutor.swift b/Sources/NativeBuilder/ContainerBuildExecutor/Executors/FilesystemOperationExecutor.swift deleted file mode 100644 index 10b2a5c1d..000000000 --- a/Sources/NativeBuilder/ContainerBuildExecutor/Executors/FilesystemOperationExecutor.swift +++ /dev/null @@ -1,132 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import ContainerBuildSnapshotter -import Foundation - -/// Executes FilesystemOperation (COPY and ADD). -public struct FilesystemOperationExecutor: OperationExecutor { - public let capabilities: ExecutorCapabilities - - public init() { - self.capabilities = ExecutorCapabilities( - supportedOperations: [.filesystem], - maxConcurrency: 10 - ) - } - - public func execute(_ operation: ContainerBuildIR.Operation, context: ExecutionContext) async throws -> ExecutionResult { - guard let fsOp = operation as? FilesystemOperation else { - throw ExecutorError( - type: .invalidConfiguration, - context: ExecutorError.ErrorContext( - operation: operation, underlyingError: NSError(domain: "Executor", code: 1), - diagnostics: ExecutorError.Diagnostics(environment: [:], workingDirectory: "", recentLogs: []))) - } - - let startTime = Date() - - do { - let (_, finalSnapshot) = try await context.withSnapshot { snapshot in - try await performFilesystemOperation(fsOp, in: snapshot) - } - - let duration = Date().timeIntervalSince(startTime) - - return ExecutionResult( - snapshot: finalSnapshot, - duration: duration, - output: nil - ) - - } catch { - - throw ExecutorError( - type: .executionFailed, - context: ExecutorError.ErrorContext( - operation: operation, - underlyingError: error, - diagnostics: ExecutorError.Diagnostics( - environment: context.environment.effectiveEnvironment, - workingDirectory: context.workingDirectory, - recentLogs: ["Failed to execute filesystem operation: \(fsOp.action)"] - ) - ) - ) - } - } - - /// Perform the filesystem operation. - /// - /// This simulates filesystem operations for development and testing purposes. - /// The snapshotter is fully functional and creates real filesystem snapshots, - /// but the actual file operations are simulated to avoid system dependencies. - /// - /// - Parameters: - /// - operation: The filesystem operation to perform - /// - snapshot: The prepared snapshot with working directory - private func performFilesystemOperation( - _ operation: FilesystemOperation, - in snapshot: Snapshot - ) async throws { - // NOTE: The snapshotter is fully operational and creates real filesystem snapshots. - // We simulate filesystem operations to: - // 1. Avoid requiring actual file system access during development - // 2. Enable predictable testing without side effects - // 3. Allow the build system to run in restricted environments - // - // In a production implementation, this would: - // 1. Get the working directory from the snapshot (already available) - // 2. Resolve the source (context, stage, URL) - // 3. Perform actual file operations (copy or add) - // 4. Apply file metadata (permissions, ownership) - // 5. Let the snapshotter track filesystem changes (already working) - - // Only COPY and ADD operations are supported for filesystem operations - switch operation.action { - case .copy: - // Simulate COPY operation - // In production: Copy files from source to destination in the snapshot - break - case .add: - // Simulate ADD operation - // In production: Add files to the snapshot, with automatic extraction for archives - break - default: - throw ExecutorError( - type: .unsupportedOperation, - context: ExecutorError.ErrorContext( - operation: operation, - underlyingError: NSError( - domain: "FilesystemOperationExecutor", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Only COPY and ADD operations are supported. Got: \(operation.action)"] - ), - diagnostics: ExecutorError.Diagnostics( - environment: [:], - workingDirectory: "", - recentLogs: ["Unsupported filesystem operation: \(operation.action)"] - ) - ) - ) - } - } - - public func canExecute(_ operation: ContainerBuildIR.Operation) -> Bool { - operation is FilesystemOperation - } -} diff --git a/Sources/NativeBuilder/ContainerBuildExecutor/Executors/ImageOperationExecutor.swift b/Sources/NativeBuilder/ContainerBuildExecutor/Executors/ImageOperationExecutor.swift deleted file mode 100644 index ab310dd05..000000000 --- a/Sources/NativeBuilder/ContainerBuildExecutor/Executors/ImageOperationExecutor.swift +++ /dev/null @@ -1,186 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import ContainerBuildSnapshotter -import Foundation - -/// Executes ImageOperation (FROM instructions). -public struct ImageOperationExecutor: OperationExecutor { - public let capabilities: ExecutorCapabilities - - public init() { - self.capabilities = ExecutorCapabilities( - supportedOperations: [.image], - maxConcurrency: 3 - ) - } - - public func execute(_ operation: ContainerBuildIR.Operation, context: ExecutionContext) async throws -> ExecutionResult { - guard let imageOp = operation as? ImageOperation else { - throw ExecutorError( - type: .invalidConfiguration, - context: ExecutorError.ErrorContext( - operation: operation, underlyingError: NSError(domain: "Executor", code: 1), - diagnostics: ExecutorError.Diagnostics(environment: [:], workingDirectory: "", recentLogs: []))) - } - - let startTime = Date() - - do { - // 1. Load/pull the base image and create initial snapshot - let baseSnapshot = try await loadBaseImage(imageOp) - - // 2. Prepare and commit the base snapshot directly (no filesystem changes needed here) - let finalSnapshot = try await context.prepareAndCommit(from: baseSnapshot) - - // 4. Update context with image configuration - try await updateImageConfiguration(imageOp, context: context) - - let duration = Date().timeIntervalSince(startTime) - - return ExecutionResult( - snapshot: finalSnapshot, - duration: duration - ) - - } catch { - - throw ExecutorError( - type: .executionFailed, - context: ExecutorError.ErrorContext( - operation: operation, - underlyingError: error, - diagnostics: ExecutorError.Diagnostics( - environment: context.environment.effectiveEnvironment, - workingDirectory: context.workingDirectory, - recentLogs: ["Failed to load base image: \(imageOp.source)", "Error: \(error.localizedDescription)"] - ) - ) - ) - } - } - - /// Load a base image and create the initial snapshot. - /// - /// This is currently a simulation. When TarSnapshotter is implemented, - /// this will pull actual images and extract their filesystem layers. - /// - /// - Parameters: - /// - operation: The image operation - /// - Returns: A base snapshot representing the image filesystem - private func loadBaseImage(_ operation: ImageOperation) async throws -> Snapshot { - // TODO: When TarSnapshotter is implemented, this will: - // 1. Pull the image from registry/load from file (if needed) - // 2. Verify the image (if verification specified) - // 3. Extract the image filesystem layers - // 4. Create a snapshot with the actual image content - - // Simulate image loading based on source type - let imageSize: Int64 - let imageDigest: Digest - - switch operation.source { - case .registry(let reference): - // Simulate pulling from registry - imageSize = 100 * 1024 * 1024 // 100MB - let fakeDataString = "fake-image-\(reference.stringValue)" - guard let fakeData = fakeDataString.data(using: .utf8) else { - throw NSError(domain: "ImageOperationExecutor", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to encode fake image data as UTF-8"]) - } - var digestBytes = Data(count: 32) - fakeData.withUnsafeBytes { bytes in - digestBytes.withUnsafeMutableBytes { digestBytesPtr in - if let destBase = digestBytesPtr.baseAddress, let srcBase = bytes.baseAddress { - memcpy(destBase, srcBase, min(32, bytes.count)) - } - } - } - imageDigest = try Digest(algorithm: .sha256, bytes: digestBytes) - - case .scratch: - // Empty image - imageSize = 0 - imageDigest = try Digest(algorithm: .sha256, bytes: Data(count: 32)) - - case .ociLayout: - // Simulate loading from OCI layout - imageSize = 50 * 1024 * 1024 // 50MB - var digestBytes = Data(count: 32) - digestBytes[0] = 1 - digestBytes[1] = 2 - digestBytes[2] = 3 - imageDigest = try Digest(algorithm: .sha256, bytes: digestBytes) - - case .tarball: - // Simulate loading from tarball - imageSize = 75 * 1024 * 1024 // 75MB - var digestBytes = Data(count: 32) - digestBytes[0] = 4 - digestBytes[1] = 5 - digestBytes[2] = 6 - imageDigest = try Digest(algorithm: .sha256, bytes: digestBytes) - } - - // Create base snapshot (no parent for base images) - // Provide a concrete mountpoint so snapshotter.prepare can ensure it exists. - let tempMountPoint = FileManager.default.temporaryDirectory - .appendingPathComponent("base-image", isDirectory: true) - .appendingPathComponent(UUID().uuidString, isDirectory: true) - - return Snapshot( - digest: imageDigest, - size: imageSize, - parent: nil, - state: .prepared(mountpoint: tempMountPoint) - ) - } - - /// Update the execution context with image configuration. - /// - /// - Parameters: - /// - operation: The image operation - /// - context: The execution context to update - private func updateImageConfiguration(_ operation: ImageOperation, context: ExecutionContext) async throws { - // TODO: When TarSnapshotter is implemented, this will: - // 1. Extract the actual image configuration from the image manifest - // 2. Set environment variables, working directory, user, etc. - // 3. Configure exposed ports, volumes, labels, etc. - - // For now, simulate basic image configuration - context.updateImageConfig { config in - // Set basic defaults that most images have - config.env = ["PATH=/usr/local/bin:/usr/bin:/bin"] - config.workingDir = "/" - - // Add source-specific configuration - switch operation.source { - case .registry(let reference): - config.labels["source"] = "registry:\(reference.stringValue)" - case .scratch: - config.labels["source"] = "scratch" - case .ociLayout: - config.labels["source"] = "oci-layout" - case .tarball: - config.labels["source"] = "tarball" - } - } - } - - public func canExecute(_ operation: ContainerBuildIR.Operation) -> Bool { - operation is ImageOperation - } -} diff --git a/Sources/NativeBuilder/ContainerBuildExecutor/Executors/MetadataOperationExecutor.swift b/Sources/NativeBuilder/ContainerBuildExecutor/Executors/MetadataOperationExecutor.swift deleted file mode 100644 index 6d00e8ecf..000000000 --- a/Sources/NativeBuilder/ContainerBuildExecutor/Executors/MetadataOperationExecutor.swift +++ /dev/null @@ -1,204 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import ContainerBuildSnapshotter -import Foundation - -/// Executes MetadataOperation (ENV, LABEL, USER, etc.). -public struct MetadataOperationExecutor: OperationExecutor { - public let capabilities: ExecutorCapabilities - - public init() { - self.capabilities = ExecutorCapabilities( - supportedOperations: [.metadata], - maxConcurrency: 20 // Metadata ops are lightweight - ) - } - - public func execute(_ operation: ContainerBuildIR.Operation, context: ExecutionContext) async throws -> ExecutionResult { - guard let metadataOp = operation as? MetadataOperation else { - throw ExecutorError( - type: .invalidConfiguration, - context: ExecutorError.ErrorContext( - operation: operation, underlyingError: NSError(domain: "Executor", code: 1), - diagnostics: ExecutorError.Diagnostics(environment: [:], workingDirectory: "", recentLogs: []))) - } - - do { - // Stub implementation - // In a real implementation, this would update the image configuration - - let startTime = Date() - var environmentChanges: [String: EnvironmentValue] = [:] - var metadataChanges: [String: String] = [:] - - // Apply metadata action - switch metadataOp.action { - case .setEnv(let key, let value): - environmentChanges[key] = value - context.updateEnvironment([key: value]) - context.updateImageConfig { $0.env.append("\(key)=\(value)") } - - // Note: There's no unsetEnv in MetadataAction - // This would need to be handled differently in a real implementation - - case .setLabel(let key, let value): - metadataChanges[key] = value - context.updateImageConfig { $0.labels[key] = value } - - case .setUser(let user): - context.setUser(user) - let userString: String - switch user { - case .named(let name): - userString = name - case .uid(let uid): - userString = String(uid) - case .userGroup(let user, let group): - userString = "\(user):\(group)" - case .uidGid(let uid, let gid): - userString = "\(uid):\(gid)" - } - context.updateImageConfig { $0.user = userString } - - case .setWorkdir(let path): - context.setWorkingDirectory(path) - context.updateImageConfig { $0.workingDir = path } - - case .setEntrypoint(let command): - context.updateImageConfig { config in - switch command { - case .exec(let args): - config.entrypoint = args - case .shell(let cmd): - config.entrypoint = ["/bin/sh", "-c", cmd] - } - } - - case .setCmd(let command): - context.updateImageConfig { config in - switch command { - case .exec(let args): - config.cmd = args - case .shell(let cmd): - config.cmd = ["/bin/sh", "-c", cmd] - } - } - - case .expose(let ports): - for p in ports { - context.updateImageConfig { config in - config.exposedPorts.insert(p.stringValue) - } - } - - case .setHealthcheck(let healthcheck): - context.updateImageConfig { $0.healthcheck = healthcheck } - - case .setStopSignal(let signal): - context.updateImageConfig { $0.stopSignal = signal } - - case .setShell(let shell): - // Shell affects how commands are executed - metadataChanges["shell"] = shell.joined(separator: " ") - - case .addVolume(let path): - context.updateImageConfig { $0.volumes.insert(path) } - - case .setEnvBatch(let vars): - for (key, value) in vars { - environmentChanges[key] = value - context.updateEnvironment([key: value]) - } - context.updateImageConfig { config in - for (key, value) in vars { - config.env.append("\(key)=\(value)") - } - } - - case .setLabelBatch(let labels): - for (key, value) in labels { - metadataChanges[key] = value - } - context.updateImageConfig { config in - for (key, value) in labels { - config.labels[key] = value - } - } - - case .declareArg(let name, let defaultValue): - // ARG declarations are build-time only - metadataChanges["arg:\(name)"] = defaultValue ?? "" - - case .addOnBuild(let instruction): - // ONBUILD is stored as metadata - metadataChanges["onbuild:\(UUID().uuidString)"] = instruction - } - - // Metadata operations don't change the filesystem, so we reuse the parent snapshot - // without going through the prepare/commit cycle - let snapshot = try getOrCreateBaseSnapshot(context: context) - - let duration = Date().timeIntervalSince(startTime) - - return ExecutionResult( - environmentChanges: environmentChanges, - metadataChanges: metadataChanges, - snapshot: snapshot, - duration: duration - ) - - } catch { - throw ExecutorError( - type: .executionFailed, - context: ExecutorError.ErrorContext( - operation: operation, - underlyingError: error, - diagnostics: ExecutorError.Diagnostics( - environment: context.environment.effectiveEnvironment, - workingDirectory: context.workingDirectory, - recentLogs: ["Failed to execute metadata operation", "Error: \(error.localizedDescription)"] - ) - ) - ) - } - } - - /// Get the latest snapshot or create a base snapshot if none exists. - /// - /// Metadata operations don't modify the filesystem, so they can reuse - /// the parent snapshot directly without snapshotter prepare/commit. - /// - /// - Parameter context: The execution context - /// - Returns: The snapshot to use for this metadata operation - private func getOrCreateBaseSnapshot(context: ExecutionContext) throws -> Snapshot { - guard let latest = context.headSnapshot else { - // Create a minimal base snapshot if no parent exists - return Snapshot( - digest: try Digest(algorithm: .sha256, bytes: Data(count: 32)), - size: 0, - parent: nil, - state: .committed() - ) - } - return latest - } - - public func canExecute(_ operation: ContainerBuildIR.Operation) -> Bool { - operation is MetadataOperation - } -} diff --git a/Sources/NativeBuilder/ContainerBuildExecutor/Executors/UnknownOperationExecutor.swift b/Sources/NativeBuilder/ContainerBuildExecutor/Executors/UnknownOperationExecutor.swift deleted file mode 100644 index 91dbec661..000000000 --- a/Sources/NativeBuilder/ContainerBuildExecutor/Executors/UnknownOperationExecutor.swift +++ /dev/null @@ -1,79 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import ContainerBuildSnapshotter -import Foundation - -/// Executes unknown/custom operations using the visitor pattern. -public struct UnknownOperationExecutor: OperationExecutor { - public let capabilities: ExecutorCapabilities - - public init() { - self.capabilities = ExecutorCapabilities( - supportedOperations: [], // Doesn't declare specific operations - maxConcurrency: 1 // Conservative default - ) - } - - public func execute(_ operation: ContainerBuildIR.Operation, context: ExecutionContext) async throws -> ExecutionResult { - do { - // This executor handles operations that don't match built-in types - // In a real implementation, this could: - // 1. Use a plugin system - // 2. Delegate to external executors - // 3. Apply custom logic based on operation metadata - - let startTime = Date() - - // For now, we'll just log and return a no-op result - print("WARNING: Executing unknown operation type: \(type(of: operation))") - print("Operation kind: \(operation.operationKind)") - - // Use the existing snapshot - let snapshot = - try context.headSnapshot - ?? ContainerBuildSnapshotter.Snapshot( - digest: try Digest(algorithm: .sha256, bytes: Data(count: 32)), - size: 0, - parent: nil, - state: .committed() - ) - - let duration = Date().timeIntervalSince(startTime) - - return ExecutionResult( - snapshot: snapshot, - duration: duration, - output: ExecutionOutput( - stdout: "Executed unknown operation: \(operation.operationKind)\n" - ) - ) - } catch { - throw ExecutorError( - type: .executionFailed, - context: ExecutorError.ErrorContext( - operation: operation, underlyingError: error, diagnostics: ExecutorError.Diagnostics(environment: [:], workingDirectory: "", recentLogs: []))) - } - } - - public func canExecute(_ operation: ContainerBuildIR.Operation) -> Bool { - // This executor can handle any operation as a fallback - // In practice, you might want to check for specific metadata - // or operation kinds that indicate custom operations - true - } -} diff --git a/Sources/NativeBuilder/ContainerBuildExecutor/OperationExecutor.swift b/Sources/NativeBuilder/ContainerBuildExecutor/OperationExecutor.swift deleted file mode 100644 index c70bcc797..000000000 --- a/Sources/NativeBuilder/ContainerBuildExecutor/OperationExecutor.swift +++ /dev/null @@ -1,158 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import ContainerBuildSnapshotter -import ContainerizationOCI -import Foundation - -/// Executes individual operations within a build. -/// -/// Implementations handle the actual execution of operations, interacting with -/// the container runtime, filesystem, and other system resources. -public protocol OperationExecutor: Sendable { - /// The capabilities this executor provides. - var capabilities: ExecutorCapabilities { get } - - /// Execute a single operation. - /// - /// - Parameters: - /// - operation: The operation to execute - /// - context: The execution context containing current state - /// - Returns: The result of executing the operation - /// - Throws: Any errors encountered during execution - func execute(_ operation: ContainerBuildIR.Operation, context: ExecutionContext) async throws -> ExecutionResult - - /// Check if this executor can handle the given operation. - /// - /// - Parameter operation: The operation to check - /// - Returns: true if this executor can handle the operation - func canExecute(_ operation: ContainerBuildIR.Operation) -> Bool -} - -/// Describes the capabilities of an executor. -/// -/// Used by the dispatcher to match operations to appropriate executors. -public struct ExecutorCapabilities: Sendable { - /// The operation kinds this executor can handle. - public let supportedOperations: Set - - /// Platform constraints (nil means all platforms). - public let supportedPlatforms: Set? - - /// Whether this executor requires privileged access. - public let requiresPrivileged: Bool - - /// Maximum concurrent operations this executor can handle. - public let maxConcurrency: Int - - /// Resource requirements. - public let resources: ResourceRequirements - - public init( - supportedOperations: Set, - supportedPlatforms: Set? = nil, - requiresPrivileged: Bool = false, - maxConcurrency: Int = 10, - resources: ResourceRequirements = .default - ) { - self.supportedOperations = supportedOperations - self.supportedPlatforms = supportedPlatforms - self.requiresPrivileged = requiresPrivileged - self.maxConcurrency = maxConcurrency - self.resources = resources - } -} - -/// Resource requirements for an executor. -public struct ResourceRequirements: Sendable { - /// Minimum available memory in bytes. - public let minMemory: Int64? - - /// Minimum available disk space in bytes. - public let minDiskSpace: Int64? - - /// Required CPU architecture. - public let cpuArchitecture: String? - - /// Custom requirements. - public let custom: [String: String] - - public init( - minMemory: Int64? = nil, - minDiskSpace: Int64? = nil, - cpuArchitecture: String? = nil, - custom: [String: String] = [:] - ) { - self.minMemory = minMemory - self.minDiskSpace = minDiskSpace - self.cpuArchitecture = cpuArchitecture - self.custom = custom - } - - /// Default resource requirements. - public static let `default` = ResourceRequirements() -} - -/// The result of executing an operation. -public struct ExecutionResult: Sendable { - /// Environment changes made by the operation. - public let environmentChanges: [String: EnvironmentValue] - - /// Metadata changes (labels, etc.). - public let metadataChanges: [String: String] - - /// The snapshot after execution. - public let snapshot: Snapshot - - /// Execution duration. - public let duration: TimeInterval - - /// Any output produced. - public let output: ExecutionOutput? - - public init( - environmentChanges: [String: EnvironmentValue] = [:], - metadataChanges: [String: String] = [:], - snapshot: Snapshot, - duration: TimeInterval, - output: ExecutionOutput? = nil - ) { - self.environmentChanges = environmentChanges - self.metadataChanges = metadataChanges - self.snapshot = snapshot - self.duration = duration - self.output = output - } -} - -/// Output from operation execution. -public struct ExecutionOutput: Sendable { - /// Standard output. - public let stdout: String - - /// Standard error. - public let stderr: String - - /// Exit code (for exec operations). - public let exitCode: Int? - - public init(stdout: String = "", stderr: String = "", exitCode: Int? = nil) { - self.stdout = stdout - self.stderr = stderr - self.exitCode = exitCode - } -} diff --git a/Sources/NativeBuilder/ContainerBuildExecutor/ReportingHelpers.swift b/Sources/NativeBuilder/ContainerBuildExecutor/ReportingHelpers.swift deleted file mode 100644 index 3cad867c2..000000000 --- a/Sources/NativeBuilder/ContainerBuildExecutor/ReportingHelpers.swift +++ /dev/null @@ -1,204 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import ContainerBuildReporting -import Foundation - -// MARK: - Helper Extensions - -extension ReportContext { - /// Create context from a build node and stage - public init(node: BuildNode, stage: BuildStage, operation: any ContainerBuildIR.Operation) { - self.init( - nodeId: node.id, - stageId: stage.name ?? "stage-\(stage.id.uuidString.prefix(8))", - description: Self.describeOperation(operation), - timestamp: Date(), - sourceMap: nil - ) - } - - /// Generate a human-readable description for an operation - public static func describeOperation(_ operation: any ContainerBuildIR.Operation) -> String { - switch operation { - case let exec as ExecOperation: - return "RUN \(exec.command.displayString)" - - case let fs as FilesystemOperation: - switch fs.action { - case .copy: - return "COPY \(Self.describeSource(fs.source)) \(fs.destination)" - case .add: - return "ADD \(Self.describeSource(fs.source)) \(fs.destination)" - case .remove: - return "REMOVE \(fs.destination)" - case .mkdir: - return "MKDIR \(fs.destination)" - case .symlink: - return "SYMLINK \(fs.destination)" - case .hardlink: - return "HARDLINK \(fs.destination)" - } - - case let img as ImageOperation: - switch img.source { - case .registry(let ref): - return "FROM \(ref.stringValue)" - case .scratch: - return "FROM scratch" - case .ociLayout(let path, let tag): - return "FROM oci-layout:\(path)\(tag.map { ":\($0)" } ?? "")" - case .tarball(let path): - return "FROM tarball:\(path)" - } - - case let meta as MetadataOperation: - switch meta.action { - case .setEnv(let key, let value): - return "ENV \(key)=\(Self.describeEnvValue(value))" - case .setEnvBatch(let vars): - return "ENV \(vars.map { "\($0.key)=\(Self.describeEnvValue($0.value))" }.joined(separator: " "))" - case .setWorkdir(let path): - return "WORKDIR \(path)" - case .setUser(let user): - return "USER \(Self.describeUser(user))" - case .setEntrypoint(let cmd): - return "ENTRYPOINT \(cmd.displayString)" - case .setCmd(let cmd): - return "CMD \(cmd.displayString)" - case .setLabel(let key, let value): - return "LABEL \(key)=\(value)" - case .setLabelBatch(let labels): - return "LABEL \(labels.map { "\($0.key)=\($0.value)" }.joined(separator: " "))" - case .declareArg(let name, let defaultValue): - return "ARG \(name)\(defaultValue.map { "=\($0)" } ?? "")" - case .expose(let ports): - return "EXPOSE \(ports.map{$0.stringValue}.joined(separator: " "))" - case .setStopSignal(let signal): - return "STOPSIGNAL \(signal)" - case .setHealthcheck(let hc): - guard let hc = hc else { - return "HEALTHCHECK NONE" - } - switch hc.test { - case .none: - return "HEALTHCHECK NONE" - case .command(let cmd): - return "HEALTHCHECK CMD \(cmd.displayString)" - case .shell(let cmd): - return "HEALTHCHECK CMD-SHELL \(cmd)" - } - case .setShell(let shell): - return "SHELL [\(shell.map { "\"\($0)\"" }.joined(separator: ", "))]" - case .addVolume(let path): - return "VOLUME \(path)" - case .addOnBuild(let instruction): - return "ONBUILD \(instruction)" - } - - default: - return "Operation \(operation.operationKind.rawValue)" - } - } - - private static func describeSource(_ source: FilesystemSource) -> String { - switch source { - case .context(let ctx): - return "\(ctx.name):\(ctx.paths.joined(separator: " "))" - case .stage(let ref, let paths): - let prefix: String - switch ref { - case .named(let name): - prefix = name - case .index(let idx): - prefix = "stage-\(idx)" - case .previous: - prefix = "previous" - } - return "\(prefix):\(paths.joined(separator: " "))" - case .image(let ref, let paths): - return "\(ref.stringValue):\(paths.joined(separator: " "))" - case .url(let url): - return url.absoluteString - case .git(let src): - return src.repository - case .inline(_): - return "" - case .scratch: - return "scratch" - } - } - - private static func describeEnvValue(_ value: EnvironmentValue) -> String { - switch value { - case .literal(let str): - return str - case .buildArg(let name): - return "${\(name)}" - case .expansion(let name, let defaultValue): - guard let defaultValue = defaultValue else { - return "${\(name)}" - } - return "${\(name):-\(defaultValue)}" - } - } - - private static func describeUser(_ user: User) -> String { - switch user { - case .named(let name): - return name - case .uid(let uid): - return String(uid) - case .userGroup(let user, let group): - return "\(user):\(group)" - case .uidGid(let uid, let gid): - return "\(uid):\(gid)" - } - } -} - -extension BuildEventError { - /// Create from ExecutorError - public init(from executorError: ExecutorError) { - let failureType: FailureType - switch executorError.type { - case .executionFailed: - failureType = .executionFailed - case .cancelled: - failureType = .cancelled - case .invalidConfiguration: - failureType = .invalidConfiguration - case .unsupportedOperation: - failureType = .executionFailed // Map unsupported operations to execution failures - } - - var diags: [String: String] = [:] - diags["workingDirectory"] = executorError.context.diagnostics.workingDirectory - for (key, value) in executorError.context.diagnostics.environment { - diags["env.\(key)"] = value - } - if !executorError.context.diagnostics.recentLogs.isEmpty { - diags["recentLogs"] = executorError.context.diagnostics.recentLogs.joined(separator: "\n") - } - - self.init( - type: failureType, - description: executorError.context.underlyingError.localizedDescription, - diagnostics: diags - ) - } -} diff --git a/Sources/NativeBuilder/ContainerBuildExecutor/Scheduler.swift b/Sources/NativeBuilder/ContainerBuildExecutor/Scheduler.swift deleted file mode 100644 index 215ef7e45..000000000 --- a/Sources/NativeBuilder/ContainerBuildExecutor/Scheduler.swift +++ /dev/null @@ -1,1477 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildCache -import ContainerBuildIR -import ContainerBuildReporting -import ContainerBuildSnapshotter -import ContainerizationOCI -import Crypto -import Foundation - -// Import specific type to avoid ambiguity with ContainerBuildIR.CacheKey -import struct ContainerBuildCache.CacheKey -import struct ContainerBuildCache.CacheStatistics -import struct ContainerBuildCache.CachedResult - -/// A production-ready, highly parallel scheduler that minimizes build time through -/// intelligent scheduling and maximum parallelization. -/// -/// Key features: -/// - Parallel execution of independent operations -/// - Dynamic work stealing for load balancing -/// - Resource-aware scheduling with throttling -/// - Priority-based execution ordering -/// - Real-time performance monitoring -/// - Integrated progress reporting via event streams -public final class Scheduler: BuildExecutor { - /// Atomic storage for reporter to maintain Sendable conformance - private let reporterStorage = AtomicStorage() - /// The reporter for this scheduler instance (if progress reporting is enabled) - public var reporter: Reporter? { reporterStorage.value } - /// Completion handler to wait for all consumers - private let completionHandlers = AtomicStorage<[@Sendable () async -> Void]>(initialValue: []) - private let dispatcher: ExecutionDispatcher - private let snapshotter: any Snapshotter - private let cache: any BuildCache - private let configuration: Configuration - - /// Scheduler configuration - public struct Configuration: Sendable { - /// Maximum number of concurrent operations - public let maxConcurrency: Int - - /// Maximum memory usage in bytes - public let maxMemoryUsage: Int64 - - /// Enable work stealing between queues - public let enableWorkStealing: Bool - - /// Enable priority scheduling - public let enablePriorityScheduling: Bool - - /// Resource monitoring interval - public let monitoringInterval: TimeInterval - - /// Fail fast on first error - public let failFast: Bool - - /// Enable progress reporting - public let enableProgressReporting: Bool - - public init( - maxConcurrency: Int = ProcessInfo.processInfo.activeProcessorCount * 2, - maxMemoryUsage: Int64 = 8 * 1024 * 1024 * 1024, // 8GB default - enableWorkStealing: Bool = true, - enablePriorityScheduling: Bool = true, - monitoringInterval: TimeInterval = 0.5, - failFast: Bool = true, - enableProgressReporting: Bool = true - ) { - self.maxConcurrency = maxConcurrency - self.maxMemoryUsage = maxMemoryUsage - self.enableWorkStealing = enableWorkStealing - self.enablePriorityScheduling = enablePriorityScheduling - self.monitoringInterval = monitoringInterval - self.failFast = failFast - self.enableProgressReporting = enableProgressReporting - } - } - - /// Execution state tracking - private let executionState = ExecutionState() - - /// Work queues for parallel execution - private let workQueues: WorkQueueManager - - /// Resource monitor - private let resourceMonitor: ResourceMonitor - - /// Metrics collector - private let metricsCollector = MetricsCollector() - - public init( - executors: [any OperationExecutor], - snapshotter: any Snapshotter, - cache: any BuildCache, - reporter: Reporter? = nil, - configuration: Configuration = Configuration() - ) { - self.dispatcher = ExecutionDispatcher(executors: executors) - self.snapshotter = snapshotter - self.cache = cache - self.configuration = configuration - self.workQueues = WorkQueueManager( - concurrency: configuration.maxConcurrency, - enableWorkStealing: configuration.enableWorkStealing - ) - self.resourceMonitor = ResourceMonitor( - maxMemory: configuration.maxMemoryUsage, - interval: configuration.monitoringInterval - ) - - // Initialize reporter based on configuration - if let reporter = reporter { - self.reporterStorage.value = reporter - } else if configuration.enableProgressReporting { - self.reporterStorage.value = Reporter() - } - } - - /// Convenience initializer with default executors - public convenience init( - snapshotter: any Snapshotter, - cache: any BuildCache, - reporter: Reporter? = nil, - configuration: Configuration = Configuration() - ) { - let defaultExecutors: [any OperationExecutor] = [ - ImageOperationExecutor(), - ExecOperationExecutor(), - FilesystemOperationExecutor(), - MetadataOperationExecutor(), - UnknownOperationExecutor(), - ] - - self.init( - executors: defaultExecutors, - snapshotter: snapshotter, - cache: cache, - reporter: reporter, - configuration: configuration - ) - } - - /// Cancel all in-flight operations and prevent new ones from starting - public func cancel() async { - await executionState.cancel() - await workQueues.cancelAll() - // Note: Individual ExecutionContext instances will clean up their own - // active snapshots when operations are cancelled and throw errors - } - - public func execute(_ graph: BuildGraph) async throws -> BuildResult { - let startTime = Date() - - // Reset state - await executionState.reset() - await metricsCollector.reset() - - // Report build started if we have a reporter - if let reporter = reporter { - let totalOperations = graph.stages.reduce(0) { $0 + $1.nodes.count + 1 } // +1 for base image - await reporter.report(.buildStarted(totalOperations: totalOperations, stages: graph.stages.count, timestamp: Date())) - } - - // Start resource monitoring - let monitoringTask = Task { - await resourceMonitor.startMonitoring(executionState: executionState) - } - - defer { - monitoringTask.cancel() - } - - // Analyze graph for parallelization opportunities - let parallelizationPlan = try analyzeGraph(graph) - - // Execute platforms in parallel when possible - let platformResults: [Platform: ImageManifest] - do { - platformResults = try await withThrowingTaskGroup(of: PlatformResult.self) { group in - for platform in graph.targetPlatforms { - group.addTask { - try await self.executePlatform( - graph: graph, - platform: platform, - plan: parallelizationPlan - ) - } - } - - var results: [Platform: ImageManifest] = [:] - do { - for try await result in group { - results[result.platform] = result.manifest - } - } catch { - // Cancel all remaining tasks on error - group.cancelAll() - // Signal cancellation to execution state - await executionState.cancel() - throw error - } - return results - } - } catch { - // Report build failure - await reporter?.report(.buildCompleted(success: false, timestamp: Date())) - await reporter?.finish() - - // Run completion handlers before throwing - for handler in completionHandlers.value { - await handler() - } - - throw error - } - - // Report build success - await reporter?.report(.buildCompleted(success: true, timestamp: Date())) - await reporter?.finish() - - // Run all completion handlers to ensure consumers finish - for handler in completionHandlers.value { - await handler() - } - - // Collect final metrics - let totalDuration = Date().timeIntervalSince(startTime) - let (metrics, logs) = await metricsCollector.finalizeMetrics(totalDuration: totalDuration, executionState: executionState) - let cacheStats = await cache.statistics() - - return BuildResult( - manifests: platformResults, - metrics: metrics, - cacheStats: cacheStats, - logs: logs - ) - } - - /// Register a completion handler that will be called after the build completes - /// but before execute() returns. This is useful for ensuring progress consumers - /// finish processing all events. - public func onCompletion(_ handler: @escaping @Sendable () async -> Void) { - completionHandlers.value.append(handler) - } - - // MARK: - Graph Analysis - - internal func analyzeGraph(_ graph: BuildGraph) throws -> ParallelizationPlan { - var plan = ParallelizationPlan() - - for stage in graph.stages { - // Analyze dependencies within stage - let analysis = try analyzeStage(stage) - plan.stageAnalyses[stage.id] = analysis - } - - return plan - } - - private func analyzeStage(_ stage: BuildStage) throws -> StageAnalysis { - let dependencyGraph = try buildDependencyGraph(stage) - let parallelizableGroups = findParallelizableGroups(dependencyGraph) - - return StageAnalysis( - dependencyGraph: dependencyGraph, - parallelizableGroups: parallelizableGroups - ) - } - - private func buildDependencyGraph(_ stage: BuildStage) throws -> DependencyGraph { - var graph = DependencyGraph() - - // Add all nodes - for node in stage.nodes { - graph.addNode(node) - } - - // Add edges based on dependencies - for node in stage.nodes { - for dep in node.dependencies { - if let depNode = stage.nodes.first(where: { $0.id == dep }) { - graph.addEdge(from: depNode, to: node) - } - } - } - - // Verify no cycles - if graph.hasCycle() { - throw BuildExecutorError.cyclicDependency - } - - return graph - } - - private func findParallelizableGroups(_ graph: DependencyGraph) -> [[BuildNode]] { - var groups: [[BuildNode]] = [] - var processed = Set() - - // Use Kahn's algorithm to find nodes that can execute in parallel - while processed.count < graph.nodeCount { - var currentGroup: [BuildNode] = [] - - // Find all nodes with no unprocessed dependencies - for node in graph.allNodes { - if !processed.contains(node.id) { - let deps = graph.dependencies(of: node) - if deps.allSatisfy({ processed.contains($0.id) }) { - currentGroup.append(node) - } - } - } - - if currentGroup.isEmpty { - break // Shouldn't happen if no cycles - } - - groups.append(currentGroup) - currentGroup.forEach { processed.insert($0.id) } - } - - return groups - } - - // MARK: - Platform Execution - - private func executePlatform( - graph: BuildGraph, - platform: Platform, - plan: ParallelizationPlan - ) async throws -> PlatformResult { - let stages = try graph.stagesForExecution(targetStage: graph.targetStage) - var stageSnapshots: [String: Snapshot] = [:] - var finalSnapshot: Snapshot? - - // Build stage dependency graph to find parallelizable stages - var stageDependencies: [UUID: Set] = [:] - for stage in stages { - stageDependencies[stage.id] = Set() - - // Check for COPY --from dependencies - for node in stage.nodes { - if let copyOp = node.operation as? FilesystemOperation, - case .stage(let stageRef, _) = copyOp.source - { - if let depStage = resolveStageReference(stageRef, in: stages, currentStage: stage) { - stageDependencies[stage.id]?.insert(depStage.id) - } - } - } - } - - // Execute all base images in parallel first - var baseImageSnapshots: [UUID: Snapshot] = [:] - let sharedContext = SharedStageContext() - - // Start all base image operations in parallel - try await withThrowingTaskGroup(of: (UUID, String?, Snapshot?).self) { group in - for stage in stages { - group.addTask { - if await self.executionState.isCancelled { - throw BuildExecutorError.cancelled - } - - let context = ExecutionContext( - stage: stage, - graph: graph, - platform: platform, - reporter: self.reporter ?? Reporter(), - snapshotter: self.snapshotter - ) - - let stageName = stage.name ?? "stage-\(stage.id.uuidString.prefix(8))" - let baseNodeId = UUID() - let baseReportContext = ReportContext( - nodeId: baseNodeId, - stageId: stageName, - description: ReportContext.describeOperation(stage.base), - timestamp: Date(), - sourceMap: nil - ) - - await self.reporter?.report(.operationStarted(context: baseReportContext)) - - let baseSnapshot = try await self.executeBaseImage(stage.base, context: context) - - await self.reporter?.report(.operationFinished(context: baseReportContext, duration: 0)) - - return (stage.id, stage.name, baseSnapshot) - } - } - - // Collect base image results and store in shared context - for try await (stageId, stageName, snapshot) in group { - if let snapshot = snapshot { - baseImageSnapshots[stageId] = snapshot - // Store in shared context so stages can access via COPY --from - if let name = stageName { - await sharedContext.setSnapshot(name, snapshot: snapshot) - } - } - } - } - - // Execute stages in parallel when possible - var completedStages = Set() - var stageResults: [UUID: Snapshot] = [:] - - while completedStages.count < stages.count { - if await executionState.isCancelled { - throw BuildExecutorError.cancelled - } - - // Find stages that can run now (all dependencies completed) - var stagesToRun: [BuildStage] = [] - for stage in stages { - if !completedStages.contains(stage.id) { - let deps = stageDependencies[stage.id] ?? [] - if deps.isSubset(of: completedStages) { - stagesToRun.append(stage) - } - } - } - - if stagesToRun.isEmpty { - throw BuildExecutorError.cyclicDependency - } - - // Execute all ready stages in parallel - try await withThrowingTaskGroup(of: (UUID, String?, Snapshot).self) { group in - for stage in stagesToRun { - let baseSnapshot = baseImageSnapshots[stage.id] - group.addTask { - // Check for cancellation before starting - if await self.executionState.isCancelled { - throw BuildExecutorError.cancelled - } - - let context = ExecutionContext( - stage: stage, - graph: graph, - platform: platform, - reporter: self.reporter ?? Reporter(), - snapshotter: self.snapshotter - ) - - let stageName = stage.name ?? "stage-\(stage.id.uuidString.prefix(8))" - await self.reporter?.report(.stageStarted(stageName: stageName, timestamp: Date())) - - guard let stageAnalysis = plan.stageAnalyses[stage.id] else { - throw BuildExecutorError.internalError("Stage analysis not found for stage \(stage.id)") - } - - // Set the base image snapshot in the context if available - if let baseSnapshot = baseSnapshot { - context.setSnapshot(baseSnapshot, for: UUID()) - await self.executionState.markNodeCompleted(UUID()) - } - - let snapshot = try await self.executeStageParallel( - stage, - context: context, - sharedContext: sharedContext, - plan: stageAnalysis, - skipBaseImage: true - ) - - await self.reporter?.report(.stageCompleted(stageName: stageName, timestamp: Date())) - - return (stage.id, stage.name, snapshot) - } - } - - // Collect results - for try await (stageId, stageName, snapshot) in group { - completedStages.insert(stageId) - stageResults[stageId] = snapshot - if let name = stageName { - await sharedContext.setSnapshot(name, snapshot: snapshot) - stageSnapshots[name] = snapshot - } - finalSnapshot = snapshot - } - } - } - - guard let snapshot = finalSnapshot else { - throw BuildExecutorError.stageNotFound("No stages executed") - } - - let configDigest = try Digest(algorithm: .sha256, bytes: Data(count: 32)) - let manifest = ImageManifest( - digest: snapshot.digest, - size: snapshot.size, - configDigest: configDigest, - layers: [LayerDescriptor(digest: snapshot.digest, size: snapshot.size)] - ) - - return PlatformResult(platform: platform, manifest: manifest) - } - - private func resolveStageReference(_ ref: StageReference, in stages: [BuildStage], currentStage: BuildStage) -> BuildStage? { - switch ref { - case .named(let name): - return stages.first { $0.name == name } - case .index(let idx): - return idx < stages.count ? stages[idx] : nil - case .previous: - if let currentIndex = stages.firstIndex(where: { $0.id == currentStage.id }), currentIndex > 0 { - return stages[currentIndex - 1] - } - return nil - } - } - - private func executeStageParallel( - _ stage: BuildStage, - context: ExecutionContext, - sharedContext: SharedStageContext, - plan: StageAnalysis, - skipBaseImage: Bool = false - ) async throws -> Snapshot { - let stageStart = Date() - defer { - let duration = Date().timeIntervalSince(stageStart) - Task { - await metricsCollector.recordStageDuration(stage.name ?? "unnamed", duration: duration) - } - } - - // Execute base image (unless it was already executed) - if !skipBaseImage { - let baseNodeId = UUID() - let baseReportContext = ReportContext( - nodeId: baseNodeId, - stageId: stage.name ?? "stage-\(stage.id.uuidString.prefix(8))", - description: ReportContext.describeOperation(stage.base), - timestamp: Date(), - sourceMap: nil - ) - - // Report base image operation started - await context.reporter.report(.operationStarted(context: baseReportContext)) - - if let baseSnapshot = try await executeBaseImage(stage.base, context: context) { - context.setSnapshot(baseSnapshot, for: baseNodeId) - - // Report base image operation finished - await context.reporter.report(.operationFinished(context: baseReportContext, duration: 0)) - - // Mark base operation as completed so nodes can depend on it - await executionState.markNodeCompleted(baseNodeId) - } - } - - // Execute nodes in parallel groups - for (groupIndex, group) in plan.parallelizableGroups.enumerated() { - do { - try await executeNodeGroup(group, context: context, stage: stage) - } catch { - // Handle errors based on configuration - if configuration.failFast { - throw BuildExecutorError.operationFailed( - group.first?.operation ?? UnknownOperation(metadata: OperationMetadata()), - underlying: error - ) - } else { - // Log error and continue if possible - print("Warning: Group \(groupIndex) in stage '\(stage.name ?? "unnamed")' failed: \(error)") - // Mark failed nodes to skip dependents - for node in group { - await executionState.markNodeFailed(node.id) - } - } - } - } - - guard let finalSnapshot = context.headSnapshot else { - throw BuildExecutorError.stageNotFound("No operations in stage") - } - - return finalSnapshot - } - - private func executeNodeGroup( - _ nodes: [BuildNode], - context: ExecutionContext, - stage: BuildStage - ) async throws { - // Wait for available execution slots - await resourceMonitor.waitForResources(count: nodes.count) - - try await withThrowingTaskGroup(of: Void.self) { group in - for node in nodes { - group.addTask { - try await self.executeNodeWithTracking(node, context: context, stage: stage) - } - } - - do { - try await group.waitForAll() - } catch { - // On error, cancel all remaining tasks - group.cancelAll() - - // Signal cancellation if configured for fail-fast - if self.configuration.failFast { - await self.executionState.cancel() - } - - throw error - } - } - } - - private func executeNodeWithTracking( - _ node: BuildNode, - context: ExecutionContext, - stage: BuildStage - ) async throws { - // Wait for ALL dependencies (including cross-stage) to complete - for depId in node.dependencies { - do { - try await executionState.waitForNode(depId) - } catch { - await executionState.markNodeFailed(node.id) - throw BuildExecutorError.operationFailed( - node.operation, - underlying: error - ) - } - } - - let nodeStart = Date() - await executionState.incrementOperationCount() - - // Create report context - let reportContext = ReportContext(node: node, stage: stage, operation: node.operation) - - // Report operation started - await context.reporter.report(.operationStarted(context: reportContext)) - - defer { - let duration = Date().timeIntervalSince(nodeStart) - Task { - await metricsCollector.recordOperationDuration(node.id, duration: duration) - await resourceMonitor.releaseResource() - } - } - - // Check cache - let cacheKey: CacheKey - do { - cacheKey = try computeCacheKey(node: node, context: context) - } catch { - // If we can't compute cache key, skip caching and execute directly - cacheKey = CacheKey( - operationDigest: try Digest(algorithm: .sha256, bytes: Data(count: 32)), - inputDigests: [], - platform: context.platform - ) - } - - if let cached = await cache.get(cacheKey, for: node.operation) { - await executionState.incrementCacheHits() - context.setSnapshot(cached.snapshot, for: node.id) - - // Report cache hit - await context.reporter.report(.operationCacheHit(context: reportContext)) - - // Mark node as completed - await executionState.markNodeCompleted(node.id) - return - } - - // Execute operation with retry logic - var lastError: Error? - var retryCount = 0 - let retryPolicy = node.operation.metadata.retryPolicy - let maxRetries = retryPolicy.maxRetries - - while retryCount <= maxRetries { - // Check for cancellation before each attempt - if await executionState.isCancelled { - throw BuildExecutorError.cancelled - } - - do { - let result = try await self.executeNode(node, context: context) - - if let output = result.output { - if !output.stdout.isEmpty { - await metricsCollector.recordLog("[\(node.id)] \(output.stdout)") - // Report operation log - await context.reporter.report(.operationLog(context: reportContext, message: output.stdout)) - } - if !output.stderr.isEmpty { - await metricsCollector.recordLog("[\(node.id)] [STDERR] \(output.stderr)") - // Report operation log for stderr - await context.reporter.report(.operationLog(context: reportContext, message: "[STDERR] \(output.stderr)")) - } - } - - context.setSnapshot(result.snapshot, for: node.id) - - // Store in cache - let cachedResult = CachedResult( - snapshot: result.snapshot, - environmentChanges: result.environmentChanges, - metadataChanges: result.metadataChanges - ) - await cache.put(cachedResult, key: cacheKey, for: node.operation) - - // Report operation finished - await context.reporter.report(.operationFinished(context: reportContext, duration: result.duration)) - - // Mark node as completed - await executionState.markNodeCompleted(node.id) - return // Success - - } catch { - lastError = error - retryCount = await executionState.incrementRetryCount(for: node.id) - - if retryCount <= maxRetries { - // Calculate backoff delay - let delay = min( - retryPolicy.initialDelay * pow(retryPolicy.backoffMultiplier, Double(retryCount - 1)), - retryPolicy.maxDelay - ) - - print("Retrying operation \(node.id) after \(delay)s (attempt \(retryCount)/\(maxRetries))") - try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - } - } - } - - // All retries failed - await executionState.markNodeFailed(node.id) - - // Report operation failed - let finalError = - lastError - ?? BuildExecutorError.operationFailed( - node.operation, - underlying: UnknownFailureError() - ) - let eventError = BuildEventError( - type: .executionFailed, - description: finalError.localizedDescription, - diagnostics: nil - ) - await context.reporter.report(.operationFailed(context: reportContext, error: eventError)) - - throw BuildExecutorError.operationFailed(node.operation, underlying: finalError) - } - - // MARK: - Node Execution - - private func executeBaseImage( - _ operation: ImageOperation, - context: ExecutionContext - ) async throws -> Snapshot? { - let result = try await dispatcher.dispatch(operation, context: context) - return result.snapshot - } - - private func executeNode( - _ node: BuildNode, - context: ExecutionContext - ) async throws -> ExecutionResult { - let constraints = buildNodeConstraints(node) - - return try await dispatcher.dispatch( - node.operation, - context: context, - constraints: constraints - ) - } - - private func buildNodeConstraints(_ node: BuildNode) -> NodeConstraints? { - guard !node.constraints.isEmpty else { return nil } - - var requiresPrivileged = false - var minMemory: Int64? - var cpuArchitecture: String? - - for constraint in node.constraints { - switch constraint { - case .requiresPrivileged: - requiresPrivileged = true - case .memoryLimit(let limit): - minMemory = Int64(limit) - case .requiresPlatform(let platform): - cpuArchitecture = platform.architecture - default: - break - } - } - - return NodeConstraints( - requiresPrivileged: requiresPrivileged, - minMemory: minMemory, - cpuArchitecture: cpuArchitecture - ) - } - - // MARK: - Cache Key Generation - - private func computeCacheKey( - node: BuildNode, - context: ExecutionContext - ) throws -> CacheKey { - let operationDigest = try node.operation.contentDigest() - - var inputDigests: [ContainerBuildIR.Digest] = [] - - // Add parent snapshot digest - if let parentSnapshot = context.headSnapshot { - inputDigests.append(parentSnapshot.digest) - } - - // Add dependency snapshots - for depId in node.dependencies { - if let depSnapshot = context.snapshot(for: depId) { - inputDigests.append(depSnapshot.digest) - } - } - - return CacheKey( - operationDigest: operationDigest, - inputDigests: inputDigests.sorted(by: { $0.stringValue < $1.stringValue }), - platform: context.platform - ) - } - -} - -// MARK: - Supporting Types - -/// Shared context for stages running in parallel -private actor SharedStageContext { - private var snapshots: [String: Snapshot] = [:] - - func setSnapshot(_ name: String, snapshot: Snapshot) { - snapshots[name] = snapshot - } - - func getSnapshot(_ name: String) -> Snapshot? { - snapshots[name] - } - - func getAllSnapshots() -> [String: Snapshot] { - snapshots - } -} - -/// Tracks execution state across the scheduler -private actor ExecutionState { - private var cancelled = false - private var operationCount = 0 - private var cacheHits = 0 - private var failedNodes: Set = [] - private var nodeRetries: [UUID: Int] = [:] - private var completedNodes: Set = [] - private var nodeCompletionWaiters: [UUID: [CheckedContinuation]] = [:] - - var isCancelled: Bool { cancelled } - - func cancel() { - cancelled = true - // Wake up any waiters with cancellation error - for (_, waiters) in nodeCompletionWaiters { - for waiter in waiters { - waiter.resume(throwing: BuildExecutorError.cancelled) - } - } - nodeCompletionWaiters.removeAll() - } - - func reset() { - cancelled = false - operationCount = 0 - cacheHits = 0 - failedNodes.removeAll() - nodeRetries.removeAll() - completedNodes.removeAll() - nodeCompletionWaiters.removeAll() - } - - func incrementOperationCount() { - operationCount += 1 - } - - func incrementCacheHits() { - cacheHits += 1 - } - - func markNodeCompleted(_ nodeId: UUID) { - completedNodes.insert(nodeId) - // Wake up any waiters for this node - if let waiters = nodeCompletionWaiters.removeValue(forKey: nodeId) { - for waiter in waiters { - waiter.resume() - } - } - } - - func markNodeFailed(_ nodeId: UUID) { - failedNodes.insert(nodeId) - // Wake up any waiters with failure - if let waiters = nodeCompletionWaiters.removeValue(forKey: nodeId) { - for waiter in waiters { - waiter.resume(throwing: DependencyFailedError(dependencyId: nodeId)) - } - } - } - - func isNodeCompleted(_ nodeId: UUID) -> Bool { - completedNodes.contains(nodeId) - } - - func isNodeFailed(_ nodeId: UUID) -> Bool { - failedNodes.contains(nodeId) - } - - func waitForNode(_ nodeId: UUID) async throws { - if completedNodes.contains(nodeId) { - return // Already completed - } - - if failedNodes.contains(nodeId) { - throw DependencyFailedError(dependencyId: nodeId) - } - - if cancelled { - throw BuildExecutorError.cancelled - } - - // Wait for the node to complete - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - // Check again in case state changed - if completedNodes.contains(nodeId) { - continuation.resume() - return - } - - if failedNodes.contains(nodeId) { - continuation.resume(throwing: DependencyFailedError(dependencyId: nodeId)) - return - } - - if cancelled { - continuation.resume(throwing: BuildExecutorError.cancelled) - return - } - - var waiters = nodeCompletionWaiters[nodeId] ?? [] - waiters.append(continuation) - nodeCompletionWaiters[nodeId] = waiters - } - } - - func incrementRetryCount(for nodeId: UUID) -> Int { - let count = (nodeRetries[nodeId] ?? 0) + 1 - nodeRetries[nodeId] = count - return count - } - - func getStats() -> (operations: Int, hits: Int, failures: Int) { - (operationCount, cacheHits, failedNodes.count) - } -} - -/// Manages work queues for parallel execution with work stealing -private actor WorkQueueManager { - private let queues: [WorkQueue] - private let enableWorkStealing: Bool - private var nextQueueIndex = 0 - - init(concurrency: Int, enableWorkStealing: Bool) { - self.queues = (0.. Void) { - // Round-robin with load balancing - let startIndex = nextQueueIndex - var minLoad = Int.max - var targetQueue = queues[startIndex] - - for i in 0.. Void] = [] - private let lock = NSLock() - private var cancelled = false - private var isProcessing = false - private var stealTargets: [WorkQueue] = [] - private let workAvailable = NSCondition() - - var currentLoad: Int { - lock.withLock { tasks.count } - } - - init(id: Int) { - self.id = id - } - - func setStealTargets(_ targets: [WorkQueue]) { - lock.withLock { - self.stealTargets = targets - } - } - - func enqueue(_ task: @escaping () async throws -> Void) { - lock.withLock { - guard !cancelled else { return } - tasks.append(task) - workAvailable.signal() - } - } - - func processLoop() async { - while !cancelled { - if let task = dequeueOrSteal() { - do { - try await task() - } catch { - // Log error but continue processing - print("Task failed: \(error)") - } - } else { - // No work available, wait - lock.withLock { - guard !cancelled && tasks.isEmpty else { return } - workAvailable.wait() - } - } - } - } - - private func dequeueOrSteal() -> (() async throws -> Void)? { - // Try to get from own queue first - if let task = dequeue() { - return task - } - - // If work stealing is enabled, try to steal from others - if !stealTargets.isEmpty { - // Randomize steal order to avoid contention - let shuffled = stealTargets.shuffled() - for target in shuffled { - if let task = target.steal() { - return task - } - } - } - - return nil - } - - private func dequeue() -> (() async throws -> Void)? { - lock.withLock { - guard !tasks.isEmpty else { return nil } - return tasks.removeFirst() - } - } - - private func steal() -> (() async throws -> Void)? { - lock.withLock { - // Steal from the back to minimize contention - guard tasks.count > 1 else { return nil } - return tasks.removeLast() - } - } - - func cancel() { - lock.withLock { - cancelled = true - tasks.removeAll() - workAvailable.broadcast() - } - } -} - -/// Monitors resource usage -private actor ResourceMonitor { - private let maxMemory: Int64 - private let interval: TimeInterval - private var availableSlots: Int - private var waiters: [CheckedContinuation] = [] - - init(maxMemory: Int64, interval: TimeInterval) { - self.maxMemory = maxMemory - self.interval = interval - self.availableSlots = ProcessInfo.processInfo.activeProcessorCount * 2 - } - - func startMonitoring(executionState: ExecutionState) async { - while await !executionState.isCancelled { - try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) - // Monitor memory and CPU usage - // Adjust available slots based on system load - } - } - - func waitForResources(count: Int) async { - while availableSlots < count { - await withCheckedContinuation { continuation in - waiters.append(continuation) - } - } - availableSlots -= count - } - - func releaseResource() { - availableSlots += 1 - if let waiter = waiters.first { - waiters.removeFirst() - waiter.resume() - } - } -} - -/// Collects execution metrics -private actor MetricsCollector { - private var operationDurations: [UUID: TimeInterval] = [:] - private var stageDurations: [String: TimeInterval] = [:] - private var logs: [String] = [] - private var startTime = Date() - - func reset() { - operationDurations.removeAll() - stageDurations.removeAll() - logs.removeAll() - startTime = Date() - } - - func recordOperationDuration(_ id: UUID, duration: TimeInterval) { - operationDurations[id] = duration - } - - func recordStageDuration(_ name: String, duration: TimeInterval) { - stageDurations[name] = duration - } - - func recordLog(_ log: String) { - logs.append(log) - } - - func finalizeMetrics(totalDuration: TimeInterval, executionState: ExecutionState) async -> (ExecutionMetrics, [String]) { - let stats = await executionState.getStats() - let metrics = ExecutionMetrics( - totalDuration: totalDuration, - stageDurations: stageDurations, - operationCount: operationDurations.count, - cachedOperationCount: stats.hits, - bytesTransferred: 0 // TODO: Track this - ) - return (metrics, logs) - } -} - -/// Result for a single platform build -private struct PlatformResult { - let platform: Platform - let manifest: ImageManifest -} - -/// Parallelization analysis results -internal struct ParallelizationPlan { - var stageAnalyses: [UUID: StageAnalysis] = [:] -} - -/// Analysis results for a single stage -internal struct StageAnalysis { - let dependencyGraph: DependencyGraph - let parallelizableGroups: [[BuildNode]] -} - -/// Dependency graph for analysis -internal struct DependencyGraph { - private var adjacencyList: [UUID: Set] = [:] - private var nodesList: [BuildNode] = [] - - var nodeCount: Int { nodesList.count } - var allNodes: [BuildNode] { nodesList } - - mutating func addNode(_ node: BuildNode) { - nodesList.append(node) - adjacencyList[node.id] = [] - } - - mutating func addEdge(from: BuildNode, to: BuildNode) { - adjacencyList[from.id]?.insert(to.id) - } - - func dependencies(of node: BuildNode) -> [BuildNode] { - nodesList.filter { node.dependencies.contains($0.id) } - } - - func hasCycle() -> Bool { - var visited = Set() - var recursionStack = Set() - - func dfs(_ nodeId: UUID) -> Bool { - visited.insert(nodeId) - recursionStack.insert(nodeId) - - if let neighbors = adjacencyList[nodeId] { - for neighbor in neighbors { - if !visited.contains(neighbor) { - if dfs(neighbor) { - return true - } - } else if recursionStack.contains(neighbor) { - return true - } - } - } - - recursionStack.remove(nodeId) - return false - } - - for node in nodesList { - if !visited.contains(node.id) { - if dfs(node.id) { - return true - } - } - } - - return false - } -} - -// MARK: - Error Types - -/// Error when a dependency failed -struct DependencyFailedError: LocalizedError { - let dependencyId: UUID - - var errorDescription: String? { - "Dependency \(dependencyId) failed" - } -} - -/// Error when operation fails for unknown reasons -struct UnknownFailureError: LocalizedError { - var errorDescription: String? { - "Operation failed for unknown reasons" - } -} - -/// Placeholder for unknown operations -struct UnknownOperation: ContainerBuildIR.Operation { - var metadata: OperationMetadata - static let operationKind = OperationKind(rawValue: "unknown") - var operationKind: OperationKind { Self.operationKind } - - func accept(_ visitor: V) throws -> V.Result where V: OperationVisitor { - throw BuildExecutorError.unsupportedOperation(self) - } -} - -// MARK: - Extensions - -extension BuildGraph { - /// Get stages in execution order for a target stage, resolving all dependencies. - func stagesForExecution(targetStage: BuildStage?) throws -> [BuildStage] { - let target = targetStage ?? stages.last - guard let target = target else { - return [] - } - - // Build dependency graph for stages - var stageDependencies: [UUID: Set] = [:] - var stagesByName: [String: BuildStage] = [:] - var stagesByID: [UUID: BuildStage] = [:] - - // Index stages - for stage in stages { - stagesByID[stage.id] = stage - if let name = stage.name { - stagesByName[name] = stage - } - stageDependencies[stage.id] = [] - } - - // Resolve FROM dependencies - // Note: In the current IR, stage dependencies are handled differently - // The base is always an ImageOperation, not a stage reference - // Stage-to-stage dependencies are handled through COPY --from operations - - // Resolve COPY --from dependencies - for stage in stages { - for node in stage.nodes { - if let copyOp = node.operation as? FilesystemOperation, - case .stage(let stageRef, _) = copyOp.source - { - let stageName: String - switch stageRef { - case .named(let name): - stageName = name - case .index(let idx): - // Find stage by index - guard idx < stages.count else { - throw BuildExecutorError.stageNotFound("Stage index \(idx) out of bounds") - } - stageName = stages[idx].name ?? "stage-\(idx)" - case .previous: - // Find the previous stage - guard let currentIndex = stages.firstIndex(where: { $0.id == stage.id }), - currentIndex > 0 - else { - throw BuildExecutorError.stageNotFound("No previous stage available") - } - stageName = stages[currentIndex - 1].name ?? "stage-\(currentIndex - 1)" - } - - guard let sourceStage = stagesByName[stageName] else { - throw BuildExecutorError.stageNotFound("Stage '\(stageName)' referenced in COPY --from not found") - } - stageDependencies[stage.id]?.insert(sourceStage.id.uuidString) - } - } - } - - // Topological sort to find execution order - var visited = Set() - var recursionStack = Set() - var executionOrder: [BuildStage] = [] - - func visit(_ stageId: UUID) throws { - if recursionStack.contains(stageId) { - throw BuildExecutorError.cyclicDependency - } - - if visited.contains(stageId) { - return - } - - recursionStack.insert(stageId) - - // Visit dependencies first - if let deps = stageDependencies[stageId] { - for depIdString in deps { - if let depId = UUID(uuidString: depIdString), - let _ = stagesByID[depId] - { - try visit(depId) - } - } - } - - recursionStack.remove(stageId) - visited.insert(stageId) - - if let stage = stagesByID[stageId] { - executionOrder.append(stage) - } - } - - // Start from target and work backwards - try visit(target.id) - - // Also visit any stages that the target transitively depends on - var targetDependencies = Set() - func collectDependencies(_ stageId: UUID) { - if let deps = stageDependencies[stageId] { - for depIdString in deps { - if let depId = UUID(uuidString: depIdString) { - if !targetDependencies.contains(depId) { - targetDependencies.insert(depId) - collectDependencies(depId) - } - } - } - } - } - collectDependencies(target.id) - - // Include all required stages - for stage in stages { - if targetDependencies.contains(stage.id) || stage.id == target.id { - if !visited.contains(stage.id) { - try visit(stage.id) - } - } - } - - return executionOrder - } -} - -// MARK: - Thread-Safe Storage - -/// Thread-safe storage for reference types -private final class AtomicStorage: @unchecked Sendable { - private var _value: T - private let lock = NSLock() - - var value: T { - get { - lock.withLock { _value } - } - set { - lock.withLock { _value = newValue } - } - } - - init(initialValue: T) { - self._value = initialValue - } -} - -extension AtomicStorage { - convenience init() where T: ExpressibleByNilLiteral { - self.init(initialValue: nil) - } -} diff --git a/Sources/NativeBuilder/ContainerBuildIR/Analysis/Analyzer.swift b/Sources/NativeBuilder/ContainerBuildIR/Analysis/Analyzer.swift deleted file mode 100644 index 0afb7d022..000000000 --- a/Sources/NativeBuilder/ContainerBuildIR/Analysis/Analyzer.swift +++ /dev/null @@ -1,44 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildReporting -import Foundation - -/// Context provided to analyzers during analysis -public struct AnalysisContext: Sendable { - /// Reporter for emitting warnings, errors, and other events - public let reporter: Reporter? - - /// Source location information if available - public let sourceMap: SourceMap? - - public init(reporter: Reporter? = nil, sourceMap: SourceMap? = nil) { - self.reporter = reporter - self.sourceMap = sourceMap - } -} - -/// Stage-level analyzer that can transform stages -public protocol StageAnalyzer { - /// Analyze and potentially transform a single stage - func analyze(_ stage: BuildStage, context: AnalysisContext) throws -> BuildStage -} - -/// Graph-level analyzer that can transform the entire build graph -public protocol GraphAnalyzer { - /// Analyze and potentially transform the entire build graph - func analyze(_ graph: BuildGraph, context: AnalysisContext) throws -> BuildGraph -} diff --git a/Sources/NativeBuilder/ContainerBuildIR/Analysis/DependencyAnalyzer.swift b/Sources/NativeBuilder/ContainerBuildIR/Analysis/DependencyAnalyzer.swift deleted file mode 100644 index f43633340..000000000 --- a/Sources/NativeBuilder/ContainerBuildIR/Analysis/DependencyAnalyzer.swift +++ /dev/null @@ -1,111 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildReporting -import Foundation - -/// Establishes dependencies between operations in the build graph. -/// -/// This analyzer is responsible for: -/// - Setting up intra-stage dependencies (sequential operations within a stage) -/// - Establishing cross-stage dependencies (COPY --from operations) -/// - Ensuring operations have proper dependencies for correct execution order -public struct DependencyAnalyzer: GraphAnalyzer { - - public init() {} - - public func analyze(_ graph: BuildGraph, context: AnalysisContext) throws -> BuildGraph { - var updatedStages: [BuildStage] = [] - - // Process each stage - for stage in graph.stages { - let updatedStage = try analyzeStage(stage, allStages: graph.stages) - updatedStages.append(updatedStage) - } - - // Create updated graph with new dependencies - return try BuildGraph( - stages: updatedStages, - buildArgs: graph.buildArgs, - targetPlatforms: graph.targetPlatforms, - metadata: graph.metadata - ) - } - - private func analyzeStage(_ stage: BuildStage, allStages: [BuildStage]) throws -> BuildStage { - var updatedNodes: [BuildNode] = [] - var lastNodeId: UUID? = nil - - // Process each node in the stage - for (index, node) in stage.nodes.enumerated() { - var dependencies = node.dependencies - - // 1. Sequential dependency: each operation depends on the previous one - if dependencies.isEmpty && index > 0 { - if let lastId = lastNodeId { - dependencies.insert(lastId) - } - } - - // 2. Cross-stage dependencies: COPY --from operations - if let copyOp = node.operation as? FilesystemOperation { - if case .stage(let stageRef, _) = copyOp.source { - // Find the referenced stage - if let sourceStage = resolveStageReference(stageRef, currentStage: stage, allStages: allStages) { - // COPY --from depends on all operations in the source stage - if let lastOp = sourceStage.nodes.last { - dependencies.insert(lastOp.id) - } - } - } - } - - // Create updated node with dependencies - let updatedNode = BuildNode( - id: node.id, - operation: node.operation, - dependencies: dependencies - ) - - updatedNodes.append(updatedNode) - lastNodeId = updatedNode.id - } - - // Return updated stage - return BuildStage( - id: stage.id, - name: stage.name, - base: stage.base, - nodes: updatedNodes, - platform: stage.platform - ) - } - - private func resolveStageReference(_ ref: StageReference, currentStage: BuildStage, allStages: [BuildStage]) -> BuildStage? { - switch ref { - case .named(let name): - return allStages.first { $0.name == name } - case .index(let idx): - return idx < allStages.count ? allStages[idx] : nil - case .previous: - // Find current stage index - if let currentIndex = allStages.firstIndex(where: { $0.id == currentStage.id }), currentIndex > 0 { - return allStages[currentIndex - 1] - } - return nil - } - } -} diff --git a/Sources/NativeBuilder/ContainerBuildIR/Analysis/SemanticAnalyzer.swift b/Sources/NativeBuilder/ContainerBuildIR/Analysis/SemanticAnalyzer.swift deleted file mode 100644 index 9a29d0e3f..000000000 --- a/Sources/NativeBuilder/ContainerBuildIR/Analysis/SemanticAnalyzer.swift +++ /dev/null @@ -1,482 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildReporting -import Foundation - -/// Performs semantic analysis on build graphs. -/// -/// Design rationale: -/// - Goes beyond structural validation to understand intent -/// - Provides optimization suggestions -/// - Detects common patterns and anti-patterns -public struct SemanticAnalyzer: GraphAnalyzer { - - /// Initialize a new semantic analyzer. - public init() {} - - /// Analyze a build graph and report issues via the reporter - public func analyze(_ graph: BuildGraph, context: AnalysisContext) throws -> BuildGraph { - // Perform the analysis - let analysis = performAnalysis(graph) - - // Report findings via the reporter - reportFindings(analysis, context: context) - - // Return the graph unchanged (semantic analyzer doesn't transform) - return graph - } - - /// Perform semantic analysis on the graph - private func performAnalysis(_ graph: BuildGraph) -> SemanticAnalysis { - let layerAnalysis = analyzeLayerEfficiency(graph) - let cacheAnalysis = analyzeCacheability(graph) - let securityAnalysis = analyzeSecurityPosture(graph) - let sizeAnalysis = analyzeSizeOptimizations(graph) - - return SemanticAnalysis( - layerEfficiency: layerAnalysis, - cacheability: cacheAnalysis, - security: securityAnalysis, - sizeOptimizations: sizeAnalysis - ) - } - - // MARK: - Layer Efficiency - - private func analyzeLayerEfficiency(_ graph: BuildGraph) -> LayerEfficiencyAnalysis { - var issues: [LayerIssue] = [] - - for stage in graph.stages { - // Check for operations that could be combined - let _ = stage.nodes.compactMap { $0.operation as? ExecOperation } - - // Detect multiple package manager invocations - var packageManagerCalls: [(command: String, node: BuildNode)] = [] - - for (_, node) in stage.nodes.enumerated() { - if let execOp = node.operation as? ExecOperation, - case .shell(let cmd) = execOp.command - { - if cmd.contains("apt-get") || cmd.contains("yum") || cmd.contains("apk") || cmd.contains("dnf") { - packageManagerCalls.append((cmd, node)) - } - } - } - - if packageManagerCalls.count > 1 { - issues.append( - LayerIssue( - type: .multipleLayers, - description: "Multiple package manager invocations create separate layers", - suggestion: "Combine package installations into a single RUN command", - estimatedImpact: .high - )) - } - - // Check for file operations followed by deletions - for (index, node) in stage.nodes.enumerated() { - if let fsOp = node.operation as? FilesystemOperation, - fsOp.action == .copy || fsOp.action == .add - { - // Look for subsequent removal - for nextNode in stage.nodes[(index + 1)...] { - if let nextFs = nextNode.operation as? FilesystemOperation, - nextFs.action == .remove - { - issues.append( - LayerIssue( - type: .unnecessaryFiles, - description: "Files added then removed still consume layer space", - suggestion: "Avoid adding files that will be deleted", - estimatedImpact: .medium - )) - } - } - } - } - } - - return LayerEfficiencyAnalysis(issues: issues) - } - - // MARK: - Cache Analysis - - private func analyzeCacheability(_ graph: BuildGraph) -> CacheabilityAnalysis { - var invalidators: [CacheInvalidator] = [] - - for stage in graph.stages { - // Check for operations that frequently invalidate cache - for (index, node) in stage.nodes.enumerated() { - if let fsOp = node.operation as? FilesystemOperation { - switch fsOp.source { - case .context(let source): - // Copying entire context early invalidates cache - if source.paths.contains(".") && index < stage.nodes.count / 2 { - invalidators.append( - CacheInvalidator( - operation: "COPY . .", - reason: "Copying entire context early in build", - suggestion: "Copy only necessary files or move COPY . . later" - )) - } - default: - break - } - } - - // Dynamic commands that change frequently - if let execOp = node.operation as? ExecOperation, - case .shell(let cmd) = execOp.command - { - if cmd.contains("date") || cmd.contains("timestamp") || cmd.contains("git rev-parse") { - invalidators.append( - CacheInvalidator( - operation: cmd, - reason: "Command output changes frequently", - suggestion: "Use build args for dynamic values" - )) - } - } - } - } - - return CacheabilityAnalysis( - cacheInvalidators: invalidators, - estimatedCacheHitRate: invalidators.isEmpty ? 0.8 : 0.3 - ) - } - - // MARK: - Security Analysis - - private func analyzeSecurityPosture(_ graph: BuildGraph) -> SecurityAnalysis { - var findings: [SecurityFinding] = [] - - for stage in graph.stages { - var currentUser: User? - var hasUserSwitch = false - - for node in stage.nodes { - // Track user context - if let metaOp = node.operation as? MetadataOperation, - case .setUser(let user) = metaOp.action - { - currentUser = user - hasUserSwitch = true - } - - // Check for security issues in exec operations - if let execOp = node.operation as? ExecOperation { - // Running privileged without user switch - if execOp.security.privileged && !hasUserSwitch { - findings.append( - SecurityFinding( - severity: .high, - type: .privilegedExecution, - description: "Privileged execution as root", - remediation: "Switch to non-root user after privileged operations" - )) - } - - // Downloading without verification - if case .shell(let cmd) = execOp.command { - if (cmd.contains("curl") || cmd.contains("wget")) && !cmd.contains("--verify") && !cmd.contains("sha256") { - findings.append( - SecurityFinding( - severity: .medium, - type: .unverifiedDownload, - description: "Downloading files without verification", - remediation: "Add checksum verification for downloaded files" - )) - } - - // Installing packages without pinning versions - if cmd.contains("install") && !cmd.contains("=") && (cmd.contains("apt-get") || cmd.contains("pip")) { - findings.append( - SecurityFinding( - severity: .low, - type: .unpinnedDependencies, - description: "Installing packages without version pinning", - remediation: "Pin package versions for reproducible builds" - )) - } - } - } - } - - // Final user check - if currentUser == nil && stage == graph.targetStage { - findings.append( - SecurityFinding( - severity: .high, - type: .rootUser, - description: "Container runs as root by default", - remediation: "Add USER instruction to run as non-root" - )) - } - } - - return SecurityAnalysis(findings: findings) - } - - // MARK: - Size Optimization - - private func analyzeSizeOptimizations(_ graph: BuildGraph) -> SizeOptimizationAnalysis { - var opportunities: [SizeOptimization] = [] - - for stage in graph.stages { - var hasCleanup = false - - for node in stage.nodes { - if let execOp = node.operation as? ExecOperation, - case .shell(let cmd) = execOp.command - { - // Check for package manager cleanup - if cmd.contains("apt-get install") && !cmd.contains("rm -rf /var/lib/apt/lists/*") { - opportunities.append( - SizeOptimization( - type: .packageManagerCache, - description: "Package manager cache not cleaned", - estimatedSavingMB: 50, - suggestion: "Add && rm -rf /var/lib/apt/lists/* after apt-get install" - )) - } - - // Check for build dependencies - if cmd.contains("build-essential") || cmd.contains("-dev") { - let isMultiStage = graph.stages.count > 1 - if !isMultiStage { - opportunities.append( - SizeOptimization( - type: .buildDependencies, - description: "Build dependencies included in final image", - estimatedSavingMB: 200, - suggestion: "Use multi-stage build to exclude build dependencies" - )) - } - } - - if cmd.contains("rm") || cmd.contains("clean") { - hasCleanup = true - } - } - } - - // Check for cleanup in separate layers - if hasCleanup { - opportunities.append( - SizeOptimization( - type: .separateCleanupLayer, - description: "Cleanup in separate RUN creates new layer", - estimatedSavingMB: 0, - suggestion: "Combine cleanup with installation in same RUN" - )) - } - } - - return SizeOptimizationAnalysis(opportunities: opportunities) - } -} - -// MARK: - Analysis Results - -/// Complete semantic analysis results. -public struct SemanticAnalysis { - public let layerEfficiency: LayerEfficiencyAnalysis - public let cacheability: CacheabilityAnalysis - public let security: SecurityAnalysis - public let sizeOptimizations: SizeOptimizationAnalysis - - /// Overall health score (0-100) - public var healthScore: Int { - var score = 100 - - // Deduct for layer issues - score -= layerEfficiency.issues.count * 5 - - // Deduct for cache invalidators - score -= cacheability.cacheInvalidators.count * 10 - - // Deduct for security findings - for finding in security.findings { - switch finding.severity { - case .high: score -= 20 - case .medium: score -= 10 - case .low: score -= 5 - } - } - - // Deduct for size opportunities - score -= min(sizeOptimizations.totalPotentialSavingMB / 50, 20) - - return max(0, score) - } -} - -/// Layer efficiency analysis. -public struct LayerEfficiencyAnalysis { - public let issues: [LayerIssue] -} - -public struct LayerIssue { - public enum IssueType { - case multipleLayers - case unnecessaryFiles - case largeLayer - case inefficientOrdering - } - - public let type: IssueType - public let description: String - public let suggestion: String - public let estimatedImpact: Impact -} - -public enum Impact { - case low, medium, high -} - -/// Cache analysis results. -public struct CacheabilityAnalysis { - public let cacheInvalidators: [CacheInvalidator] - public let estimatedCacheHitRate: Double -} - -public struct CacheInvalidator { - public let operation: String - public let reason: String - public let suggestion: String -} - -/// Security analysis results. -public struct SecurityAnalysis { - public let findings: [SecurityFinding] -} - -public struct SecurityFinding { - public enum Severity { - case low, medium, high - } - - public enum FindingType { - case rootUser - case privilegedExecution - case unverifiedDownload - case unpinnedDependencies - case exposedSecrets - } - - public let severity: Severity - public let type: FindingType - public let description: String - public let remediation: String -} - -/// Size optimization analysis. -public struct SizeOptimizationAnalysis { - public let opportunities: [SizeOptimization] - - public var totalPotentialSavingMB: Int { - opportunities.reduce(0) { $0 + $1.estimatedSavingMB } - } -} - -public struct SizeOptimization { - public enum OptimizationType { - case packageManagerCache - case buildDependencies - case unnecessaryFiles - case separateCleanupLayer - case duplicateFiles - } - - public let type: OptimizationType - public let description: String - public let estimatedSavingMB: Int - public let suggestion: String -} - -// MARK: - Reporting - -extension SemanticAnalyzer { - private func reportFindings(_ analysis: SemanticAnalysis, context: AnalysisContext) { - guard let reporter = context.reporter else { return } - - // Report layer efficiency issues - for issue in analysis.layerEfficiency.issues { - let description = "\(issue.description). \(issue.suggestion)" - let eventType: IREventType = issue.estimatedImpact == .high ? .error : .warning - Task { - await reporter.report( - .irEvent( - context: ReportContext( - description: description, - sourceMap: nil - ), - type: eventType - )) - } - } - - // Report security findings - for finding in analysis.security.findings { - let description = "\(finding.description). \(finding.remediation)" - let eventType: IREventType = finding.severity == .high ? .error : .warning - Task { - await reporter.report( - .irEvent( - context: ReportContext( - description: description, - sourceMap: nil - ), - type: eventType - )) - } - } - - // Report cache invalidators - for invalidator in analysis.cacheability.cacheInvalidators { - let description = "Cache invalidator: \(invalidator.reason). \(invalidator.suggestion)" - Task { - await reporter.report( - .irEvent( - context: ReportContext( - description: description, - sourceMap: nil - ), - type: .warning - )) - } - } - - // Report size optimizations - for optimization in analysis.sizeOptimizations.opportunities { - if optimization.estimatedSavingMB > 100 { - let description = "\(optimization.description). \(optimization.suggestion) (potential saving: \(optimization.estimatedSavingMB)MB)" - Task { - await reporter.report( - .irEvent( - context: ReportContext( - description: description, - sourceMap: nil - ), - type: .warning - )) - } - } - } - } -} diff --git a/Sources/NativeBuilder/ContainerBuildIR/Analysis/ValidatorAdapter.swift b/Sources/NativeBuilder/ContainerBuildIR/Analysis/ValidatorAdapter.swift deleted file mode 100644 index 3ba59b6b3..000000000 --- a/Sources/NativeBuilder/ContainerBuildIR/Analysis/ValidatorAdapter.swift +++ /dev/null @@ -1,76 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildReporting -import Foundation - -/// Adapts a BuildValidator to work as a GraphAnalyzer -public struct ValidatorAnalyzer: GraphAnalyzer { - private let validator: any BuildValidator & Sendable - - public init(validator: V) { - self.validator = validator - } - - public func analyze(_ graph: BuildGraph, context: AnalysisContext) throws -> BuildGraph { - let result = validator.validate(graph) - - // Report errors via reporter - if let reporter = context.reporter { - let sourceMap = context.sourceMap - for error in result.errors { - let description = error.localizedDescription - Task { - await reporter.report( - .irEvent( - context: ReportContext( - description: description, - sourceMap: sourceMap - ), - type: .error - )) - } - } - } - - // Report warnings via reporter - if let reporter = context.reporter { - let sourceMap = context.sourceMap - for warning in result.warnings { - let message = warning.suggestion != nil ? "\(warning.message). \(warning.suggestion!)" : warning.message - - Task { - await reporter.report( - .irEvent( - context: ReportContext( - description: message, - sourceMap: sourceMap - ), - type: .warning - )) - } - } - } - - // Always fail on error as requested - if !result.errors.isEmpty { - throw result.errors.first! - } - - // Validation doesn't transform the graph - return graph - } -} diff --git a/Sources/NativeBuilder/ContainerBuildIR/BuildDefinitionError.swift b/Sources/NativeBuilder/ContainerBuildIR/BuildDefinitionError.swift deleted file mode 100644 index 04490dc50..000000000 --- a/Sources/NativeBuilder/ContainerBuildIR/BuildDefinitionError.swift +++ /dev/null @@ -1,47 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -/// An error that occurs during the parsing or validation of a build definition. -public enum BuildDefinitionError: Error, LocalizedError { - /// A syntax error was found at a specific location. - case invalidSyntax(line: Int, column: Int, message: String) - - /// An instruction or command is not recognized. - case unknownInstruction(name: String, line: Int) - - /// An argument for an instruction is invalid. - case invalidArgument(instruction: String, argument: String, reason: String) - - /// A required resource, like a file for a `COPY` command, was not found. - case sourceNotFound(path: String, instructionLine: Int) - - // MARK: - LocalizedError Conformance - - public var errorDescription: String? { - switch self { - case .invalidSyntax(let line, let col, let msg): - return "Syntax error on line \(line):\(col): \(msg)" - case .unknownInstruction(let name, let line): - return "Unknown instruction '\(name)' on line \(line)" - case .invalidArgument(let instruction, let argument, let reason): - return "Invalid argument '\(argument)' for instruction '\(instruction)': \(reason)" - case .sourceNotFound(let path, let line): - return "Source path '\(path)' not found for instruction on line \(line)" - } - } -} diff --git a/Sources/NativeBuilder/ContainerBuildIR/Digest.swift b/Sources/NativeBuilder/ContainerBuildIR/Digest.swift deleted file mode 100644 index 4ca3cc4ed..000000000 --- a/Sources/NativeBuilder/ContainerBuildIR/Digest.swift +++ /dev/null @@ -1,173 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 Crypto -import Foundation - -/// A content-addressed identifier for build artifacts. -/// -/// Design rationale: -/// - Immutable value type ensures digests cannot be modified after creation -/// - Strong typing prevents mixing different hash algorithms -/// - Validates format on creation to catch errors early -/// - Supports common container ecosystem digest formats -public struct Digest: Hashable, Sendable { - /// The algorithm used to compute this digest - public enum Algorithm: String, CaseIterable, Sendable { - case sha256 - case sha384 - case sha512 - - /// Expected byte length for this algorithm - var byteLength: Int { - switch self { - case .sha256: return 32 - case .sha384: return 48 - case .sha512: return 64 - } - } - } - - public let algorithm: Algorithm - public let bytes: Data - - /// Create a digest from raw bytes - /// - Throws: If bytes length doesn't match algorithm requirements - public init(algorithm: Algorithm, bytes: Data) throws { - guard bytes.count == algorithm.byteLength else { - throw DigestError.invalidLength(expected: algorithm.byteLength, actual: bytes.count) - } - self.algorithm = algorithm - self.bytes = bytes - } - - /// Create a digest from a hex string (e.g., "sha256:abc123...") - public init(parsing string: String) throws { - let components = string.split(separator: ":", maxSplits: 1) - guard components.count == 2 else { - throw DigestError.invalidFormat(string) - } - - guard let algorithm = Algorithm(rawValue: String(components[0])) else { - throw DigestError.unsupportedAlgorithm(String(components[0])) - } - - guard let bytes = Data(hexString: String(components[1])) else { - throw DigestError.invalidHex(String(components[1])) - } - - try self.init(algorithm: algorithm, bytes: bytes) - } - - /// String representation in standard format (e.g., "sha256:abc123...") - public var stringValue: String { - "\(algorithm.rawValue):\(bytes.hexString)" - } - - /// Compute digest of data - /// - Throws: DigestError.cryptoInternalError if Crypto produces unexpected results - public static func compute(_ data: Data, using algorithm: Algorithm = .sha256) throws -> Digest { - let bytes: Data - switch algorithm { - case .sha256: - var hasher = SHA256() - hasher.update(data: data) - bytes = Data(hasher.finalize()) - case .sha384: - var hasher = SHA384() - hasher.update(data: data) - bytes = Data(hasher.finalize()) - case .sha512: - var hasher = SHA512() - hasher.update(data: data) - bytes = Data(hasher.finalize()) - } - - // This should never fail as Crypto produces the correct byte length - // But we handle it gracefully for production safety - do { - return try Digest(algorithm: algorithm, bytes: bytes) - } catch { - // This should never happen in practice, but we provide proper error handling - throw DigestError.cryptoInternalError("Crypto produced digest with incorrect length for \(algorithm): \(error)") - } - } -} - -extension Digest: CustomStringConvertible { - public var description: String { stringValue } -} - -extension Digest: Codable { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - let string = try container.decode(String.self) - try self.init(parsing: string) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(stringValue) - } -} - -public enum DigestError: LocalizedError { - case invalidFormat(String) - case unsupportedAlgorithm(String) - case invalidHex(String) - case invalidLength(expected: Int, actual: Int) - case cryptoInternalError(String) - - public var errorDescription: String? { - switch self { - case .invalidFormat(let string): - return "Invalid digest format: '\(string)'. Expected 'algorithm:hex'." - case .unsupportedAlgorithm(let algo): - return "Unsupported digest algorithm: '\(algo)'" - case .invalidHex(let hex): - return "Invalid hex string: '\(hex)'" - case .invalidLength(let expected, let actual): - return "Invalid digest length: expected \(expected) bytes, got \(actual)" - case .cryptoInternalError(let details): - return "Crypto internal error: \(details)" - } - } -} - -// MARK: - Utility Extensions - -extension Data { - fileprivate init?(hexString: String) { - guard hexString.count % 2 == 0 else { return nil } - - var data = Data(capacity: hexString.count / 2) - - for i in stride(from: 0, to: hexString.count, by: 2) { - let startIndex = hexString.index(hexString.startIndex, offsetBy: i) - let endIndex = hexString.index(startIndex, offsetBy: 2) - let hexByte = hexString[startIndex.. - - /// Graph metadata - public let metadata: BuildGraphMetadata - - public init( - stages: [BuildStage], - buildArgs: [String: String] = [:], - targetPlatforms: Set = [], - metadata: BuildGraphMetadata = BuildGraphMetadata() - ) throws { - self.stages = stages - self.buildArgs = buildArgs - self.targetPlatforms = targetPlatforms.isEmpty ? [Platform.current] : targetPlatforms - self.metadata = metadata - - // Validate graph structure - try Self.validate(stages: stages) - } - - /// Get stage by name - public func stage(named name: String) -> BuildStage? { - stages.first { $0.name == name } - } - - /// Get stage by index - public func stage(at index: Int) -> BuildStage? { - guard index >= 0 && index < stages.count else { return nil } - return stages[index] - } - - /// Resolve a stage reference - public func resolveStage(_ reference: StageReference) -> BuildStage? { - switch reference { - case .named(let name): - return stage(named: name) - case .index(let idx): - return stage(at: idx) - case .previous: - // This needs context of current stage to resolve - return nil - } - } - - /// Get the final stage (build target) - public var targetStage: BuildStage? { - stages.last - } - - // MARK: - Validation - - private static func validate(stages: [BuildStage]) throws { - // Check for duplicate stage names - var seenNames = Set() - for stage in stages { - if let name = stage.name { - guard seenNames.insert(name).inserted else { - throw BuildGraphError.duplicateStageName(name) - } - } - } - - // Collect all node IDs across all stages - var allNodeIds = Set() - for stage in stages { - for node in stage.nodes { - allNodeIds.insert(node.id) - } - } - - // Validate node dependencies (can be cross-stage) - for stage in stages { - for node in stage.nodes { - for dep in node.dependencies { - guard allNodeIds.contains(dep) else { - throw BuildGraphError.invalidDependency(node.id, dep) - } - } - } - } - - // Validate DAG structure (check for cycles across entire graph) - try validateGlobalDAG(stages: stages) - } - - private static func validateGlobalDAG(stages: [BuildStage]) throws { - // Build adjacency list for all nodes across all stages - var adjacencyList: [UUID: Set] = [:] - var allNodes = Set() - - for stage in stages { - for node in stage.nodes { - allNodes.insert(node.id) - adjacencyList[node.id] = node.dependencies - } - } - - // Check for cycles using DFS - var visited = Set() - var recursionStack = Set() - - func hasCycle(from nodeId: UUID) -> Bool { - visited.insert(nodeId) - recursionStack.insert(nodeId) - - if let dependencies = adjacencyList[nodeId] { - for dep in dependencies { - if !visited.contains(dep) { - if hasCycle(from: dep) { - return true - } - } else if recursionStack.contains(dep) { - return true - } - } - } - - recursionStack.remove(nodeId) - return false - } - - // Check each unvisited node - for nodeId in allNodes { - if !visited.contains(nodeId) { - if hasCycle(from: nodeId) { - throw BuildGraphError.cyclicDependency - } - } - } - } -} - -/// A build stage (FROM ... AS name). -/// -/// Design rationale: -/// - Represents a single FROM instruction and its operations -/// - Maintains operation order for correct execution -/// - Supports both named and anonymous stages -/// - Tracks dependencies on other stages -public struct BuildStage: Sendable, Equatable { - /// Unique identifier - public let id: UUID - - /// Stage name (FROM ... AS name) - public let name: String? - - /// Base image operation - public let base: ImageOperation - - /// Nodes in this stage (topologically sorted) - public let nodes: [BuildNode] - - /// Platform constraints for this stage - public let platform: Platform? - - public init( - id: UUID = UUID(), - name: String? = nil, - base: ImageOperation, - nodes: [BuildNode] = [], - platform: Platform? = nil - ) { - self.id = id - self.name = name - self.base = base - self.nodes = nodes - self.platform = platform - } - - /// All operations in this stage (including base) - public var operations: [any Operation] { - [base] + nodes.map { $0.operation } - } - - /// Find dependencies on other stages - public func stageDependencies() -> Set { - var deps = Set() - - for node in nodes { - // Check filesystem operations for stage references - if let fsOp = node.operation as? FilesystemOperation { - switch fsOp.source { - case .stage(let ref, _): - deps.insert(ref) - default: - break - } - } - - // Check mount sources - if let execOp = node.operation as? ExecOperation { - for mount in execOp.mounts { - if case .stage(let ref, _) = mount.source { - deps.insert(ref) - } - } - } - } - - return deps - } - - // MARK: - Validation - - func validate() throws { - // Stage-level validation is now done at the graph level - // to support cross-stage dependencies - } - -} - -/// A node in the build graph. -/// -/// Design rationale: -/// - Represents a single operation and its dependencies -/// - Immutable for safe concurrent access -/// - Tracks both data and execution dependencies -/// - Supports caching and incremental builds -public struct BuildNode: Sendable, Equatable { - /// Unique identifier - public let id: UUID - - /// The operation this node performs - public let operation: any Operation - - /// IDs of nodes this depends on - public let dependencies: Set - - /// Cache key for this operation - public let cacheKey: CacheKey? - - /// Execution constraints - public let constraints: Set - - public init( - id: UUID = UUID(), - operation: any Operation, - dependencies: Set = [], - cacheKey: CacheKey? = nil, - constraints: Set = [] - ) { - self.id = id - self.operation = operation - self.dependencies = dependencies - self.cacheKey = cacheKey - self.constraints = constraints - } - - // Custom Equatable implementation - public static func == (lhs: BuildNode, rhs: BuildNode) -> Bool { - // Compare by ID for node equality - lhs.id == rhs.id - } -} - -/// Build graph metadata. -public struct BuildGraphMetadata: Sendable { - /// Source file that generated this graph - public let sourceFile: String? - - /// Build context path - public let contextPath: String? - - /// Original frontend used (e.g., "dockerfile", "llb") - public let frontend: String? - - /// Frontend version - public let frontendVersion: String? - - /// Additional metadata - public let attributes: [String: AttributeValue] - - public init( - sourceFile: String? = nil, - contextPath: String? = nil, - frontend: String? = nil, - frontendVersion: String? = nil, - attributes: [String: AttributeValue] = [:] - ) { - self.sourceFile = sourceFile - self.contextPath = contextPath - self.frontend = frontend - self.frontendVersion = frontendVersion - self.attributes = attributes - } -} - -/// Cache key for operations. -/// -/// Design rationale: -/// - Content-addressed for reliable caching -/// - Includes all inputs that affect output -/// - Platform-aware for cross-compilation -public struct CacheKey: Hashable, Sendable { - /// Operation digest - public let operationDigest: Digest - - /// Input digests (from dependencies) - public let inputDigests: Set - - /// Platform (if platform-specific) - public let platform: Platform? - - /// Additional cache inputs - public let additionalInputs: [String: String] - - public init( - operationDigest: Digest, - inputDigests: Set = [], - platform: Platform? = nil, - additionalInputs: [String: String] = [:] - ) { - self.operationDigest = operationDigest - self.inputDigests = inputDigests - self.platform = platform - self.additionalInputs = additionalInputs - } - - /// Compute combined cache key - public var digest: Digest { - var data = Data() - data.append(operationDigest.bytes) - - for input in inputDigests.sorted(by: { $0.stringValue < $1.stringValue }) { - data.append(input.bytes) - } - - if let platform = platform { - data.append(contentsOf: platform.description.utf8) - } - - for (key, value) in additionalInputs.sorted(by: { $0.key < $1.key }) { - data.append(contentsOf: key.utf8) - data.append(contentsOf: value.utf8) - } - - do { - return try Digest.compute(data) - } catch { - // Fallback to a deterministic digest if computation fails - // This should never happen in practice - return try! Digest(algorithm: .sha256, bytes: Data(count: 32)) - } - } -} - -/// Execution constraints for nodes. -public enum Constraint: Hashable, Sendable { - /// Requires network access - case requiresNetwork - - /// Requires privileged execution - case requiresPrivileged - - /// Requires specific capability - case requiresCapability(String) - - /// Must run on specific platform - case requiresPlatform(Platform) - - /// Maximum execution time - case timeout(TimeInterval) - - /// Maximum memory - case memoryLimit(Int) - - /// CPU limit - case cpuLimit(Double) -} - -// MARK: - Errors - -public enum BuildGraphError: LocalizedError { - case duplicateStageName(String) - case cyclicDependency - case invalidDependency(UUID, UUID) - case stageNotFound(StageReference) - - public var errorDescription: String? { - switch self { - case .duplicateStageName(let name): - return "Duplicate stage name: '\(name)'" - case .cyclicDependency: - return "Build graph contains cyclic dependencies" - case .invalidDependency(let node, let dep): - return "Node \(node) has invalid dependency \(dep)" - case .stageNotFound(let ref): - return "Stage not found: \(ref)" - } - } -} - -// MARK: - Codable - -extension BuildGraph: Codable {} -extension BuildStage: Codable {} -extension BuildNode: Codable { - // Custom coding to handle type-erased Operation - enum CodingKeys: String, CodingKey { - case id - case operation - case dependencies - case cacheKey - case constraints - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(dependencies, forKey: .dependencies) - try container.encode(cacheKey, forKey: .cacheKey) - try container.encode(constraints, forKey: .constraints) - - // For operation, we need type information - // This is handled by SerializedOperation in IRCoder.swift - // For now, skip encoding the operation directly - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.id = try container.decode(UUID.self, forKey: .id) - self.dependencies = try container.decode(Set.self, forKey: .dependencies) - self.cacheKey = try container.decodeIfPresent(CacheKey.self, forKey: .cacheKey) - self.constraints = try container.decode(Set.self, forKey: .constraints) - - // For operation, we need a placeholder - // Real decoding is handled by SerializedNode in IRCoder.swift - self.operation = MetadataOperation(action: .setLabel(key: "placeholder", value: "placeholder")) - } -} -extension BuildGraphMetadata: Codable {} -extension CacheKey: Codable {} -extension Constraint: Codable {} diff --git a/Sources/NativeBuilder/ContainerBuildIR/Graph/GraphBuilder+Arg.swift b/Sources/NativeBuilder/ContainerBuildIR/Graph/GraphBuilder+Arg.swift deleted file mode 100644 index f8e7f54b5..000000000 --- a/Sources/NativeBuilder/ContainerBuildIR/Graph/GraphBuilder+Arg.swift +++ /dev/null @@ -1,177 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -extension GraphBuilder { - /// Add a build argument. - /// These arguments are available within the stage. - @discardableResult - public func arg(_ name: String, defaultValue: String? = nil) throws -> Self { - let operation = MetadataOperation( - action: .declareArg(name: name, defaultValue: defaultValue) - ) - return try add(operation) - } - - /// Add a FROM-only build argument (ARG before the first FROM). - /// These arguments are only available in FROM instructions, not within stages. - @discardableResult - public func fromOnlyArg(_ name: String, defaultValue: String? = nil) -> Self { - buildArgs[name] = defaultValue - return self - } - - /// Check if there's an active stage. - public var hasActiveStage: Bool { - currentStage != nil - } - - /// Resolve an ARG value. - /// - Parameters: - /// - key: The ARG name to resolve. - /// - inFromContext: Whether this is being called from a FROM instruction context. - /// - Returns: The resolved ARG value, or nil if not found. - public func resolveArg(key: String, inFromContext: Bool = false) -> String? { - // A global FROM-only ARG. - guard let currentStage, !inFromContext else { - return resolveBuildArgWithFallback(key: key) - } - - let (found, defaultValue) = currentStage.getDeclaredArg(key) - if found { - // A stage-local ARG with a default value. - if let defaultValue { - return defaultValue - } - // A stage-local ARG without a default value - a redeclared global FROM-only ARG. - return resolveBuildArgWithFallback(key: key) - } - - // Check the predefined ARGs only. - return resolvePredefinedArg(key: key) - } - - private func resolvePredefinedArg(key: String) -> String? { - guard GraphBuilder.predefinedArgs.contains(key) else { - return nil - } - return ProcessInfo.processInfo.environment[key] - } - - private func resolveBuildArgWithFallback(key: String) -> String? { - let value = buildArgs[key] - if value != nil { - return value - } - return resolvePredefinedArg(key: key) - } - - static let predefinedArgs: Set = { - let baseArgs = [ - "HTTP_PROXY", - "HTTPS_PROXY", - "FTP_PROXY", - "NO_PROXY", - "ALL_PROXY", - ] - - var allArgs = Set() - for arg in baseArgs { - allArgs.insert(arg) - allArgs.insert(arg.lowercased()) - } - return allArgs - }() - - private static let argRegex: NSRegularExpression = { - // `${ARG}`, `${ARG:-default}`, `${ARG:+value}`, or `$ARG` - try! NSRegularExpression(pattern: #"\$(?:\{([A-Za-z_][A-Za-z0-9_]*)(?:(:-|:\+)([^}]*))?\}|([A-Za-z_][A-Za-z0-9_]*))"#) - }() - - /// Substitute ARG variables in a string. - /// - Parameters: - /// - input: The string that may contain `${ARG}`, `${ARG:-default}`, `${ARG:+value}`, or `$ARG`. - /// - inFromContext: Whether this substitution is happening in a FROM instruction context. - /// - Returns: The string with ARG variables substituted. - public func substituteArgs(_ input: String, inFromContext: Bool) -> String { - let range = NSRange(location: 0, length: input.utf16.count) - - var result = input - var offset = 0 - - GraphBuilder.argRegex.enumerateMatches(in: input, range: range) { match, _, _ in - guard let match else { - return - } - - var varName: String - var operatorType: String? = nil - var operatorValue: String? = nil - - // Check if it's the `${var}` format (capture group 1) or `$var` format (capture group 4). - if match.numberOfRanges > 1 && match.range(at: 1).location != NSNotFound { - // The `${var}` format: variable name is in capture group 1. - guard let varRange = Range(match.range(at: 1), in: input) else { - return - } - varName = String(input[varRange]) - - // The operator (capture group 2) and value (capture group 3). - if match.numberOfRanges > 2 && match.range(at: 2).location != NSNotFound { - if let operatorRange = Range(match.range(at: 2), in: input) { - operatorType = String(input[operatorRange]) - } - } - if match.numberOfRanges > 3 && match.range(at: 3).location != NSNotFound { - if let valueRange = Range(match.range(at: 3), in: input) { - operatorValue = String(input[valueRange]) - } - } - } else if match.numberOfRanges > 4 && match.range(at: 4).location != NSNotFound { - // The `$var` format: the variable name is in capture group 4. - guard let varRange = Range(match.range(at: 4), in: input) else { - return - } - varName = String(input[varRange]) - } else { - return - } - - let resolvedValue = resolveArg(key: varName, inFromContext: inFromContext) - - let replacement: String - switch operatorType { - case ":-": - replacement = resolvedValue ?? operatorValue ?? "" - case ":+": - replacement = (resolvedValue != nil && !resolvedValue!.isEmpty) ? (operatorValue ?? "") : "" - default: - replacement = resolvedValue ?? "" - } - - let matchRange = match.range - let adjustedMatchRange = NSRange(location: matchRange.location + offset, length: matchRange.length) - - if let replacementRange = Range(adjustedMatchRange, in: result) { - result.replaceSubrange(replacementRange, with: replacement) - offset += replacement.utf16.count - matchRange.length - } - } - - return result - } -} diff --git a/Sources/NativeBuilder/ContainerBuildIR/Graph/GraphBuilder.swift b/Sources/NativeBuilder/ContainerBuildIR/Graph/GraphBuilder.swift deleted file mode 100644 index f085b6af2..000000000 --- a/Sources/NativeBuilder/ContainerBuildIR/Graph/GraphBuilder.swift +++ /dev/null @@ -1,483 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildReporting -import ContainerizationOCI -import Foundation - -/// Errors that can occur during graph building -public enum GraphBuilderError: Error, LocalizedError { - case noActiveStage - case invalidOperation(String) - case missingDependency(UUID) - - public var errorDescription: String? { - switch self { - case .noActiveStage: - return "No active stage. Call stage() or scratch() first." - case .invalidOperation(let message): - return "Invalid operation: \(message)" - case .missingDependency(let id): - return "Missing dependency with ID: \(id)" - } - } -} - -/// Builder for constructing build graphs. -/// -/// Design rationale: -/// - Fluent API for easy graph construction -/// - Validates as you build to catch errors early -/// - Handles dependency resolution automatically -/// - Supports incremental construction -public final class GraphBuilder { - private var stages: [BuildStage] = [] - var currentStage: StageBuilder? - var buildArgs: [String: String] = [:] - private var targetPlatforms: Set = [] - private var metadata = BuildGraphMetadata() - private let graphAnalyzers: [any GraphAnalyzer] - private let stageAnalyzers: [any StageAnalyzer] - private let reporter: Reporter? - - /// Public initializer with optional reporter - public init(reporter: Reporter? = nil) { - self.graphAnalyzers = Self.defaultGraphAnalyzers - self.stageAnalyzers = Self.defaultStageAnalyzers - self.reporter = reporter - } - - /// Internal initializer for custom analyzers - internal init( - graphAnalyzers: [any GraphAnalyzer], - stageAnalyzers: [any StageAnalyzer], - reporter: Reporter? = nil - ) { - self.graphAnalyzers = graphAnalyzers - self.stageAnalyzers = stageAnalyzers - self.reporter = reporter - } - - /// Default graph analyzers - private nonisolated(unsafe) static let defaultGraphAnalyzers: [any GraphAnalyzer] = [ - DependencyAnalyzer(), - ValidatorAnalyzer(validator: StandardValidator()), - SemanticAnalyzer(), - ] - - /// Default stage analyzers (empty for now) - private nonisolated(unsafe) static let defaultStageAnalyzers: [any StageAnalyzer] = [] - - /// Start a new stage - @discardableResult - public func stage( - name: String? = nil, - from image: ImageReference, - platform: Platform? = nil - ) throws -> Self { - // Finish current stage if any - if let current = currentStage { - stages.append(try current.build()) - } - - // Report stage creation - if let reporter = reporter { - Task { - await reporter.report( - .irEvent( - context: ReportContext( - stageId: name, - description: "Creating stage \(name ?? "unnamed") from \(image)", - sourceMap: nil - ), - type: .stageAdded - )) - } - } - - // Create new stage - let imageOp = ImageOperation( - source: .registry(image), - platform: platform - ) - - currentStage = StageBuilder( - name: name, - base: imageOp, - platform: platform, - analyzers: stageAnalyzers, - reporter: reporter - ) - return self - } - - /// Start a stage from scratch - @discardableResult - public func scratch(name: String? = nil) throws -> Self { - if let current = currentStage { - stages.append(try current.build()) - } - - // Report stage creation - if let reporter = reporter { - Task { - await reporter.report( - .irEvent( - context: ReportContext( - stageId: name, - description: "Creating stage \(name ?? "unnamed") from scratch", - sourceMap: nil - ), - type: .stageAdded - )) - } - } - - let imageOp = ImageOperation(source: .scratch) - currentStage = StageBuilder( - name: name, - base: imageOp, - analyzers: stageAnalyzers, - reporter: reporter - ) - return self - } - - /// Add an operation to current stage - @discardableResult - public func add(_ operation: any Operation, dependsOn: [UUID] = []) throws -> Self { - guard let current = currentStage else { - throw GraphBuilderError.noActiveStage - } - - current.add(operation, dependsOn: Set(dependsOn)) - return self - } - - /// Add a RUN operation - @discardableResult - public func run( - _ command: String, - shell: Bool = true, - env: [String: String] = [:], - workdir: String? = nil, - user: User? = nil, - mounts: [Mount] = [], - network: NetworkMode = .default, - ) throws -> Self { - let cmd = shell ? Command.shell(command) : Command.exec(command.split(separator: " ").map(String.init)) - return try runWithCmd(cmd, shell: shell, env: env, workdir: workdir, user: user, mounts: mounts, network: network) - } - - @discardableResult - public func runWithCmd( - _ cmd: Command, - shell: Bool = true, - env: [String: String] = [:], - workdir: String? = nil, - user: User? = nil, - mounts: [Mount] = [], - network: NetworkMode = .default, - ) throws -> Self { - let envVars = env.map { (key: $0.key, value: EnvironmentValue.literal($0.value)) } - let operation = ExecOperation( - command: cmd, - environment: Environment(envVars), - mounts: mounts, - workingDirectory: workdir, - user: user, - network: network, - ) - - return try add(operation) - } - - /// Add a COPY operation - @discardableResult - public func copy( - from source: FilesystemSource, - to destination: String, - chown: Ownership? = nil, - chmod: Permissions? = nil - ) throws -> Self { - let operation = FilesystemOperation( - action: .copy, - source: source, - destination: destination, - fileMetadata: FileMetadata( - ownership: chown, - permissions: chmod ?? .preserve - ) - ) - - return try add(operation) - } - - /// Add COPY from context - @discardableResult - public func copyFromContext( - name: String = "default", - paths: [String], - to destination: String, - chown: Ownership? = nil, - chmod: Permissions? = nil - ) throws -> Self { - try copy( - from: .context(ContextSource(name: name, paths: paths)), - to: destination, - chown: chown, - chmod: chmod - ) - } - - /// Add COPY from stage - @discardableResult - public func copyFromStage( - _ stage: StageReference, - paths: [String], - to destination: String, - chown: Ownership? = nil, - chmod: Permissions? = nil - ) throws -> Self { - try copy( - from: .stage(stage, paths: paths), - to: destination, - chown: chown, - chmod: chmod - ) - } - - /// Set environment variable - @discardableResult - public func env(_ key: String, _ value: String) throws -> Self { - let operation = MetadataOperation( - action: .setEnv(key: key, value: .literal(value)) - ) - return try add(operation) - } - - /// Set working directory - @discardableResult - public func workdir(_ path: String) throws -> Self { - let operation = MetadataOperation(action: .setWorkdir(path: path)) - return try add(operation) - } - - /// Set user - @discardableResult - public func user(_ user: User) throws -> Self { - let operation = MetadataOperation(action: .setUser(user: user)) - return try add(operation) - } - - /// Add label - @discardableResult - public func label(_ key: String, _ value: String) throws -> Self { - let operation = MetadataOperation( - action: .setLabel(key: key, value: value) - ) - return try add(operation) - } - - @discardableResult - public func labelBatch(labels: [String: String]) throws -> Self { - let operation = MetadataOperation( - action: .setLabelBatch(labels) - ) - return try add(operation) - } - - /// Expose port - @discardableResult - public func expose(_ ports: [PortSpec]) throws -> Self { - let operation = MetadataOperation( - action: .expose(ports: ports) - ) - return try add(operation) - } - - /// Set entrypoint - @discardableResult - public func entrypoint(_ command: Command) throws -> Self { - let operation = MetadataOperation(action: .setEntrypoint(command: command)) - return try add(operation) - } - - /// Set CMD - @discardableResult - public func cmd(_ command: Command) throws -> Self { - let operation = MetadataOperation(action: .setCmd(command: command)) - return try add(operation) - } - - /// Set healthcheck - @discardableResult - public func healthcheck( - test: HealthcheckTest, - interval: TimeInterval? = nil, - timeout: TimeInterval? = nil, - startPeriod: TimeInterval? = nil, - retries: Int? = nil - ) throws -> Self { - let healthcheck = Healthcheck( - test: test, - interval: interval, - timeout: timeout, - startPeriod: startPeriod, - retries: retries - ) - let operation = MetadataOperation(action: .setHealthcheck(healthcheck: healthcheck)) - return try add(operation) - } - - /// Set target platforms - @discardableResult - public func platforms(_ platforms: Platform...) -> Self { - targetPlatforms = Set(platforms) - return self - } - - /// Set metadata - @discardableResult - public func metadata( - sourceFile: String? = nil, - contextPath: String? = nil, - frontend: String? = nil - ) -> Self { - if let sourceFile = sourceFile { - metadata = BuildGraphMetadata( - sourceFile: sourceFile, - contextPath: contextPath ?? metadata.contextPath, - frontend: frontend ?? metadata.frontend - ) - } - return self - } - - /// Build the final graph - public func build() throws -> BuildGraph { - // Report build start - if let reporter = reporter { - let stageCount = stages.count - Task { - await reporter.report( - .irEvent( - context: ReportContext( - description: "Building graph with \(stageCount) stages", - sourceMap: nil - ), - type: .graphStarted - )) - } - } - - // Add final stage if any - if let current = currentStage { - stages.append(try current.build()) - currentStage = nil - } - - // Create initial graph - var graph = try BuildGraph( - stages: stages, - buildArgs: buildArgs, - targetPlatforms: targetPlatforms, - metadata: metadata - ) - - // Create analysis context - let analysisContext = AnalysisContext(reporter: reporter) - - // Run all graph analyzers in sequence - for analyzer in graphAnalyzers { - let analyzerName = String(describing: type(of: analyzer)) - - // Report analyzer start - if let reporter = reporter { - Task { - await reporter.report( - .irEvent( - context: ReportContext( - description: "Running \(analyzerName)", - sourceMap: nil - ), - type: .analyzing - )) - } - } - - graph = try analyzer.analyze(graph, context: analysisContext) - } - - // Report completion - if let reporter = reporter { - let totalNodes = graph.stages.reduce(0) { $0 + $1.nodes.count } - let stageCount = graph.stages.count - Task { - await reporter.report( - .irEvent( - context: ReportContext( - description: "Graph built: \(stageCount) stages, \(totalNodes) nodes", - sourceMap: nil - ), - type: .graphCompleted - )) - } - } - - return graph - } -} - -// MARK: - Convenience Extensions - -extension GraphBuilder { - /// Create a simple single-stage build - public static func singleStage( - name: String? = nil, - from image: ImageReference, - platform: Platform? = nil, - reporter: Reporter? = nil, - _ configure: (GraphBuilder) throws -> Void - ) throws -> BuildGraph { - let builder = GraphBuilder(reporter: reporter) - if let platform = platform { - builder.platforms(platform) - } - try builder.stage(name: name, from: image, platform: platform) - try configure(builder) - return try builder.build() - } - - /// Create a multi-stage build - public static func multiStage( - reporter: Reporter? = nil, - _ configure: (GraphBuilder) throws -> Void - ) throws -> BuildGraph { - let builder = GraphBuilder(reporter: reporter) - try configure(builder) - return try builder.build() - } - - public func getStage(stageName: String) -> BuildStage? { - for s in self.stages { - if s.name == stageName { - return s - } - } - return nil - } -} diff --git a/Sources/NativeBuilder/ContainerBuildIR/Graph/GraphTraversal.swift b/Sources/NativeBuilder/ContainerBuildIR/Graph/GraphTraversal.swift deleted file mode 100644 index a8c48dd46..000000000 --- a/Sources/NativeBuilder/ContainerBuildIR/Graph/GraphTraversal.swift +++ /dev/null @@ -1,288 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -/// Utilities for traversing and analyzing build graphs. -/// -/// Design rationale: -/// - Provides common graph algorithms (topological sort, dependency analysis) -/// - Supports both forward and reverse traversal -/// - Enables optimization passes and validation -public enum GraphTraversal { - - /// Perform topological sort on nodes in a stage. - /// - /// - Returns: Nodes in execution order - /// - Throws: If graph contains cycles - public static func topologicalSort(_ stage: BuildStage) throws -> [BuildNode] { - var sorted: [BuildNode] = [] - var visited = Set() - var visiting = Set() - - func visit(_ nodeId: UUID) throws { - if visiting.contains(nodeId) { - throw BuildGraphError.cyclicDependency - } - - if visited.contains(nodeId) { - return - } - - guard let node = stage.nodes.first(where: { $0.id == nodeId }) else { - return - } - - visiting.insert(nodeId) - - for dep in node.dependencies { - try visit(dep) - } - - visiting.remove(nodeId) - visited.insert(nodeId) - sorted.append(node) - } - - for node in stage.nodes { - try visit(node.id) - } - - return sorted - } - - /// Find all nodes that depend on a given node. - public static func findDependents( - of nodeId: UUID, - in stage: BuildStage - ) -> Set { - var dependents = Set() - - for node in stage.nodes { - if node.dependencies.contains(nodeId) { - dependents.insert(node.id) - // Recursively find transitive dependents - let transitive = findDependents(of: node.id, in: stage) - dependents.formUnion(transitive) - } - } - - return dependents - } - - /// Find all nodes that a given node depends on. - public static func findDependencies( - of nodeId: UUID, - in stage: BuildStage - ) -> Set { - guard let node = stage.nodes.first(where: { $0.id == nodeId }) else { - return [] - } - - var allDeps = node.dependencies - - for dep in node.dependencies { - let transitive = findDependencies(of: dep, in: stage) - allDeps.formUnion(transitive) - } - - return allDeps - } - - /// Find stages that a given stage depends on. - public static func findStageDependencies( - of stage: BuildStage, - in graph: BuildGraph - ) -> Set { - var stageDeps = Set() - - for dep in stage.stageDependencies() { - switch dep { - case .named(let name): - stageDeps.insert(name) - case .index(let idx): - if let depStage = graph.stage(at: idx), - let name = depStage.name - { - stageDeps.insert(name) - } - case .previous: - if let stageIndex = graph.stages.firstIndex(where: { $0.id == stage.id }), - stageIndex > 0, - let prevName = graph.stages[stageIndex - 1].name - { - stageDeps.insert(prevName) - } - } - } - - return stageDeps - } - - /// Perform a depth-first traversal of the graph. - public static func depthFirst( - stage: BuildStage, - visit: (BuildNode) throws -> Void - ) throws { - var visited = Set() - - func dfs(_ nodeId: UUID) throws { - guard !visited.contains(nodeId) else { return } - visited.insert(nodeId) - - guard let node = stage.nodes.first(where: { $0.id == nodeId }) else { - return - } - - // Visit dependencies first - for dep in node.dependencies { - try dfs(dep) - } - - try visit(node) - } - - // Visit all nodes, starting from roots but ensuring all nodes are visited - for node in stage.nodes { - try dfs(node.id) - } - } - - /// Find root nodes (nodes with no dependencies). - public static func findRoots(in stage: BuildStage) -> [BuildNode] { - stage.nodes.filter { $0.dependencies.isEmpty } - } - - /// Find leaf nodes (nodes with no dependents). - public static func findLeaves(in stage: BuildStage) -> [BuildNode] { - stage.nodes.filter { node in - !stage.nodes.contains { $0.dependencies.contains(node.id) } - } - } - - /// Calculate the critical path (longest path) through the graph. - public static func criticalPath(in stage: BuildStage) -> [BuildNode] { - // This is a simplified version - real implementation would - // consider execution times - var pathLengths = [UUID: Int]() - var nextInPath = [UUID: UUID?]() - - // Initialize all nodes - for node in stage.nodes { - pathLengths[node.id] = 1 - nextInPath[node.id] = nil - } - - // Calculate longest paths - if let sorted = try? topologicalSort(stage) { - for node in sorted.reversed() { - for dep in node.dependencies { - guard let nodeLength = pathLengths[node.id], - let depLength = pathLengths[dep] - else { - continue - } - - let newLength = nodeLength + 1 - if newLength > depLength { - pathLengths[dep] = newLength - nextInPath[dep] = node.id - } - } - } - } - - // Find the starting node with longest path - let start = pathLengths.max(by: { $0.value < $1.value })?.key - - // Build the path - var path: [BuildNode] = [] - var current = start - - while let nodeId = current, - let node = stage.nodes.first(where: { $0.id == nodeId }) - { - path.append(node) - current = nextInPath[nodeId] ?? nil - } - - return path - } -} - -/// Graph analysis results. -public struct GraphAnalysis { - /// Total number of operations - public let operationCount: Int - - /// Operations by type - public let operationsByType: [OperationKind: Int] - - /// Number of stages - public let stageCount: Int - - /// Stage dependencies - public let stageDependencies: [String: Set] - - /// Maximum depth of the graph - public let maxDepth: Int - - /// Critical path length - public let criticalPathLength: Int - - /// Parallelism opportunities (nodes that can run concurrently) - public let parallelismOpportunities: [[UUID]] -} - -extension BuildGraph { - /// Analyze the build graph structure. - public func analyze() -> GraphAnalysis { - var operationsByType = [OperationKind: Int]() - var stageDeps = [String: Set]() - var maxDepth = 0 - var criticalLength = 0 - - // Count operations and analyze stages - for stage in stages { - if let name = stage.name { - stageDeps[name] = GraphTraversal.findStageDependencies(of: stage, in: self) - } - - for op in stage.operations { - operationsByType[op.operationKind, default: 0] += 1 - } - - // Calculate depth - if let sorted = try? GraphTraversal.topologicalSort(stage) { - maxDepth = max(maxDepth, sorted.count) - } - - // Critical path - let critical = GraphTraversal.criticalPath(in: stage) - criticalLength = max(criticalLength, critical.count) - } - - return GraphAnalysis( - operationCount: stages.flatMap { $0.operations }.count, - operationsByType: operationsByType, - stageCount: stages.count, - stageDependencies: stageDeps, - maxDepth: maxDepth, - criticalPathLength: criticalLength, - parallelismOpportunities: [] // TODO: Implement - ) - } -} diff --git a/Sources/NativeBuilder/ContainerBuildIR/Graph/StageBuilder.swift b/Sources/NativeBuilder/ContainerBuildIR/Graph/StageBuilder.swift deleted file mode 100644 index b621f33e6..000000000 --- a/Sources/NativeBuilder/ContainerBuildIR/Graph/StageBuilder.swift +++ /dev/null @@ -1,95 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildReporting -import ContainerizationOCI -import Foundation - -/// Builder for individual stages. -final class StageBuilder { - let name: String? - let base: ImageOperation - let platform: Platform? - private var nodes: [BuildNode] = [] - private var lastNodeId: UUID? - private let analyzers: [any StageAnalyzer] - private let reporter: Reporter? - - init(name: String?, base: ImageOperation, platform: Platform? = nil, analyzers: [any StageAnalyzer] = [], reporter: Reporter? = nil) { - self.name = name - self.base = base - self.platform = platform - self.analyzers = analyzers - self.reporter = reporter - } - - @discardableResult - func add(_ operation: any Operation, dependsOn: Set = []) -> UUID { - let node = BuildNode( - operation: operation, - dependencies: dependsOn - ) - - // Report node addition - if let reporter = reporter { - let stageId = name ?? "stage-\(node.id.uuidString.prefix(8))" - Task { - await reporter.report( - .irEvent( - context: ReportContext( - nodeId: node.id, - stageId: stageId, - description: "Added \(type(of: operation))", - sourceMap: nil - ), - type: .nodeAdded - )) - } - } - - nodes.append(node) - lastNodeId = node.id - return node.id - } - - func getDeclaredArg(_ name: String) -> (found: Bool, defaultValue: String?) { - for node in nodes { - if let metadataOp = node.operation as? MetadataOperation, case .declareArg(let argName, let defaultValue) = metadataOp.action, argName == name { - return (found: true, defaultValue: defaultValue) - } - } - return (found: false, defaultValue: nil) - } - - func build() throws -> BuildStage { - var stage = BuildStage( - name: name, - base: base, - nodes: nodes, - platform: platform - ) - - // Create analysis context - let analysisContext = AnalysisContext(reporter: reporter) - - // Run stage analyzers - for analyzer in analyzers { - stage = try analyzer.analyze(stage, context: analysisContext) - } - - return stage - } -} diff --git a/Sources/NativeBuilder/ContainerBuildIR/Operations/ExecOperation.swift b/Sources/NativeBuilder/ContainerBuildIR/Operations/ExecOperation.swift deleted file mode 100644 index 884f84f50..000000000 --- a/Sources/NativeBuilder/ContainerBuildIR/Operations/ExecOperation.swift +++ /dev/null @@ -1,365 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -/// Represents a command execution operation (RUN in Dockerfile). -/// -/// Design rationale: -/// - Captures all execution context (env, mounts, user, workdir) -/// - Supports advanced features like secrets and SSH forwarding -/// - Shell vs exec form preserved for accurate execution -/// - Network and security controls built-in -public struct ExecOperation: Operation, Hashable { - public static let operationKind = OperationKind.exec - public var operationKind: OperationKind { Self.operationKind } - - /// The command to execute - public let command: Command - - /// Environment variables - public let environment: Environment - - /// Mounts (cache, bind, tmpfs, secret, ssh) - public let mounts: [Mount] - - /// Working directory - public let workingDirectory: String? - - /// User to run as - public let user: User? - - /// Network mode - public let network: NetworkMode - - /// Security options - public let security: SecurityOptions - - /// Operation metadata - public let metadata: OperationMetadata - - public init( - command: Command, - environment: Environment = .empty, - mounts: [Mount] = [], - workingDirectory: String? = nil, - user: User? = nil, - network: NetworkMode = .default, - security: SecurityOptions = .default, - metadata: OperationMetadata = OperationMetadata() - ) { - self.command = command - self.environment = environment - self.mounts = mounts - self.workingDirectory = workingDirectory - self.user = user - self.network = network - self.security = security - self.metadata = metadata - } - - public func accept(_ visitor: V) throws -> V.Result { - try visitor.visit(self) - } -} - -// MARK: - Command - -/// Represents a command to execute. -/// -/// Design rationale: -/// - Preserves shell vs exec form from Dockerfile -/// - Shell form uses default shell with command as string -/// - Exec form bypasses shell for direct execution -public enum Command: Hashable, Sendable { - /// Shell form: command is passed to shell - case shell(String) - - /// Exec form: direct execution without shell - case exec([String]) - - /// The command string for display - public var displayString: String { - switch self { - case .shell(let cmd): - return cmd - case .exec(let args): - return args.joined(separator: " ") - } - } - - /// Arguments for execution - public var arguments: [String] { - switch self { - case .shell(let cmd): - return ["/bin/sh", "-c", cmd] - case .exec(let args): - return args - } - } -} - -// MARK: - Environment - -/// Environment variables for execution. -/// -/// Design rationale: -/// - Preserves order for predictable overwrites -/// - Supports both literal values and build args -/// - Case-sensitive on Linux, case-insensitive on Windows -public struct Environment: Hashable, Sendable { - public let variables: [(key: String, value: EnvironmentValue)] - - public init(_ variables: [(key: String, value: EnvironmentValue)] = []) { - self.variables = variables - } - - public static let empty = Environment() - - /// Get effective environment as dictionary (last value wins) - public var effectiveEnvironment: [String: String] { - var result: [String: String] = [:] - for (key, value) in variables { - if case .literal(let str) = value { - result[key] = str - } - } - return result - } -} - -// Custom Hashable conformance for Environment -extension Environment { - public static func == (lhs: Environment, rhs: Environment) -> Bool { - guard lhs.variables.count == rhs.variables.count else { return false } - for (index, (lkey, lvalue)) in lhs.variables.enumerated() { - let (rkey, rvalue) = rhs.variables[index] - if lkey != rkey || lvalue != rvalue { - return false - } - } - return true - } - - public func hash(into hasher: inout Hasher) { - for (key, value) in variables { - hasher.combine(key) - hasher.combine(value) - } - } -} - -/// Environment variable value. -public enum EnvironmentValue: Hashable, Sendable { - /// Literal string value - case literal(String) - - /// Reference to build argument - case buildArg(String) - - /// Expansion with default - case expansion(name: String, default: String?) -} - -// MARK: - Mounts - -/// Represents a mount in the container. -/// -/// Design rationale: -/// - Type-safe mount specifications -/// - Supports all Dockerfile mount types -/// - Extensible for future mount types -public struct Mount: Hashable, Sendable { - public let type: MountType - public let target: String? - public let envTarget: String? - public let source: MountSource? - public let options: MountOptions - - public init( - type: MountType, - target: String? = nil, - envTarget: String? = nil, - source: MountSource? = nil, - options: MountOptions = MountOptions() - ) { - self.type = type - self.target = target - self.envTarget = envTarget - self.source = source - self.options = options - } -} - -/// Type of mount. -public enum MountType: String, Hashable, Sendable { - case bind - case cache - case tmpfs - case secret - case ssh -} - -/// Source of mount data. -public enum MountSource: Hashable, Sendable { - /// Local path - case local(String) - - /// From another stage - case stage(StageReference, path: String) - - /// From image - case image(ImageReference, path: String) - - /// Build context - case context(String, path: String) - - /// Secret by ID - case secret(String) - - /// SSH agent socket - case sshAgent -} - -/// Mount options. -public struct MountOptions: Hashable, Sendable { - public let readOnly: Bool - public let uid: UInt32? - public let gid: UInt32? - public let mode: UInt32? - public let size: UInt32? // For tmpfs - public let sharing: SharingMode? // For cache mounts - public let required: Bool? - - public init( - readOnly: Bool = false, - uid: UInt32? = nil, - gid: UInt32? = nil, - mode: UInt32? = nil, - size: UInt32? = nil, - sharing: SharingMode? = nil, - required: Bool? = nil, - ) { - self.readOnly = readOnly - self.uid = uid - self.gid = gid - self.mode = mode - self.size = size - self.sharing = sharing - self.required = required - } -} - -// MARK: - User - -/// User specification for command execution. -public enum User: Hashable, Sendable { - /// User by name - case named(String) - - /// User by UID - case uid(UInt32) - - /// User and group - case userGroup(user: String, group: String) - - /// UID and GID - case uidGid(uid: UInt32, gid: UInt32) -} - -// MARK: - Network - -/// Network mode for execution. -public enum NetworkMode: String, Hashable, Sendable { - /// Default network - case `default` - - /// No network - case none - - /// Host network - case host -} - -// MARK: - Security - -/// Security options for execution. -public struct SecurityOptions: Hashable, Sendable { - public let privileged: Bool - public let capabilities: SecurityCapabilities? - public let seccompProfile: String? - public let apparmorProfile: String? - public let noNewPrivileges: Bool - - public init( - privileged: Bool = false, - capabilities: SecurityCapabilities? = nil, - seccompProfile: String? = nil, - apparmorProfile: String? = nil, - noNewPrivileges: Bool = true - ) { - self.privileged = privileged - self.capabilities = capabilities - self.seccompProfile = seccompProfile - self.apparmorProfile = apparmorProfile - self.noNewPrivileges = noNewPrivileges - } - - public static let `default` = SecurityOptions() -} - -/// Linux capabilities. -public struct SecurityCapabilities: Hashable, Sendable { - public let add: Set - public let drop: Set - - public init(add: Set = [], drop: Set = []) { - self.add = add - self.drop = drop - } -} - -// MARK: - Codable - -extension ExecOperation: Codable {} -extension Command: Codable {} -extension Environment: Codable { - private struct EnvVar: Codable { - let key: String - let value: EnvironmentValue - } - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - let envVars = try container.decode([EnvVar].self) - self.variables = envVars.map { ($0.key, $0.value) } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - let envVars = variables.map { EnvVar(key: $0.key, value: $0.value) } - try container.encode(envVars) - } -} -extension EnvironmentValue: Codable {} -extension Mount: Codable {} -extension MountType: Codable {} -extension MountSource: Codable {} -extension MountOptions: Codable {} -extension User: Codable {} -extension NetworkMode: Codable {} -extension SecurityOptions: Codable {} -extension SecurityCapabilities: Codable {} diff --git a/Sources/NativeBuilder/ContainerBuildIR/Operations/FilesystemOperation.swift b/Sources/NativeBuilder/ContainerBuildIR/Operations/FilesystemOperation.swift deleted file mode 100644 index 231f44bd7..000000000 --- a/Sources/NativeBuilder/ContainerBuildIR/Operations/FilesystemOperation.swift +++ /dev/null @@ -1,313 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -/// Represents filesystem operations (COPY, ADD, etc.). -/// -/// Design rationale: -/// - Unified handling of all filesystem modifications -/// - Preserves source context (local, stage, URL) -/// - Supports advanced features like ownership and permissions -/// - Extensible for future filesystem operations -public struct FilesystemOperation: Operation, Hashable { - public static let operationKind = OperationKind.filesystem - public var operationKind: OperationKind { Self.operationKind } - - /// The filesystem action to perform - public let action: FilesystemAction - - /// Source of the files - public let source: FilesystemSource - - /// Destination path - public let destination: String - - /// File metadata (ownership, permissions) - public let fileMetadata: FileMetadata - - /// Copy options - public let options: FilesystemOptions - - /// Operation metadata - public let metadata: OperationMetadata - - public init( - action: FilesystemAction, - source: FilesystemSource, - destination: String, - fileMetadata: FileMetadata = FileMetadata(), - options: FilesystemOptions = FilesystemOptions(), - metadata: OperationMetadata = OperationMetadata() - ) { - self.action = action - self.source = source - self.destination = destination - self.fileMetadata = fileMetadata - self.options = options - self.metadata = metadata - } - - public func accept(_ visitor: V) throws -> V.Result { - try visitor.visit(self) - } -} - -// MARK: - Filesystem Action - -/// Type of filesystem action. -/// -/// Design rationale: -/// - Covers all Dockerfile filesystem operations -/// - Extensible for future operations -/// - Clear semantics for each action -public enum FilesystemAction: String, Hashable, Sendable { - /// Copy files (COPY instruction) - case copy - - /// Add files with URL/tar support (ADD instruction) - case add - - /// Remove files - case remove - - /// Create directory - case mkdir - - /// Create symlink - case symlink - - /// Create hard link - case hardlink -} - -// MARK: - Filesystem Source - -/// Source of filesystem content. -/// -/// Design rationale: -/// - Type-safe source specifications -/// - Supports all Dockerfile source types -/// - Extensible for future sources -public enum FilesystemSource: Hashable, Sendable { - /// Files from build context - case context(ContextSource) - - /// Files from another stage - case stage(StageReference, paths: [String]) - - /// Files from an image - case image(ImageReference, paths: [String]) - - /// URL to download - case url(URL) - - /// Git repository - case git(GitSource) - - /// Inline content - case inline(Data) - - /// Empty/scratch - case scratch -} - -/// Build context source. -public struct ContextSource: Hashable, Sendable { - /// Name of the context - public let name: String - - /// Paths relative to context root - public let paths: [String] - - /// Include patterns (if empty, all files match) - public let includes: [String] - - /// Exclude patterns - public let excludes: [String] - - public init(name: String = "default", paths: [String], includes: [String] = [], excludes: [String] = []) { - self.name = name - self.paths = paths - self.includes = includes - self.excludes = excludes - } -} - -/// Git repository source. -public struct GitSource: Hashable, Sendable { - public let repository: String - public let reference: String? // branch, tag, commit - public let submodules: Bool - - public init(repository: String, reference: String? = nil, submodules: Bool = false) { - self.repository = repository - self.reference = reference - self.submodules = submodules - } -} - -// MARK: - File Metadata - -/// Metadata for created/modified files. -/// -/// Design rationale: -/// - Captures all file attributes -/// - Platform-aware (Unix permissions vs Windows ACLs) -/// - Preserves source metadata by default -public struct FileMetadata: Hashable, Sendable { - /// File ownership - public let ownership: Ownership? - - /// File permissions (Unix mode) - public let permissions: Permissions? - - /// Timestamps - public let timestamps: Timestamps? - - /// Extended attributes - public let xattrs: [String: Data] - - public init( - ownership: Ownership? = nil, - permissions: Permissions? = nil, - timestamps: Timestamps? = nil, - xattrs: [String: Data] = [:] - ) { - self.ownership = ownership - self.permissions = permissions - self.timestamps = timestamps - self.xattrs = xattrs - } -} - -public enum OwnershipID: Hashable, Sendable { - /// Numeric UID/GID - case numeric(id: UInt32) - - /// Named user/group - case named(id: String) -} - -/// File ownership. -public struct Ownership: Hashable, Sendable { - public let userID: OwnershipID? - public let groupID: OwnershipID? - - public init(user: OwnershipID? = nil, group: OwnershipID? = nil) { - self.userID = user - self.groupID = group - } -} - -/// File permissions. -public enum Permissions: Hashable, Sendable { - /// Unix mode (e.g., 0755) - case mode(UInt32) - - /// Symbolic (e.g., "u+x") - case symbolic(String) - - /// Preserve from source - case preserve -} - -/// File timestamps. -public struct Timestamps: Hashable, Sendable { - public let created: Date? - public let modified: Date? - public let accessed: Date? - - public init(created: Date? = nil, modified: Date? = nil, accessed: Date? = nil) { - self.created = created - self.modified = modified - self.accessed = accessed - } -} - -// MARK: - Filesystem Options - -/// Options for filesystem operations. -/// -/// Design rationale: -/// - Controls operation behavior -/// - Platform-specific handling -/// - Performance optimizations -public struct FilesystemOptions: Hashable, Sendable { - /// Follow symlinks in source - public let followSymlinks: Bool - - /// Preserve timestamps - public let preserveTimestamps: Bool - - /// Merge directories (don't replace) - public let merge: Bool - - /// Create parent directories - public let createParents: Bool - - /// For ADD: auto-extract archives - public let extractArchives: Bool - - /// Copy strategy - public let copyStrategy: CopyStrategy - - public init( - followSymlinks: Bool = true, - preserveTimestamps: Bool = false, - merge: Bool = true, - createParents: Bool = true, - extractArchives: Bool = true, - copyStrategy: CopyStrategy = .auto - ) { - self.followSymlinks = followSymlinks - self.preserveTimestamps = preserveTimestamps - self.merge = merge - self.createParents = createParents - self.extractArchives = extractArchives - self.copyStrategy = copyStrategy - } -} - -/// Strategy for copying files. -public enum CopyStrategy: String, Hashable, Sendable { - /// Automatically choose best method - case auto - - /// Copy-on-write if available - case cow - - /// Hard link if possible - case hardlink - - /// Always full copy - case copy -} - -// MARK: - Codable - -extension FilesystemOperation: Codable {} -extension FilesystemAction: Codable {} -extension FilesystemSource: Codable {} -extension ContextSource: Codable {} -extension GitSource: Codable {} -extension FileMetadata: Codable {} -extension OwnershipID: Codable {} -extension Ownership: Codable {} -extension Permissions: Codable {} -extension Timestamps: Codable {} -extension FilesystemOptions: Codable {} -extension CopyStrategy: Codable {} diff --git a/Sources/NativeBuilder/ContainerBuildIR/Operations/ImageOperation.swift b/Sources/NativeBuilder/ContainerBuildIR/Operations/ImageOperation.swift deleted file mode 100644 index 41aacd2f4..000000000 --- a/Sources/NativeBuilder/ContainerBuildIR/Operations/ImageOperation.swift +++ /dev/null @@ -1,173 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerizationOCI -import Foundation - -/// Represents base image operations (FROM in Dockerfile). -/// -/// Design rationale: -/// - Handles both image pulls and scratch images -/// - Platform-aware for multi-arch support -/// - Supports image verification and policy -public struct ImageOperation: Operation, Hashable, Equatable { - public static let operationKind = OperationKind.image - public var operationKind: OperationKind { Self.operationKind } - - /// Image source - public let source: ImageSource - - /// Target platform - public let platform: Platform? - - /// Pull policy - public let pullPolicy: PullPolicy - - /// Image verification - public let verification: ImageVerification? - - /// Operation metadata - public let metadata: OperationMetadata - - public init( - source: ImageSource, - platform: Platform? = nil, - pullPolicy: PullPolicy = .ifNotPresent, - verification: ImageVerification? = nil, - metadata: OperationMetadata = OperationMetadata() - ) { - self.source = source - self.platform = platform - self.pullPolicy = pullPolicy - self.verification = verification - self.metadata = metadata - } - - public func accept(_ visitor: V) throws -> V.Result { - try visitor.visit(self) - } -} - -// MARK: - Image Source - -/// Source of base image. -/// -/// Design rationale: -/// - Supports registry images and scratch -/// - Extensible for future image sources -/// - Clear semantics for each source type -public enum ImageSource: Hashable, Sendable { - /// Image from registry - case registry(ImageReference) - - /// Empty image (FROM scratch) - case scratch - - /// Local OCI layout - case ociLayout(path: String, tag: String?) - - /// Tarball - case tarball(path: String) -} - -// MARK: - Pull Policy - -/// Policy for pulling images. -/// -/// Design rationale: -/// - Matches Kubernetes/Docker semantics -/// - Allows optimization vs freshness tradeoffs -public enum PullPolicy: String, Hashable, Sendable { - /// Always pull - case always - - /// Pull if not present locally - case ifNotPresent - - /// Never pull (must exist locally) - case never -} - -// MARK: - Image Verification - -/// Image verification requirements. -/// -/// Design rationale: -/// - Supports multiple verification methods -/// - Extensible for new verification types -/// - Policy-based for enterprise requirements -public struct ImageVerification: Hashable, Sendable { - /// Verification method - public let method: VerificationMethod - - /// Required signatures - public let requiredSignatures: Int - - /// Trusted keys/identities - public let trustedKeys: Set - - public init( - method: VerificationMethod, - requiredSignatures: Int = 1, - trustedKeys: Set = [] - ) { - self.method = method - self.requiredSignatures = requiredSignatures - self.trustedKeys = trustedKeys - } -} - -/// Verification method. -public enum VerificationMethod: String, Hashable, Sendable { - /// No verification - case none - - /// Verify digest only - case digest - - /// Cosign signatures - case cosign - - /// Notary v2 - case notary - - /// In-toto attestations - case intoto -} - -/// Trusted key for verification. -public enum TrustedKey: Hashable, Sendable { - /// Public key - case publicKey(Data) - - /// Key ID (for key servers) - case keyID(String) - - /// Certificate - case certificate(Data) - - /// OIDC identity - case oidcIdentity(issuer: String, subject: String) -} - -// MARK: - Codable - -extension ImageOperation: Codable {} -extension ImageSource: Codable {} -extension PullPolicy: Codable {} -extension ImageVerification: Codable {} -extension VerificationMethod: Codable {} -extension TrustedKey: Codable {} diff --git a/Sources/NativeBuilder/ContainerBuildIR/Operations/MetadataOperation.swift b/Sources/NativeBuilder/ContainerBuildIR/Operations/MetadataOperation.swift deleted file mode 100644 index faca6dccd..000000000 --- a/Sources/NativeBuilder/ContainerBuildIR/Operations/MetadataOperation.swift +++ /dev/null @@ -1,454 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -/// Represents metadata operations (ENV, LABEL, ARG, etc.). -/// -/// Design rationale: -/// - Unified handling of all metadata modifications -/// - No filesystem changes, only configuration -/// - Supports both build-time and runtime metadata -public struct MetadataOperation: Operation, Hashable { - public static let operationKind = OperationKind.metadata - public var operationKind: OperationKind { Self.operationKind } - - /// Type of metadata operation - public let action: MetadataAction - - /// Operation metadata - public let metadata: OperationMetadata - - public init( - action: MetadataAction, - metadata: OperationMetadata = OperationMetadata() - ) { - self.action = action - self.metadata = metadata - } - - public func accept(_ visitor: V) throws -> V.Result { - try visitor.visit(self) - } -} - -// MARK: - Metadata Action - -/// Type of metadata modification. -/// -/// Design rationale: -/// - Each action is self-contained with its data -/// - Clear separation between build and runtime metadata -/// - Extensible for future metadata types -public enum MetadataAction: Sendable { - /// Set environment variable (ENV) - case setEnv(key: String, value: EnvironmentValue) - - /// Set multiple environment variables - case setEnvBatch([(key: String, value: EnvironmentValue)]) - - /// Set label (LABEL) - case setLabel(key: String, value: String) - - /// Set multiple labels - case setLabelBatch([String: String]) - - /// Define build argument (ARG) - case declareArg(name: String, defaultValue: String?) - - /// Set exposed ports (EXPOSE) - case expose(ports: [PortSpec]) - - /// Set working directory (WORKDIR) - case setWorkdir(path: String) - - /// Set user (USER) - case setUser(user: User) - - /// Set entrypoint (ENTRYPOINT) - case setEntrypoint(command: Command) - - /// Set default command (CMD) - case setCmd(command: Command) - - /// Set shell (SHELL) - case setShell(shell: [String]) - - /// Set healthcheck (HEALTHCHECK) - case setHealthcheck(healthcheck: Healthcheck?) - - /// Set stop signal (STOPSIGNAL) - case setStopSignal(signal: String) - - /// Add volume (VOLUME) - case addVolume(path: String) - - /// Add onbuild trigger (ONBUILD) - case addOnBuild(instruction: String) -} - -// MARK: - Port Specification - -/// Port exposure specification. -/// -/// Design rationale: -/// - Supports TCP/UDP/SCTP -/// - Range support for multiple ports -/// - Documentation via description -public struct PortSpec: Hashable, Sendable { - public enum NetworkProtocol: String, Hashable, Sendable { - case tcp - case udp - case sctp - } - - /// Port number or range start - public let port: UInt16 - - /// Range end (if range) - public let endPort: UInt16? - - /// Protocol - public let `protocol`: NetworkProtocol - - /// Human-readable description - public let description: String? - - public init( - port: UInt16, - endPort: UInt16? = nil, - protocol: NetworkProtocol = .tcp, - description: String? = nil - ) { - self.port = port - self.endPort = endPort - self.`protocol` = `protocol` - self.description = description - } - - /// String representation (e.g., "80/tcp", "8000-8100/udp") - public var stringValue: String { - guard let endPort = endPort else { - return "\(port)/\(`protocol`.rawValue)" - } - return "\(port)-\(endPort)/\(`protocol`.rawValue)" - } -} - -// MARK: - Healthcheck - -/// Container healthcheck configuration. -/// -/// Design rationale: -/// - Matches Docker/OCI healthcheck spec -/// - Flexible timing configuration -/// - Supports disabling inherited healthchecks -public struct Healthcheck: Hashable, Sendable { - /// Test command - public let test: HealthcheckTest - - /// Time between checks - public let interval: TimeInterval? - - /// Timeout for each check - public let timeout: TimeInterval? - - /// Initial delay before first check - public let startPeriod: TimeInterval? - - /// Number of retries before unhealthy - public let retries: Int? - - public init( - test: HealthcheckTest, - interval: TimeInterval? = nil, - timeout: TimeInterval? = nil, - startPeriod: TimeInterval? = nil, - retries: Int? = nil - ) { - self.test = test - self.interval = interval - self.timeout = timeout - self.startPeriod = startPeriod - self.retries = retries - } -} - -/// Healthcheck test specification. -public enum HealthcheckTest: Hashable, Sendable { - /// No healthcheck (NONE) - case none - - /// Command to run (CMD) - case command(Command) - - /// Command with shell (CMD-SHELL) - case shell(String) -} - -// MARK: - Hashable & Equatable - -extension MetadataAction: Hashable, Equatable { - public static func == (lhs: MetadataAction, rhs: MetadataAction) -> Bool { - switch (lhs, rhs) { - case (.setEnv(let lk, let lv), .setEnv(let rk, let rv)): - return lk == rk && lv == rv - case (.setEnvBatch(let l), .setEnvBatch(let r)): - guard l.count == r.count else { return false } - for (index, (lk, lv)) in l.enumerated() { - let (rk, rv) = r[index] - if lk != rk || lv != rv { return false } - } - return true - case (.setLabel(let lk, let lv), .setLabel(let rk, let rv)): - return lk == rk && lv == rv - case (.setLabelBatch(let l), .setLabelBatch(let r)): - return l == r - case (.declareArg(let ln, let ld), .declareArg(let rn, let rd)): - return ln == rn && ld == rd - case (.expose(let l), .expose(let r)): - return l == r - case (.setWorkdir(let l), .setWorkdir(let r)): - return l == r - case (.setUser(let l), .setUser(let r)): - return l == r - case (.setEntrypoint(let l), .setEntrypoint(let r)): - return l == r - case (.setCmd(let l), .setCmd(let r)): - return l == r - case (.setShell(let l), .setShell(let r)): - return l == r - case (.setHealthcheck(let l), .setHealthcheck(let r)): - return l == r - case (.setStopSignal(let l), .setStopSignal(let r)): - return l == r - case (.addVolume(let l), .addVolume(let r)): - return l == r - case (.addOnBuild(let l), .addOnBuild(let r)): - return l == r - default: - return false - } - } - - public func hash(into hasher: inout Hasher) { - switch self { - case .setEnv(let key, let value): - hasher.combine(0) - hasher.combine(key) - hasher.combine(value) - case .setEnvBatch(let vars): - hasher.combine(1) - for (key, value) in vars { - hasher.combine(key) - hasher.combine(value) - } - case .setLabel(let key, let value): - hasher.combine(2) - hasher.combine(key) - hasher.combine(value) - case .setLabelBatch(let labels): - hasher.combine(3) - hasher.combine(labels) - case .declareArg(let name, let defaultValue): - hasher.combine(4) - hasher.combine(name) - hasher.combine(defaultValue) - case .expose(let ports): - hasher.combine(5) - hasher.combine(ports) - case .setWorkdir(let path): - hasher.combine(6) - hasher.combine(path) - case .setUser(let user): - hasher.combine(7) - hasher.combine(user) - case .setEntrypoint(let command): - hasher.combine(8) - hasher.combine(command) - case .setCmd(let command): - hasher.combine(9) - hasher.combine(command) - case .setShell(let shell): - hasher.combine(10) - hasher.combine(shell) - case .setHealthcheck(let healthcheck): - hasher.combine(11) - hasher.combine(healthcheck) - case .setStopSignal(let signal): - hasher.combine(12) - hasher.combine(signal) - case .addVolume(let path): - hasher.combine(13) - hasher.combine(path) - case .addOnBuild(let instruction): - hasher.combine(14) - hasher.combine(instruction) - } - } -} - -// MARK: - Codable - -extension MetadataOperation: Codable {} -extension MetadataAction: Codable { - private enum CodingKeys: String, CodingKey { - case type - case key - case value - case envVars - case labels - case name - case defaultValue - case ports - case path - case user - case command - case shell - case healthcheck - case signal - case instruction - } - - private struct EnvVar: Codable { - let key: String - let value: EnvironmentValue - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - switch self { - case .setEnv(let key, let value): - try container.encode("setEnv", forKey: .type) - try container.encode(key, forKey: .key) - try container.encode(value, forKey: .value) - case .setEnvBatch(let vars): - try container.encode("setEnvBatch", forKey: .type) - let envVars = vars.map { EnvVar(key: $0.key, value: $0.value) } - try container.encode(envVars, forKey: .envVars) - case .setLabel(let key, let value): - try container.encode("setLabel", forKey: .type) - try container.encode(key, forKey: .key) - try container.encode(value, forKey: .value) - case .setLabelBatch(let labels): - try container.encode("setLabelBatch", forKey: .type) - try container.encode(labels, forKey: .labels) - case .declareArg(let name, let defaultValue): - try container.encode("declareArg", forKey: .type) - try container.encode(name, forKey: .name) - try container.encode(defaultValue, forKey: .defaultValue) - case .expose(let ports): - try container.encode("expose", forKey: .type) - try container.encode(ports, forKey: .ports) - case .setWorkdir(let path): - try container.encode("setWorkdir", forKey: .type) - try container.encode(path, forKey: .path) - case .setUser(let user): - try container.encode("setUser", forKey: .type) - try container.encode(user, forKey: .user) - case .setEntrypoint(let command): - try container.encode("setEntrypoint", forKey: .type) - try container.encode(command, forKey: .command) - case .setCmd(let command): - try container.encode("setCmd", forKey: .type) - try container.encode(command, forKey: .command) - case .setShell(let shell): - try container.encode("setShell", forKey: .type) - try container.encode(shell, forKey: .shell) - case .setHealthcheck(let healthcheck): - try container.encode("setHealthcheck", forKey: .type) - try container.encode(healthcheck, forKey: .healthcheck) - case .setStopSignal(let signal): - try container.encode("setStopSignal", forKey: .type) - try container.encode(signal, forKey: .signal) - case .addVolume(let path): - try container.encode("addVolume", forKey: .type) - try container.encode(path, forKey: .path) - case .addOnBuild(let instruction): - try container.encode("addOnBuild", forKey: .type) - try container.encode(instruction, forKey: .instruction) - } - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try container.decode(String.self, forKey: .type) - - switch type { - case "setEnv": - let key = try container.decode(String.self, forKey: .key) - let value = try container.decode(EnvironmentValue.self, forKey: .value) - self = .setEnv(key: key, value: value) - case "setEnvBatch": - let envVars = try container.decode([EnvVar].self, forKey: .envVars) - self = .setEnvBatch(envVars.map { ($0.key, $0.value) }) - case "setLabel": - let key = try container.decode(String.self, forKey: .key) - let value = try container.decode(String.self, forKey: .value) - self = .setLabel(key: key, value: value) - case "setLabelBatch": - let labels = try container.decode([String: String].self, forKey: .labels) - self = .setLabelBatch(labels) - case "declareArg": - let name = try container.decode(String.self, forKey: .name) - let defaultValue = try container.decodeIfPresent(String.self, forKey: .defaultValue) - self = .declareArg(name: name, defaultValue: defaultValue) - case "expose": - let ports = try container.decode([PortSpec].self, forKey: .ports) - self = .expose(ports: ports) - case "setWorkdir": - let path = try container.decode(String.self, forKey: .path) - self = .setWorkdir(path: path) - case "setUser": - let user = try container.decode(User.self, forKey: .user) - self = .setUser(user: user) - case "setEntrypoint": - let command = try container.decode(Command.self, forKey: .command) - self = .setEntrypoint(command: command) - case "setCmd": - let command = try container.decode(Command.self, forKey: .command) - self = .setCmd(command: command) - case "setShell": - let shell = try container.decode([String].self, forKey: .shell) - self = .setShell(shell: shell) - case "setHealthcheck": - let healthcheck = try container.decodeIfPresent(Healthcheck.self, forKey: .healthcheck) - self = .setHealthcheck(healthcheck: healthcheck) - case "setStopSignal": - let signal = try container.decode(String.self, forKey: .signal) - self = .setStopSignal(signal: signal) - case "addVolume": - let path = try container.decode(String.self, forKey: .path) - self = .addVolume(path: path) - case "addOnBuild": - let instruction = try container.decode(String.self, forKey: .instruction) - self = .addOnBuild(instruction: instruction) - default: - throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unknown MetadataAction type: \(type)") - } - } -} -extension PortSpec: Codable { - enum CodingKeys: String, CodingKey { - case port - case endPort - case `protocol` - case description - } -} -extension PortSpec.NetworkProtocol: Codable {} -extension Healthcheck: Codable {} -extension HealthcheckTest: Codable {} diff --git a/Sources/NativeBuilder/ContainerBuildIR/Operations/Operation.swift b/Sources/NativeBuilder/ContainerBuildIR/Operations/Operation.swift deleted file mode 100644 index fadb30a97..000000000 --- a/Sources/NativeBuilder/ContainerBuildIR/Operations/Operation.swift +++ /dev/null @@ -1,288 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerizationOCI -import Crypto -import Foundation - -/// Errors that can occur during operation processing. -public enum OperationError: LocalizedError { - case encodingFailed(String) - case digestComputationFailed(Error) - - public var errorDescription: String? { - switch self { - case .encodingFailed(let details): - return "Failed to encode operation data: \(details)" - case .digestComputationFailed(let error): - return "Failed to compute operation digest: \(error.localizedDescription)" - } - } -} - -/// Core operation protocol that all build operations must conform to. -/// -/// Design rationale: -/// - Protocol-based design allows extending with new operations without modifying existing code -/// - Each operation is self-contained with all necessary data -/// - Operations are immutable for thread safety and predictable behavior -/// - Visitor pattern support for traversal and transformation -public protocol Operation: Sendable { - /// Common metadata associated with this operation. - var metadata: OperationMetadata { get } - - /// Unique identifier for this operation type - static var operationKind: OperationKind { get } - - /// Instance identifier - var operationKind: OperationKind { get } - - /// Accept a visitor for traversal/transformation - func accept(_ visitor: V) throws -> V.Result -} - -/// Identifies the type of operation. -/// -/// Design rationale: -/// - String-based for extensibility (third-party operations) -/// - Comparable for consistent ordering -/// - Provides namespace for operation types -public struct OperationKind: RawRepresentable, Hashable, Sendable { - public let rawValue: String - - public init(rawValue: String) { - self.rawValue = rawValue - } - - // Core operation kinds - public static let exec = OperationKind(rawValue: "core.exec") - public static let filesystem = OperationKind(rawValue: "core.filesystem") - public static let image = OperationKind(rawValue: "core.image") - public static let metadata = OperationKind(rawValue: "core.metadata") - public static let mount = OperationKind(rawValue: "core.mount") -} - -/// Visitor pattern for operation traversal. -/// -/// Design rationale: -/// - Type-safe traversal without casting -/// - Extensible for new operations -/// - Supports both read-only traversal and transformation -public protocol OperationVisitor { - associatedtype Result - - func visit(_ operation: ExecOperation) throws -> Result - func visit(_ operation: FilesystemOperation) throws -> Result - func visit(_ operation: ImageOperation) throws -> Result - func visit(_ operation: MetadataOperation) throws -> Result - - /// Default handler for unknown operations - func visitUnknown(_ operation: any Operation) throws -> Result -} - -/// Base class for operations providing common functionality. -/// -/// Design rationale: -/// - While we prefer protocols, a base class here provides default implementations -/// - Reduces boilerplate for operation implementations -/// - Still allows protocol-based extension -@available(*, unavailable, message: "Use specific operation types instead") -open class BaseOperation: @unchecked Sendable, Operation { - public var metadata: OperationMetadata - - public init(metadata: OperationMetadata) { - self.metadata = metadata - } - - public static var operationKind: OperationKind { - // This class is unavailable for use, but we need to provide a value - // for protocol conformance. This should never be called in practice. - OperationKind(rawValue: "base.unavailable") - } - public var operationKind: OperationKind { Self.operationKind } - - public func accept(_ visitor: V) throws -> V.Result { - try visitor.visitUnknown(self) - } -} - -// MARK: - Operation Metadata - -/// Common metadata that can be attached to any operation. -/// -/// Design rationale: -/// - Extensible key-value storage for future needs -/// - Strongly-typed for common attributes -/// - Preserves unknown attributes for forward compatibility -public struct OperationMetadata: Sendable, Hashable { - /// Human-readable comment/description - public let comment: String? - - /// Source location (file:line) where this operation was defined - public let sourceLocation: SourceLocation? - - /// Platform constraints for this operation - public let platforms: Set? - - /// Cache configuration - public let cacheConfig: CacheConfig? - - /// The retry policy for this specific operation. - public let retryPolicy: RetryPolicy - - /// Additional attributes - public let attributes: [String: AttributeValue] - - public init( - comment: String? = nil, - sourceLocation: SourceLocation? = nil, - platforms: Set? = nil, - cacheConfig: CacheConfig? = nil, - retryPolicy: RetryPolicy = RetryPolicy(maxRetries: 0), - attributes: [String: AttributeValue] = [:] - ) { - self.comment = comment - self.sourceLocation = sourceLocation - self.platforms = platforms - self.cacheConfig = cacheConfig - self.retryPolicy = retryPolicy - self.attributes = attributes - } -} - -/// Defines the retry behavior for a failed operation. -public struct RetryPolicy: Sendable, Hashable, Codable { - /// The maximum number of times to retry the operation. A value of 0 means no retries. - public let maxRetries: Int - - /// The multiplier to apply to the delay between retries. A value of 1.0 is a linear backoff. - public let backoffMultiplier: Double - - /// The initial delay to wait before the first retry. - public let initialDelay: TimeInterval - - /// The maximum possible delay between retries. - public let maxDelay: TimeInterval - - public init( - maxRetries: Int = 3, - backoffMultiplier: Double = 2.0, - initialDelay: TimeInterval = 1.0, - maxDelay: TimeInterval = 60.0 - ) { - self.maxRetries = maxRetries - self.backoffMultiplier = backoffMultiplier - self.initialDelay = initialDelay - self.maxDelay = maxDelay - } -} - -// MARK: - Operation Extensions - -extension Operation { - /// Compute a content digest for cache key generation using stable serialization - /// - Throws: OperationError if encoding or digest computation fails - public func contentDigest() throws -> Digest { - var hasher = SHA256() - - // For now, use a simple string representation of the operation - // In production, this would need proper type-specific hashing - let operationString = String(describing: self) - guard let operationData = operationString.data(using: String.Encoding.utf8) else { - // This should never happen as String descriptions are valid UTF-8 - throw OperationError.encodingFailed("Failed to encode operation string as UTF-8: \(operationString)") - } - hasher.update(data: operationData) - - let digest = hasher.finalize() - do { - return try Digest(algorithm: .sha256, bytes: Data(digest)) - } catch { - // This should never happen as SHA256 produces correct byte length - throw OperationError.digestComputationFailed(error) - } - } -} - -/// Source location information. -public struct SourceLocation: Sendable, Hashable { - public let file: String - public let line: Int - public let column: Int? - - public init(file: String, line: Int, column: Int? = nil) { - self.file = file - self.line = line - self.column = column - } -} - -/// Cache configuration for operations. -public struct CacheConfig: Sendable, Hashable { - public enum CacheMode: String, Sendable, Hashable { - case `default` - case none - case locked - case shared - } - - public let mode: CacheMode - public let id: String? - public let sharing: SharingMode? - - public init(mode: CacheMode = .default, id: String? = nil, sharing: SharingMode? = nil) { - self.mode = mode - self.id = id - self.sharing = sharing - } -} - -/// Cache sharing mode. -public enum SharingMode: String, Sendable, Hashable { - case locked - case shared - case `private` -} - -/// Attribute value for extensible metadata. -public enum AttributeValue: Sendable, Hashable { - case string(String) - case integer(Int) - case double(Double) - case boolean(Bool) - case data(Data) - case array([AttributeValue]) - case dictionary([String: AttributeValue]) -} - -// MARK: - Codable Support - -extension OperationKind: Codable {} - -extension OperationMetadata: Codable { - // Implementation would handle encoding/decoding of all fields -} - -extension SourceLocation: Codable {} - -extension CacheConfig.CacheMode: Codable {} -extension CacheConfig: Codable {} - -extension SharingMode: Codable {} - -extension AttributeValue: Codable { - // Implementation would handle all cases -} diff --git a/Sources/NativeBuilder/ContainerBuildIR/Platform.swift b/Sources/NativeBuilder/ContainerBuildIR/Platform.swift deleted file mode 100644 index 82edb8d4d..000000000 --- a/Sources/NativeBuilder/ContainerBuildIR/Platform.swift +++ /dev/null @@ -1,26 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerizationOCI -import Foundation - -extension Platform { - /// Linux on AMD64 (Intel/AMD 64-bit) - public static let linuxAMD64 = Platform(arch: "amd64", os: "linux") - - /// Linux on ARM64 (64-bit ARM) - public static let linuxARM64 = Platform(arch: "arm64", os: "linux") -} diff --git a/Sources/NativeBuilder/ContainerBuildIR/Reference.swift b/Sources/NativeBuilder/ContainerBuildIR/Reference.swift deleted file mode 100644 index c8464dd16..000000000 --- a/Sources/NativeBuilder/ContainerBuildIR/Reference.swift +++ /dev/null @@ -1,291 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -/// A reference to a container image. -/// -/// Design rationale: -/// - Supports all common reference formats (registry, tag, digest) -/// - Validates references on creation to catch errors early -/// - Immutable to ensure references remain valid -/// - Follows Docker/OCI reference specification -public struct ImageReference: Hashable, Sendable { - /// Registry host (e.g., "docker.io", "ghcr.io") - public let registry: String? - - /// Repository path (e.g., "library/ubuntu", "myorg/myapp") - public let repository: String - - /// Tag (e.g., "latest", "v1.2.3") - public let tag: String? - - /// Digest for content-addressed reference - public let digest: Digest? - - /// Create an image reference - /// - Note: Must have either tag or digest (or both) - public init( - registry: String? = nil, - repository: String, - tag: String? = nil, - digest: Digest? = nil - ) throws { - guard tag != nil || digest != nil else { - throw ReferenceError.missingTagOrDigest - } - - // Validate repository format - guard Self.isValidRepository(repository) else { - throw ReferenceError.invalidRepository(repository) - } - - // Validate registry if provided - if let registry = registry { - guard Self.isValidRegistry(registry) else { - throw ReferenceError.invalidRegistry(registry) - } - } - - self.registry = registry - self.repository = repository - self.tag = tag - self.digest = digest - } - - /// Parse an image reference string (e.g., "ubuntu:20.04", "ghcr.io/myorg/app@sha256:...") - public init?(parsing string: String) { - // Handle digest references (containing @) - let digestSplit = string.split(separator: "@", maxSplits: 1) - let beforeDigest = String(digestSplit[0]) - let digest: Digest? - if digestSplit.count == 2 { - let digestString = String(digestSplit[1]) - // If it starts with sha256:, it's already in the right format - // Otherwise prepend sha256: - let fullDigestString = digestString.hasPrefix("sha256:") ? digestString : "sha256:\(digestString)" - guard let d = try? Digest(parsing: fullDigestString) else { return nil } - digest = d - } else { - digest = nil - } - - // First, determine if we have a registry by looking at the first component - let pathComponents = beforeDigest.split(separator: "/") - - let registry: String? - let repoAndTag: String - - if pathComponents.count >= 2 { - let firstComponent = String(pathComponents[0]) - // Check if first component looks like a registry - // It's a registry if it contains a dot (domain) or colon (port) or is "localhost" - if firstComponent.contains(".") || firstComponent.contains(":") || firstComponent == "localhost" { - registry = firstComponent - repoAndTag = pathComponents.dropFirst().joined(separator: "/") - } else { - registry = nil - repoAndTag = beforeDigest - } - } else { - registry = nil - repoAndTag = beforeDigest - } - - // Now handle tag in the repository part - let tagSplit = repoAndTag.split(separator: ":", maxSplits: 1) - let repository = String(tagSplit[0]) - let tag: String? = tagSplit.count == 2 ? String(tagSplit[1]) : nil - - do { - try self.init( - registry: registry, - repository: repository, - tag: tag ?? (digest == nil ? "latest" : nil), - digest: digest - ) - } catch { - return nil - } - } - - /// Full reference string - public var stringValue: String { - var result = "" - - if let registry = registry { - result += registry + "/" - } - - result += repository - - if let tag = tag { - result += ":" + tag - } - - if let digest = digest { - result += "@" + digest.stringValue - } - - return result - } - - /// Reference without registry (for local use) - public var localReference: String { - var result = repository - - if let tag = tag { - result += ":" + tag - } - - if let digest = digest { - result += "@" + digest.stringValue - } - - return result - } - - // MARK: - Validation - - private static func isValidRepository(_ repo: String) -> Bool { - // Basic validation - can be enhanced - !repo.isEmpty && repo.allSatisfy { $0.isLetter || $0.isNumber || $0 == "/" || $0 == "-" || $0 == "_" || $0 == "." } - } - - private static func isValidRegistry(_ registry: String) -> Bool { - // Must contain a dot or colon (to distinguish from repository) - registry.contains(".") || registry.contains(":") - } - - private static func looksLikeRegistry(_ component: String) -> Bool { - // Contains dot (domain) or is "localhost" - // Note: Don't check for colon here as it could be a tag separator - component.contains(".") || component == "localhost" - } -} - -/// A reference to a build stage. -/// -/// Design rationale: -/// - Supports both named stages and index-based references -/// - Type-safe to prevent mixing stage and image references -/// - Lightweight for efficient graph operations -public enum StageReference: Hashable, Sendable { - /// Reference by stage name (FROM ubuntu AS builder -> "builder") - case named(String) - - /// Reference by stage index (0-based) - case index(Int) - - /// The previous stage (used for implicit references) - case previous - - public var stringValue: String { - switch self { - case .named(let name): - return name - case .index(let idx): - return String(idx) - case .previous: - return "" - } - } -} - -// MARK: - Errors - -public enum ReferenceError: LocalizedError { - case missingTagOrDigest - case invalidRepository(String) - case invalidRegistry(String) - case invalidFormat(String) - - public var errorDescription: String? { - switch self { - case .missingTagOrDigest: - return "Image reference must have either a tag or digest" - case .invalidRepository(let repo): - return "Invalid repository format: '\(repo)'" - case .invalidRegistry(let registry): - return "Invalid registry format: '\(registry)'" - case .invalidFormat(let string): - return "Invalid reference format: '\(string)'" - } - } -} - -// MARK: - Codable - -extension ImageReference: Codable { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - let string = try container.decode(String.self) - guard let parsed = ImageReference(parsing: string) else { - throw DecodingError.dataCorruptedError( - in: container, - debugDescription: "Invalid image reference: \(string)" - ) - } - self = parsed - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(stringValue) - } -} - -extension StageReference: Codable { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - - if let index = try? container.decode(Int.self) { - self = .index(index) - } else if let name = try? container.decode(String.self) { - if name == "" { - self = .previous - } else { - self = .named(name) - } - } else { - throw DecodingError.dataCorrupted( - DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Invalid stage reference") - ) - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .named(let name): - try container.encode(name) - case .index(let idx): - try container.encode(idx) - case .previous: - try container.encode("") - } - } -} - -// MARK: - CustomStringConvertible - -extension ImageReference: CustomStringConvertible { - public var description: String { stringValue } -} - -extension StageReference: CustomStringConvertible { - public var description: String { stringValue } -} diff --git a/Sources/NativeBuilder/ContainerBuildIR/Serialization/IRCoder.swift b/Sources/NativeBuilder/ContainerBuildIR/Serialization/IRCoder.swift deleted file mode 100644 index d44325a3f..000000000 --- a/Sources/NativeBuilder/ContainerBuildIR/Serialization/IRCoder.swift +++ /dev/null @@ -1,321 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerizationOCI -import Foundation - -/// Protocol for encoding/decoding operations. -/// -/// Design rationale: -/// - Type-erased operations need special handling -/// - Preserve unknown operation types for forward compatibility -/// - Support multiple serialization formats -public protocol IRCoder { - func encode(_ graph: BuildGraph) throws -> Data - func decode(_ data: Data) throws -> BuildGraph -} - -/// JSON-based IR coder. -/// -/// Design rationale: -/// - Human-readable for debugging -/// - Wide tooling support -/// - Good balance of size and readability -public struct JSONIRCoder: IRCoder { - private let encoder: JSONEncoder - private let decoder: JSONDecoder - - public init(prettyPrint: Bool = false) { - encoder = JSONEncoder() - decoder = JSONDecoder() - - if prettyPrint { - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - } - - // Configure date encoding - encoder.dateEncodingStrategy = .iso8601 - decoder.dateDecodingStrategy = .iso8601 - } - - public func encode(_ graph: BuildGraph) throws -> Data { - let container = try BuildGraphContainer(graph: graph) - return try encoder.encode(container) - } - - public func decode(_ data: Data) throws -> BuildGraph { - let container = try decoder.decode(BuildGraphContainer.self, from: data) - return try container.toBuildGraph() - } -} - -/// Container for serializing build graphs. -/// -/// Design rationale: -/// - Wraps the graph with version information -/// - Enables format evolution -private struct BuildGraphContainer: Codable { - let version: String - let graph: SerializedBuildGraph - - init(graph: BuildGraph) throws { - self.version = "1.0" - self.graph = try SerializedBuildGraph(from: graph) - } - - func toBuildGraph() throws -> BuildGraph { - try graph.toBuildGraph() - } -} - -/// Serializable representation of BuildGraph. -private struct SerializedBuildGraph: Codable { - let stages: [SerializedStage] - let buildArgs: [String: String] - let targetPlatforms: Set - let metadata: BuildGraphMetadata - - init(from graph: BuildGraph) throws { - self.stages = try graph.stages.map { try SerializedStage(from: $0) } - self.buildArgs = graph.buildArgs - self.targetPlatforms = graph.targetPlatforms - self.metadata = graph.metadata - } - - func toBuildGraph() throws -> BuildGraph { - let stages = try self.stages.map { try $0.toBuildStage() } - return try BuildGraph( - stages: stages, - buildArgs: buildArgs, - targetPlatforms: targetPlatforms, - metadata: metadata - ) - } -} - -/// Serializable representation of BuildStage. -private struct SerializedStage: Codable { - let id: UUID - let name: String? - let base: SerializedOperation - let nodes: [SerializedNode] - let platform: Platform? - - init(from stage: BuildStage) throws { - self.id = stage.id - self.name = stage.name - self.base = try SerializedOperation(from: stage.base) - self.nodes = try stage.nodes.map { try SerializedNode(from: $0) } - self.platform = stage.platform - } - - func toBuildStage() throws -> BuildStage { - guard let baseOp = try base.toOperation() as? ImageOperation else { - throw IRDecodingError.invalidOperationType( - expected: "ImageOperation", - actual: String(describing: type(of: base)) - ) - } - - let nodes = try self.nodes.map { try $0.toBuildNode() } - - return BuildStage( - id: id, - name: name, - base: baseOp, - nodes: nodes, - platform: platform - ) - } -} - -/// Serializable representation of BuildNode. -private struct SerializedNode: Codable { - let id: UUID - let operation: SerializedOperation - let dependencies: Set - let cacheKey: CacheKey? - let constraints: Set - - init(from node: BuildNode) throws { - self.id = node.id - self.operation = try SerializedOperation(from: node.operation) - self.dependencies = node.dependencies - self.cacheKey = node.cacheKey - self.constraints = node.constraints - } - - func toBuildNode() throws -> BuildNode { - BuildNode( - id: id, - operation: try operation.toOperation(), - dependencies: dependencies, - cacheKey: cacheKey, - constraints: constraints - ) - } -} - -/// Serializable representation of Operation. -/// -/// Design rationale: -/// - Type-erased operations need explicit type tracking -/// - Support for unknown operation types (forward compatibility) -private struct SerializedOperation: Codable { - let kind: OperationKind - let data: Data - - init(from operation: any Operation) throws { - self.kind = operation.operationKind - - // Encode the specific operation type - let encoder = JSONEncoder() - switch operation { - case let op as ExecOperation: - self.data = try encoder.encode(op) - case let op as FilesystemOperation: - self.data = try encoder.encode(op) - case let op as ImageOperation: - self.data = try encoder.encode(op) - case let op as MetadataOperation: - self.data = try encoder.encode(op) - default: - // For unknown types, try generic encoding - guard let encodable = operation as? Encodable else { - throw IREncodingError.unsupportedOperationType(kind) - } - self.data = try encoder.encode(AnyEncodable(encodable)) - } - } - - func toOperation() throws -> any Operation { - let decoder = JSONDecoder() - - switch kind { - case .exec: - return try decoder.decode(ExecOperation.self, from: data) - case .filesystem: - return try decoder.decode(FilesystemOperation.self, from: data) - case .image: - return try decoder.decode(ImageOperation.self, from: data) - case .metadata: - return try decoder.decode(MetadataOperation.self, from: data) - default: - // Unknown operation type - preserve for forward compatibility - throw IRDecodingError.unknownOperationType(kind) - } - } -} - -/// Type-erased encodable wrapper. -private struct AnyEncodable: Encodable { - private let encode: (Encoder) throws -> Void - - init(_ encodable: Encodable) { - self.encode = encodable.encode - } - - func encode(to encoder: Encoder) throws { - try encode(encoder) - } -} - -// MARK: - Binary Coder - -/// Binary IR coder for compact representation. -/// -/// Design rationale: -/// - Optimized for size and speed -/// - Uses property list binary format -/// - Good for cache storage -public struct BinaryIRCoder: IRCoder { - public init() {} - - public func encode(_ graph: BuildGraph) throws -> Data { - // First encode to intermediate format - let jsonCoder = JSONIRCoder() - let jsonData = try jsonCoder.encode(graph) - - // Convert to property list - let jsonObject = try JSONSerialization.jsonObject(with: jsonData) - return try PropertyListSerialization.data( - fromPropertyList: jsonObject, - format: .binary, - options: 0 - ) - } - - public func decode(_ data: Data) throws -> BuildGraph { - // Decode from property list - let plistObject = try PropertyListSerialization.propertyList( - from: data, - format: nil - ) - - // Convert back to JSON - let jsonData = try JSONSerialization.data(withJSONObject: plistObject) - - // Decode using JSON coder - let jsonCoder = JSONIRCoder() - return try jsonCoder.decode(jsonData) - } -} - -// MARK: - Errors - -public enum IREncodingError: LocalizedError { - case unsupportedOperationType(OperationKind) - - public var errorDescription: String? { - switch self { - case .unsupportedOperationType(let kind): - return "Cannot encode operation type: \(kind.rawValue)" - } - } -} - -public enum IRDecodingError: LocalizedError { - case unknownOperationType(OperationKind) - case invalidOperationType(expected: String, actual: String) - case invalidFormat - - public var errorDescription: String? { - switch self { - case .unknownOperationType(let kind): - return "Unknown operation type: \(kind.rawValue)" - case .invalidOperationType(let expected, let actual): - return "Expected \(expected) but got \(actual)" - case .invalidFormat: - return "Invalid IR format" - } - } -} - -// MARK: - Convenience Extensions - -extension BuildGraph { - /// Save graph to file. - public func save(to url: URL, using coder: IRCoder = JSONIRCoder(prettyPrint: true)) throws { - let data = try coder.encode(self) - try data.write(to: url) - } - - /// Load graph from file. - public static func load(from url: URL, using coder: IRCoder = JSONIRCoder()) throws -> BuildGraph { - let data = try Data(contentsOf: url) - return try coder.decode(data) - } -} diff --git a/Sources/NativeBuilder/ContainerBuildIR/Validation/Validator.swift b/Sources/NativeBuilder/ContainerBuildIR/Validation/Validator.swift deleted file mode 100644 index 533504264..000000000 --- a/Sources/NativeBuilder/ContainerBuildIR/Validation/Validator.swift +++ /dev/null @@ -1,411 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -/// Protocol for build graph validators. -/// -/// Design rationale: -/// - Composable validation rules -/// - Collect all errors rather than fail-fast -/// - Extensible for custom validation -public protocol BuildValidator { - /// Validate a build graph - func validate(_ graph: BuildGraph) -> ValidationResult -} - -/// Result of validation. -public struct ValidationResult { - public let errors: [ValidationError] - public let warnings: [ValidationWarning] - - public var isValid: Bool { errors.isEmpty } - - public init(errors: [ValidationError] = [], warnings: [ValidationWarning] = []) { - self.errors = errors - self.warnings = warnings - } - - /// Combine multiple results - public static func combine(_ results: [ValidationResult]) -> ValidationResult { - ValidationResult( - errors: results.flatMap { $0.errors }, - warnings: results.flatMap { $0.warnings } - ) - } -} - -// MARK: - Validation Error Enum - -/// A structured error that prevents a build from proceeding. -public enum ValidationError: Error, LocalizedError, Sendable { - // Structural Errors - case duplicateNodeID(id: UUID, location: ValidationLocation) - case cyclicDependency(location: ValidationLocation) - case missingDependency(dependencyID: UUID, location: ValidationLocation) - - // Reference Errors - case undefinedStageReference(name: String, location: ValidationLocation) - case stageIndexOutOfBounds(index: Int, location: ValidationLocation) - case invalidPreviousReference(location: ValidationLocation) - - // Path Errors - case emptyDestinationPath(location: ValidationLocation) - case absoluteContextPath(path: String, location: ValidationLocation) - case emptyMountTarget(location: ValidationLocation) - - public var errorDescription: String? { - switch self { - case .duplicateNodeID(let id, _): - return "Duplicate node ID found: \(id)." - case .cyclicDependency: - return "Stage contains a cyclic dependency." - case .missingDependency(let dependencyID, _): - return "Node references a non-existent dependency: \(dependencyID)." - case .undefinedStageReference(let name, _): - return "Reference to an undefined stage: '\(name)'." - case .stageIndexOutOfBounds(let index, _): - return "Stage index is out of bounds: \(index)." - case .invalidPreviousReference: - return "Cannot reference the previous stage from the first stage." - case .emptyDestinationPath: - return "Filesystem operation has an empty destination path." - case .absoluteContextPath(let path, _): - return "Source path for a context operation must be relative, but found absolute path: '\(path)'." - case .emptyMountTarget: - return "An execution mount has an empty target path." - } - } -} - -// MARK: - Validation Warning Enum - -/// A structured warning that does not prevent a build but indicates a potential issue. -public enum ValidationWarning: Sendable { - // Reference Warnings - case forwardStageReferenceByName(name: String, location: ValidationLocation) - case forwardStageReferenceByIndex(index: Int, location: ValidationLocation) - - // Path Warnings - case pathContainsDotDot(path: String, location: ValidationLocation) - - // Security Warnings - case privilegedExecution(location: ValidationLocation) - case runningAsRoot(location: ValidationLocation) - case readWriteSecretMount(location: ValidationLocation) - - // Best Practice Warnings - case aptGetUpdateWithoutInstall(location: ValidationLocation) - case missingHealthcheck(location: ValidationLocation) - - /// A human-readable description of the warning. - public var message: String { - switch self { - case .forwardStageReferenceByName(let name, _): - return "Forward reference to stage '\(name)'. Build may be inefficient." - case .forwardStageReferenceByIndex(let index, _): - return "Forward reference to stage at index \(index). Build may be inefficient." - case .pathContainsDotDot(let path, _): - return "Path contains '..', which could lead to accessing files outside the build context: '\(path)'." - case .privilegedExecution: - return "Operation is configured to run with privileged access." - case .runningAsRoot: - return "Operation is configured to run as the root user." - case .readWriteSecretMount: - return "A secret is mounted as read-write, which is insecure." - case .aptGetUpdateWithoutInstall: - return "'apt-get update' is run in a separate command from 'apt-get install'." - case .missingHealthcheck: - return "The final image has no HEALTHCHECK defined." - } - } - - /// A suggestion for how to resolve the warning. - public var suggestion: String? { - switch self { - case .forwardStageReferenceByName, .forwardStageReferenceByIndex: - return "Consider reordering stages to ensure all dependencies are built first." - case .pathContainsDotDot: - return "Use explicit paths from the context root instead of relative parent paths." - case .privilegedExecution: - return "Ensure the operation truly requires privileged mode to run." - case .runningAsRoot: - return "Consider specifying a non-root user with the USER instruction for enhanced security." - case .readWriteSecretMount: - return "Secrets should always be mounted as read-only." - case .aptGetUpdateWithoutInstall: - return "Combine 'apt-get update' and 'apt-get install' in the same RUN command to reduce image layers and ensure cache correctness." - case .missingHealthcheck: - return "Consider adding a HEALTHCHECK instruction to your final stage for production-ready images." - } - } -} - -/// Location information for validation messages. -public enum ValidationLocation: Sendable { - case stage(name: String?) - case node(stageIndex: Int, nodeIndex: Int) - case operation(OperationKind) - case sourceLocation(SourceLocation) -} - -/// Composite validator that runs multiple validators. -public struct CompositeValidator: BuildValidator, Sendable { - private let validators: [any BuildValidator & Sendable] - - public init(validators: [any BuildValidator & Sendable]) { - self.validators = validators - } - - public func validate(_ graph: BuildGraph) -> ValidationResult { - ValidationResult.combine(validators.map { $0.validate(graph) }) - } -} - -/// Standard validator with all built-in rules. -public struct StandardValidator: BuildValidator, Sendable { - private let validator: CompositeValidator - - public init() { - validator = CompositeValidator(validators: [ - StructuralValidator(), - ReferenceValidator(), - PathValidator(), - SecurityValidator(), - BestPracticesValidator(), - ]) - } - - public func validate(_ graph: BuildGraph) -> ValidationResult { - validator.validate(graph) - } -} - -/// Validates graph structure (cycles, dependencies). -public struct StructuralValidator: BuildValidator, Sendable { - public func validate(_ graph: BuildGraph) -> ValidationResult { - var errors: [ValidationError] = [] - - // Check each stage - for (_, stage) in graph.stages.enumerated() { - let stageLocation = ValidationLocation.stage(name: stage.name) - // Check for duplicate node IDs - var seenIds = Set() - for node in stage.nodes { - if !seenIds.insert(node.id).inserted { - errors.append(.duplicateNodeID(id: node.id, location: stageLocation)) - } - } - - // Cycle detection will be done globally due to cross-stage dependencies - - // Dependencies will be checked globally after collecting all node IDs - } - - // Collect all node IDs across all stages - var allNodeIds = Set() - for stage in graph.stages { - for node in stage.nodes { - allNodeIds.insert(node.id) - } - } - - // Now check that all dependencies exist (can be cross-stage) - for (stageIndex, stage) in graph.stages.enumerated() { - for (nodeIndex, node) in stage.nodes.enumerated() { - for dep in node.dependencies { - if !allNodeIds.contains(dep) { - let nodeLocation = ValidationLocation.node(stageIndex: stageIndex, nodeIndex: nodeIndex) - errors.append(.missingDependency(dependencyID: dep, location: nodeLocation)) - } - } - } - } - - return ValidationResult(errors: errors) - } -} - -/// Validates cross-stage references. -public struct ReferenceValidator: BuildValidator, Sendable { - public func validate(_ graph: BuildGraph) -> ValidationResult { - var errors: [ValidationError] = [] - var warnings: [ValidationWarning] = [] - - for (stageIndex, stage) in graph.stages.enumerated() { - let stageLocation = ValidationLocation.stage(name: stage.name) - let stageDeps = stage.stageDependencies() - - for dep in stageDeps { - // Validate reference exists - let exists: Bool - switch dep { - case .named(let name): - exists = graph.stages.contains { $0.name == name } - if !exists { - errors.append(.undefinedStageReference(name: name, location: stageLocation)) - } - case .index(let idx): - exists = idx >= 0 && idx < graph.stages.count - if !exists { - errors.append(.stageIndexOutOfBounds(index: idx, location: stageLocation)) - } - case .previous: - exists = stageIndex > 0 - if !exists { - errors.append(.invalidPreviousReference(location: stageLocation)) - } - } - - // Check for forward references (warning) - if exists { - switch dep { - case .named(let name): - if let depIndex = graph.stages.firstIndex(where: { $0.name == name }), - depIndex > stageIndex - { - warnings.append(.forwardStageReferenceByName(name: name, location: stageLocation)) - } - case .index(let idx): - if idx > stageIndex { - warnings.append(.forwardStageReferenceByIndex(index: idx, location: stageLocation)) - } - case .previous: - break // Always valid - } - } - } - } - - return ValidationResult(errors: errors, warnings: warnings) - } -} - -/// Validates filesystem paths and operations. -public struct PathValidator: BuildValidator, Sendable { - public func validate(_ graph: BuildGraph) -> ValidationResult { - var errors: [ValidationError] = [] - var warnings: [ValidationWarning] = [] - - for stage in graph.stages { - for node in stage.nodes { - if let fsOp = node.operation as? FilesystemOperation { - let opLocation = ValidationLocation.operation(node.operation.operationKind) - // Validate destination path - if fsOp.destination.isEmpty { - errors.append(.emptyDestinationPath(location: opLocation)) - } - - // Check for absolute paths in context source - if case .context(let source) = fsOp.source { - for path in source.paths { - if path.hasPrefix("/") { - errors.append(.absoluteContextPath(path: path, location: opLocation)) - } - - if path.contains("..") { - warnings.append(.pathContainsDotDot(path: path, location: opLocation)) - } - } - } - } - - // Validate mount paths - if let execOp = node.operation as? ExecOperation { - let opLocation = ValidationLocation.operation(node.operation.operationKind) - for mount in execOp.mounts { - if mount.target == nil && mount.envTarget == nil { - errors.append(.emptyMountTarget(location: opLocation)) - } - } - } - } - } - - return ValidationResult(errors: errors, warnings: warnings) - } -} - -/// Validates security constraints. -public struct SecurityValidator: BuildValidator, Sendable { - public func validate(_ graph: BuildGraph) -> ValidationResult { - var warnings: [ValidationWarning] = [] - - for stage in graph.stages { - for node in stage.nodes { - if let execOp = node.operation as? ExecOperation { - let opLocation = ValidationLocation.operation(node.operation.operationKind) - // Warn about privileged execution - if execOp.security.privileged { - warnings.append(.privilegedExecution(location: opLocation)) - } - - // Warn about running as root - if execOp.user == nil { - warnings.append(.runningAsRoot(location: opLocation)) - } - - // Check for secret mounts - for mount in execOp.mounts { - if mount.type == .secret && !mount.options.readOnly { - warnings.append(.readWriteSecretMount(location: opLocation)) - } - } - } - } - } - - return ValidationResult(warnings: warnings) - } -} - -/// Validates against best practices. -public struct BestPracticesValidator: BuildValidator, Sendable { - public func validate(_ graph: BuildGraph) -> ValidationResult { - var warnings: [ValidationWarning] = [] - - for stage in graph.stages { - var hasHealthcheck = false - - for node in stage.nodes { - let opLocation = ValidationLocation.operation(node.operation.operationKind) - // Check for multiple RUN commands that could be combined - if let execOp = node.operation as? ExecOperation { - if case .shell(let cmd) = execOp.command { - if cmd.contains("apt-get update") && !cmd.contains("apt-get install") { - warnings.append(.aptGetUpdateWithoutInstall(location: opLocation)) - } - } - } - - // Track user changes - if let metaOp = node.operation as? MetadataOperation { - if case .setHealthcheck = metaOp.action { - hasHealthcheck = true - } - } - } - - // Warn if no healthcheck defined - if !hasHealthcheck && stage == graph.targetStage { - let stageLocation = ValidationLocation.stage(name: stage.name) - warnings.append(.missingHealthcheck(location: stageLocation)) - } - } - - return ValidationResult(warnings: warnings) - } -} diff --git a/Sources/NativeBuilder/ContainerBuildParser/Docker/DockerInstructionVisitor.swift b/Sources/NativeBuilder/ContainerBuildParser/Docker/DockerInstructionVisitor.swift deleted file mode 100644 index 93f2e4d42..000000000 --- a/Sources/NativeBuilder/ContainerBuildParser/Docker/DockerInstructionVisitor.swift +++ /dev/null @@ -1,207 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR - -protocol InstructionVisitor { - func visit(_ from: FromInstruction) throws - func visit(_ run: RunInstruction) throws - func visit(_ copy: CopyInstruction) throws - func visit(_ cmd: CMDInstruction) throws - func visit(_ label: LabelInstruction) throws - func visit(_ expose: ExposeInstruction) throws - func visit(_ arg: ArgInstruction) throws - // TODO: These instructions have to perform the ARG substitution checked in `testSimpleDockerfileArgInInstructions()`: - // - ADD - // - ENV - // - STOPSIGNAL - // - USER - // - VOLUME - // - WORKDIR - // - ONBUILD - // - ENTRYPOINT -} - -/// DockerInstructionVisitor visits each provided DockerInstruction and builds a -/// build graph from the instructions. -public class DockerInstructionVisitor: InstructionVisitor { - - internal let graphBuilder: GraphBuilder - - init() { - self.graphBuilder = GraphBuilder() - } - - func buildGraph(from: [any DockerInstruction]) throws -> BuildGraph { - for instruction in from { - try instruction.accept(self) - } - return try graphBuilder.build() - } -} - -extension DockerInstructionVisitor { - private func substituteArgsInCommand(_ command: Command) -> Command { - switch command { - case .shell(let cmd): - let substitutedCmd = graphBuilder.substituteArgs(cmd, inFromContext: false) - return .shell(substitutedCmd) - case .exec(let args): - let substitutedArgs = args.map { graphBuilder.substituteArgs($0, inFromContext: false) } - return .exec(substitutedArgs) - } - } - - func visit(_ from: FromInstruction) throws { - let imageString = graphBuilder.substituteArgs(from.image, inFromContext: true) - - guard let imageRef = ImageReference(parsing: imageString) else { - throw ParseError.invalidImage(imageString) - } - - if let stageName = from.stageName { - try graphBuilder.stage(name: stageName, from: imageRef, platform: from.platform) - } else { - try graphBuilder.stage(from: imageRef, platform: from.platform) - } - } - - func visit(_ run: RunInstruction) throws { - var mounts: [Mount] = [] - for m in run.mounts { - guard let type = m.type else { - throw ParseError.unexpectedValue - } - - let mountSource: MountSource? - switch m.type { - case .bind, .cache: - guard let source = m.source else { - throw ParseError.missingRequiredField(MountOptionNames.source.rawValue) - } - if let from = m.from, from != "" { - if let _ = graphBuilder.getStage(stageName: from) { - mountSource = .stage(.named(from), path: source) - } else if let context = graphBuilder.resolveArg(key: from, inFromContext: false) { - mountSource = .context(context, path: source) - } else { - // mount source is an image name - guard let imageRef = ImageReference(parsing: from) else { - throw ParseError.invalidImage(from) - } - mountSource = .image(imageRef, path: source) - } - } else { - // from was not set or is empty, default is local source - mountSource = .local(source) - } - case .secret: - mountSource = .secret(m.id!) - case .ssh: - mountSource = .sshAgent - default: - // this covers .tmpfs case as well - mountSource = nil - } - - guard let options = m.options else { - throw ParseError.unexpectedValue - } - - guard let readonly = options.readonly else { - throw ParseError.unexpectedValue - } - - let mountOptions = MountOptions( - readOnly: readonly, - uid: options.uid, - gid: options.gid, - mode: options.mode, - size: options.size, - sharing: options.sharing, - required: options.required) - - let graphMount = Mount( - type: type, - target: m.target, - envTarget: m.env, - source: mountSource, - options: mountOptions) - - mounts.append(graphMount) - } - - let substitutedCommand = substituteArgsInCommand(run.command) - try graphBuilder.runWithCmd(substitutedCommand, mounts: mounts) - } - - func visit(_ copy: CopyInstruction) throws { - // TODO katiewasnothere: plumb through "--link" option - - let substitutedSources = copy.sources.map { graphBuilder.substituteArgs($0, inFromContext: false) } - let substitutedDestination = graphBuilder.substituteArgs(copy.destination, inFromContext: false) - - if let from = copy.from { - var source: FilesystemSource - if let _ = graphBuilder.getStage(stageName: from) { - source = .stage(.named(from), paths: substitutedSources) - } else if let context = graphBuilder.resolveArg(key: from, inFromContext: false) { - source = .context(ContextSource(name: context, paths: substitutedSources)) - } else { - guard let imageRef = ImageReference(parsing: from) else { - throw ParseError.invalidImage(from) - } - source = .image(imageRef, paths: substitutedSources) - } - try graphBuilder.copy(from: source, to: substitutedDestination, chown: copy.chown, chmod: copy.chmod) - return - } - try graphBuilder.copyFromContext(paths: substitutedSources, to: substitutedDestination, chown: copy.chown, chmod: copy.chmod) - } - - func visit(_ cmd: CMDInstruction) throws { - let substitutedCommand = substituteArgsInCommand(cmd.command) - try graphBuilder.cmd(substitutedCommand) - } - - func visit(_ label: LabelInstruction) throws { - let substitutedLabels = label.labels.mapValues { graphBuilder.substituteArgs($0, inFromContext: false) } - try graphBuilder.labelBatch(labels: substitutedLabels) - } - - func visit(_ expose: ExposeInstruction) throws { - let substitutedPortStrings = expose.ports.map { graphBuilder.substituteArgs($0, inFromContext: false) } - let substitutedPorts = try substitutedPortStrings.map(parsePort) - try graphBuilder.expose(substitutedPorts) - } - - func visit(_ arg: ArgInstruction) throws { - for argDef in arg.args { - let substitutedDefaultValue: String? - if let defaultValue = argDef.defaultValue { - substitutedDefaultValue = graphBuilder.substituteArgs(defaultValue, inFromContext: !graphBuilder.hasActiveStage) - } else { - substitutedDefaultValue = nil - } - - if graphBuilder.hasActiveStage { - try graphBuilder.arg(argDef.name, defaultValue: substitutedDefaultValue) - } else { - graphBuilder.fromOnlyArg(argDef.name, defaultValue: substitutedDefaultValue) - } - } - } -} diff --git a/Sources/NativeBuilder/ContainerBuildParser/Docker/DockerfileParser.swift b/Sources/NativeBuilder/ContainerBuildParser/Docker/DockerfileParser.swift deleted file mode 100644 index 9a5071fbe..000000000 --- a/Sources/NativeBuilder/ContainerBuildParser/Docker/DockerfileParser.swift +++ /dev/null @@ -1,495 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import Foundation - -/// DockerfileParser parses a dockerfile to a BuildGraph. -public struct DockerfileParser: BuildParser { - public func parse(_ input: String) throws -> BuildGraph { - var instructions = [any DockerInstruction]() - let lines = input.components(separatedBy: .newlines) - var lineIndex = 0 - while lineIndex < lines.count { - var line = lines[lineIndex].trimmingCharacters(in: .whitespacesAndNewlines) - if line.isEmpty { - lineIndex += 1 - continue - } - - while lineIndex < lines.count && line.hasSuffix("\\") { - line = String(line.dropLast("\\".count)) - let next = lineIndex + 1 - if next < lines.count { - let nextLine = String(lines[next].trimmingCharacters(in: .whitespacesAndNewlines)) - line.append(nextLine) - lineIndex += 1 - } - } - - var tokenizer = DockerfileTokenizer(line) - let tokens = try tokenizer.getTokens() - - try instructions.append(tokensToDockerInstruction(tokens: tokens)) - - lineIndex += 1 - } - let visitor = DockerInstructionVisitor() - return try visitor.buildGraph(from: instructions) - } - - private func tokensToDockerInstruction(tokens: [Token]) throws -> any DockerInstruction { - guard case .stringLiteral(let value) = tokens.first else { - throw ParseError.missingInstruction - } - - let instruction = DockerInstructionName(rawValue: value.lowercased()) - - switch instruction { - case .FROM: - return try tokensToFromInstruction(tokens: tokens) - case .RUN: - return try tokensToRunInstruction(tokens: tokens) - case .COPY: - return try tokensToCopyInstruction(tokens: tokens) - case .CMD: - return try tokensToCMDInstruction(tokens: tokens) - case .LABEL: - return try tokensToLabelInstruction(tokens: tokens) - case .EXPOSE: - return try tokensToExposeInstruction(tokens: tokens) - case .ARG: - return try tokensToArgInstruction(tokens: tokens) - default: - throw ParseError.invalidInstruction(value) - } - } - - private struct InstructionOpt { - let key: String - let value: String - } - - private func parseInstructionOpts(start: Int, tokens: [Token]) throws -> (Int, [InstructionOpt]) { - var done = false - var opts: [InstructionOpt] = [] - var index = start - - while index < tokens.endIndex, !done { - guard case .stringLiteral(let raw) = tokens[index] else { - done = true - break - } - - guard raw.hasPrefix("--") else { - done = true - break - } - - let components = raw.split(separator: "=", maxSplits: 1) - if components.count == 2 { - opts.append(InstructionOpt(key: String(components[0]), value: String(components[1]))) - } else { - // instruction options should ALWAYS have a value, so if we're at - // the end or the next value is a string list, that's invalid input - index += 1 - guard index < tokens.endIndex else { - throw ParseError.invalidOption(raw) - } - - guard case .stringLiteral(let value) = tokens[index] else { - throw ParseError.invalidOption(raw) - } - - opts.append(InstructionOpt(key: raw, value: value)) - } - index += 1 - } - - return (index, opts) - } - - func tokensToFromInstruction(tokens: [Token]) throws -> FromInstruction { - var index = tokens.startIndex + 1 // skip the instruction - - var stageName: String? - var platform: String? - var imageName: String? - - // Step 1: Parse instruction options - let (newIndex, instructionOpts) = try parseInstructionOpts(start: index, tokens: tokens) - index = newIndex - - for option in instructionOpts { - guard FromOptions(rawValue: String(option.key)) == .platform else { - throw ParseError.unexpectedValue - } - if platform != nil { - throw ParseError.duplicateOptionSet(FromOptions.platform.rawValue) - } - platform = option.value - } - - // Step 2: Parse image name - if index < tokens.endIndex { - guard case .stringLiteral(let value) = tokens[index] else { - throw ParseError.unexpectedValue - } - imageName = value - index += 1 - } - - // Step 3 (optional): Parse stage name - if index < tokens.endIndex { - guard case .stringLiteral(let value) = tokens[index], - DockerKeyword(rawValue: value.lowercased()) == .AS - else { - throw ParseError.unexpectedValue - } - index += 1 - guard index < tokens.endIndex, case .stringLiteral(let name) = tokens[index] else { - throw ParseError.invalidSyntax - } - stageName = name - index += 1 - } - - guard let imageName = imageName else { - throw ParseError.invalidSyntax - } - - // check for extra tokens - if index < tokens.endIndex { - throw ParseError.unexpectedValue - } - - return try FromInstruction(image: imageName, platform: platform, stageName: stageName) - } - - func tokensToRunInstruction(tokens: [Token]) throws -> RunInstruction { - var index = tokens.startIndex + 1 // skip the instruction - - var rawMounts = [String]() - var network: String? = nil - - // Step 1: Parse instruction options - let (lastOption, instructionOpts) = try parseInstructionOpts(start: index, tokens: tokens) - index = lastOption - - for option in instructionOpts { - guard let runOpt = RunOptions(rawValue: option.key) else { - throw ParseError.unexpectedValue - } - - switch runOpt { - case .mount: - rawMounts.append(option.value) - case .network: - network = option.value - } - } - - // Step 2: parse run command - let (newIndex, cmd) = getCommand(start: index, tokens: tokens) - index = newIndex - - // check for extra tokens - if index < tokens.endIndex { - throw ParseError.unexpectedValue - } - - return try RunInstruction(command: cmd, rawMounts: rawMounts, network: network) - } - - private func getCommand(start: Int, tokens: [Token]) -> (index: Int, cmd: Command) { - var command = [String]() - var shell = true - - var index = start - while index < tokens.endIndex { - if case .stringList(let value) = tokens[index], command.isEmpty { - // when using the exec form, there should only be a single list for the command - // if there's other content in the command already, the input was invalid - command = value - shell = false - index += 1 - break - } else if case .stringLiteral(let value) = tokens[index] { - command.append(value) - } else { - break - } - index += 1 - } - - let cmd = shell ? Command.shell(command.joined(separator: " ")) : Command.exec(command) - return (index, cmd) - } - - func tokensToCopyInstruction(tokens: [Token]) throws -> CopyInstruction { - var index = tokens.startIndex + 1 // skip the instruction - - var from: String? = nil - var chmod: String? = nil - var chown: String? = nil - var link: String? = nil - - // Step 1: Parse instruction options - let (newIndex, instructionOpts) = try parseInstructionOpts(start: index, tokens: tokens) - index = newIndex - - for option in instructionOpts { - guard let copyOpt = CopyOptions(rawValue: option.key) else { - throw ParseError.unexpectedValue - } - - switch copyOpt { - case .from: - if from != nil { - throw ParseError.duplicateOptionSet(CopyOptions.from.rawValue) - } - from = option.value - case .chown: - if chown != nil { - throw ParseError.duplicateOptionSet(CopyOptions.chown.rawValue) - } - chown = option.value - case .chmod: - if chmod != nil { - throw ParseError.duplicateOptionSet(CopyOptions.chmod.rawValue) - } - chmod = option.value - case .link: - if link != nil { - throw ParseError.duplicateOptionSet(CopyOptions.link.rawValue) - } - link = option.value - } - } - - // Step 2: Get all source paths and destination path - var sources: [String] = [] - var destination: String? - while index < tokens.endIndex { - guard case .stringLiteral(let value) = tokens[index] else { - break - } - if index + 1 == tokens.endIndex { - // this is the last path provided, it must be the destination - destination = value - } else { - sources.append(value) - } - index += 1 - } - - // check for extra tokens - if index < tokens.endIndex { - throw ParseError.unexpectedValue - } - - return try CopyInstruction(sources: sources, destination: destination, from: from, ownership: chown, permissions: chmod) - } - - func tokensToCMDInstruction(tokens: [Token]) throws -> CMDInstruction { - var index = tokens.startIndex + 1 // skip the instruction - - // get the command - let (newIndex, cmd) = getCommand(start: index, tokens: tokens) - index = newIndex - - // check for extra tokens - if index < tokens.endIndex { - throw ParseError.unexpectedValue - } - - return CMDInstruction(command: cmd) - } - - func tokensToLabelInstruction(tokens: [Token]) throws -> LabelInstruction { - var index = tokens.startIndex + 1 // skip the instruction - - var labels: [String: String] = [:] - while index < tokens.endIndex { - guard case .stringLiteral(let option) = tokens[index] else { - break - } - let components = option.split(separator: "=", maxSplits: 1) - guard components.count == 2 else { - throw ParseError.unexpectedValue - } - let key = String(components[0]) - guard labels[key] == nil else { - throw ParseError.duplicateOptionSet(key) - } - labels[key] = String(components[1]) - index += 1 - } - - // check for extra tokens - if index < tokens.endIndex { - throw ParseError.unexpectedValue - } - - return LabelInstruction(labels: labels) - } - - func tokensToExposeInstruction(tokens: [Token]) throws -> ExposeInstruction { - var index = tokens.startIndex + 1 // skip the instruction - - var rawPorts: [String] = [] - while index < tokens.endIndex { - guard case .stringLiteral(let port) = tokens[index] else { - throw ParseError.unexpectedValue - } - rawPorts.append(port) - index += 1 - } - - guard !rawPorts.isEmpty else { - throw ParseError.missingRequiredField("port") - } - - return ExposeInstruction(rawPorts) - } - - func tokensToArgInstruction(tokens: [Token]) throws -> ArgInstruction { - var index = tokens.startIndex + 1 // skip the instruction - guard index < tokens.endIndex else { - throw ParseError.missingRequiredField("name") - } - - // Collect all remaining tokens. - var argTokens: [String] = [] - while index < tokens.endIndex { - guard case .stringLiteral(let tokenValue) = tokens[index] else { - throw ParseError.unexpectedValue - } - argTokens.append(tokenValue) - index += 1 - } - - let argSpec = argTokens.joined(separator: " ") - let argDefinitions = try parseMultipleArgs(from: argSpec) - return try ArgInstruction(args: argDefinitions) - } - - private func parseMultipleArgs(from argSpec: String) throws -> [ArgDefinition] { - var definitions: [ArgDefinition] = [] - var remaining = argSpec - - while !remaining.isEmpty { - let (name, defaultValue, rest) = try parseNextArg(from: remaining) - let definition = ArgDefinition(name: name, defaultValue: defaultValue) - definitions.append(definition) - remaining = rest - } - - guard !definitions.isEmpty else { - throw ParseError.missingRequiredField("name") - } - - return definitions - } - - private func parseNextArg(from input: String) throws -> (name: String, defaultValue: String?, remaining: String) { - let trimmed = input.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty else { - throw ParseError.missingRequiredField("name") - } - - // Find the name. - guard let nameEnd = trimmed.firstIndex(where: { $0.isWhitespace || $0 == "=" }) else { - return (name: trimmed, defaultValue: nil, remaining: "") - } - - let name = String(trimmed[.. (value: String, remaining: String) { - if input.hasPrefix("\"") { - return try parseQuotedValue(from: input, quote: "\"") - } else if input.hasPrefix("'") { - return try parseQuotedValue(from: input, quote: "'") - } else { - return parseUnquotedValue(from: input) - } - } - - private func parseUnquotedValue(from input: String) -> (value: String, remaining: String) { - guard let spaceIndex = input.firstIndex(where: { $0.isWhitespace }) else { - return (value: input, remaining: "") - } - - let value = String(input[.. (value: String, remaining: String) { - var pos = input.index(after: input.startIndex) - var value = "" - var escaped = false - - while pos < input.endIndex { - let char = input[pos] - - if !escaped && char == quote { - pos = input.index(after: pos) - let remaining = String(input[pos...]) - return (value: value, remaining: remaining) - } - - if escaped { - switch char { - case "\"", "'", "\\": - value.append(char) - default: - value.append("\\") - value.append(char) - } - escaped = false - } else if char == "\\" { - escaped = true - } else { - value.append(char) - } - - pos = input.index(after: pos) - } - - throw ParseError.invalidSyntax - } -} diff --git a/Sources/NativeBuilder/ContainerBuildParser/Docker/DockerfileTokenizer.swift b/Sources/NativeBuilder/ContainerBuildParser/Docker/DockerfileTokenizer.swift deleted file mode 100644 index a50e161f5..000000000 --- a/Sources/NativeBuilder/ContainerBuildParser/Docker/DockerfileTokenizer.swift +++ /dev/null @@ -1,121 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -/// DockerfileTokenizer takes as input a line from a dockerfile and outputs an array -/// of Tokens that represent the line's contents -struct DockerfileTokenizer { - private let input: String - private var position: String.Index - private let endPosition: String.Index - - public init(_ from: String) { - input = from - position = input.startIndex - endPosition = input.endIndex - } - - mutating func getTokens() throws -> [Token] { - var results = [Token]() - - while position < endPosition { - let char = input[position] - if char.isWhitespace { - // ignore white spaces that are not part of other things - position = input.index(after: position) - continue - } - - if char == "\"" || char == "'" { - position = input.index(after: position) // do not include the initial quote - let start = position - parseQuotedString() - - let quote = String(input[start.. Token { - let start = position - while position < endPosition { - let char = input[position] - if char == "]" { - // we want to include the ending ] in the rawJSON so that swift can - // correctly handle decoding the value - position = input.index(after: position) - break - } - position = input.index(after: position) - } - let rawJSON = String(input[start.. Ownership? { - guard let input = input, !input.isEmpty else { - return Ownership(user: .numeric(id: 0), group: .numeric(id: 0)) - } - var user: OwnershipID? = nil - var group: OwnershipID? = nil - - let components = input.components(separatedBy: ":") - guard components.count <= 2 else { - throw ParseError.invalidOption(input) - } - user = parseID(id: components[0]) - if components.count == 2 { - group = parseID(id: components[1]) - } - if user == nil && group == nil { - throw ParseError.invalidOption(input) - } - return Ownership(user: user, group: group) - } - - static private func parseID(id: String) -> OwnershipID? { - if id == "" { - return nil - } - if let numberID = UInt32(id) { - return .numeric(id: numberID) - } - return .named(id: id) - } - - static internal func parsePermissions(input: String?) throws -> Permissions? { - guard let input = input else { - return nil - } - guard let mode = UInt32(input) else { - throw ParseError.invalidUint32Option(input) - } - return Permissions.mode(mode) - } - - func accept(_ visitor: DockerInstructionVisitor) throws { - try visitor.visit(self) - } -} diff --git a/Sources/NativeBuilder/ContainerBuildParser/Docker/Instructions/DockerInstruction.swift b/Sources/NativeBuilder/ContainerBuildParser/Docker/Instructions/DockerInstruction.swift deleted file mode 100644 index d04139053..000000000 --- a/Sources/NativeBuilder/ContainerBuildParser/Docker/Instructions/DockerInstruction.swift +++ /dev/null @@ -1,81 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import ContainerizationOCI - -/// DockerInstruction represents a single docker instruction with its given options -/// and arguments. Instructions are "visited" to add to a build graph. -protocol DockerInstruction: Sendable, Equatable { - func accept(_ visitor: DockerInstructionVisitor) throws -} - -enum FromOptions: String { - case platform = "--platform" -} - -struct FromInstruction: DockerInstruction { - let image: String - let platform: Platform? - let stageName: String? - - init(image: String, platform: String? = nil, stageName: String? = nil) throws { - self.image = image - - var platformSpec = Platform.current - if let platform = platform { - platformSpec = try Platform(from: platform) - } - self.platform = platformSpec - self.stageName = stageName - } - - func accept(_ visitor: DockerInstructionVisitor) throws { - try visitor.visit(self) - } -} - -/// DockerInstructionName defines a dockerfile instruction such as FROM, RUN, etc. -enum DockerInstructionName: String { - case FROM = "from" - case RUN = "run" - case COPY = "copy" - case CMD = "cmd" - case LABEL = "label" - case EXPOSE = "expose" - case ARG = "arg" -} - -/// DockerKeyword defines words that are used as keywords within a line of a dockerfile -/// to provide additional instruction -enum DockerKeyword: String { - case AS = "as" -} - -struct CMDInstruction: DockerInstruction { - let command: Command - - func accept(_ visitor: DockerInstructionVisitor) throws { - try visitor.visit(self) - } -} - -struct LabelInstruction: DockerInstruction { - let labels: [String: String] - func accept(_ visitor: DockerInstructionVisitor) throws { - try visitor.visit(self) - } -} diff --git a/Sources/NativeBuilder/ContainerBuildParser/Docker/Instructions/ExposeInstruction.swift b/Sources/NativeBuilder/ContainerBuildParser/Docker/Instructions/ExposeInstruction.swift deleted file mode 100644 index 4b0f434d1..000000000 --- a/Sources/NativeBuilder/ContainerBuildParser/Docker/Instructions/ExposeInstruction.swift +++ /dev/null @@ -1,69 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR - -struct ExposeInstruction: DockerInstruction { - let ports: [String] - - init(_ ports: [String]) { - self.ports = ports - } - - internal init(ports: [PortSpec]) { - self.ports = ports.map { $0.stringValue } - } - - func accept(_ visitor: DockerInstructionVisitor) throws { - try visitor.visit(self) - } -} - -func parsePort(_ p: String) throws -> PortSpec { - let parts = p.split(separator: "/", maxSplits: 1).map(String.init) - guard let rangePart = parts.first, !rangePart.isEmpty else { - throw ParseError.invalidOption(p) - } - - // parse the port range - let range = rangePart.split(separator: "-", maxSplits: 1) - guard let port = UInt16(range[0]), port != 0 else { - throw ParseError.invalidOption(p) - } - - // parse the end of the range if it exists - let end = range.count == 2 ? UInt16(range[1]) : nil - if range.count == 2, end == nil { - throw ParseError.invalidOption(p) - } - - if end != nil, end == 0 { - throw ParseError.invalidOption(p) - } - - // parse the protocol if one was specified - let protocolType: PortSpec.NetworkProtocol = try { - if parts.count == 2 { - guard let proto = PortSpec.NetworkProtocol(rawValue: String(parts[1]).lowercased()) else { - throw ParseError.invalidOption(p) - } - return proto - } - return .tcp - }() - - return PortSpec(port: port, endPort: end, protocol: protocolType) -} diff --git a/Sources/NativeBuilder/ContainerBuildParser/Docker/Instructions/RunInstruction.swift b/Sources/NativeBuilder/ContainerBuildParser/Docker/Instructions/RunInstruction.swift deleted file mode 100644 index 29eb70ef4..000000000 --- a/Sources/NativeBuilder/ContainerBuildParser/Docker/Instructions/RunInstruction.swift +++ /dev/null @@ -1,389 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import Foundation - -enum RunOptions: String { - case mount = "--mount" - case network = "--network" -} - -enum MountOptionNames: String { - case type = "type" - case source = "source" - case from = "from" - case target = "target" - case dst = "dst" - case destination = "destination" - - // permissions related - case readonly = "readonly" - case ro = "ro" - case readwrite = "readwrite" - case rw = "rw" - case uid = "uid" - case gid = "gid" - case mode = "mode" - case size = "size" - - case sharing = "sharing" - case required = "required" - case env = "env" - case id = "id" -} - -extension MountType { - var allowedOptions: Set { - switch self { - case .bind: - return [.source, .from, .target, .destination, .dst, .readwrite, .rw] - case .cache: - return [.id, .target, .destination, .dst, .readonly, .ro, .sharing, .from, .source, .mode, .gid, .uid] - case .tmpfs: - return [.target, .dst, .destination, .size] - case .secret: - return [.id, .target, .dst, .destination, .env, .required, .mode, .uid, .gid] - case .ssh: - return [.id, .target, .dst, .destination, .required, .mode, .uid, .gid] - } - } -} - -/// RunMount represents a mount option with its suboptions from a docker RUN instruction -struct RunMount: Equatable { - var type: MountType? - var source: String? - var from: String? - var id: String? - var env: String? - var target: String? - var options: RunMountOptions? - - init() {} - - init( - type: MountType? = nil, - source: String? = nil, - from: String? = nil, - id: String? = nil, - env: String? = nil, - target: String? = nil, - options: RunMountOptions? = nil - ) { - self.type = type - self.source = source - self.from = from - self.id = id - self.env = env - self.target = target - self.options = options - } - - mutating internal func setOption(_ keyPath: WritableKeyPath, _ value: T) throws { - guard self.options?[keyPath: keyPath] == nil else { - throw ParseError.invalidOption("\(keyPath):\(value)") - } - if self.options == nil { - self.options = RunMountOptions() - } - if self.options?[keyPath: keyPath] != nil { - throw ParseError.duplicateOptionSet("\(keyPath):\(value)") - } - self.options?[keyPath: keyPath] = value - } - - mutating internal func setField(_ keyPath: WritableKeyPath, _ value: T) throws { - if self[keyPath: keyPath] != nil { - throw ParseError.invalidOption("\(keyPath)") - } - self[keyPath: keyPath] = value - } - - /// Validate required fields are set and set any defaults iff they are not already set - mutating internal func finalize() throws { - switch self.type { - case .bind: - if self.target == nil { - throw ParseError.missingRequiredField(MountOptionNames.target.rawValue) - } - if self.source == nil { - try self.setField(\.source, "/") - } - if self.options?.readonly == nil { - try self.setOption(\.readonly, true) - } - case .cache: - if self.target == nil { - throw ParseError.missingRequiredField(MountOptionNames.target.rawValue) - } - if self.id == nil { - try self.setField(\.id, self.target!) - } - if self.options?.readonly == nil { - try self.setOption(\.readonly, false) - } - if self.options?.sharing == nil { - try self.setOption(\.sharing, .shared) - } - if self.from == nil { - try self.setField(\.from, "") - } - if self.source == nil { - try self.setField(\.source, "/") - } - if self.options?.mode == nil { - try self.setOption(\.mode, 0755) - } - if self.options?.uid == nil { - try self.setOption(\.uid, 0) - } - if self.options?.gid == nil { - try self.setOption(\.gid, 0) - } - case .tmpfs: - if self.target == nil { - throw ParseError.missingRequiredField(MountOptionNames.target.rawValue) - } - if self.options?.readonly == nil { - try self.setOption(\.readonly, false) - } - case .secret: - if self.target == nil { - if self.env == nil { - guard let id = self.id else { - throw ParseError.missingRequiredField("id must be set when target and env are unset") - } - try self.setField(\.target, "/run/secrets/\(id)") - } - } - if id == nil { - guard let target = self.target else { - throw ParseError.missingRequiredField("target must be set when id is unset") - } - let targetURL = URL(string: target) - guard let targetURL = targetURL else { - throw ParseError.invalidOption("target is not a valid url \(target)") - } - try self.setField(\.id, targetURL.lastPathComponent) - } - if self.options?.readonly == nil { - try self.setOption(\.readonly, true) - } - if self.options?.required == nil { - try self.setOption(\.required, false) - } - if self.options?.mode == nil { - try self.setOption(\.mode, 0400) - } - if self.options?.uid == nil { - try self.setOption(\.uid, 0) - } - if self.options?.gid == nil { - try self.setOption(\.gid, 0) - } - case .ssh: - if self.id == nil { - try self.setField(\.id, "default") - } - if self.target == nil { - // TODO katiewasnothere add sufix based on number of agents added - try self.setField(\.target, "/run/buildkit/ssh_agent") - } - if self.options?.readonly == nil { - try self.setOption(\.readonly, true) - } - if self.options?.required == nil { - try self.setOption(\.required, false) - } - if self.options?.mode == nil { - try self.setOption(\.mode, 0600) - } - if self.options?.uid == nil { - try self.setOption(\.uid, 0) - } - if self.options?.gid == nil { - try self.setOption(\.gid, 0) - } - default: - throw ParseError.invalidOption("unsupported mount type \(String(describing: self.type))") - } - } -} - -/// RunMountOptions represent the suboptions set on a RUN mount option -struct RunMountOptions: Equatable { - var readonly: Bool? - var required: Bool? - var uid: UInt32? - var gid: UInt32? - var mode: UInt32? - var size: UInt32? - var sharing: SharingMode? - - init() {} - - init( - readonly: Bool? = nil, - required: Bool? = nil, - uid: UInt32? = nil, - gid: UInt32? = nil, - mode: UInt32? = nil, - size: UInt32? = nil, - sharing: SharingMode? = nil - ) { - self.readonly = readonly - self.required = required - self.uid = uid - self.gid = gid - self.mode = mode - self.size = size - self.sharing = sharing - } -} - -/// RunInstruction represents a RUN instruction from a dockerfile -struct RunInstruction: DockerInstruction, Equatable { - let command: Command - let mounts: [RunMount] - let network: NetworkMode - - init() { - self.command = .shell("") - self.mounts = [] - self.network = .default - } - - init(command: Command, rawMounts: [String], network: String?) throws { - self.command = command - self.network = try RunInstruction.parseNetworkMode(mode: network) - var parsedMounts: [RunMount] = [] - for m in rawMounts { - parsedMounts.append(try RunInstruction.parseMount(m)) - } - self.mounts = parsedMounts - } - - static internal func parseNetworkMode(mode: String?) throws -> NetworkMode { - guard let mode = mode else { - return .default - } - guard let nMode = NetworkMode(rawValue: mode) else { - throw ParseError.invalidOption(mode) - } - return nMode - } - - static internal func parseMount(_ rawMount: String) throws -> RunMount { - let components = rawMount.components(separatedBy: ",") - if components.isEmpty { - throw ParseError.invalidOption("no options set on mount") - } - - var runMount = RunMount() - for c in components { - let optionComps = c.components(separatedBy: "=") - guard optionComps.count == 2 else { - throw ParseError.invalidOption("option \(c) is not in the form key=value") - } - guard optionComps[1] != "" else { - throw ParseError.invalidOption("option \(c) is not in the form key=value") - } - let key = optionComps[0] - let value = optionComps[1] - guard let mountOption = MountOptionNames(rawValue: key) else { - throw ParseError.invalidOption("option \(key) is not supported") - } - - if let type = runMount.type { - guard type.allowedOptions.contains(mountOption) else { - throw ParseError.invalidOption("option \(mountOption) is not supported for type \(type)") - } - } else { - if let mountType = MountType(rawValue: value) { - runMount.type = mountType - continue - } else { - // still need to eval this option, so we need to go to the switch - // statement from here - runMount.type = .bind - } - } - - switch mountOption { - case .id: - try runMount.setField(\.id, value) - case .env: - try runMount.setField(\.env, value) - case .source: - try runMount.setField(\.source, value) - case .from: - try runMount.setField(\.from, value) - case .dst, .target, .destination: - try runMount.setField(\.target, value) - case .readonly, .ro: - guard let readonly = Bool(value) else { - throw ParseError.invalidBoolOption(value) - } - try runMount.setOption(\.readonly, readonly) - case .readwrite, .rw: - guard let readwrite = Bool(value) else { - throw ParseError.invalidBoolOption(value) - } - try runMount.setOption(\.readonly, !readwrite) - case .gid: - guard let gid = UInt32(value) else { - throw ParseError.invalidUint32Option(value) - } - try runMount.setOption(\.gid, gid) - case .uid: - guard let uid = UInt32(value) else { - throw ParseError.invalidUint32Option(value) - } - try runMount.setOption(\.uid, uid) - case .mode: - guard let mode = UInt32(value) else { - throw ParseError.invalidUint32Option(value) - } - try runMount.setOption(\.mode, mode) - case .size: - guard let size = UInt32(value) else { - throw ParseError.invalidUint32Option(value) - } - try runMount.setOption(\.size, size) - case .sharing: - guard let sharing = SharingMode(rawValue: value) else { - throw ParseError.invalidOption("invalid sharing type \(value)") - } - try runMount.setOption(\.sharing, sharing) - case .required: - guard let requiredVal = Bool(value) else { - throw ParseError.invalidBoolOption(value) - } - try runMount.setOption(\.required, requiredVal) - default: - throw ParseError.invalidOption("\(key) unsupported") - } - } - - try runMount.finalize() - return runMount - } - - func accept(_ visitor: DockerInstructionVisitor) throws { - try visitor.visit(self) - } -} diff --git a/Sources/NativeBuilder/ContainerBuildParser/Types.swift b/Sources/NativeBuilder/ContainerBuildParser/Types.swift deleted file mode 100644 index 0ee1a445e..000000000 --- a/Sources/NativeBuilder/ContainerBuildParser/Types.swift +++ /dev/null @@ -1,44 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR - -public protocol BuildParser { - associatedtype Input - func parse(_ input: Input) throws -> BuildGraph -} - -/// Error types encountered while parsing. -/// TODO: These will be removed/enhanced -public enum ParseError: Error, Equatable { - case invalidImage(String) - case missingInstruction - case invalidInstruction(String) - case unexpectedValue - case invalidOption(String) - case missingRequiredField(String) - case duplicateOptionSet(String) - case invalidSyntax - case invalidBoolOption(String) - case invalidUint32Option(String) -} - -/// Token represents a logical unit within a line of builder input, such as -/// a dockerfile -public enum Token: Sendable, Equatable { - case stringLiteral(String) - case stringList([String]) -} diff --git a/Sources/NativeBuilder/ContainerBuildReporting/BaseProgressConsumer.swift b/Sources/NativeBuilder/ContainerBuildReporting/BaseProgressConsumer.swift deleted file mode 100644 index 64e5b3182..000000000 --- a/Sources/NativeBuilder/ContainerBuildReporting/BaseProgressConsumer.swift +++ /dev/null @@ -1,179 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -/// Base implementation for progress consumers that provides common statistics tracking. -/// -/// This class handles: -/// - Event accumulation -/// - Statistics calculation -/// - Thread-safe state management -/// -/// Subclasses should override `formatEvent` to provide custom output formatting. -open class BaseProgressConsumer: ProgressConsumer, @unchecked Sendable { - public let configuration: Configuration - private let lock = NSLock() - - // Statistics tracking - private var accumulatedEvents: [BuildEvent] = [] - private var buildStartTime: Date? - private var buildEndTime: Date? - private var buildSuccess: Bool? - private var totalOperationCount = 0 - private var executedOperationCount = 0 - private var cacheHitCount = 0 - private var failedOperationCount = 0 - private var stageStats: [String: MutableStageStats] = [:] - - private struct MutableStageStats { - var name: String - var startTime: Date? - var endTime: Date? - var operationCount = 0 - var cacheHits = 0 - var failures = 0 - } - - public required init(configuration: Configuration) { - self.configuration = configuration - } - - public func consume(reporter: Reporter) async throws { - for await event in reporter.stream { - try await handle(event) - } - } - - public func handle(_ event: BuildEvent) async throws { - // Store event and update statistics - lock.withLock { - accumulatedEvents.append(event) - updateStatistics(event) - } - - // Let subclass format the output - try await formatAndOutput(event) - } - - /// Subclasses must implement this to format and output events. - open func formatAndOutput(_ event: BuildEvent) async throws { - fatalError("Subclasses must implement formatAndOutput(_:)") - } - - public func getStatistics() -> BuildStatistics { - lock.withLock { - let stageStatistics = stageStats.mapValues { stats in - StageStatistics( - name: stats.name, - startTime: stats.startTime, - endTime: stats.endTime, - operationCount: stats.operationCount, - cacheHits: stats.cacheHits, - failures: stats.failures - ) - } - - return BuildStatistics( - startTime: buildStartTime, - endTime: buildEndTime, - success: buildSuccess, - totalOperations: totalOperationCount, - executedOperations: executedOperationCount, - cacheHits: cacheHitCount, - failedOperations: failedOperationCount, - totalStages: stageStats.count, - stageStatistics: stageStatistics, - events: accumulatedEvents - ) - } - } - - public func getEvents() -> [BuildEvent] { - lock.withLock { - accumulatedEvents - } - } - - private func updateStatistics(_ event: BuildEvent) { - switch event { - case .buildStarted(let totalOps, _, let timestamp): - buildStartTime = timestamp - totalOperationCount = totalOps - - case .buildCompleted(let success, let timestamp): - buildEndTime = timestamp - buildSuccess = success - - case .stageStarted(let stageName, let timestamp): - if stageStats[stageName] == nil { - stageStats[stageName] = MutableStageStats(name: stageName) - } - stageStats[stageName]?.startTime = timestamp - - case .stageCompleted(let stageName, let timestamp): - if stageStats[stageName] == nil { - stageStats[stageName] = MutableStageStats(name: stageName) - } - stageStats[stageName]?.endTime = timestamp - - case .operationStarted(let context): - if let stage = context.stageId { - if stageStats[stage] == nil { - stageStats[stage] = MutableStageStats(name: stage) - } - stageStats[stage]?.operationCount += 1 - } - - case .operationFinished: - executedOperationCount += 1 - - case .operationFailed(let context, _): - failedOperationCount += 1 - if let stage = context.stageId { - if stageStats[stage] == nil { - stageStats[stage] = MutableStageStats(name: stage) - } - stageStats[stage]?.failures += 1 - } - - case .operationCacheHit(let context): - cacheHitCount += 1 - if let stage = context.stageId { - if stageStats[stage] == nil { - stageStats[stage] = MutableStageStats(name: stage) - } - stageStats[stage]?.cacheHits += 1 - } - - case .operationProgress, .operationLog: - break // These don't affect statistics - - case .irEvent(_, _): - // Track IR events if needed in the future - break - } - } -} - -// Helper extension for thread-safe lock usage -extension NSLock { - fileprivate func withLock(_ body: () throws -> T) rethrows -> T { - lock() - defer { unlock() } - return try body() - } -} diff --git a/Sources/NativeBuilder/ContainerBuildReporting/BuildEvent.swift b/Sources/NativeBuilder/ContainerBuildReporting/BuildEvent.swift deleted file mode 100644 index 75b539044..000000000 --- a/Sources/NativeBuilder/ContainerBuildReporting/BuildEvent.swift +++ /dev/null @@ -1,189 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -/// Represents all types of events that can occur during a build. -/// -/// Design rationale: -/// - Single enum to capture all build activity (operations, logs, progress) -/// - Each case carries relevant context and data -/// - Sendable and Codable for thread safety and serialization -/// - Extensible for future event types -public enum BuildEvent: Sendable, Codable { - // MARK: - Build Lifecycle Events - - /// Build has started - case buildStarted(totalOperations: Int, stages: Int, timestamp: Date) - - /// Build has completed - case buildCompleted(success: Bool, timestamp: Date) - - // MARK: - Stage Events - - /// A build stage has started - case stageStarted(stageName: String, timestamp: Date) - - /// A build stage has completed - case stageCompleted(stageName: String, timestamp: Date) - - // MARK: - Operation Events - - /// An operation has started executing - case operationStarted(context: ReportContext) - - /// An operation has finished successfully - case operationFinished(context: ReportContext, duration: TimeInterval) - - /// An operation has failed - case operationFailed(context: ReportContext, error: BuildEventError) - - /// An operation was satisfied from cache - case operationCacheHit(context: ReportContext) - - /// Progress update for a long-running operation - case operationProgress(context: ReportContext, fraction: Double) - - /// Log output from an operation - case operationLog(context: ReportContext, message: String) - - // MARK: - IR Events - - /// IR construction or analysis event - case irEvent(context: ReportContext, type: IREventType) -} - -/// Context information for events. -/// -/// Design rationale: -/// - Provides provenance for each event -/// - Enables grouping and correlation of events -/// - Rich context for UI/logging decisions -/// - Source mapping for precise error location -public struct ReportContext: Sendable, Codable { - /// Unique identifier for the node (if applicable) - public let nodeId: UUID? - - /// Identifier for the build stage (if applicable) - public let stageId: String? - - /// Human-readable description - public let description: String - - /// Timestamp when the event was generated - public let timestamp: Date - - /// Source location mapping (if available) - public let sourceMap: SourceMap? - - public init( - nodeId: UUID? = nil, - stageId: String? = nil, - description: String, - timestamp: Date = Date(), - sourceMap: SourceMap? = nil - ) { - self.nodeId = nodeId - self.stageId = stageId - self.description = description - self.timestamp = timestamp - self.sourceMap = sourceMap - } - - /// Convenience init for operation events (backwards compatibility) - public init( - nodeId: UUID, - stageId: String, - operationDescription: String, - timestamp: Date = Date() - ) { - self.init( - nodeId: nodeId, - stageId: stageId, - description: operationDescription, - timestamp: timestamp - ) - } -} - -/// Source location information for precise error reporting -public struct SourceMap: Sendable, Codable { - /// Source file path (e.g., Dockerfile path) - public let file: String? - - /// Line number (1-based) - public let line: Int? - - /// Column number (1-based) - public let column: Int? - - /// Source text snippet for context - public let snippet: String? - - public init(file: String? = nil, line: Int? = nil, column: Int? = nil, snippet: String? = nil) { - self.file = file - self.line = line - self.column = column - self.snippet = snippet - } -} - -/// Types of IR events -public enum IREventType: String, Sendable, Codable { - case graphStarted = "graph_started" - case graphCompleted = "graph_completed" - case stageAdded = "stage_added" - case nodeAdded = "node_added" - case analyzing = "analyzing" - case validating = "validating" - case error = "error" - case warning = "warning" -} - -/// Error information for build events. -/// -/// Design rationale: -/// - Structured error representation for serialization -/// - Captures error type and description -/// - Extensible with diagnostics -public struct BuildEventError: Sendable, Codable { - /// The type of failure - public let type: FailureType - - /// Human-readable error description - public let description: String - - /// Additional diagnostic information - public let diagnostics: [String: String]? - - public init( - type: FailureType, - description: String, - diagnostics: [String: String]? = nil - ) { - self.type = type - self.description = description - self.diagnostics = diagnostics - } - - public enum FailureType: String, Sendable, Codable { - case executionFailed - case cancelled - case invalidConfiguration - case timeout - case resourceExhausted - } -} diff --git a/Sources/NativeBuilder/ContainerBuildReporting/BuildStatistics.swift b/Sources/NativeBuilder/ContainerBuildReporting/BuildStatistics.swift deleted file mode 100644 index 407bb6b83..000000000 --- a/Sources/NativeBuilder/ContainerBuildReporting/BuildStatistics.swift +++ /dev/null @@ -1,123 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -/// Statistics collected during a build execution. -public struct BuildStatistics: Sendable { - /// When the build started - public let startTime: Date? - - /// When the build completed - public let endTime: Date? - - /// Total build duration - public var duration: TimeInterval? { - guard let start = startTime, let end = endTime else { return nil } - return end.timeIntervalSince(start) - } - - /// Whether the build succeeded - public let success: Bool? - - /// Total number of operations - public let totalOperations: Int - - /// Number of operations executed - public let executedOperations: Int - - /// Number of cache hits - public let cacheHits: Int - - /// Number of failed operations - public let failedOperations: Int - - /// Number of stages - public let totalStages: Int - - /// Per-stage statistics - public let stageStatistics: [String: StageStatistics] - - /// All events that occurred during the build - public let events: [BuildEvent] - - public init( - startTime: Date? = nil, - endTime: Date? = nil, - success: Bool? = nil, - totalOperations: Int = 0, - executedOperations: Int = 0, - cacheHits: Int = 0, - failedOperations: Int = 0, - totalStages: Int = 0, - stageStatistics: [String: StageStatistics] = [:], - events: [BuildEvent] = [] - ) { - self.startTime = startTime - self.endTime = endTime - self.success = success - self.totalOperations = totalOperations - self.executedOperations = executedOperations - self.cacheHits = cacheHits - self.failedOperations = failedOperations - self.totalStages = totalStages - self.stageStatistics = stageStatistics - self.events = events - } -} - -/// Statistics for a single build stage. -public struct StageStatistics: Sendable { - /// Stage name - public let name: String - - /// When the stage started - public let startTime: Date? - - /// When the stage completed - public let endTime: Date? - - /// Stage duration - public var duration: TimeInterval? { - guard let start = startTime, let end = endTime else { return nil } - return end.timeIntervalSince(start) - } - - /// Number of operations in this stage - public let operationCount: Int - - /// Number of cache hits in this stage - public let cacheHits: Int - - /// Number of failures in this stage - public let failures: Int - - public init( - name: String, - startTime: Date? = nil, - endTime: Date? = nil, - operationCount: Int = 0, - cacheHits: Int = 0, - failures: Int = 0 - ) { - self.name = name - self.startTime = startTime - self.endTime = endTime - self.operationCount = operationCount - self.cacheHits = cacheHits - self.failures = failures - } -} diff --git a/Sources/NativeBuilder/ContainerBuildReporting/JSONProgressConsumer.swift b/Sources/NativeBuilder/ContainerBuildReporting/JSONProgressConsumer.swift deleted file mode 100644 index cc7ec31ed..000000000 --- a/Sources/NativeBuilder/ContainerBuildReporting/JSONProgressConsumer.swift +++ /dev/null @@ -1,312 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -/// JSON progress consumer that outputs newline-delimited JSON events. -/// -/// This consumer is useful for: -/// - Machine-readable output -/// - Integration with external tools -/// - Structured logging systems -/// -/// Example output: -/// ``` -/// {"type":"operation_started","operation":"#1","description":"[internal] load metadata for alpine:latest","timestamp":"2025-01-09T10:30:45.123Z"} -/// {"type":"operation_finished","operation":"#1","duration":0.5,"timestamp":"2025-01-09T10:30:45.623Z"} -/// ``` -public final class JSONProgressConsumer: BaseProgressConsumer, @unchecked Sendable { - public struct Configuration: Sendable { - /// File handle to write output to (default: stdout) - public let output: FileHandle - - /// Pretty print JSON (with indentation) - public let prettyPrint: Bool - - public init( - output: FileHandle = .standardOutput, - prettyPrint: Bool = false - ) { - self.output = output - self.prettyPrint = prettyPrint - } - } - - private let encoder: JSONEncoder - private let lock = NSLock() - private var operationNumbers: [UUID: Int] = [:] - private var nextOperationNumber = 1 - - public required init(configuration: Configuration) { - self.encoder = JSONEncoder() - if configuration.prettyPrint { - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - } - encoder.dateEncodingStrategy = .iso8601 - super.init(configuration: configuration) - } - - override public func formatAndOutput(_ event: BuildEvent) async throws { - try lock.withLock { - if let output = try formatEventAsJSON(event) { - try configuration.output.write(contentsOf: output) - try configuration.output.write(contentsOf: "\n".data(using: .utf8)!) - } - } - } - - private func formatEventAsJSON(_ event: BuildEvent) throws -> Data? { - let jsonEvent: JSONEvent? = { - switch event { - case .buildStarted(let totalOps, let stages, let timestamp): - return JSONEvent( - type: "build_started", - timestamp: timestamp, - data: [ - "total_operations": totalOps, - "stages": stages, - ] - ) - - case .buildCompleted(let success, let timestamp): - return JSONEvent( - type: "build_completed", - timestamp: timestamp, - data: ["success": success] - ) - - case .stageStarted(let stageName, let timestamp): - return JSONEvent( - type: "stage_started", - timestamp: timestamp, - data: ["stage": stageName] - ) - - case .stageCompleted(let stageName, let timestamp): - return JSONEvent( - type: "stage_completed", - timestamp: timestamp, - data: ["stage": stageName] - ) - - case .operationStarted(let context): - guard let nodeId = context.nodeId else { return nil } - let number = assignOperationNumber(for: nodeId) - var data: [String: Any] = [ - "operation": "#\(number)", - "description": context.description, - ] - if let stageId = context.stageId { - data["stage"] = stageId - } - return JSONEvent( - type: "operation_started", - timestamp: context.timestamp, - data: data - ) - - case .operationFinished(let context, let duration): - guard let nodeId = context.nodeId, - let number = operationNumbers[nodeId] - else { return nil } - return JSONEvent( - type: "operation_finished", - timestamp: context.timestamp, - data: [ - "operation": "#\(number)", - "duration": duration, - ] - ) - - case .operationFailed(let context, let error): - guard let nodeId = context.nodeId, - let number = operationNumbers[nodeId] - else { return nil } - return JSONEvent( - type: "operation_failed", - timestamp: context.timestamp, - data: [ - "operation": "#\(number)", - "error": error.description, - "error_type": error.type.rawValue, - ] - ) - - case .operationCacheHit(let context): - guard let nodeId = context.nodeId else { return nil } - let number = assignOperationNumber(for: nodeId) - return JSONEvent( - type: "operation_cache_hit", - timestamp: context.timestamp, - data: [ - "operation": "#\(number)", - "description": context.description, - ] - ) - - case .operationProgress(let context, let fraction): - guard let nodeId = context.nodeId, - let number = operationNumbers[nodeId] - else { return nil } - return JSONEvent( - type: "operation_progress", - timestamp: context.timestamp, - data: [ - "operation": "#\(number)", - "progress": fraction, - ] - ) - - case .operationLog(let context, let message): - guard let nodeId = context.nodeId, - let number = operationNumbers[nodeId] - else { return nil } - return JSONEvent( - type: "operation_log", - timestamp: context.timestamp, - data: [ - "operation": "#\(number)", - "message": message, - ] - ) - - case .irEvent(let context, let type): - var data: [String: Any] = [ - "event_type": type.rawValue, - "description": context.description, - ] - - if let nodeId = context.nodeId { - data["node_id"] = nodeId.uuidString - } - if let stageId = context.stageId { - data["stage_id"] = stageId - } - if let sourceMap = context.sourceMap { - var mapData: [String: Any] = [:] - if let file = sourceMap.file { mapData["file"] = file } - if let line = sourceMap.line { mapData["line"] = line } - if let column = sourceMap.column { mapData["column"] = column } - if let snippet = sourceMap.snippet { mapData["snippet"] = snippet } - data["source_map"] = mapData - } - - return JSONEvent( - type: "ir_event", - timestamp: context.timestamp, - data: data - ) - } - }() - - if let jsonEvent = jsonEvent { - return try encoder.encode(jsonEvent) - } - return nil - } - - private func assignOperationNumber(for nodeId: UUID) -> Int { - if let existing = operationNumbers[nodeId] { - return existing - } - let number = nextOperationNumber - operationNumbers[nodeId] = number - nextOperationNumber += 1 - return number - } - - private struct JSONEvent: Encodable { - let type: String - let timestamp: Date - let data: [String: Any] - - enum CodingKeys: String, CodingKey { - case type - case timestamp - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(type, forKey: .type) - try container.encode(timestamp, forKey: .timestamp) - - // Encode the dynamic data fields - var dataContainer = encoder.container(keyedBy: DynamicCodingKey.self) - for (key, value) in data { - let codingKey = DynamicCodingKey(stringValue: key) - switch value { - case let intValue as Int: - try dataContainer.encode(intValue, forKey: codingKey) - case let doubleValue as Double: - try dataContainer.encode(doubleValue, forKey: codingKey) - case let stringValue as String: - try dataContainer.encode(stringValue, forKey: codingKey) - case let boolValue as Bool: - try dataContainer.encode(boolValue, forKey: codingKey) - case let dictValue as [String: Any]: - // For dictionary values, we need to encode them properly - let nestedContainer = dataContainer.nestedContainer(keyedBy: DynamicCodingKey.self, forKey: codingKey) - try encodeNestedDictionary(dictValue, to: nestedContainer) - default: - // Skip unsupported types - break - } - } - } - - private func encodeNestedDictionary(_ dict: [String: Any], to container: KeyedEncodingContainer) throws { - var container = container - for (key, value) in dict { - let codingKey = DynamicCodingKey(stringValue: key) - switch value { - case let intValue as Int: - try container.encode(intValue, forKey: codingKey) - case let doubleValue as Double: - try container.encode(doubleValue, forKey: codingKey) - case let stringValue as String: - try container.encode(stringValue, forKey: codingKey) - case let boolValue as Bool: - try container.encode(boolValue, forKey: codingKey) - default: - // Skip unsupported types - break - } - } - } - } - - private struct DynamicCodingKey: CodingKey { - let stringValue: String - let intValue: Int? = nil - - init(stringValue: String) { - self.stringValue = stringValue - } - - init?(intValue: Int) { - return nil - } - } -} - -// Helper extension for thread-safe lock usage -extension NSLock { - fileprivate func withLock(_ body: () throws -> T) rethrows -> T { - lock() - defer { unlock() } - return try body() - } -} diff --git a/Sources/NativeBuilder/ContainerBuildReporting/PlainProgressConsumer.swift b/Sources/NativeBuilder/ContainerBuildReporting/PlainProgressConsumer.swift deleted file mode 100644 index 29bef942a..000000000 --- a/Sources/NativeBuilder/ContainerBuildReporting/PlainProgressConsumer.swift +++ /dev/null @@ -1,225 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -/// Plain text progress consumer that mimics BuildKit's progress=plain format. -/// -/// Output format matches BuildKit's clean, numbered operation display: -/// ``` -/// #1 [internal] load build definition -/// #1 DONE 0.1s -/// -/// #2 [base 1/3] FROM alpine:latest -/// #2 CACHED -/// -/// #3 [base 2/3] RUN apk add --no-cache git -/// #3 0.245 fetch https://dl-cdn.alpinelinux.org/alpine/... -/// #3 DONE 1.2s -/// ``` -public final class PlainProgressConsumer: BaseProgressConsumer, @unchecked Sendable { - public struct Configuration: Sendable { - /// File handle to write output to (default: stdout) - public let output: FileHandle - - public init( - output: FileHandle = .standardOutput, - includeEventIds: Bool = false, - timestampFormat: TimestampFormat = .iso8601 - ) { - self.output = output - } - - public enum TimestampFormat: Sendable { - case iso8601 - case unix - case relative(startTime: Date) - } - } - - private let lock = NSLock() - - // State tracking for BuildKit-style formatting - private var operationNumbers: [UUID: Int] = [:] - private var operationStartTimes: [UUID: Date] = [:] - private var nextOperationNumber = 1 - - public required init(configuration: Configuration) { - super.init(configuration: configuration) - } - - override public func formatAndOutput(_ event: BuildEvent) async throws { - let output = formatEvent(event) - if !output.isEmpty { - let data = (output + "\n").data(using: .utf8) ?? Data() - try configuration.output.write(contentsOf: data) - } - } - - private func formatEvent(_ event: BuildEvent) -> String { - lock.withLock { - switch event { - case .buildStarted: - return "" // BuildKit doesn't show explicit build start - - case .buildCompleted: - return "" // Let caller handle build completion messages - - case .stageStarted: - return "" // Stages are implicit in operation descriptions - - case .stageCompleted: - return "" // Stages are implicit in operation descriptions - - case .operationStarted(let context): - guard let nodeId = context.nodeId else { return "" } - let number = assignOperationNumber(for: nodeId) - operationStartTimes[nodeId] = context.timestamp - return "#\(number) \(formatOperationDescription(context.description, stage: context.stageId))" - - case .operationFinished(let context, _): - guard let nodeId = context.nodeId, - let number = operationNumbers[nodeId], - let startTime = operationStartTimes[nodeId] - else { - return "" - } - let duration = context.timestamp.timeIntervalSince(startTime) - operationStartTimes.removeValue(forKey: nodeId) - return "#\(number) DONE \(formatDuration(duration))" - - case .operationFailed(let context, let error): - guard let nodeId = context.nodeId, - let number = operationNumbers[nodeId] - else { return "" } - operationStartTimes.removeValue(forKey: nodeId) - return "#\(number) ERROR: \(error.description)" - - case .operationCacheHit(let context): - guard let nodeId = context.nodeId else { return "" } - - // Check if this operation was already started - let wasStarted = operationNumbers[nodeId] != nil - let number = assignOperationNumber(for: nodeId) - operationStartTimes.removeValue(forKey: nodeId) - - guard wasStarted else { - // Cache hit without prior start - show both description and CACHED - let description = formatOperationDescription(context.description, stage: context.stageId) - return "#\(number) \(description)\n#\(number) CACHED" - } - // Just show CACHED - the operation description was already shown - return "#\(number) CACHED" - - case .operationProgress(let context, let fraction): - guard let nodeId = context.nodeId, - let number = operationNumbers[nodeId] - else { return "" } - let percentage = Int(fraction * 100) - return "#\(number) \(percentage)% complete" - - case .operationLog(let context, let message): - guard let nodeId = context.nodeId, - let number = operationNumbers[nodeId], - let startTime = operationStartTimes[nodeId] - else { - return "" - } - let elapsed = context.timestamp.timeIntervalSince(startTime) - // Format log lines like BuildKit: #N elapsed message - return "#\(number) \(String(format: "%.3f", elapsed)) \(message)" - - case .irEvent(let context, let type): - // Format IR events based on type - switch type { - case .graphStarted, .graphCompleted: - return "" // Don't show graph-level events in plain output - case .stageAdded: - return "" // Stage creation is implicit in BuildKit output - case .nodeAdded: - return "" // Node addition is shown when executed - case .analyzing: - return "=> \(context.description)" - case .validating: - return "=> \(context.description)" - case .error: - if let sourceMap = context.sourceMap { - return "ERROR: \(context.description) at \(sourceMap.file ?? "unknown"):\(sourceMap.line ?? 0)" - } - return "ERROR: \(context.description)" - case .warning: - if let sourceMap = context.sourceMap { - return "WARNING: \(context.description) at \(sourceMap.file ?? "unknown"):\(sourceMap.line ?? 0)" - } - return "WARNING: \(context.description)" - } - } - } - } - - private func assignOperationNumber(for nodeId: UUID) -> Int { - if let existing = operationNumbers[nodeId] { - return existing - } - let number = nextOperationNumber - operationNumbers[nodeId] = number - nextOperationNumber += 1 - return number - } - - private func formatOperationDescription(_ description: String, stage: String? = nil) -> String { - // Transform operation descriptions to BuildKit style - // Examples: - // "FROM alpine:latest" -> "[internal] load metadata for alpine:latest" - // "RUN apk add git" -> "[stage-name] RUN apk add git" - - if description.hasPrefix("FROM ") { - let imageName = description.replacingOccurrences(of: "FROM ", with: "") - return "[internal] load metadata for \(imageName)" - } else if description.hasPrefix("BaseImage:") { - // Transform our internal representation - let imageName = description.replacingOccurrences(of: "BaseImage: ", with: "") - return "[internal] load metadata for \(imageName)" - } else if let stage = stage { - // Clean up stage name (remove "stage-" prefix if it's a UUID) - let stageName = stage.hasPrefix("stage-") && stage.count > 12 ? "stage" : stage - return "[\(stageName)] \(description)" - } else { - return "[stage] \(description)" - } - } - - private func formatDuration(_ duration: TimeInterval) -> String { - if duration < 1.0 { - return String(format: "%.1fs", duration) - } else if duration < 60.0 { - return String(format: "%.1fs", duration) - } else { - let minutes = Int(duration / 60) - let seconds = Int(duration.truncatingRemainder(dividingBy: 60)) - return String(format: "%dm%ds", minutes, seconds) - } - } -} - -// Helper extension for thread-safe lock usage -extension NSLock { - fileprivate func withLock(_ body: () throws -> T) rethrows -> T { - lock() - defer { unlock() } - return try body() - } -} diff --git a/Sources/NativeBuilder/ContainerBuildReporting/ProgressConsumer.swift b/Sources/NativeBuilder/ContainerBuildReporting/ProgressConsumer.swift deleted file mode 100644 index 910f12a69..000000000 --- a/Sources/NativeBuilder/ContainerBuildReporting/ProgressConsumer.swift +++ /dev/null @@ -1,44 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -/// Protocol for consuming and displaying build progress events. -/// -/// Design rationale: -/// - Protocol-based design allows for different output formats -/// - Async/await support for streaming events -/// - Flexible configuration for different environments -/// - Built-in statistics tracking for all consumers -public protocol ProgressConsumer: Sendable { - /// Configuration for the progress consumer - associatedtype Configuration: Sendable - - /// Initialize with configuration - init(configuration: Configuration) - - /// Consume events from the reporter and display progress - func consume(reporter: Reporter) async throws - - /// Handle a single event (for testing or custom implementations) - func handle(_ event: BuildEvent) async throws - - /// Get the accumulated build statistics - func getStatistics() -> BuildStatistics - - /// Get all accumulated events - func getEvents() -> [BuildEvent] -} diff --git a/Sources/NativeBuilder/ContainerBuildReporting/ReportableError.swift b/Sources/NativeBuilder/ContainerBuildReporting/ReportableError.swift deleted file mode 100644 index 1872aeeee..000000000 --- a/Sources/NativeBuilder/ContainerBuildReporting/ReportableError.swift +++ /dev/null @@ -1,258 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -/// Protocol for errors that can be reported through the build event system. -/// -/// Design rationale: -/// - Enforces consistent error formatting across the system -/// - Provides structured error information for reporting -/// - Enables centralized error formatting control -public protocol ReportableError: Error { - /// The category of the error - var errorCategory: ErrorCategory { get } - - /// A short, user-friendly description of what went wrong - var shortDescription: String { get } - - /// Detailed information about the error for debugging - var detailedDescription: String? { get } - - /// Structured diagnostic information - var diagnostics: ErrorDiagnostics { get } - - /// The underlying error that caused this error (if any) - var underlyingError: Error? { get } - - /// Convert to BuildEventError for reporting - func toBuildEventError() -> BuildEventError -} - -/// Categories of errors that can occur during build -public enum ErrorCategory: String, Sendable, Codable { - // Execution errors - case executionFailed = "execution_failed" - case commandNotFound = "command_not_found" - case permissionDenied = "permission_denied" - case timeout = "timeout" - case cancelled = "cancelled" - - // Configuration errors - case invalidConfiguration = "invalid_configuration" - case missingDependency = "missing_dependency" - case incompatiblePlatform = "incompatible_platform" - - // Resource errors - case resourceExhausted = "resource_exhausted" - case diskFull = "disk_full" - case memoryExhausted = "memory_exhausted" - - // I/O errors - case fileNotFound = "file_not_found" - case fileAccessDenied = "file_access_denied" - case networkError = "network_error" - - // Parse/validation errors - case syntaxError = "syntax_error" - case validationError = "validation_error" - case unsupportedFeature = "unsupported_feature" - - // Cache errors - case cacheCorrupted = "cache_corrupted" - case cacheMiss = "cache_miss" - - // Unknown - case unknown = "unknown" -} - -/// Structured diagnostic information for errors -public struct ErrorDiagnostics: Sendable, Codable { - /// The operation that was being performed - public let operation: String? - - /// The file or resource involved - public let path: String? - - /// Line number (for parse errors) - public let line: Int? - - /// Column number (for parse errors) - public let column: Int? - - /// Exit code (for execution errors) - public let exitCode: Int? - - /// Working directory - public let workingDirectory: String? - - /// Relevant environment variables - public let environment: [String: String]? - - /// Recent log output - public let recentLogs: [String]? - - /// Additional context-specific information - public let additionalInfo: [String: String]? - - public init( - operation: String? = nil, - path: String? = nil, - line: Int? = nil, - column: Int? = nil, - exitCode: Int? = nil, - workingDirectory: String? = nil, - environment: [String: String]? = nil, - recentLogs: [String]? = nil, - additionalInfo: [String: String]? = nil - ) { - self.operation = operation - self.path = path - self.line = line - self.column = column - self.exitCode = exitCode - self.workingDirectory = workingDirectory - self.environment = environment - self.recentLogs = recentLogs - self.additionalInfo = additionalInfo - } - - /// Convert to flat dictionary for BuildEventError - func toDictionary() -> [String: String] { - var dict: [String: String] = [:] - - if let operation = operation { - dict["operation"] = operation - } - if let path = path { - dict["path"] = path - } - if let line = line { - dict["line"] = String(line) - } - if let column = column { - dict["column"] = String(column) - } - if let exitCode = exitCode { - dict["exitCode"] = String(exitCode) - } - if let workingDirectory = workingDirectory { - dict["workingDirectory"] = workingDirectory - } - if let environment = environment { - for (key, value) in environment { - dict["env.\(key)"] = value - } - } - if let recentLogs = recentLogs, !recentLogs.isEmpty { - dict["recentLogs"] = recentLogs.joined(separator: "\n") - } - if let additionalInfo = additionalInfo { - for (key, value) in additionalInfo { - dict[key] = value - } - } - - return dict - } -} - -// MARK: - Default Implementation - -extension ReportableError { - /// Default implementation that converts to BuildEventError - public func toBuildEventError() -> BuildEventError { - // Map error category to BuildEventError.FailureType - let failureType: BuildEventError.FailureType - switch errorCategory { - case .executionFailed, .commandNotFound, .permissionDenied: - failureType = .executionFailed - case .timeout: - failureType = .timeout - case .cancelled: - failureType = .cancelled - case .invalidConfiguration, .missingDependency, .incompatiblePlatform, - .syntaxError, .validationError, .unsupportedFeature: - failureType = .invalidConfiguration - case .resourceExhausted, .diskFull, .memoryExhausted: - failureType = .resourceExhausted - case .fileNotFound, .fileAccessDenied, .networkError, - .cacheCorrupted, .cacheMiss, .unknown: - failureType = .executionFailed - } - - // Build description - var description = shortDescription - if let detailed = detailedDescription { - description += ". \(detailed)" - } - if let underlying = underlyingError { - description += ". Caused by: \(underlying.localizedDescription)" - } - - return BuildEventError( - type: failureType, - description: description, - diagnostics: diagnostics.toDictionary() - ) - } - - /// Default values for optional properties - public var detailedDescription: String? { nil } - public var underlyingError: Error? { nil } -} - -// MARK: - Generic Error Extension - -/// Extension to make any Error reportable with basic information -extension Error { - /// Convert any error to a ReportableError - public func asReportableError() -> ReportableError { - if let reportable = self as? ReportableError { - return reportable - } - return GenericReportableError(underlying: self) - } -} - -/// Wrapper for non-ReportableError errors -private struct GenericReportableError: ReportableError { - let underlying: Error - - var errorCategory: ErrorCategory { .unknown } - - var shortDescription: String { - (underlying as NSError).localizedDescription - } - - var diagnostics: ErrorDiagnostics { - var additionalInfo: [String: String] = [:] - - let nsError = underlying as NSError - additionalInfo["domain"] = nsError.domain - additionalInfo["code"] = String(nsError.code) - - for (key, value) in nsError.userInfo { - if let stringValue = value as? String { - additionalInfo["userInfo.\(key)"] = stringValue - } - } - - return ErrorDiagnostics(additionalInfo: additionalInfo) - } - - var underlyingError: Error? { underlying } -} diff --git a/Sources/NativeBuilder/ContainerBuildReporting/Reporter.swift b/Sources/NativeBuilder/ContainerBuildReporting/Reporter.swift deleted file mode 100644 index e4dec8fc1..000000000 --- a/Sources/NativeBuilder/ContainerBuildReporting/Reporter.swift +++ /dev/null @@ -1,46 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -/// Central event hub for build progress reporting. -/// -/// Design rationale: -/// - Actor-based for thread safety without manual locking -/// - AsyncStream for real-time event consumption -/// - Bounded buffer to prevent unbounded memory growth -/// - Single source of truth for all build events -public actor Reporter { - private let continuation: AsyncStream.Continuation - public nonisolated let stream: AsyncStream - - /// Initialize with a buffer size for the event stream - public init(bufferSize: Int = 100) { - (self.stream, self.continuation) = AsyncStream.makeStream( - bufferingPolicy: .bufferingNewest(bufferSize) - ) - } - - /// Report a new event - public func report(_ event: BuildEvent) { - continuation.yield(event) - } - - /// Finish the event stream (no more events will be reported) - public func finish() { - continuation.finish() - } -} diff --git a/Sources/NativeBuilder/ContainerBuildSnapshotter/BinaryPath.swift b/Sources/NativeBuilder/ContainerBuildSnapshotter/BinaryPath.swift deleted file mode 100644 index b3c71bb34..000000000 --- a/Sources/NativeBuilder/ContainerBuildSnapshotter/BinaryPath.swift +++ /dev/null @@ -1,319 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -/// A filesystem path that preserves exact byte sequences, including non-UTF8 paths. -/// -/// This type is designed to handle paths from any filesystem, including Linux paths -/// that may contain non-UTF8 byte sequences. It stores paths as raw bytes internally -/// but provides convenient String access when the bytes are valid UTF-8. -/// -/// Use cases: -/// - Processing Linux filesystem paths from macOS -/// - Preserving exact path bytes for deterministic hashing (DiffKey) -/// - Round-trip preservation of paths through tar archives -/// - Cross-platform path handling without encoding loss -public struct BinaryPath: Sendable, Hashable, Codable { - /// The raw bytes of the path, including any non-UTF8 sequences - private let bytes: Data - - // MARK: - Initialization - - /// Initialize from a Swift String (always valid UTF-8) - public init(string: String) { - self.bytes = Data(string.utf8) - } - - /// Initialize from raw bytes (may contain non-UTF8 sequences) - public init(bytes: Data) { - self.bytes = bytes - } - - /// Initialize from a C string pointer (null-terminated) - public init(cString: UnsafePointer) { - let length = strlen(cString) - self.bytes = Data(bytes: cString, count: length) - } - - /// Initialize from a filesystem URL, capturing the exact bytes - public init(url: URL) { - self.bytes = url.withUnsafeFileSystemRepresentation { ptr in - guard let ptr = ptr else { - // Fallback to String representation if pointer is nil - return Data(url.path.utf8) - } - let length = strlen(ptr) - return Data(bytes: ptr, count: length) - } - } - - // MARK: - Accessors - - /// Returns the path as a String if it contains valid UTF-8, nil otherwise - public var stringValue: String? { - String(data: bytes, encoding: .utf8) - } - - /// Returns the path as a String, replacing invalid UTF-8 sequences with replacement character - public var requireString: String { - // Try UTF-8 first - if let str = String(data: bytes, encoding: .utf8) { - return str - } - - // Fallback: decode with replacement character for invalid sequences - var str = "" - var iterator = bytes.makeIterator() - var buffer: [UInt8] = [] - - while let byte = iterator.next() { - buffer.append(byte) - - // Try to decode accumulated bytes - if let decoded = String(bytes: buffer, encoding: .utf8) { - str.append(decoded) - buffer.removeAll() - } else if buffer.count >= 4 { - // Invalid UTF-8 sequence, use replacement character - str.append("\u{FFFD}") // Unicode replacement character - buffer.removeAll() - } - } - - // Handle any remaining bytes - if !buffer.isEmpty { - str.append("\u{FFFD}") - } - - return str.isEmpty ? "/" : str - } - - /// The raw bytes of the path - public var rawBytes: Data { - bytes - } - - /// Returns true if the path contains valid UTF-8 - public var isValidUTF8: Bool { - stringValue != nil - } - - /// Returns true if this represents an empty path - public var isEmpty: Bool { - bytes.isEmpty - } - - // MARK: - Path Operations - - /// Appends a path component - public func appending(_ component: BinaryPath) -> BinaryPath { - guard !component.isEmpty else { return self } - guard !self.isEmpty else { return component } - - var result = bytes - - // Add separator if needed - if !result.isEmpty && result.last != UInt8(ascii: "/") { - result.append(UInt8(ascii: "/")) - } - - // Skip leading separator in component if present - let componentBytes = component.bytes - if componentBytes.first == UInt8(ascii: "/") { - result.append(componentBytes.dropFirst()) - } else { - result.append(componentBytes) - } - - return BinaryPath(bytes: result) - } - - /// Removes the last path component - public func deletingLastPathComponent() -> BinaryPath { - guard !bytes.isEmpty else { return self } - - // Find last separator - if let lastSlash = bytes.lastIndex(of: UInt8(ascii: "/")) { - // Keep the slash if it's the root - if lastSlash == bytes.startIndex { - return BinaryPath(bytes: Data([UInt8(ascii: "/")])) - } - return BinaryPath(bytes: bytes.prefix(upTo: lastSlash)) - } - - // No separator found, return empty - return BinaryPath(bytes: Data()) - } - - /// Returns the last path component - public var lastPathComponent: BinaryPath { - guard !bytes.isEmpty else { return self } - - // Find last separator - if let lastSlash = bytes.lastIndex(of: UInt8(ascii: "/")) { - let afterSlash = bytes.index(after: lastSlash) - if afterSlash < bytes.endIndex { - return BinaryPath(bytes: bytes.suffix(from: afterSlash)) - } - return BinaryPath(bytes: Data()) - } - - // No separator, entire path is the component - return self - } - - /// Returns path components split by separator - public var components: [BinaryPath] { - guard !bytes.isEmpty else { return [] } - - var components: [BinaryPath] = [] - var current = Data() - - for byte in bytes { - if byte == UInt8(ascii: "/") { - if !current.isEmpty { - components.append(BinaryPath(bytes: current)) - current = Data() - } - } else { - current.append(byte) - } - } - - if !current.isEmpty { - components.append(BinaryPath(bytes: current)) - } - - return components - } - - // MARK: - Interop - - /// Execute a closure with a C string representation of the path - public func withCString(_ body: (UnsafePointer) throws -> T) rethrows -> T { - // Ensure null termination - var nullTerminated = bytes - if nullTerminated.isEmpty || nullTerminated.last != 0 { - nullTerminated.append(0) - } - - return try nullTerminated.withUnsafeBytes { buffer in - let cString = buffer.bindMemory(to: CChar.self).baseAddress! - return try body(cString) - } - } - - /// Create a URL if the path is valid UTF-8 - public var url: URL? { - guard let str = stringValue else { return nil } - return URL(fileURLWithPath: str) - } - - // MARK: - Comparison - - /// Lexicographic comparison of raw bytes (for deterministic sorting) - public static func < (lhs: BinaryPath, rhs: BinaryPath) -> Bool { - lhs.bytes.lexicographicallyPrecedes(rhs.bytes) - } - - // MARK: - Codable - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - - // First try to decode as String (common case) - if let string = try? container.decode(String.self) { - self.bytes = Data(string.utf8) - return - } - - // Fallback to base64-encoded Data for non-UTF8 paths - let encodedData = try container.decode(Data.self) - self.bytes = encodedData - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - - // Encode as String if valid UTF-8 - if let string = stringValue { - try container.encode(string) - } else { - // Encode as base64 Data for non-UTF8 paths - try container.encode(bytes) - } - } - - // MARK: - CustomStringConvertible - - public var description: String { - if let str = stringValue { - return str - } - return "" - } -} - -// MARK: - Convenience Extensions - -extension BinaryPath: ExpressibleByStringLiteral { - public init(stringLiteral value: String) { - self.init(string: value) - } -} - -extension BinaryPath { - /// Creates a relative path from base to self - public func relativePath(from base: BinaryPath) -> BinaryPath? { - let baseBytes = base.bytes - let selfBytes = self.bytes - - // Ensure base ends with separator for proper prefix matching - var baseWithSep = baseBytes - if !baseWithSep.isEmpty && baseWithSep.last != UInt8(ascii: "/") { - baseWithSep.append(UInt8(ascii: "/")) - } - - // Check if self starts with base - if selfBytes.starts(with: baseWithSep) { - return BinaryPath(bytes: selfBytes.dropFirst(baseWithSep.count)) - } else if selfBytes == baseBytes { - return BinaryPath(bytes: Data()) - } - - return nil - } - - /// Checks if this path has the given prefix - public func hasPrefix(_ prefix: BinaryPath) -> Bool { - bytes.starts(with: prefix.bytes) - } - - /// Checks if this path has the given suffix - public func hasSuffix(_ suffix: BinaryPath) -> Bool { - bytes.hasSuffix(suffix.bytes) - } -} - -// MARK: - Data Extension - -extension Data { - fileprivate func hasSuffix(_ suffix: Data) -> Bool { - guard count >= suffix.count else { return false } - return self.suffix(suffix.count) == suffix - } -} diff --git a/Sources/NativeBuilder/ContainerBuildSnapshotter/DiffKey.swift b/Sources/NativeBuilder/ContainerBuildSnapshotter/DiffKey.swift deleted file mode 100644 index 6d0bcbbc5..000000000 --- a/Sources/NativeBuilder/ContainerBuildSnapshotter/DiffKey.swift +++ /dev/null @@ -1,473 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import Crypto -import Foundation - -/// A canonical, Merkle-based diff key for filesystem layer reuse. -/// -/// The key is computed deterministically from the ordered set of filesystem -/// changes between (base,target). It incorporates normalized metadata and, where -/// applicable, per-entry content digests. The final key is namespaced and -/// versioned for forward compatibility. -/// -/// Encoding: single value string "sha256:" for easy persistence/interchange. -public struct DiffKey: Sendable, Hashable, Codable { - // MARK: - Constants - - /// Protocol prefix for DiffKey string representation - private static let protocolPrefix = "sha256:" - - /// Expected hex string length for SHA256 - private static let sha256HexLength = 64 - - /// Version byte for record encoding - private static let recordVersion: UInt8 = 0x01 - - /// Record type tags - private static let addedTag: UInt8 = 0x41 // 'A' - private static let modifiedTag: UInt8 = 0x4D // 'M' - private static let deletedTag: UInt8 = 0x44 // 'D' - - /// Merkle tree node type tags - private static let leafTag: UInt8 = 0x4C // 'L' - private static let innerTag: UInt8 = 0x49 // 'I' - private static let emptyTag: UInt8 = 0x45 // 'E' - - /// Domain separation prefix - private static let domainPrefix = "diffkey:v1|" - - /// Base tags for coupling - private static let scratchBaseTag = "scratch" - private static let anyBaseTag = "anybase" - - /// Empty marker for missing values - private static let missingValueMarker = "-" - - /// Prefix markers for structured fields - private static let xattrsPrefix = "xh:" - private static let contentHashPrefix = "ch:" - private static let opaquePrefix = "opq:" - - /// Node type strings - private static let regularNodeType = "reg" - private static let directoryNodeType = "dir" - private static let symlinkNodeType = "sym" - private static let deviceNodeType = "dev" - private static let fifoNodeType = "fifo" - private static let socketNodeType = "sock" - - /// Modification kind strings - private static let metadataKind = "meta" - private static let contentKind = "content" - private static let typeKind = "type" - private static let symlinkKind = "symlink" - - /// Empty tree marker - private static let emptyTreeMarker = "empty" - - // Stored as "sha256:" - private let value: String - - /// Return the canonical string form, e.g. "sha256:". - public var stringValue: String { value } - - /// Return the raw hex portion (without the "sha256:" prefix). - public var rawHex: String { - if let idx = value.firstIndex(of: ":") { - return String(value[value.index(after: idx)...]) - } - return value - } - - public init(parsing string: String) throws { - // Only accept canonical "sha256:" form to avoid ambiguity. - guard string.hasPrefix(Self.protocolPrefix) else { - throw DiffKeyError.invalidFormat("unsupported format, expected \(Self.protocolPrefix)") - } - // Basic sanity check on hex length (64 for sha256) - let hex = String(string.dropFirst(Self.protocolPrefix.count)) - guard hex.count == Self.sha256HexLength, hex.allSatisfy({ ("0"..."9").contains($0) || ("a"..."f").contains($0) }) else { - throw DiffKeyError.invalidFormat("invalid sha256 hex") - } - self.value = string - } - - public init(bytes: Data) { - self.value = "\(Self.protocolPrefix)\(Self.hex(bytes))" - } - - // MARK: - Codable - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - let s = try container.decode(String.self) - try self.init(parsing: s) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(value) - } - - // MARK: - Compute - - /// Compute a canonical DiffKey from precomputed filesystem diffs. - /// - /// On-wire per-record format (lossless, byte-oriented): - /// - Header: 0x01 (version) followed by record tag: - /// * 0x41 ('A') for Added - /// * 0x4D ('M') for Modified - /// * 0x44 ('D') for Deleted - /// - Fields: For each record-dependent field, append as: - /// [len32 big-endian][UTF-8 bytes], no separators or escaping. - /// Numeric fields (permissions, uid, gid) are encoded as decimal strings; absent values are "-". - /// Link target and content hash are "-" when absent. - /// xattrs field is a single string "xh:" where is the deterministic xattrs hash - /// computed by length-prefixing key/value pairs (see xattrsHashHex(_:)). - /// - /// Sorting + fold-hash: - /// - Sort the complete per-record byte sequences using unsigned byte lexicographic order. - /// - Leaf hash: SHA256(0x4C 'L' || recordBytes) - /// - Inner hash: SHA256(0x49 'I' || leftHash || rightHash); duplicate last leaf when odd - /// - Empty set: SHA256(0x45 'E' || "empty") - /// - Domain separate the final root by hashing with the prefix "diffkey:v1||". - /// - /// Policy and limitations: - /// - Paths are serialized as raw bytes from BinaryPath, preserving non-UTF-8 filenames exactly. - /// This ensures deterministic DiffKeys regardless of path encoding. - /// - Sockets and device nodes are excluded from DiffKey records to match typical tar emission behavior. - /// - Xattrs: values are raw bytes; keys are treated as UTF-8 strings and sorted by their UTF-8 - /// byte order, and the tar emitter must mirror this. - /// - /// - Parameters: - /// - changes: The precomputed diff entries between base and target - /// - baseDigest: Optional digest of the base snapshot; baked into the root - /// as a domain separator to couple reuse semantics to lineage. - /// - baseMount: Optional prepared mountpoint of the base; needed for deleted entry metadata - /// - targetMount: Prepared mountpoint of the target snapshot; needed for content hashing - /// - hasher: Content hasher for regular file content. - /// - coupleToBase: When false, baseTag becomes "anybase" and keys are parent-agnostic; when true (default) it uses baseDigest or "scratch". - public static func computeFromDiffs( - _ changes: [Diff], - baseDigest: ContainerBuildIR.Digest? = nil, - baseMount: URL? = nil, - targetMount: URL, - hasher: any ContentHasher = SHA256ContentHasher(), - coupleToBase: Bool = true - ) async throws -> DiffKey { - // Local helper to append a field with 4-byte big-endian length prefix - func appendField(_ string: String, to data: inout Data) { - let bytes = Data(string.utf8) - var len = UInt32(bytes.count).bigEndian - withUnsafeBytes(of: &len) { data.append(contentsOf: $0) } - data.append(bytes) - } - - // Helper to append a BinaryPath field with 4-byte big-endian length prefix - func appendPathField(_ path: BinaryPath, to data: inout Data) { - let bytes = path.rawBytes - var len = UInt32(bytes.count).bigEndian - withUnsafeBytes(of: &len) { data.append(contentsOf: $0) } - data.append(bytes) - } - - // Build canonical per-record bytes using lossless, length-prefixed binary encoding. - var records: [Data] = [] - records.reserveCapacity(changes.count) - - for change in changes { - switch change { - case .added(let a): - // Exclude socket and device nodes from DiffKey. - guard a.node != .socket, a.node != .device else { continue } - var rec = Data() - rec.append(Self.recordVersion) - rec.append(Self.addedTag) - let node = Self.nodeString(a.node) - let permsField = a.permissions.map { String($0.rawValue) } ?? Self.missingValueMarker - let uid = a.uid.map(String.init) ?? Self.missingValueMarker - let gid = a.gid.map(String.init) ?? Self.missingValueMarker - let lnk = a.linkTarget?.requireString ?? Self.missingValueMarker - let xh = Self.xattrsHashHex(a.xattrs) // hex over sorted xattrs - let ch = try await Self.contentHashHexIfNeeded( - node: a.node, - kind: .contentChanged, // additions imply content surfaced - at: targetMount.appendingPathComponent(a.path.requireString), - hasher: hasher - ) - appendPathField(a.path, to: &rec) - appendField(node, to: &rec) - appendField(permsField, to: &rec) - appendField(uid, to: &rec) - appendField(gid, to: &rec) - appendField(lnk, to: &rec) - appendField("\(Self.xattrsPrefix)\(xh)", to: &rec) - appendField("\(Self.contentHashPrefix)\(ch ?? Self.missingValueMarker)", to: &rec) - records.append(rec) - - case .modified(let m): - // Exclude socket and device nodes from DiffKey. - guard m.node != .socket, m.node != .device else { continue } - var rec = Data() - rec.append(Self.recordVersion) - rec.append(Self.modifiedTag) - let node = Self.nodeString(m.node) - let kind = Self.kindString(m.kind) - let permsField = m.permissions.map { String($0.rawValue) } ?? Self.missingValueMarker - let uid = m.uid.map(String.init) ?? Self.missingValueMarker - let gid = m.gid.map(String.init) ?? Self.missingValueMarker - let lnk = m.linkTarget?.requireString ?? Self.missingValueMarker - let xh = Self.xattrsHashHex(m.xattrs) // hex over sorted xattrs - let ch = try await Self.contentHashHexIfNeeded( - node: m.node, - kind: m.kind, - at: targetMount.appendingPathComponent(m.path.requireString), - hasher: hasher - ) - appendPathField(m.path, to: &rec) - appendField(kind, to: &rec) - appendField(node, to: &rec) - appendField(permsField, to: &rec) - appendField(uid, to: &rec) - appendField(gid, to: &rec) - appendField(lnk, to: &rec) - appendField("\(Self.xattrsPrefix)\(xh)", to: &rec) - appendField("\(Self.contentHashPrefix)\(ch ?? Self.missingValueMarker)", to: &rec) - records.append(rec) - - case .deleted(let path): - var rec = Data() - rec.append(Self.recordVersion) - rec.append(Self.deletedTag) - appendPathField(path, to: &rec) - - // Determine node type and opaqueness from base - let baseURL: URL? = { - guard let baseMount = baseMount else { return nil } - // Try to create URL from path if it's valid UTF-8 - if let pathString = path.stringValue { - return baseMount.appendingPathComponent(pathString) - } - // For non-UTF8 paths, we can't determine node info from base - return nil - }() - let (nodeType, opaque) = Self.deletedNodeInfo(at: baseURL) - - // Skip sockets and device nodes for parity with policy - if nodeType == Self.socketNodeType { continue } - if nodeType == Self.deviceNodeType { continue } - - appendField(nodeType, to: &rec) - appendField(opaque ? "\(Self.opaquePrefix)1" : "\(Self.opaquePrefix)0", to: &rec) - - records.append(rec) - } - } - - // *** Spec-compliant canonical ordering: sort by complete record bytes *** - records.sort { $0.lexicographicallyPrecedes($1) } - - // Compute leaf hashes directly from record bytes - var leaves: [Data] = [] - leaves.reserveCapacity(records.count) - for rec in records { - var h = SHA256() - h.update(data: Data([Self.leafTag])) - h.update(data: rec) - leaves.append(Data(h.finalize())) - } - - let root = Self.merkleRoot(leaves) - - // Domain separation and base coupling - var final = SHA256() - let baseTag: String = coupleToBase ? (baseDigest?.stringValue ?? Self.scratchBaseTag) : Self.anyBaseTag - let prefix = "\(Self.domainPrefix)\(baseTag)|" - if let prefixData = prefix.data(using: .utf8) { - final.update(data: prefixData) - } - final.update(data: root) - let digest = Data(final.finalize()) - return DiffKey(bytes: digest) - } - - // MARK: - Internals - - private static func nodeString(_ node: Diff.Modified.Node) -> String { - switch node { - case .regular: return regularNodeType - case .directory: return directoryNodeType - case .symlink: return symlinkNodeType - case .device: return deviceNodeType - case .fifo: return fifoNodeType - case .socket: return socketNodeType - } - } - - private static func kindString(_ kind: Diff.Modified.Kind) -> String { - switch kind { - case .metadataOnly: return metadataKind - case .contentChanged: return contentKind - case .typeChanged: return typeKind - case .symlinkTargetChanged: return symlinkKind - } - } - - /// Deterministic xattrs hashing: - /// - Sort entries by key using binary lex ordering of key UTF-8 bytes - /// - For each entry, append: len32(key) + key bytes + len32(value) + value bytes - /// - Hash the concatenated bytes with SHA-256 and return lowercase hex - /// - Empty or missing xattrs hash to SHA-256 of empty byte stream - private static func xattrsHashHex(_ xattrs: [String: Data]?) -> String { - var blob = Data() - - if let xattrs, !xattrs.isEmpty { - // Sort keys by binary lex order of UTF-8 bytes - let sortedKeys = xattrs.keys.sorted { - Data($0.utf8).lexicographicallyPrecedes(Data($1.utf8)) - } - - for k in sortedKeys { - let keyBytes = Data(k.utf8) - let valBytes = xattrs[k] ?? Data() - - // len32(key) + key - var klen = UInt32(keyBytes.count).bigEndian - withUnsafeBytes(of: &klen) { blob.append(contentsOf: $0) } - blob.append(keyBytes) - - // len32(value) + value - var vlen = UInt32(valBytes.count).bigEndian - withUnsafeBytes(of: &vlen) { blob.append(contentsOf: $0) } - blob.append(valBytes) - } - } - - var h = SHA256() - h.update(data: blob) - return hex(Data(h.finalize())) - } - - private static func contentHashHexIfNeeded( - node: Diff.Modified.Node, - kind: Diff.Modified.Kind, - at url: URL, - hasher: any ContentHasher - ) async throws -> String? { - // Only for regular files when content changes or when added. - guard node == .regular else { return nil } - guard kind == .contentChanged else { return nil } - - // Hash may throw if file disappeared; treat as no content hash if not present - if !FileManager.default.fileExists(atPath: url.path) { - return nil - } - let d = try hasher.hash(fileURL: url) - return hex(d) // works for Data or [UInt8] via overloads - } - - private static func merkleRoot(_ leaves: [Data]) -> Data { - switch leaves.count { - case 0: - // Empty diff still produces a deterministic key - var h = SHA256() - h.update(data: Data([emptyTag])) - h.update(data: Data(emptyTreeMarker.utf8)) - return Data(h.finalize()) - case 1: - return leaves[0] - default: - var level = leaves - while level.count > 1 { - var next: [Data] = [] - next.reserveCapacity((level.count + 1) / 2) - var i = 0 - while i < level.count { - let left = level[i] - let right = (i + 1 < level.count) ? level[i + 1] : level[i] // duplicate last if odd - var h = SHA256() - h.update(data: Data([innerTag])) - h.update(data: left) - h.update(data: right) - next.append(Data(h.finalize())) - i += 2 - } - level = next - } - return level[0] - } - } - - private static func deletedNodeInfo(at url: URL?) -> (String, Bool) { - guard let url = url else { return (missingValueMarker, false) } - var st = stat() - let ok: Bool = url.withUnsafeFileSystemRepresentation { cPath in - guard let cPath else { return false } - return lstat(cPath, &st) == 0 - } - if !ok { - return (missingValueMarker, false) - } - // Map st_mode to our node string - let mode = st.st_mode - let typeBits = mode & S_IFMT - let nodeType: String - switch typeBits { - case S_IFREG: nodeType = regularNodeType - case S_IFDIR: nodeType = directoryNodeType - case S_IFLNK: nodeType = symlinkNodeType - case S_IFCHR, S_IFBLK: nodeType = deviceNodeType - case S_IFIFO: nodeType = fifoNodeType - case S_IFSOCK: nodeType = socketNodeType - default: nodeType = missingValueMarker - } - var opaque = false - if nodeType == directoryNodeType { - opaque = baseDirectoryHadChildren(at: url) - } - return (nodeType, opaque) - } - - private static func baseDirectoryHadChildren(at url: URL) -> Bool { - // Returns true if directory exists and has at least one entry (excluding "." and "..") - if let children = try? FileManager.default.contentsOfDirectory(atPath: url.path) { - return !children.isEmpty - } - return false - } - - // MARK: - Hex helpers (overloads for Data and [UInt8]) - - private static func hex(_ data: Data) -> String { - data.map { String(format: "%02x", $0) }.joined() - } - - private static func hex(_ bytes: [UInt8]) -> String { - bytes.map { String(format: "%02x", $0) }.joined() - } -} - -public enum DiffKeyError: Error, CustomStringConvertible { - case invalidFormat(String) - - public var description: String { - switch self { - case .invalidFormat(let m): return "DiffKey invalid format: \(m)" - } - } -} diff --git a/Sources/NativeBuilder/ContainerBuildSnapshotter/Differ.swift b/Sources/NativeBuilder/ContainerBuildSnapshotter/Differ.swift deleted file mode 100644 index 8a3d17648..000000000 --- a/Sources/NativeBuilder/ContainerBuildSnapshotter/Differ.swift +++ /dev/null @@ -1,198 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import ContainerizationOCI -import Foundation - -/// A protocol for computing and storing filesystem diffs -/// -/// The Differ is responsible for: -/// 1. Computing the delta between two filesystem states -/// 2. Serializing that delta into an OCI-compliant layer format -/// 3. Storing the layer to a content store -/// 4. Returning a descriptor that can be used in OCI manifests -public protocol Differ: Sendable { - /// The content store where diffs will be stored - var contentStore: any ContentStore { get } - - /// Compute the difference between two snapshots and store it. - /// - /// This method performs the complete diff workflow: - /// 1. Computes filesystem changes between base and target - /// 2. Creates a tar archive of those changes - /// 3. Applies the specified compression format - /// 4. Stores the result to the content store - /// 5. Returns a descriptor suitable for OCI manifests - /// - /// - Parameters: - /// - base: The base snapshot (nil for initial/scratch layers) - /// - target: The target snapshot to diff against base - /// - format: The compression format to use for the layer - /// - Returns: A Descriptor containing the descriptor and statistics - /// - Throws: If diff computation or storage fails - func diff( - base: Snapshot?, - target: Snapshot - ) async throws -> Descriptor - - /// Apply a stored diff to a base snapshot to produce a target. - /// - /// This is the inverse operation of computeAndStore, used when: - /// - Materializing snapshots from cached layers - /// - Applying patches during incremental builds - /// - Validating diff correctness - /// - /// - Parameters: - /// - descriptor: The descriptor of the stored diff - /// - base: The base snapshot to apply the diff to (nil for scratch) - /// - Returns: The resulting snapshot after applying the diff - /// - Throws: If the diff cannot be applied - func apply( - descriptor: Descriptor, - to base: Snapshot? - ) async throws -> Snapshot -} - -/// Diff represents a filesystem diff entry. -/// -/// - Additions and deletions only need the path previously, but we now surface -/// normalized attributes for both additions and modifications to avoid re-reading -/// from the OS during archive creation. -public enum Diff: Sendable, Equatable { - /// Details for an addition entry with surfaced attributes. - public struct Added: Sendable, Equatable { - public let path: BinaryPath - public let node: Modified.Node - public let permissions: FilePermissions? - public let size: Int64? - public let modificationTime: Date? - public let linkTarget: BinaryPath? - public let uid: UInt32? - public let gid: UInt32? - public let xattrs: [String: Data]? - public let devMajor: UInt32? - public let devMinor: UInt32? - public let nlink: UInt64? - - public init( - path: BinaryPath, - node: Modified.Node, - permissions: FilePermissions?, - size: Int64?, - modificationTime: Date?, - linkTarget: BinaryPath?, - uid: UInt32?, - gid: UInt32?, - xattrs: [String: Data]?, - devMajor: UInt32?, - devMinor: UInt32?, - nlink: UInt64? - ) { - self.path = path - self.node = node - self.permissions = permissions - self.size = size - self.modificationTime = modificationTime - self.linkTarget = linkTarget - self.uid = uid - self.gid = gid - self.xattrs = xattrs - self.devMajor = devMajor - self.devMinor = devMinor - self.nlink = nlink - } - } - - /// Details for a modification entry. - public struct Modified: Sendable, Equatable { - /// The kind of modification detected. Derived from FileDiffResult. - public enum Kind: Sendable, Equatable { - case metadataOnly - case contentChanged - case typeChanged - case symlinkTargetChanged - } - - /// Kind of filesystem node (target state). - public enum Node: Sendable, Equatable { - case regular - case directory - case symlink - case device - case fifo - case socket - } - - public let path: BinaryPath - public let kind: Kind - public let node: Node - public let permissions: FilePermissions? - public let size: Int64? - public let modificationTime: Date? - public let linkTarget: BinaryPath? - public let uid: UInt32? - public let gid: UInt32? - public let xattrs: [String: Data]? - public let devMajor: UInt32? - public let devMinor: UInt32? - public let nlink: UInt64? - - public init( - path: BinaryPath, - kind: Kind, - node: Node, - permissions: FilePermissions?, - size: Int64?, - modificationTime: Date?, - linkTarget: BinaryPath?, - uid: UInt32?, - gid: UInt32?, - xattrs: [String: Data]?, - devMajor: UInt32?, - devMinor: UInt32?, - nlink: UInt64? - ) { - self.path = path - self.kind = kind - self.node = node - self.permissions = permissions - self.size = size - self.modificationTime = modificationTime - self.linkTarget = linkTarget - self.uid = uid - self.gid = gid - self.xattrs = xattrs - self.devMajor = devMajor - self.devMinor = devMinor - self.nlink = nlink - } - } - - case added(Added) - case modified(Modified) - case deleted(path: BinaryPath) -} - -/// POSIX file permission bits -public struct FilePermissions: Equatable, Sendable { - public let rawValue: UInt16 - public init(rawValue: UInt16) { self.rawValue = rawValue } -} - -public protocol ContentHasher: Sendable { - func hash(fileURL: URL) throws -> Data -} diff --git a/Sources/NativeBuilder/ContainerBuildSnapshotter/FileContentDiffer.swift b/Sources/NativeBuilder/ContainerBuildSnapshotter/FileContentDiffer.swift deleted file mode 100644 index 572787a34..000000000 --- a/Sources/NativeBuilder/ContainerBuildSnapshotter/FileContentDiffer.swift +++ /dev/null @@ -1,65 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 Crypto -import Foundation - -public enum FileContentDiffResult: Equatable { - case attributeOnly - case contentChanged -} - -/// Compares regular file byte content using a ContentHasher. -/// Notes: -/// - For symlinks and special files, treat as attribute-only; symlink target comparison is metadata. -/// - If either URL is nil (addition or deletion), treat as contentChanged. -public struct FileContentDiffer: Sendable { - private let hasher: any ContentHasher - - public init(hasher: any ContentHasher = SHA256ContentHasher()) { - self.hasher = hasher - } - - public func diff(oldURL: URL?, newURL: URL?, attributesOnly: Bool = false) throws -> FileContentDiffResult { - if attributesOnly { return .attributeOnly } - guard let lhs = oldURL, let rhs = newURL else { return .contentChanged } - let a = try hasher.hash(fileURL: lhs) - let b = try hasher.hash(fileURL: rhs) - return a == b ? .attributeOnly : .contentChanged - } -} - -public struct SHA256ContentHasher: ContentHasher { - - public init() {} - - public func hash(fileURL: URL) throws -> Data { - let handle = try FileHandle(forReadingFrom: fileURL) - defer { try? handle.close() } - - var hasher = SHA256() - let chunkSize = 4 * 1024 * 1024 // 4 MB - - while true { - let data = try handle.read(upToCount: chunkSize) ?? Data() - if data.isEmpty { break } - hasher.update(data: data) - } - - let digest = hasher.finalize() - return Data(digest) - } -} diff --git a/Sources/NativeBuilder/ContainerBuildSnapshotter/Snapshotter.swift b/Sources/NativeBuilder/ContainerBuildSnapshotter/Snapshotter.swift deleted file mode 100644 index ce1351bd9..000000000 --- a/Sources/NativeBuilder/ContainerBuildSnapshotter/Snapshotter.swift +++ /dev/null @@ -1,42 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import Foundation - -/// Manages filesystem snapshots during build execution. -/// -/// The snapshotter is responsible for creating and managing filesystem -/// snapshots that represent the state at different points in the build. -public protocol Snapshotter: Sendable { - /// Prepare a snapshot for use (e.g., mount it). - /// - /// - Parameter snapshot: The snapshot to prepare - /// - Returns: The prepared snapshot - func prepare(_ snapshot: Snapshot) async throws -> Snapshot - - /// Commit a snapshot, making it permanent. Returns a new Snapshot - /// that is base + changes - /// - /// - Parameter snapshot: The snapshot to commit - /// - Returns: The committed snapshot with final digest - func commit(_ snapshot: Snapshot) async throws -> Snapshot - - /// Remove a snapshot. - /// - /// - Parameter snapshot: The snapshot to remove - func remove(_ snapshot: Snapshot) async throws -} diff --git a/Sources/NativeBuilder/ContainerBuildSnapshotter/Types.swift b/Sources/NativeBuilder/ContainerBuildSnapshotter/Types.swift deleted file mode 100644 index ded9a8260..000000000 --- a/Sources/NativeBuilder/ContainerBuildSnapshotter/Types.swift +++ /dev/null @@ -1,193 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import Foundation - -/// A filesystem snapshot representing state at a point in the build. -public final class Snapshot: Sendable, Codable { - /// Unique identifier for this snapshot. - public let id: UUID - - /// The digest of the snapshot content. - public let digest: Digest - - /// Size of the snapshot in bytes. - public let size: Int64 - - /// Parent snapshot (if any). Must be in committed state to serve as base. - public let parent: Snapshot? - - /// Timestamp when the snapshot was created. - public let createdAt: Date - - /// The current state of the snapshot. - public let state: SnapshotState - - /// Represents the lifecycle state of a snapshot. - /// - /// A snapshot transitions through different states during its lifecycle: - /// - `prepared`: The snapshot is ready for operations to be performed on it. - /// - `inProgress`: The snapshot is currently being modified by an operation. - /// - `committed`: The snapshot has been finalized and is immutable. - public enum SnapshotState: Sendable, Codable, Equatable { - /// The snapshot is ready for operations to be performed on it. - /// This is the initial state after a snapshot is created or prepared. - /// - Parameter mountpoint: The URL where the snapshot filesystem is mounted and accessible - case prepared(mountpoint: URL) - - /// The snapshot is currently being modified by an operation. - /// - Parameter operationId: The UUID identifying the specific operation modifying the snapshot. - case inProgress(operationId: UUID) - - /// The snapshot has been finalized and is immutable. - /// No further modifications can be made to a committed snapshot. - /// - Parameters: - /// - layerDigest: Optional digest of the tar layer produced (for tar-based snapshotters) - /// - layerSize: Optional size of the tar layer in bytes - /// - layerMediaType: Optional media type of the layer (e.g., "application/vnd.oci.image.layer.v1.tar+gzip") - /// - diffKey: Optional key for layer deduplication in cache - case committed(layerDigest: String? = nil, layerSize: Int64? = nil, layerMediaType: String? = nil, diffKey: DiffKey? = nil) - - // MARK: - Helper Properties - - /// Returns true if the snapshot is in the prepared state. - public var isPrepared: Bool { - if case .prepared = self { - return true - } - return false - } - - /// Returns the mountpoint URL if the snapshot is prepared, nil otherwise. - public var mountpoint: URL? { - if case .prepared(let mountpoint) = self { - return mountpoint - } - return nil - } - - /// Returns true if the snapshot is in the inProgress state. - public var isInProgress: Bool { - if case .inProgress = self { - return true - } - return false - } - - /// Returns true if the snapshot is in the committed state. - public var isCommitted: Bool { - if case .committed = self { - return true - } - return false - } - - /// Returns the layer digest if the snapshot is committed with layer info, nil otherwise. - public var layerDigest: String? { - if case .committed(let digest, _, _, _) = self { - return digest - } - return nil - } - - /// Returns the layer size if the snapshot is committed with layer info, nil otherwise. - public var layerSize: Int64? { - if case .committed(_, let size, _, _) = self { - return size - } - return nil - } - - /// Returns the layer media type if the snapshot is committed with layer info, nil otherwise. - public var layerMediaType: String? { - if case .committed(_, _, let mediaType, _) = self { - return mediaType - } - return nil - } - - /// Returns the diff key if the snapshot is committed with layer info, nil otherwise. - public var diffKey: DiffKey? { - if case .committed(_, _, _, let key) = self { - return key - } - return nil - } - - /// Returns the operation ID if the snapshot is in progress, nil otherwise. - public var operationID: UUID? { - if case .inProgress(operationId: let id) = self { - return id - } - return nil - } - - // MARK: - Semantic Helper Properties - - /// Can this snapshot be modified? - /// Returns true only if the snapshot is in the prepared state. - public var canExecute: Bool { - if case .prepared = self { - return true - } - return false - } - - /// Is this snapshot finalized? - /// Returns true only if the snapshot is in the committed state. - public var isFinalized: Bool { - if case .committed = self { - return true - } - return false - } - - /// Is this snapshot locked for modification? - /// Returns true if the snapshot is currently being modified by an operation. - public var isLocked: Bool { - if case .inProgress = self { - return true - } - return false - } - } - - public init( - id: UUID = UUID(), - digest: Digest, - size: Int64, - parent: Snapshot? = nil, - createdAt: Date = Date(), - state: SnapshotState - ) { - self.id = id - self.digest = digest - self.size = size - self.parent = parent - self.createdAt = createdAt - self.state = state - } -} - -/// Resource limits used by the snapshotter to bound memory/IO. -public struct ResourceLimits: Sendable { - public var maxInFlightBytes: Int64 - - public init(maxInFlightBytes: Int64 = 64 * 1024 * 1024) { - self.maxInFlightBytes = maxInFlightBytes - } -} diff --git a/Sources/NativeBuilder/README.md b/Sources/NativeBuilder/README.md deleted file mode 100644 index 4a9f13f29..000000000 --- a/Sources/NativeBuilder/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Swift Native Builder - -A pure-Swift container build system leveraging Containerization.framework for fast, secure, and reproducible builds. - -## Overview - -Swift Native Builder is a modern container build system that replaces traditional container builders with a Swift-native implementation. It runs each build step in isolated VMs, uses content-addressable storage for caching, and produces OCI-compliant container images. - -### Key Features - -- **Headless** - No daemon required, runs as a simple CLI tool -- **Mac-Native** - Built on Containerization.framework and Swift Concurrency -- **Secure** - Hardware-backed signing with Secure Enclave, biometric-protected secrets -- **Fast** - Parallel execution with intelligent caching - -## Quick Start - -```bash -# Build a container image -swift run builder build . - -# Build with secrets -swift run builder secret add github-token --biometric -swift run builder build --build-secret=id=github-token,target=/run/secrets/token . -``` - -## Architecture - -The project consists of several components: - -- **ContainerBuildIR** - Intermediate representation for build operations -- **Parser** - Dockerfile parser and build graph generator -- **Scheduler** - DAG scheduler for parallel execution -- **Executor** - VM-based execution engine -- **CAS** - Content-addressable storage for layers - -## Development - -```bash -# Build the project -swift build - -# Run tests -swift test - -# Run demo -swift run container-build-demo -``` - -## Status - -This project is under active development. The IR layer is implemented and functional. \ No newline at end of file diff --git a/Sources/NativeBuilder/docs/ContainerBuildCache/Architecture.md b/Sources/NativeBuilder/docs/ContainerBuildCache/Architecture.md deleted file mode 100644 index 10073cd9c..000000000 --- a/Sources/NativeBuilder/docs/ContainerBuildCache/Architecture.md +++ /dev/null @@ -1,313 +0,0 @@ -# ContainerBuildCache Architecture - -This document outlines the high-level architecture of ContainerBuildCache, focusing on system design, component interaction, and data flow patterns. - -## Overview - -The cache is designed around three key layers: - -1. **BuildCache API Layer** - Public interface matching the BuildCache protocol -2. **Content-Based Cache Layer** - Manages cache entries as OCI artifacts with metadata indexing -3. **ContentStore** - Provides reliable, content-addressable storage with built-in deduplication - -### Design Principles - -- **Simplicity** - Clean separation of concerns with ContentStore handling storage complexity -- **Reliability** - Leverages ContentStore's atomic operations and content verification -- **Performance** - Content-addressable lookups with automatic deduplication and compression -- **Scalability** - Lightweight index with support for sharding and distributed storage -- **Maintainability** - Standard OCI artifact format with minimal custom code - -## High-Level Architecture - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ BuildCache API │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ get() │ │ put() │ │ statistics()│ │ -│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ -│ │ │ │ │ -├─────────┴─────────────────┴─────────────────┴───────────────────┤ -│ BuildCache Implementation │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ Content-Based Cache Layer │ │ -│ │ ┌───────────┐ ┌──────────────┐ ┌────────────────┐ │ │ -│ │ │ Index │ │ Manifest │ │ Compressor │ │ │ -│ │ │ Manager │ │ Builder │ │ Engine │ │ │ -│ │ └─────┬─────┘ └──────┬───────┘ └────────┬───────┘ │ │ -│ │ │ │ │ │ │ -│ └────────┴────────────────┴────────────────────┴──────────┘ │ -│ │ │ -├────────────────────────────┴────────────────────────────────────┤ -│ ContentStore │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ Content-Addressable Storage (CAS) │ │ -│ │ - Deduplication │ │ -│ │ - Atomic Operations │ │ -│ │ - Content Verification │ │ -│ └─────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -## Component Responsibilities - -### 1. BuildCache API Layer -- **Purpose**: Provides the public interface matching the BuildCache protocol -- **Responsibilities**: - - Input validation and sanitization - - Error handling and user-friendly error messages - - API versioning and backward compatibility - - Metrics collection and logging - -### 2. Content-Based Cache Layer -- **Purpose**: Manages cache entries as OCI-compliant artifacts with metadata indexing -- **Components**: - - **Index Manager**: SQLite-based metadata storage for fast lookups - - **Manifest Builder**: Creates OCI-compliant manifests for cache entries - - **Compressor Engine**: Handles compression/decompression of cache data - -### 3. ContentStore -- **Purpose**: Provides reliable, content-addressable storage with built-in deduplication -- **Features**: - - Content-addressable storage (CAS) with SHA256 addressing - - Atomic operations ensuring consistency - - Built-in content verification and integrity checking - - Automatic deduplication of identical content - -## Data Flow Patterns - -### Cache PUT Operation Flow - -``` -Client BuildCache ContentStore Index - │ │ │ │ - ├─put(result, key)────>│ │ │ - │ │ │ │ - │ ├─1. Generate digest │ │ - │ │ from cache key │ │ - │ │ │ │ - │ ├─2. Serialize components │ │ - │ │ - Snapshot │ │ - │ │ - Environment │ │ - │ │ - Metadata │ │ - │ │ │ │ - │ ├─3. Store blobs─────────>│ │ - │ │<──────blob digests──────│ │ - │ │ │ │ - │ ├─4. Create manifest │ │ - │ │ │ │ - │ ├─5. Store manifest──────>│ │ - │ │<────manifest digest─────│ │ - │ │ │ │ - │ ├─6. Update index────────────────────────────>│ - │ │<───────────────────────────────success──────│ - │ │ │ │ - │<────────success──────│ │ │ -``` - -### Cache GET Operation Flow - -``` -Client BuildCache ContentStore Index - │ │ │ │ - ├─get(key)────────────>│ │ │ - │ │ │ │ - │ ├─1. Generate digest │ │ - │ │ from cache key │ │ - │ │ │ │ - │ ├─2. Lookup in index─────────────────────────>│ - │ │<───────────────────────entry metadata───────│ - │ │ │ │ - │ ├─3. Fetch manifest──────>│ │ - │ │<────manifest data───────│ │ - │ │ │ │ - │ ├─4. Fetch layers────────>│ │ - │ │<─────layer data─────────│ │ - │ │ │ │ - │ ├─5. Reconstruct result │ │ - │ │ │ │ - │ ├─6. Update access time──────────────────────>│ - │ │ │ │ - │<────CachedResult─────│ │ │ -``` -## Cache Key Generation - -Cache keys are deterministically generated from operation characteristics: - -``` -CacheKey = SHA256( - version || - operation_digest || - sorted(input_digests) || - normalized_platform || - operation_type || - operation_content -) -``` - -This ensures that identical operations with the same inputs produce the same cache key, enabling reliable cache hits across different build environments. - -## Cache Entry Architecture - -### OCI Artifact Structure - -Each cache entry follows the OCI artifact specification: - -``` -Cache Entry (OCI Artifact) -├── Manifest (JSON) -│ ├── schemaVersion: 2 -│ ├── mediaType: "application/vnd.container-build.cache.manifest.v2+json" -│ ├── config: CacheConfig -│ │ ├── cacheKey: SerializedCacheKey -│ │ ├── operationType: String -│ │ ├── platform: Platform -│ │ └── buildVersion: String -│ └── layers: [ -│ ├── Layer 1: Snapshot Data -│ │ ├── mediaType: "application/vnd.container-build.snapshot.v1+json" -│ │ ├── digest: "sha256:..." -│ │ └── size: Int64 -│ ├── Layer 2: Environment Changes (optional) -│ │ ├── mediaType: "application/vnd.container-build.environment.v1+json" -│ │ ├── digest: "sha256:..." -│ │ └── size: Int64 -│ └── Layer 3: Metadata (optional) -│ ├── mediaType: "application/vnd.container-build.metadata.v1+json" -│ ├── digest: "sha256:..." -│ └── size: Int64 -│ ] -└── Content Blobs - ├── Snapshot blob (compressed) - ├── Environment blob (if present) - └── Metadata blob (if present) -``` - -### Index Architecture - -The SQLite index provides fast metadata access: - -```sql --- Cache entries table -CREATE TABLE cache_entries ( - digest TEXT PRIMARY KEY, - created_at INTEGER NOT NULL, - last_accessed_at INTEGER NOT NULL, - access_count INTEGER DEFAULT 1, - total_size INTEGER NOT NULL, - platform_os TEXT NOT NULL, - platform_arch TEXT NOT NULL, - operation_type TEXT NOT NULL -); - --- Indexes for efficient queries -CREATE INDEX idx_lru ON cache_entries(last_accessed_at); -CREATE INDEX idx_age ON cache_entries(created_at); -CREATE INDEX idx_platform ON cache_entries(platform_os, platform_arch); -CREATE INDEX idx_size ON cache_entries(total_size); -``` - -## Eviction Architecture - -### Eviction Manager - -``` -┌─────────────────────────────────────────┐ -│ Eviction Manager │ -│ │ -│ 1. Check trigger conditions: │ -│ - Total size > maxSize │ -│ - Entry age > maxAge │ -│ - Manual trigger │ -│ │ -│ 2. Select victims: │ -│ - Query index by policy │ -│ - Build eviction list │ -│ │ -│ 3. Execute eviction: │ -│ - Remove from ContentStore │ -│ - Update index │ -│ - Log metrics │ -└─────────────────────────────────────────┘ -``` - -### Eviction Policies - -- **LRU (Least Recently Used)**: Evicts entries with oldest access time -- **LFU (Least Frequently Used)**: Evicts entries with lowest access count -- **FIFO (First In First Out)**: Evicts entries with oldest creation time -- **TTL (Time To Live)**: Evicts entries older than specified age -- **ARC (Adaptive Replacement Cache)**: Adaptive policy balancing recency and frequency - -## Concurrency Model - -### Actor-Based Design - -```swift -public actor ContentAddressableCache: BuildCache { - private let contentStore: ContentStore - private let index: CacheIndex - private let configuration: CacheConfiguration - - // All operations are serialized through the actor - public func get(_ key: CacheKey, for operation: ContainerBuildIR.Operation) async -> CachedResult? - public func put(_ result: CachedResult, key: CacheKey, for operation: ContainerBuildIR.Operation) async - public func statistics() async -> CacheStatistics -} -``` - -### Parallel Operations - -- **Layer Storage**: Multiple layers can be stored concurrently -- **Layer Retrieval**: Parallel fetching of cache entry layers -- **Background Cleanup**: Eviction runs in background tasks -- **Index Updates**: Batched for improved performance - -## Error Handling Strategy - -### Graceful Degradation - -1. **Index Corruption**: Rebuild from ContentStore manifests -2. **ContentStore Errors**: Fall back to cache miss behavior -3. **Partial Cache Entries**: Clean up orphaned data automatically -4. **Disk Space Issues**: Trigger aggressive eviction - -### Recovery Mechanisms - -- **Orphan Cleanup**: Remove index entries without corresponding ContentStore data -- **Consistency Checks**: Periodic validation of index vs ContentStore state -- **Automatic Repair**: Self-healing for common corruption scenarios - -## Performance Characteristics - -### Time Complexity - -- **Cache Lookup**: O(1) - Direct content-addressable access -- **Cache Storage**: O(1) - Parallel layer storage -- **Eviction Query**: O(log n) - Indexed database queries -- **Index Updates**: O(1) - Single row operations - -### Space Complexity - -- **Deduplication**: Automatic content deduplication in ContentStore -- **Compression**: Configurable compression levels for space/CPU tradeoff -- **Index Overhead**: Minimal metadata storage in SQLite - -This architecture provides a robust, scalable foundation for build caching while maintaining simplicity and leveraging proven storage technologies. - -## Benefits - -### Reliability -- **Atomic Operations** - ContentStore ensures crash-safe updates -- **Content Verification** - Built-in integrity checking prevents corruption -- **Deduplication** - Automatic space savings for identical content - -### Performance -- **O(1) Lookups** - Content-addressable storage enables fast retrieval -- **Parallel Operations** - Concurrent layer fetching and storage -- **Compression** - Reduces I/O overhead and storage requirements - -### Maintainability -- **Standard Format** - OCI artifacts are well-understood and toolable -- **Clear Data Model** - Explicit separation of concerns -- **Minimal Custom Code** - Leverages proven ContentStore implementation diff --git a/Sources/NativeBuilder/docs/ContainerBuildExecutor/Architecture.md b/Sources/NativeBuilder/docs/ContainerBuildExecutor/Architecture.md deleted file mode 100644 index 77637be68..000000000 --- a/Sources/NativeBuilder/docs/ContainerBuildExecutor/Architecture.md +++ /dev/null @@ -1,177 +0,0 @@ -# ContainerBuildExecutor Architecture - -## Overview - -The ContainerBuildExecutor implements a clean, production-ready execution layer for ContainerBuildIR using the **Executor Pattern** rather than a complex scheduler. - -## Architecture - -### Core Components - -1. **BuildExecutor** - The main orchestrator that executes complete build graphs -2. **OperationExecutor** - Executes individual operations (pluggable for different operation types) -3. **ExecutionContext** - Carries mutable state through the build process -4. **ExecutionDispatcher** - Routes operations to appropriate executors based on capabilities -5. **BuildCache** - Caches operation results to avoid redundant work -6. **Snapshotter** - Manages filesystem snapshots for layer creation - -### Design Principles - -- **Simplicity** - Clean interfaces with minimal complexity -- **Extensibility** - Easy to add new operation types and executors -- **Performance** - Parallel execution where possible, with efficient caching -- **Type Safety** - Leverages Swift's type system for correctness - -## Execution Flow - -```mermaid -graph TD - A[BuildGraph] --> B[SimpleExecutor] - B --> C[Stage Ordering] - C --> D[For Each Stage] - D --> E[Topological Sort] - E --> F[For Each Node] - F --> G{Check Cache} - G -->|Hit| H[Return Cached Result] - G -->|Miss| I[ExecutionDispatcher] - I --> J[Find Suitable Executor] - J --> K[Execute Operation] - K --> L[Update Context] - L --> M[Cache Result] - M --> N[Next Node] -``` - -## Capability-Based Routing - -The dispatcher matches operations to executors based on: - -1. **Operation Type** - Does the executor support this operation kind? -2. **Platform** - Can the executor handle the target platform? -3. **Privileges** - Does the executor have required privileges? -4. **Resources** - Are resource requirements satisfied? - -## Concurrency Model - -### Stage-Level Execution - -Stages are executed sequentially to respect dependencies: - -```swift -for stage in graph.stagesInDependencyOrder() { - let context = ExecutionContext(stage: stage, ...) - let snapshot = try await executeStage(stage, context: context) - stageSnapshots[stage.name] = snapshot -} -``` - -### Node-Level Parallelism - -Within a stage, independent nodes execute concurrently: - -```swift -let levels = try GraphTraversal.topologicalLevels(stage) -for level in levels { - await withTaskGroup(of: ExecutionResult.self) { group in - for node in level { - group.addTask { - try await executeNode(node, context) - } - } - } -} -``` - -## State Management - -### Snapshot Evolution - -Each operation produces a new filesystem snapshot: - -``` -Initial Snapshot (S0) - ↓ -Operation 1 → Snapshot S1 - ↓ -Operation 2 → Snapshot S2 - ↓ -Operation 3 → Snapshot S3 (Final) -``` - -### Environment Propagation - -Environment changes cascade through operations: - -```swift -context.updateEnvironment(["FOO": .literal("bar")]) -// This update is visible to all subsequent operations in the stage -``` - -## Caching Strategy - -### Cache Key Generation - -Cache keys include: -- Operation digest (content hash) -- Input digests (from dependencies) -- Platform identifier -- Additional context - -### Cache Lookup Flow - -1. Compute cache key for operation -2. Check cache for existing result -3. If hit: skip execution, use cached result -4. If miss: execute operation, store result - -## Error Handling - -Errors are categorized and handled appropriately: - -- **Unsupported Operations** - No executor can handle the operation -- **Resource Constraints** - Requirements cannot be satisfied -- **Execution Failures** - Operation failed during execution -- **Cancellation** - Build was cancelled by user - -## Extensibility Points - -### Adding New Operation Types - -1. Define the operation in ContainerBuildIR -2. Create a specific executor implementing `OperationExecutor` -3. Register the executor with the dispatcher - -### Custom Caching - -Implement the `BuildCache` protocol: - -```swift -public protocol BuildCache: Sendable { - func get(_ key: CacheKey, for operation: Operation) async -> CachedResult? - func put(_ result: ExecutionResult, key: CacheKey, for operation: Operation) async -} -``` - -### Alternative Snapshotters - -Implement the `Snapshotter` protocol for different backends: - -```swift -public protocol Snapshotter: Sendable { - func createSnapshot(from parent: Snapshot?, applying changes: FilesystemChanges) async throws -> Snapshot - func prepare(_ snapshot: Snapshot) async throws -> SnapshotHandle -} -``` - -## Performance Considerations - -1. **Lazy Snapshot Creation** - Only create snapshots when filesystem changes occur -2. **Parallel Execution** - Maximize concurrency within dependency constraints -3. **Efficient Caching** - Cache keys designed for fast lookup -4. **Resource Pooling** - Reuse expensive resources like container instances - -## Future Enhancements - -1. **Distributed Execution** - Execute operations across multiple machines -2. **Incremental Builds** - Skip unchanged portions of the graph -3. **Progress Reporting** - Real-time feedback during execution -4. **Resource Monitoring** - Track CPU, memory, and I/O usage \ No newline at end of file diff --git a/Sources/NativeBuilder/docs/ContainerBuildIR/Graph.md b/Sources/NativeBuilder/docs/ContainerBuildIR/Graph.md deleted file mode 100644 index 9a512b757..000000000 --- a/Sources/NativeBuilder/docs/ContainerBuildIR/Graph.md +++ /dev/null @@ -1,238 +0,0 @@ -# Build Graph Architecture - -The ContainerBuildIR build graph is a directed acyclic graph (DAG) that represents the sequence of operations needed to build a container image. This document explains the design decisions, tradeoffs, and implementation details. - -## Design Overview - -### Core Structure - -```swift -BuildGraph -├── stages: [BuildStage] -├── targetStage: BuildStage? -├── buildArgs: [String: BuildArg] -└── targetPlatforms: [Platform] - -BuildStage -├── name: String? -├── base: ImageOperation -├── nodes: [BuildNode] -└── platform: Platform? - -BuildNode -├── id: UUID -├── operation: Operation -└── dependencies: Set -``` - -### Design Rationale - -#### 1. Stage-Based Organization - -**Why**: Container builds naturally organize into stages (multi-stage builds), where each stage can: -- Start from a different base image -- Be referenced by other stages -- Produce intermediate artifacts - -**Tradeoff**: Adds complexity compared to a flat operation list, but enables: -- Clear separation of build phases -- Efficient layer caching strategies -- Support for `COPY --from` patterns - -#### 2. UUID-Based Node Identity - -**Why**: Using UUIDs for node identification provides: -- Guaranteed uniqueness without coordination -- Stable references across graph transformations -- No naming conflicts - -**Tradeoff**: Less human-readable than string names, but ensures correctness in complex graphs. - -#### 3. Explicit Dependencies - -**Why**: Each node explicitly declares its dependencies rather than relying on insertion order: -- Enables parallel execution of independent operations -- Makes the graph self-documenting -- Simplifies graph analysis and optimization - -**Tradeoff**: Requires explicit dependency management, but prevents implicit ordering bugs. - -## Graph Construction - -### Using GraphBuilder - -The `GraphBuilder` provides a fluent API for constructing graphs: - -```swift -// Single-stage build -let graph = try GraphBuilder.singleStage( - from: ImageReference(parsing: "ubuntu:22.04")!, - platform: .linuxAMD64 -) { builder in - builder - .run("apt-get update") - .run("apt-get install -y python3") - .workdir("/app") - .copyFromContext(["*.py"], to: "/app/") - .cmd(Command.exec(["python3", "app.py"])) -} -``` - -## Dependency Management - -### Automatic Dependencies - -The GraphBuilder automatically manages dependencies based on operation order: - -```swift -builder - .run("command1") // No dependencies - .run("command2") // Depends on command1 - .run("command3") // Depends on command2 -``` - -### Cross-Stage Dependencies - -Dependencies between stages are tracked through stage references: - -```swift -// This creates an implicit dependency on the "builder" stage -.copyFromStage(.named("builder"), paths: ["/app"], to: "/") -``` - -### Parallel Operations - -Operations without dependencies can execute in parallel: - -```swift -// These operations have no interdependencies -let node1 = BuildNode(operation: op1, dependencies: []) -let node2 = BuildNode(operation: op2, dependencies: []) -let node3 = BuildNode(operation: op3, dependencies: [node1.id, node2.id]) -// node1 and node2 can run in parallel, node3 waits for both -``` - -## Graph Analysis - -### Traversal Utilities - -The framework provides utilities for graph analysis: - -```swift -// Topological sort for execution order -let executionOrder = try GraphTraversal.topologicalSort(stage) - -// Find entry points (nodes with no dependencies) -let roots = GraphTraversal.findRoots(in: stage) - -// Find terminal nodes -let leaves = GraphTraversal.findLeaves(in: stage) - -// Check for cycles -GraphTraversal.detectCycles(in: stage) // Throws if cycles exist -``` - -### Visitor Pattern - -Use the visitor pattern to analyze or transform the graph: - -```swift -class DependencyAnalyzer: OperationVisitor { - private var packageCommands: [String] = [] - - func visit(_ operation: ExecOperation) { - if case .shell(let cmd) = operation.command, - cmd.contains("apt-get install") || cmd.contains("pip install") { - packageCommands.append(cmd) - } - } -} - -// Apply visitor to all operations -let analyzer = DependencyAnalyzer() -for stage in graph.stages { - for node in stage.nodes { - node.operation.accept(analyzer) - } -} -``` - -## Best Practices - -### 1. Keep Stages Focused - -Each stage should have a single responsibility: -- Dependencies stage -- Build stage -- Runtime stage - -### 2. Minimize Inter-Stage Dependencies - -Reduce coupling between stages by only copying necessary artifacts: - -```swift -// Good: Copy only the binary -.copyFromStage(.named("builder"), paths: ["/app/binary"], to: "/usr/local/bin/") - -// Avoid: Copying entire directories unnecessarily -.copyFromStage(.named("builder"), paths: ["/"], to: "/") -``` - -### 3. Use Platform-Specific Stages - -When building for multiple platforms: - -```swift -let graph = BuildGraph( - stages: stages, - targetPlatforms: [.linuxAMD64, .linuxARM64] -) -``` - -### 4. Leverage Validation - -Always validate graphs before execution: - -```swift -let validator = StandardValidator() -let result = validator.validate(graph) -if !result.isValid { - // Handle validation errors -} -``` - -## Performance Considerations - -### Memory Usage - -- Graphs are immutable after construction -- Node operations are copy-on-write -- Large graphs (1000+ nodes) use ~100KB of memory - -### Construction Performance - -- GraphBuilder uses efficient array building -- O(1) node insertion -- O(n) validation where n is node count - -### Traversal Performance - -- Topological sort: O(V + E) where V is vertices, E is edges -- Cycle detection: O(V + E) -- Visitor traversal: O(V) - -## Future Considerations - -### Potential Enhancements - -1. **Subgraph Extraction**: Extract portions of the graph for partial builds -2. **Graph Merging**: Combine multiple graphs for complex workflows -3. **Lazy Evaluation**: Defer operation construction until needed -4. **Graph Caching**: Serialize graphs for faster subsequent loads - -### Maintaining Compatibility - -The graph structure is designed for extensibility: -- New operation types can be added without breaking existing graphs -- Additional metadata can be attached to nodes -- Stage properties can be extended \ No newline at end of file diff --git a/Sources/NativeBuilder/docs/ContainerBuildIR/Operations.md b/Sources/NativeBuilder/docs/ContainerBuildIR/Operations.md deleted file mode 100644 index 7ff36ec6c..000000000 --- a/Sources/NativeBuilder/docs/ContainerBuildIR/Operations.md +++ /dev/null @@ -1,231 +0,0 @@ -# Operations Design - -Operations are the fundamental building blocks of the ContainerBuildIR. This document explains their design, implementation patterns, and the rationale behind key decisions. - -## Design Overview - -### Operation Protocol - -```swift -public protocol Operation: Sendable { - /// Unique type identifier - static var operationKind: OperationKind { get } - - /// Instance operation kind - var operationKind: OperationKind { get } - - /// Accept a visitor for traversal - func accept(_ visitor: V) throws -> V.Result -} - -// Operations also conform to Codable, Hashable, and Equatable -// through protocol extensions or direct conformance -``` - -### Core Operation Types - -1. **ExecOperation** - Command execution (RUN) -2. **FilesystemOperation** - File manipulation (COPY, ADD) -3. **ImageOperation** - Base image specification (FROM) -4. **MetadataOperation** - Container metadata (ENV, LABEL, USER) - -## Design Philosophy - -### 1. Protocol-Based Design - -**Why**: Using protocols instead of enums provides: -- Open extensibility for custom operations -- Type safety with associated types -- Clean separation of concerns - -**Tradeoff**: Requires visitor pattern for exhaustive handling, but enables third-party extensions. - -### 2. Immutable Operations - -**Why**: All operations are immutable value types: -- Thread-safe by default (Sendable) -- Predictable behavior -- Easy to reason about - -**Tradeoff**: Modifications require creating new instances, but prevents accidental mutations. - -### 3. Self-Contained Operations - -**Why**: Each operation contains all information needed for execution: -- No external state dependencies -- Simplifies serialization -- Enables operation reuse - -**Tradeoff**: Some data duplication possible, but ensures operation independence. - -## Implementing Custom Operations - -### Step 1: Define the Operation - -```swift -public struct CompressOperation: Operation, Codable, Hashable { - public let sourcePath: String - public let algorithm: CompressionAlgorithm - public let level: Int - public let metadata: OperationMetadata? - - public static let operationKind = OperationKind(rawValue: "compress") - public var operationKind: OperationKind { Self.operationKind } - - public func accept(_ visitor: V) throws -> V.Result { - // Custom operations use visitUnknown - return try visitor.visitUnknown(self) - } -} - -public enum CompressionAlgorithm: String, Codable, Sendable { - case gzip - case bzip2 - case xz - case zstd -} -``` - -### Step 2: Visitor Pattern - -The OperationVisitor protocol provides methods for all built-in operations: - -```swift -public protocol OperationVisitor { - associatedtype Result - - func visit(_ operation: ExecOperation) throws -> Result - func visit(_ operation: FilesystemOperation) throws -> Result - func visit(_ operation: ImageOperation) throws -> Result - func visit(_ operation: MetadataOperation) throws -> Result - func visitUnknown(_ operation: Operation) throws -> Result -} -``` - -Custom operations are handled through `visitUnknown`, which provides a default implementation that throws an error for unrecognized operations. - -### Step 3: Operation Metadata - -All operations can include metadata for debugging and analysis: - -```swift -public struct OperationMetadata: Codable, Hashable, Sendable { - public let description: String? - public let location: SourceLocation? - public let annotations: [String: String]? - public let cacheConfig: CacheConfig? -} - -public struct SourceLocation: Codable, Hashable, Sendable { - public let file: String? - public let line: Int? - public let column: Int? -} -``` - -### Step 4: Add Builder Support - -```swift -extension StageBuilder { - @discardableResult - public func compress(_ path: String, algorithm: CompressionAlgorithm = .gzip, level: Int = 6) -> Self { - let operation = CompressOperation( - sourcePath: path, - algorithm: algorithm, - level: level - ) - addNode(BuildNode(operation: operation)) - return self - } -} -``` - -## Performance Considerations - -### Memory Efficiency - -Operations are designed to be lightweight: -- Use copy-on-write for collections -- Share common data through references -- Typical operation: 200-500 bytes - -### Serialization Performance - -- Codable implementation is optimized for speed -- Custom operations should implement efficient coding -- Consider using CodingKeys for stable serialization - -## Best Practices - -### 1. Keep Operations Focused - -Each operation should do one thing well: -```swift -// Good: Single responsibility -ExecOperation(command: .shell("apt-get update")) -ExecOperation(command: .shell("apt-get install -y curl")) - -// Avoid: Multiple unrelated commands -ExecOperation(command: .shell("apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*")) -``` - -### 2. Use Type-Safe Enums - -Prefer enums over strings for operation parameters: -```swift -// Good: Type-safe -public enum PackageManager { - case apt - case yum - case apk -} - -// Avoid: Stringly-typed -let packageManager = "apt-get" -``` - -### 3. Provide Meaningful Descriptions - -Implement descriptive `description` properties: -```swift -public var description: String { - switch action { - case .copy: - return "Copy \(source.displayName) to \(destination)" - case .add: - return "Add \(source.displayName) to \(destination)" - case .remove: - return "Remove \(destination)" - } -} -``` - -### 4. Design for Extensibility - -Consider future needs when designing operations: -```swift -public struct ExecOperation: Operation { - // Core functionality - public let command: Command - public let environment: Environment - - // Extensibility points - public let metadata: [String: Any]? // For future extensions - public let extensions: OperationExtensions? // Type-safe extensions -} -``` - -## Future Directions - -### Potential Enhancements - -1. **Operation Macros**: Higher-level operations that expand to multiple primitives -2. **Conditional Operations**: Operations that execute based on runtime conditions -3. **Parallel Operations**: Explicit parallel execution hints -4. **Operation Fragments**: Reusable operation templates - -### Maintaining Backward Compatibility - -- New operation types can be added without breaking existing code -- Optional properties can be added to existing operations -- The visitor pattern allows graceful handling of unknown operations \ No newline at end of file diff --git a/Sources/NativeBuilder/docs/Design.md b/Sources/NativeBuilder/docs/Design.md deleted file mode 100644 index bc03bf95e..000000000 --- a/Sources/NativeBuilder/docs/Design.md +++ /dev/null @@ -1,238 +0,0 @@ -Swift Native Builder - -## Introduction - -Swift Native Builder is a pure-Swift container build system that replaces the current Swift+Go architecture. It leverages Containerization.framework to run each build step in isolated VMs, writing output to a content-addressable store, for faster, deterministic and reproducible builds while maintaining native performance. - -### Design Principles - -- **Headless**: No resident daemon. CLI spins up, orchestrates the build, and exits cleanly -- **Mac-Native**: Native integration with Containerization.framework, Swift Concurrency, Swift Data, Keychain -- **Secure by default**: Hardware-backed signing (Secure Enclave), biometric authentication, opt-in secrets via `--build-secret` -- **Fast**: Maximal parallelism with incremental caching and negligible per-step overhead -- **OCI-compliant**: Produces standard container images without external dependencies - -## High-Level Architecture -``` -┌─────────────────────────┐ -│ builder build │ -└───────────┬─────────────┘ - │ - ▼ -┌─────────────────────────┐ -│ Parser │ -└───────────┬─────────────┘ - │ - ▼ -┌─────────────────────────┐ -│ DAG Scheduler │ -└───────────┬─────────────┘ - │ - │ - ▼ ┌──────────────────┐ -┌────────────────────────┐ │ │ -│ Executor │◄─────────────| Content │ -│ VM-RUN │ | Addressable │ -└────────────┬───────────┘ │ Store │ - | │ (CAS) │ - | │ │ - │ └────────▲─────────┘ - ▼ │ -┌─────────────────────────┐ │ -│ Diff & Snapshotter ├──────────────────────┘ -└────────────┬────────────┘ - │ - ▼ - ┌─────────┐ - │ Signer │ - └────┬────┘ - │ - ▼ - ┌────────────────┐ - │ OCI Image / │ - │ ext4 block │ - └────────────────┘ -``` - -### Parser - -The parser must be **fully Dockerfile-compliant**, supporting all current semantics. More importantly, the parser architecture must **accommodate future evolution** without breaking changes. - -#### Intermediate Representation (IR) - -The core of the parser is an **extensible IR** that can represent any container build operation (just like buildkit's LLB). - -See [**ContainerBuildIR**](./ContainerBuildIR/) for more detailed information. - -This IR is intentionally generic - it represents operations, not Dockerfile instructions. This allows: -- Any Dockerfile construct to map to these primitives -- Future instructions to reuse existing operations -- Non-Dockerfile frontends to target the same IR - -#### Frontend - -A **Frontend** transforms a Dockerfile into our IR: - -```swift -protocol Frontend { - associatedtype InputFormat // Dockerfile or other format - func transform(_ input: InputFormat) throws -> BuildGraph -} - -// Dockerfile frontend implementation -struct DockerfileFrontend: Frontend { - typealias InputFormat = Dockerfile - - func transform(_ dockerfile: Dockerfile) throws -> BuildGraph { - // Handles all Dockerfile semantics: - // - Stage name resolution for COPY --from - // - Build arg substitution - // - Multi-stage dependency tracking - // - Cache mount specifications - } -} -``` - -This separation enables: -- Multiple frontend languages (Dockerfile, Buildkit LLB, future formats) -- Frontend-specific optimizations without affecting the build engine -- Easier testing of language semantics vs execution semantics - -#### Build Graph - -The parser's only job is to produce a valid AST. All semantic understanding (stage references, variable substitution, etc.) happens in the construction of the Build Graph. - -The Build Graph is a directed acyclic graph (DAG) of operations, where each node represents an operation and each edge represents a dependency between operations. This clean separation ensures we can evolve the language without touching the core build engine. - - -See [**ContainerBuildIR**](./ContainerBuildIR/) for more detailed information. - -### Scheduler - -The Scheduler orchestrates the execution of the build graph once it's fully constructed by the parser. It analyzes the dependency graph to maximize parallelism, executing all nodes whose input artifacts are available while respecting the topological ordering of dependencies. - -#### Core Components - -A complete build operation requires three components (Scheduler, Executor, Cache) working in concert. - -#### Execution Flow - -1. **Initialization**: Scheduler receives the complete BuildGraph from the parser -2. **Dependency Analysis**: Identifies nodes with no dependencies (typically base image pulls) -3. **Parallel Execution**: Launches concurrent tasks for all executable nodes -4. **Artifact Storage**: Each completed node's output is immediately written to the CAS -5. **Progress Tracking**: As nodes complete, their artifacts unlock dependent nodes -6. **Completion**: Returns when all nodes have executed successfully - -#### Cache Integration - -The scheduler treats the CAS as the source of truth for artifacts. - -This architecture enables: -- Maximum parallelism within dependency constraints -- Cache-aware execution to skip redundant work -- Clean separation between scheduling logic and execution mechanics -- Support for speculative execution - -### Executor Registry - -The Executor Registry maintains a collection of executors, each advertising specific capabilities. For any given operation and its constraints, the registry selects the first matching executor. - -#### Built-in Executors - -| Executor | Operations | Description | -|----------|------------|-------------| -| `VMExecutor` | `RUN`, `SHELL` | Spins up LinuxKit VM, mounts parent layer via virtio-fs | -| `NativeExecutor` | `COPY`, `ADD` | Direct filesystem operations with copy-on-write | -| `CacheExecutor` | `--mount=type=cache` | Manages persistent cache directories | -| `MetadataExecutor` | `ENV`, `LABEL`, `ARG` | Updates image config without execution | - -The registry uses first-match selection, allowing specialized executors (WASM, GPU) to take precedence over general-purpose ones. - -### Differ & Snapshotter - -The Differ and Snapshotter work together to capture filesystem changes after each build step, converting them into portable OCI layers. - -#### Differ - -The Differ computes the delta between filesystem states before and after an operation: - -#### Snapshotter - -The Snapshotter manages filesystem snapshots and converts layers to OCI format: - -#### Layer Format - -Each layer follows the OCI Image Layer Specification: - -``` -layer.tar.gz -├── bin/ -│ └── myapp # Added file -├── etc/ -│ └── config.json # Modified file -├── .wh.oldfile # Deletion marker -└── .wh..wh..opq # Opaque directory marker -``` - -This architecture enables: -- Efficient storage through deduplication -- Fast layer generation using native filesystem features -- Full OCI compatibility for cross-platform deployment -- Minimal overhead for unchanged files - - -### Content-Addressable Store (CAS) - -The CAS serves as the central artifact repository, storing all build outputs indexed by content hash. Its design mirrors containerd's content store for compatibility. - -#### Storage Layout - -``` -content/ -├── sha256/ -│ ├── 44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a -│ ├── 6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b -│ └── ... -└── metadata/ - └── ... -``` - -#### Pluggable Backends - -The protocol enables multiple storage implementations: -- `LocalCache`: Filesystem-based storage with atomic writes -- `S3Cache`: Remote storage for distributed builds -- `HybridCache`: Tiered storage with local fast path - -### Security - -Swift Native Builder leverages macOS security features to protect secrets and ensure image integrity. - -#### Secret Management - -Build secrets are stored in the system Keychain with hardware-backed encryption. Secrets are injected at build time without persisting in layers. - -```bash -# Use in build without exposing in image -builder build --build-secret=id=github-token,target=/run/secrets/token . -``` - -#### Image Signing - -Every built image is cryptographically signed using the Secure Enclave: - -#### Trust Verification - -Images are verified before execution. - -This architecture provides: -- Zero-trust secret management with biometric protection -- Hardware-backed image signatures -- Transparent verification without runtime overhead -- Compatibility with existing OCI signature specifications - -### Future Work - -* SBOM and provenance generation -* Support for additional image formats (e.g. OCI, Docker, etc.) diff --git a/Sources/NativeBuilder/docs/README.md b/Sources/NativeBuilder/docs/README.md deleted file mode 100644 index 0d0a02dde..000000000 --- a/Sources/NativeBuilder/docs/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# Swift Native Builder Documentation - -Welcome to the Swift Native Builder documentation. These docs contain detailed information about the project's architecture and design decisions. - -## Documentation Structure - -### Core Documentation - -- [**Design.md**](./Design.md) - Complete architectural design and implementation details - - High-level architecture overview - - Component descriptions (Parser, Scheduler, Executor, CAS) - - Security model and extensibility - -### Component Documentation - -- [**ContainerBuildIR**](./ContainerBuildIR/) - Intermediate Representation documentation - - Core types and operations - - Build graph structure - - Validation and analysis tools - -- [**ContainerBuildExecutor**](./ContainerBuildExecutor/) - Execution layer documentation - - Executor architecture and patterns - -- [**ContainerBuildCache**](./ContainerBuildCache/) - Caching layer documentation - - ContentStore-based cache architecture - - OCI-compliant cache entry format - - Eviction policies and index management - -## Quick Links - -- [Project README](../README.md) - Getting started and overview -- [Examples](../Sources/ContainerBuildDemo/) - Sample code and usage patterns - -## Contributing - -When adding new documentation: -1. Place architectural documents in this docs folder -2. Component-specific documentation goes under `docs//` -3. Update this README with links to new documents \ No newline at end of file diff --git a/Tests/NativeBuilderTests/ContainerBuildCacheTests/CacheIndexTests.swift b/Tests/NativeBuilderTests/ContainerBuildCacheTests/CacheIndexTests.swift deleted file mode 100644 index 07c85ee19..000000000 --- a/Tests/NativeBuilderTests/ContainerBuildCacheTests/CacheIndexTests.swift +++ /dev/null @@ -1,523 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import ContainerizationOCI -import Foundation -import Testing - -@testable import ContainerBuildCache - -struct CacheIndexTests { - - @Test func putAndGet() async throws { - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: tempDir) } - let cacheIndex = try CacheIndex(path: tempDir) - - // Create test data - let key = "test-key-123" - let descriptor = Descriptor( - mediaType: "application/vnd.test+json", - digest: "sha256:1234567890abcdef", - size: 1024, - urls: nil, - annotations: nil, - platform: nil - ) - let platform = Platform( - arch: "amd64", - os: "linux", - ) - let metadata = CacheMetadata( - operationHash: "op-hash-123", - platform: platform, - ttl: 3600, - tags: ["test": "true"] - ) - - // Test put - try await cacheIndex.put(key: key, descriptor: descriptor, metadata: metadata) - - // Test get - let entry = try await cacheIndex.get(key: key) - #expect(entry != nil) - #expect(entry?.descriptor.digest == descriptor.digest) - #expect(entry?.metadata.operationHash == metadata.operationHash) - - // Test cache.json was created - let cacheJsonPath = tempDir.appendingPathComponent("cache.json") - #expect(FileManager.default.fileExists(atPath: cacheJsonPath.path) == true) - } - - @Test func remove() async throws { - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: tempDir) } - let cacheIndex = try CacheIndex(path: tempDir) - - // Add entries - let keys = ["key1", "key2", "key3"] - for key in keys { - let descriptor = Descriptor( - mediaType: "application/vnd.test+json", - digest: "sha256:\(key)", - size: 100, - urls: nil, - annotations: nil, - platform: nil - ) - let metadata = CacheMetadata( - operationHash: "hash-\(key)", - platform: Platform(arch: "amd64", os: "linux") - ) - try await cacheIndex.put(key: key, descriptor: descriptor, metadata: metadata) - } - - // Remove some entries - try await cacheIndex.remove(keys: ["key1", "key3"]) - - // Verify - let entry1 = try await cacheIndex.get(key: "key1") - let entry2 = try await cacheIndex.get(key: "key2") - let entry3 = try await cacheIndex.get(key: "key3") - - #expect(entry1 == nil) - #expect(entry2 != nil) - #expect(entry3 == nil) - } - - @Test func statistics() async throws { - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: tempDir) } - let cacheIndex = try CacheIndex(path: tempDir) - - // Add some entries - for i in 1...5 { - let descriptor = Descriptor( - mediaType: "application/vnd.test+json", - digest: "sha256:entry\(i)", - size: Int64(i * 1000), - urls: nil, - annotations: nil, - platform: nil - ) - let metadata = CacheMetadata( - operationHash: "hash-\(i)", - platform: Platform(arch: "amd64", os: "linux") - ) - try await cacheIndex.put(key: "key\(i)", descriptor: descriptor, metadata: metadata) - } - - // Get some entries to affect hit rate - _ = try await cacheIndex.get(key: "key1") - _ = try await cacheIndex.get(key: "key2") - _ = try await cacheIndex.get(key: "key-missing") // This should be a miss - - let stats = try await cacheIndex.statistics() - - #expect(stats.entryCount == 5) - #expect(stats.totalSize == 15000) // 1000 + 2000 + 3000 + 4000 + 5000 - #expect(stats.averageEntrySize == 3000) - #expect(stats.hitRate > 0.6) // 2 hits out of 3 attempts - } - - @Test func allEntries() async throws { - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: tempDir) } - let cacheIndex = try CacheIndex(path: tempDir) - - // Add entries - let entries = [ - ("key1", "hash1"), - ("key2", "hash2"), - ("key3", "hash3"), - ] - - for (key, hash) in entries { - let descriptor = Descriptor( - mediaType: "application/vnd.test+json", - digest: "sha256:\(hash)", - size: 100, - urls: nil, - annotations: nil, - platform: nil - ) - let metadata = CacheMetadata( - operationHash: hash, - platform: Platform(arch: "amd64", os: "linux") - ) - try await cacheIndex.put(key: key, descriptor: descriptor, metadata: metadata) - } - - // Get all entries - let allEntries = try await cacheIndex.allEntries() - - #expect(allEntries.count == 3) - #expect(allEntries["key1"] != nil) - #expect(allEntries["key2"] != nil) - #expect(allEntries["key3"] != nil) - } - - @Test func persistence() async throws { - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: tempDir) } - let cacheIndex = try CacheIndex(path: tempDir) - - // Add an entry - let key = "persistent-key" - let descriptor = Descriptor( - mediaType: "application/vnd.test+json", - digest: "sha256:persistent", - size: 2048, - urls: nil, - annotations: nil, - platform: nil - ) - let metadata = CacheMetadata( - operationHash: "persistent-hash", - platform: Platform(arch: "arm64", os: "linux") - ) - - try await cacheIndex.put(key: key, descriptor: descriptor, metadata: metadata) - - // Create a new cache index with same path - let newCacheIndex = try CacheIndex(path: tempDir) - - // Verify data persisted - let entry = try await newCacheIndex.get(key: key) - #expect(entry != nil) - #expect(entry?.descriptor.digest == descriptor.digest) - #expect(entry?.metadata.platform.os == "linux") - #expect(entry?.metadata.platform.architecture == "arm64") - } - - @Test func accessTimeUpdate() async throws { - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: tempDir) } - let cacheIndex = try CacheIndex(path: tempDir) - - let key = "access-test" - let descriptor = Descriptor( - mediaType: "application/vnd.test+json", - digest: "sha256:access", - size: 512, - urls: nil, - annotations: nil, - platform: nil - ) - let metadata = CacheMetadata( - operationHash: "access-hash", - platform: Platform(arch: "amd64", os: "linux") - ) - - try await cacheIndex.put(key: key, descriptor: descriptor, metadata: metadata) - - // Get initial access time - let entry1 = try await cacheIndex.get(key: key) - let accessTime1 = entry1?.metadata.accessedAt - - // Wait a bit - try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds - - // Access again - let entry2 = try await cacheIndex.get(key: key) - let accessTime2 = entry2?.metadata.accessedAt - - // Access time should be updated - #expect(accessTime1 != nil) - #expect(accessTime2 != nil) - #expect(accessTime2! > accessTime1!) - } - - // MARK: - Additional Tests for Concurrency, Large Datasets, and Error Handling - - @Test func cacheIndexConcurrentAccess() async throws { - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: tempDir) } - let cacheIndex = try CacheIndex(path: tempDir) - - let entryCount = 50 - - // Test concurrent puts and gets - await withTaskGroup(of: Void.self) { group in - // Concurrent puts - for i in 0.. 0) - } - - @Test func cacheIndexLargeDataSet() async throws { - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: tempDir) } - let cacheIndex = try CacheIndex(path: tempDir) - - let largeEntryCount = 1000 - - // Add a large number of entries - for i in 0.. 0) - #expect(stats.hitRate > 0.9) // Should have high hit rate - } - - @Test func cacheIndexCorruptedIndexFile() async throws { - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: tempDir) } - let cacheIndex = try CacheIndex(path: tempDir) - - // Add some entries first - let key = "test-key" - let descriptor = Descriptor( - mediaType: "application/vnd.test+json", - digest: "sha256:test", - size: 1024, - urls: nil, - annotations: nil, - platform: nil - ) - let metadata = CacheMetadata( - operationHash: "test-hash", - platform: Platform(arch: "amd64", os: "linux") - ) - - try await cacheIndex.put(key: key, descriptor: descriptor, metadata: metadata) - - // Verify entry exists - let entry1 = try await cacheIndex.get(key: key) - #expect(entry1 != nil) - - // Corrupt the cache.json file - let cacheJsonPath = tempDir.appendingPathComponent("cache.json") - try "corrupted data".write(to: cacheJsonPath, atomically: true, encoding: .utf8) - - // Create a new cache index - should handle corruption gracefully - let newCacheIndex = try CacheIndex(path: tempDir) - - // Should start with empty state - let entry2 = try await newCacheIndex.get(key: key) - #expect(entry2 == nil) // Entry should be lost due to corruption - - // Should still be functional for new entries - try await newCacheIndex.put(key: "new-key", descriptor: descriptor, metadata: metadata) - let newEntry = try await newCacheIndex.get(key: "new-key") - #expect(newEntry != nil) - } - - @Test func cacheIndexStatisticsAccuracy() async throws { - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: tempDir) } - let cacheIndex = try CacheIndex(path: tempDir) - - // Test that statistics are accurately maintained - let initialStats = try await cacheIndex.statistics() - #expect(initialStats.entryCount == 0) - #expect(initialStats.totalSize == 0) - #expect(initialStats.hitRate == 0.0) - - // Add entries with known sizes - let entrySizes: [Int64] = [100, 200, 300, 400, 500] - for (i, size) in entrySizes.enumerated() { - let key = "stats-key-\(i)" - let descriptor = Descriptor( - mediaType: "application/vnd.test+json", - digest: "sha256:stats\(i)", - size: size, - urls: nil, - annotations: nil, - platform: nil - ) - let metadata = CacheMetadata( - operationHash: "stats-hash-\(i)", - platform: Platform(arch: "amd64", os: "linux") - ) - - try await cacheIndex.put(key: key, descriptor: descriptor, metadata: metadata) - } - - // Check statistics after puts - let afterPutStats = try await cacheIndex.statistics() - #expect(afterPutStats.entryCount == entrySizes.count) - #expect(afterPutStats.totalSize == UInt64(entrySizes.reduce(0, +))) - let expectedAverage = UInt64(entrySizes.reduce(0, +)) / UInt64(entrySizes.count) - #expect(afterPutStats.averageEntrySize == expectedAverage) - - // Perform some gets (hits and misses) - _ = try await cacheIndex.get(key: "stats-key-0") // Hit - _ = try await cacheIndex.get(key: "stats-key-1") // Hit - _ = try await cacheIndex.get(key: "stats-key-2") // Hit - _ = try await cacheIndex.get(key: "nonexistent-1") // Miss - _ = try await cacheIndex.get(key: "nonexistent-2") // Miss - - // Check final statistics - let finalStats = try await cacheIndex.statistics() - #expect(finalStats.entryCount == entrySizes.count) - #expect(finalStats.totalSize == UInt64(entrySizes.reduce(0, +))) - // Hit rate should be 3 hits out of 5 total operations (3 hits + 2 misses) - #expect(abs(finalStats.hitRate - 3.0 / 5.0) < 0.001) - } - - @Test func cacheIndexTTLExpiration() async throws { - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: tempDir) } - let cacheIndex = try CacheIndex(path: tempDir) - - // Test TTL-based expiration detection - let key = "ttl-test" - let descriptor = Descriptor( - mediaType: "application/vnd.test+json", - digest: "sha256:ttl", - size: 1024, - urls: nil, - annotations: nil, - platform: nil - ) - - // Create metadata with short TTL - let now = Date() - let metadata = CacheMetadata( - createdAt: now, - accessedAt: now, - operationHash: "ttl-hash", - platform: Platform(arch: "amd64", os: "linux"), - ttl: 2.0 // 2 seconds to account for test execution time - ) - - try await cacheIndex.put(key: key, descriptor: descriptor, metadata: metadata) - - // Immediately check - should not be expired - let entry1 = try await cacheIndex.get(key: key) - #expect(entry1 != nil) - #expect(entry1!.metadata.isExpired == false) // Entry should not be expired immediately after creation - - // Wait for TTL to expire - try await Task.sleep(nanoseconds: 2_100_000_000) // 2.1 seconds - - // Check expiration status - let entry2 = try await cacheIndex.get(key: key) - #expect(entry2 != nil) // Entry still exists in index - #expect(entry2!.metadata.isExpired == true) // But is marked as expired - } - - @Test func cacheIndexEmptyDirectory() async throws { - // Test creating cache index in empty directory - let emptyDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: emptyDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: emptyDir) } - - let emptyCacheIndex = try CacheIndex(path: emptyDir) - - // Should start with empty statistics - let stats = try await emptyCacheIndex.statistics() - #expect(stats.entryCount == 0) - #expect(stats.totalSize == 0) - - // Should be functional - let key = "empty-test" - let descriptor = Descriptor( - mediaType: "application/vnd.test+json", - digest: "sha256:empty", - size: 512, - urls: nil, - annotations: nil, - platform: nil - ) - let metadata = CacheMetadata( - operationHash: "empty-hash", - platform: Platform(arch: "amd64", os: "linux") - ) - - try await emptyCacheIndex.put(key: key, descriptor: descriptor, metadata: metadata) - let entry = try await emptyCacheIndex.get(key: key) - #expect(entry != nil) - } -} diff --git a/Tests/NativeBuilderTests/ContainerBuildCacheTests/CacheTestHelpers.swift b/Tests/NativeBuilderTests/ContainerBuildCacheTests/CacheTestHelpers.swift deleted file mode 100644 index 678504647..000000000 --- a/Tests/NativeBuilderTests/ContainerBuildCacheTests/CacheTestHelpers.swift +++ /dev/null @@ -1,535 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import ContainerBuildSnapshotter -import ContainerizationOCI -import Crypto -import Foundation -import Testing - -@testable import ContainerBuildCache - -// MARK: - Missing Type Definitions for Testing - -/// ContentWriter mock for testing -public struct ContentWriter { - public let ingestDir: URL - - public init(for ingestDir: URL) throws { - self.ingestDir = ingestDir - } - - public func write(_ data: Data) throws -> (Int64, SHA256.Digest) { - let digest = SHA256.hash(data: data) - // Use the digest string without the sha256: prefix for the filename - let digestString = digest.map { String(format: "%02x", $0) }.joined() - let filePath = ingestDir.appendingPathComponent(digestString) - try data.write(to: filePath) - return (Int64(data.count), digest) - } - - public func create(from manifest: CacheManifest) throws -> (Int64, SHA256.Digest) { - let data = try JSONEncoder().encode(manifest) - return try write(data) - } -} - -extension Data { - var sha256: String { - let digest = (try? ContainerBuildIR.Digest.compute(self, using: .sha256)) ?? (try! ContainerBuildIR.Digest(algorithm: .sha256, bytes: Data(count: 32))) - return digest.stringValue.replacingOccurrences(of: "sha256:", with: "") - } -} - -// MARK: - Test Environment - -/// Test environment with common setup and utilities for cache tests -public struct CacheTestEnvironment { - public let tempDir: URL - - public init() throws { - self.tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - } - - public func cleanup() { - try? FileManager.default.removeItem(at: tempDir) - } -} - -/// Convenience function to create and manage test environment -public func withCacheTestEnvironment( - _ operation: (CacheTestEnvironment) async throws -> T -) async throws -> T { - let environment = try CacheTestEnvironment() - defer { environment.cleanup() } - return try await operation(environment) -} - -// MARK: - Compatibility Layer - -/// Compatibility base class for existing XCTest-based tests during migration -/// This maintains the same interface as the original CacheTestCase for gradual migration -open class CacheTestCase { - public var tempDir: URL! - - public init() {} - - open func setUp() async throws { - tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - } - - open func tearDown() async throws { - if let tempDir = tempDir { - try? FileManager.default.removeItem(at: tempDir) - } - } -} - -// MARK: - ContentStore Protocol -// Using the real ContentStore from ContainerizationOCI - -// MARK: - Mock Content - -/// Mock Content implementation for testing -public struct MockContent: Content { - public let path: URL - private let _data: Data - - public init(data: Data) { - self._data = data - self.path = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - } - - public func digest() throws -> SHA256.Digest { - SHA256.hash(data: _data) - } - - public func size() throws -> UInt64 { - UInt64(_data.count) - } - - public func data() throws -> Data { - _data - } - - public func data(offset: UInt64, length: Int) throws -> Data? { - let start = Int(offset) - let end = min(start + length, _data.count) - guard start < _data.count else { return nil } - return _data.subdata(in: start..() throws -> T { - let decoder = JSONDecoder() - return try decoder.decode(T.self, from: _data) - } -} - -// MARK: - Mock ContentStore - -/// Mock ContentStore for testing cache implementations -public actor MockContentStore: ContentStore { - private var storage: [String: Data] = [:] - private var manifests: [String: CacheManifest] = [:] - private var sessions: [String: URL] = [:] - private var nextSessionId = 0 - private let baseDir: URL - - public init(baseDir: URL? = nil) { - self.baseDir = baseDir ?? FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try? FileManager.default.createDirectory(at: self.baseDir, withIntermediateDirectories: true) - } - - // MARK: - ContentStore Interface - - // MARK: - ContentStore Protocol Implementation - - public func get(digest: String) async throws -> (any Content)? { - guard let data = storage[digest] else { - return nil - } - // Return a mock Content object - return MockContent(data: data) - } - - public func get(digest: String) async throws -> T? { - guard let data = storage[digest] else { - return nil - } - let decoder = JSONDecoder() - return try decoder.decode(T.self, from: data) - } - - public func put(_ object: T, digest: String) async throws { - let encoder = JSONEncoder() - let data = try encoder.encode(object) - storage[digest] = data - } - - @discardableResult - public func delete(digests: [String]) async throws -> ([String], UInt64) { - var deletedDigests: [String] = [] - var totalSize: UInt64 = 0 - - for digest in digests { - if let data = storage.removeValue(forKey: digest) { - deletedDigests.append(digest) - totalSize += UInt64(data.count) - } - manifests.removeValue(forKey: digest) - } - - return (deletedDigests, totalSize) - } - - @discardableResult - public func delete(keeping: [String]) async throws -> ([String], UInt64) { - let keepSet = Set(keeping) - var deletedDigests: [String] = [] - var totalSize: UInt64 = 0 - - for (digest, data) in storage { - if !keepSet.contains(digest) { - storage.removeValue(forKey: digest) - manifests.removeValue(forKey: digest) - deletedDigests.append(digest) - totalSize += UInt64(data.count) - } - } - - return (deletedDigests, totalSize) - } - - @discardableResult - public func ingest(_ body: @Sendable @escaping (URL) async throws -> Void) async throws -> [String] { - let tempDir = baseDir.appendingPathComponent("ingest-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: tempDir) } - - try await body(tempDir) - - // Mock implementation - return empty array - return [] - } - - public func newIngestSession() async throws -> (id: String, ingestDir: URL) { - nextSessionId += 1 - let sessionId = "session-\(nextSessionId)" - let sessionDir = baseDir.appendingPathComponent(sessionId) - try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true) - sessions[sessionId] = sessionDir - return (id: sessionId, ingestDir: sessionDir) - } - - @discardableResult - public func completeIngestSession(_ sessionId: String) async throws -> [String] { - guard let sessionDir = sessions[sessionId] else { - throw MockContentStoreError.sessionNotFound(sessionId) - } - - // Read all files from the session directory and store them - var digests: [String] = [] - - if FileManager.default.fileExists(atPath: sessionDir.path) { - let files = try FileManager.default.contentsOfDirectory(at: sessionDir, includingPropertiesForKeys: nil) - for file in files { - let data = try Data(contentsOf: file) - let digestHash = file.lastPathComponent - // Ensure the digest has the sha256: prefix for storage - let digest = digestHash.hasPrefix("sha256:") ? digestHash : "sha256:\(digestHash)" - storage[digest] = data - digests.append(digest) - } - } - - // Clean up session - sessions.removeValue(forKey: sessionId) - try? FileManager.default.removeItem(at: sessionDir) - - return digests - } - - public func cancelIngestSession(_ sessionId: String) async throws { - guard let sessionDir = sessions[sessionId] else { - throw MockContentStoreError.sessionNotFound(sessionId) - } - - sessions.removeValue(forKey: sessionId) - try? FileManager.default.removeItem(at: sessionDir) - } - - // MARK: - Test Utilities - - public func hasContent(digest: String) async -> Bool { - storage[digest] != nil - } - - public func clear() async { - storage.removeAll() - manifests.removeAll() - for (_, sessionDir) in sessions { - try? FileManager.default.removeItem(at: sessionDir) - } - sessions.removeAll() - } - - public func contentCount() async -> Int { - storage.count - } -} - -// MARK: - Mock ContentStore Errors - -public enum MockContentStoreError: LocalizedError { - case notFound(String) - case sessionNotFound(String) - case encodingFailed(Error) - - public var errorDescription: String? { - switch self { - case .notFound(let digest): - return "Content not found: \(digest)" - case .sessionNotFound(let sessionId): - return "Session not found: \(sessionId)" - case .encodingFailed(let error): - return "Encoding failed: \(error.localizedDescription)" - } - } -} - -// MARK: - Test Data Factory - -/// Factory for creating test data objects -public enum TestDataFactory { - - public static func createDigest(from string: String = "test-content") -> ContainerBuildIR.Digest { - let data = string.data(using: .utf8)! - return (try? ContainerBuildIR.Digest.compute(data, using: .sha256)) ?? (try! ContainerBuildIR.Digest(algorithm: .sha256, bytes: Data(count: 32))) - } - - public static func createSnapshot( - id: UUID = UUID(), - content: String = "test-snapshot", - size: Int64 = 1024, - parent: Snapshot? = nil - ) -> Snapshot { - let digest = createDigest(from: content) - return Snapshot( - id: id, - digest: digest, - size: size, - parent: parent, - state: .prepared(mountpoint: FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)) - ) - } - - public static func createCacheKey( - operation: ContainerBuildIR.Operation? = nil, - operationContent: String = "test-operation", - inputContents: [String] = ["input1", "input2"], - platform: Platform = .linuxAMD64 - ) -> ContainerBuildCache.CacheKey { - let operationDigest: ContainerBuildIR.Digest - if let operation = operation { - operationDigest = (try? operation.contentDigest()) ?? createDigest(from: operationContent) - } else { - operationDigest = createDigest(from: operationContent) - } - let inputDigests = inputContents.map { createDigest(from: $0) } - - return ContainerBuildCache.CacheKey( - operationDigest: operationDigest, - inputDigests: inputDigests, - platform: platform - ) - } - - public static func createCachedResult( - snapshotContent: String = "test-result", - environmentChanges: [String: EnvironmentValue] = ["PATH": .literal("/usr/bin")], - metadataChanges: [String: String] = ["build.time": "2024-01-01T12:00:00Z"] - ) -> CachedResult { - let snapshot = createSnapshot(content: snapshotContent) - return CachedResult( - snapshot: snapshot, - environmentChanges: environmentChanges, - metadataChanges: metadataChanges - ) - } - - public static func createOperation( - kind: String = "test", - content: String = "test-operation" - ) -> MockOperation { - MockOperation(kind: kind, content: content) - } - - public static func createCacheMetadata( - operationHash: String = "test-hash", - platform: Platform = .linuxAMD64, - ttl: TimeInterval? = nil, - tags: [String: String] = [:] - ) -> CacheMetadata { - CacheMetadata( - operationHash: operationHash, - platform: platform, - ttl: ttl, - tags: tags - ) - } - - public static func createDescriptor( - mediaType: String = "application/vnd.test+json", - digest: String = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - size: Int64 = 1024 - ) -> Descriptor { - Descriptor( - mediaType: mediaType, - digest: digest, - size: size, - urls: nil, - annotations: nil, - platform: nil - ) - } -} - -// MARK: - Mock Operation - -public struct MockOperation: ContainerBuildIR.Operation { - public let kind: String - public let content: String - public let metadata: OperationMetadata - - public static let operationKind = OperationKind(rawValue: "mock") - public var operationKind: OperationKind { - OperationKind(rawValue: kind) - } - - public init(kind: String = "mock", content: String = "test", metadata: OperationMetadata = OperationMetadata()) { - self.kind = kind - self.content = content - self.metadata = metadata - } - - public func accept(_ visitor: V) throws -> V.Result { - try visitor.visitUnknown(self) - } -} - -extension MockOperation: Hashable { - public func hash(into hasher: inout Hasher) { - hasher.combine(kind) - hasher.combine(content) - } - - public static func == (lhs: MockOperation, rhs: MockOperation) -> Bool { - lhs.kind == rhs.kind && lhs.content == rhs.content - } -} - -// MARK: - Performance Measurement - -/// Utility for measuring test performance -public struct PerformanceMeasurement { - public static func measure( - _ operation: () async throws -> T, - file: StaticString = #file, - line: UInt = #line - ) async rethrows -> (result: T, duration: TimeInterval) { - let startTime = Date() - let result = try await operation() - let endTime = Date() - let duration = endTime.timeIntervalSince(startTime) - - print("Performance measurement at \(file):\(line): \(duration)s") - return (result, duration) - } - - public static func measureAndAssert( - _ operation: () async throws -> T, - maxDuration: TimeInterval, - file: StaticString = #file, - line: UInt = #line - ) async rethrows -> T { - let (result, duration) = try await measure(operation, file: file, line: line) - if duration >= maxDuration { - Issue.record( - "Operation took too long: \(duration)s >= \(maxDuration)s", - sourceLocation: SourceLocation(fileID: file.description, filePath: file.description, line: Int(line), column: 1)) - } - return result - } -} - -// MARK: - Async Test Utilities - -/// Utilities for async testing -public enum AsyncTestUtilities { - - /// Wait for a condition to become true with timeout - public static func waitFor( - condition: @escaping () async -> Bool, - timeout: TimeInterval = 5.0, - interval: TimeInterval = 0.1 - ) async throws { - let deadline = Date().addingTimeInterval(timeout) - - while Date() < deadline { - if await condition() { - return - } - try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) - } - - throw AsyncTestError.timeout - } - - /// Run multiple async operations concurrently and collect results - public static func runConcurrently( - count: Int, - operation: @escaping @Sendable (Int) async throws -> T - ) async throws -> [T] { - try await withThrowingTaskGroup(of: T.self) { group in - for i in 0.. (ContentAddressableCache, MockContentStore) { - let indexPath = tempDir.appendingPathComponent("index", isDirectory: true) - try FileManager.default.createDirectory(at: indexPath, withIntermediateDirectories: true) - - let contentStore = store ?? MockContentStore(baseDir: tempDir.appendingPathComponent("store", isDirectory: true)) - let config = CacheConfiguration( - maxSize: 1024 * 1024 * 1024, - maxAge: 7 * 24 * 60 * 60, - indexPath: indexPath, - evictionPolicy: .lru, - concurrency: .default, - verifyIntegrity: true, - sharding: nil, - gcInterval: gcInterval, - cacheKeyVersion: "test-v1", - defaultTTL: ttl - ) - - let cache = try await ContentAddressableCache(contentStore: contentStore, configuration: config) - return (cache, contentStore) - } - - @Test func putAndGetRoundTrip() async throws { - try await withCacheTestEnvironment { env in - let (cache, store) = try await makeCache(tempDir: env.tempDir) - - let op = TestDataFactory.createOperation(kind: "run", content: "echo hello") - let key = TestDataFactory.createCacheKey(operation: op, inputContents: ["in1", "in2"], platform: .linuxAMD64) - let result = TestDataFactory.createCachedResult( - snapshotContent: "snap-A", - environmentChanges: ["PATH": .literal("/usr/bin:/bin")], - metadataChanges: ["build.time": "2024-08-01T00:00:00Z"] - ) - - await cache.put(result, key: key, for: op) - - // Stored once in content store - #expect(await store.contentCount() == 1) - - let fetched = await cache.get(key, for: op) - let fr = try #require(fetched) - - // Verify snapshot and metadata round-trip - #expect(fr.snapshot.digest == result.snapshot.digest) - #expect(fr.snapshot.size == result.snapshot.size) - #expect(fr.environmentChanges == result.environmentChanges) - #expect(fr.metadataChanges == result.metadataChanges) - } - } - - @Test func idempotentPutDoesNotDuplicateStorage() async throws { - try await withCacheTestEnvironment { env in - let (cache, store) = try await makeCache(tempDir: env.tempDir) - - let op = TestDataFactory.createOperation(kind: "run", content: "op-idem") - let key = TestDataFactory.createCacheKey(operation: op, inputContents: ["a", "b"], platform: .linuxAMD64) - let result = TestDataFactory.createCachedResult(snapshotContent: "idem-snap") - - await cache.put(result, key: key, for: op) - await cache.put(result, key: key, for: op) // second put should be a no-op - - #expect(await store.contentCount() == 1) - } - } - - @Test func hasKeyAndMiss() async throws { - try await withCacheTestEnvironment { env in - let (cache, _) = try await makeCache(tempDir: env.tempDir) - - let op = TestDataFactory.createOperation(kind: "cmd", content: "A") - let keyHit = TestDataFactory.createCacheKey(operation: op, inputContents: ["x"], platform: .linuxAMD64) - let keyMiss = TestDataFactory.createCacheKey(operation: op, inputContents: ["different"], platform: .linuxAMD64) - - let result = TestDataFactory.createCachedResult(snapshotContent: "rt") - await cache.put(result, key: keyHit, for: op) - - #expect(await cache.has(key: keyHit)) - #expect(!(await cache.has(key: keyMiss))) - } - } - - @Test func deterministicKeyOrderInvariance() async throws { - try await withCacheTestEnvironment { env in - let (cache, store) = try await makeCache(tempDir: env.tempDir) - - let op = TestDataFactory.createOperation(kind: "run", content: "det") - // Same set of inputs but different order - let key1 = TestDataFactory.createCacheKey(operation: op, inputContents: ["i1", "i2", "i3"], platform: .linuxAMD64) - let key2 = TestDataFactory.createCacheKey(operation: op, inputContents: ["i3", "i2", "i1"], platform: .linuxAMD64) - let result = TestDataFactory.createCachedResult(snapshotContent: "det-snap") - - await cache.put(result, key: key1, for: op) - - // Expect that the permuted key hits the same entry (no additional storage) - #expect(await cache.has(key: key2)) - let fetched = await cache.get(key2, for: op) - #expect(fetched?.snapshot.digest == result.snapshot.digest) - #expect(await store.contentCount() == 1) - } - } - - @Test func evictRemovesIndexAndContent() async throws { - try await withCacheTestEnvironment { env in - let (cache, store) = try await makeCache(tempDir: env.tempDir) - - let op = TestDataFactory.createOperation(kind: "run", content: "evict-op") - let key1 = TestDataFactory.createCacheKey(operation: op, inputContents: ["k1"], platform: .linuxAMD64) - let key2 = TestDataFactory.createCacheKey(operation: op, inputContents: ["k2"], platform: .linuxAMD64) - let r1 = TestDataFactory.createCachedResult(snapshotContent: "S1") - let r2 = TestDataFactory.createCachedResult(snapshotContent: "S2") - - await cache.put(r1, key: key1, for: op) - await cache.put(r2, key: key2, for: op) - #expect(await store.contentCount() == 2) - - await cache.evict(keys: [key1]) - - #expect(!(await cache.has(key: key1))) - #expect(await cache.has(key: key2)) - #expect(await store.contentCount() == 1) - } - } - - @Test func statisticsReflectEntries() async throws { - try await withCacheTestEnvironment { env in - let (cache, _) = try await makeCache(tempDir: env.tempDir) - - let op = TestDataFactory.createOperation() - let k1 = TestDataFactory.createCacheKey(operation: op, inputContents: ["a"], platform: .linuxAMD64) - let k2 = TestDataFactory.createCacheKey(operation: op, inputContents: ["b"], platform: .linuxAMD64) - let r = TestDataFactory.createCachedResult() - - await cache.put(r, key: k1, for: op) - await cache.put(r, key: k2, for: op) - - _ = await cache.get(k1, for: op) // one hit - _ = await cache.get(k2, for: op) // another hit - _ = await cache.get(TestDataFactory.createCacheKey(operation: op, inputContents: ["c"], platform: .linuxAMD64), for: op) // miss - - let stats = await cache.statistics() - #expect(stats.entryCount == 2) - #expect(stats.totalSize > 0) - #expect(stats.averageEntrySize > 0) - #expect(stats.hitRate > 0) - #expect(stats.evictionPolicy == "lru") - } - } - - @Test func ttlEvictionViaBackgroundGC() async throws { - try await withCacheTestEnvironment { env in - // Short TTL and GC interval to exercise background cleanup - let (cache, _) = try await makeCache(tempDir: env.tempDir, ttl: 0.05, gcInterval: 0.02) - - let op = TestDataFactory.createOperation() - let key = TestDataFactory.createCacheKey(operation: op, inputContents: ["exp"], platform: .linuxAMD64) - let r = TestDataFactory.createCachedResult() - await cache.put(r, key: key, for: op) - - #expect(await cache.has(key: key)) - // Wait long enough for TTL to expire and GC to run - try await Task.sleep(nanoseconds: 200_000_000) // 0.2s - #expect(!(await cache.has(key: key))) - } - } - - @Test func orphanedIndexEntryIsCleanedOnMiss() async throws { - try await withCacheTestEnvironment { env in - // Seed a valid entry, then corrupt the stored manifest to simulate orphan/invalid content - let (cache, store) = try await makeCache(tempDir: env.tempDir) - let index = try CacheIndex(path: env.tempDir.appendingPathComponent("index", isDirectory: true)) - - let op = TestDataFactory.createOperation(kind: "run", content: "corrupt") - let key = TestDataFactory.createCacheKey(operation: op, inputContents: ["x", "y"], platform: .linuxAMD64) - let r = TestDataFactory.createCachedResult(snapshotContent: "S") - await cache.put(r, key: key, for: op) - - // Find the stored index entry - let entries = try await index.allEntries() - #expect(entries.count == 1) - let (_, entry) = try #require(entries.first) - - // Overwrite manifest content with an invalid one (missing snapshot) - let bad = CacheManifest( - config: CacheConfig( - cacheKey: SerializedCacheKey(from: key), - operationType: String(describing: type(of: op)), - platform: key.platform, - buildVersion: "1.0" - ), - annotations: [:], - subject: nil, - snapshot: nil, // <-- corrupt - environmentChanges: [:], - metadataChanges: [:] - ) - try await store.put(bad, digest: entry.descriptor.digest) - - // Now a get should fail and effectively behave like a miss - let got = await cache.get(key, for: op) - #expect(got == nil) - } - } -} diff --git a/Tests/NativeBuilderTests/ContainerBuildExecutorTests/ExecutionDispatcherTests.swift b/Tests/NativeBuilderTests/ContainerBuildExecutorTests/ExecutionDispatcherTests.swift deleted file mode 100644 index bd217aa5f..000000000 --- a/Tests/NativeBuilderTests/ContainerBuildExecutorTests/ExecutionDispatcherTests.swift +++ /dev/null @@ -1,265 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import ContainerBuildReporting -import ContainerBuildSnapshotter -import Foundation -import Testing - -@testable import ContainerBuildExecutor - -struct ExecutionDispatcherTests { - - @Test func dispatcherRouting() async throws { - // Create test executors with different capabilities - let execExecutor = ExecOperationExecutor() - let fsExecutor = FilesystemOperationExecutor() - let metadataExecutor = MetadataOperationExecutor() - - let dispatcher = ExecutionDispatcher(executors: [ - execExecutor, - fsExecutor, - metadataExecutor, - ]) - - // Create test context - let context = try createTestContext() - - // Test exec operation routing - let execOp = ExecOperation( - command: .shell("echo test"), - environment: .empty, - mounts: [], - workingDirectory: nil, - user: nil, - network: .default, - security: .default, - metadata: OperationMetadata() - ) - - let execResult = try await dispatcher.dispatch(execOp, context: context) - #expect(execResult.duration > 0) - - // Test filesystem operation routing - let fsOp = FilesystemOperation( - action: .copy, - source: .context(ContextSource(paths: ["test.txt"])), - destination: "/app/test.txt", - fileMetadata: FileMetadata(), - options: FilesystemOptions(), - metadata: OperationMetadata() - ) - - let _ = try await dispatcher.dispatch(fsOp, context: context) - - // Test metadata operation routing - let metadataOp = MetadataOperation( - action: .setEnv(key: "TEST", value: .literal("value")), - metadata: OperationMetadata() - ) - - let metadataResult = try await dispatcher.dispatch(metadataOp, context: context) - #expect(metadataResult.environmentChanges["TEST"] == EnvironmentValue.literal("value")) - } - - @Test func capabilityMatching() async throws { - // Create a custom executor with specific capabilities - struct PrivilegedExecutor: OperationExecutor { - let capabilities = ExecutorCapabilities( - supportedOperations: [.exec], - requiresPrivileged: true, - maxConcurrency: 1 - ) - - func execute(_ operation: ContainerBuildIR.Operation, context: ExecutionContext) async throws -> ExecutionResult { - let digest = try! Digest(algorithm: .sha256, bytes: Data(count: 32)) - let mountPoint = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - let snapshot = Snapshot( - digest: digest, - size: 0, - state: .prepared(mountpoint: mountPoint) - ) - return ExecutionResult( - environmentChanges: [:], - metadataChanges: [:], - snapshot: snapshot, - duration: 0.001 - ) - } - - func canExecute(_ operation: ContainerBuildIR.Operation) -> Bool { - operation is ExecOperation - } - } - - let regularExecutor = ExecOperationExecutor() - let privilegedExecutor = PrivilegedExecutor() - - let dispatcher = ExecutionDispatcher(executors: [ - regularExecutor, - privilegedExecutor, - ]) - - let context = try createTestContext() - let execOp = ExecOperation( - command: .shell("privileged command"), - environment: .empty, - mounts: [], - workingDirectory: nil, - user: nil, - network: .default, - security: .default, - metadata: OperationMetadata() - ) - - // Without constraints, should use regular executor - _ = try await dispatcher.dispatch(execOp, context: context) - - // With privileged constraint, should use privileged executor - let constraints = NodeConstraints(requiresPrivileged: true) - _ = try await dispatcher.dispatch( - execOp, - context: context, - constraints: constraints - ) - } - - @Test func unsupportedOperation() async throws { - // Create dispatcher with limited executors - let dispatcher = ExecutionDispatcher(executors: [ - ExecOperationExecutor() - ]) - - let context = try createTestContext() - - // Try to dispatch an unsupported operation - struct CustomOperation: ContainerBuildIR.Operation { - static let operationKind = OperationKind(rawValue: "custom") - var operationKind: OperationKind { Self.operationKind } - let metadata: OperationMetadata = OperationMetadata() - - func accept(_ visitor: V) throws -> V.Result { - try visitor.visitUnknown(self) - } - } - - let customOp = CustomOperation() - - await #expect(throws: (any Error).self) { - try await dispatcher.dispatch(customOp, context: context) - } - } - - @Test func concurrencyLimiting() async throws { - // Create executor with limited concurrency - struct SlowExecutor: OperationExecutor { - let capabilities = ExecutorCapabilities( - supportedOperations: [.exec], - maxConcurrency: 2 - ) - - func execute(_ operation: ContainerBuildIR.Operation, context: ExecutionContext) async throws -> ExecutionResult { - // Simulate slow operation - try await Task.sleep(nanoseconds: 100_000_000) // 100ms - let digest = try! Digest(algorithm: .sha256, bytes: Data(count: 32)) - let mountPoint = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - let snapshot = Snapshot( - digest: digest, - size: 0, - state: .prepared(mountpoint: mountPoint) - ) - return ExecutionResult( - environmentChanges: [:], - metadataChanges: [:], - snapshot: snapshot, - duration: 0.1 - ) - } - - func canExecute(_ operation: ContainerBuildIR.Operation) -> Bool { - operation is ExecOperation - } - } - - let dispatcher = ExecutionDispatcher(executors: [SlowExecutor()]) - let context = try createTestContext() - - // Create multiple operations - let operations = (0..<5).map { i in - ExecOperation( - command: .shell("echo \(i)"), - environment: .empty, - mounts: [], - workingDirectory: nil, - user: nil, - network: .default, - security: .default, - metadata: OperationMetadata() - ) - } - - // Dispatch all operations concurrently - let startTime = Date() - try await withThrowingTaskGroup(of: Void.self) { group in - for op in operations { - group.addTask { - _ = try await dispatcher.dispatch(op, context: context) - } - } - try await group.waitForAll() - } - let duration = Date().timeIntervalSince(startTime) - - // With max concurrency 2 and 5 operations at 100ms each, - // should take at least 300ms (3 batches) - #expect(duration > 0.25) - } - - // MARK: - Helpers - - private func createTestContext() throws -> ExecutionContext { - let stage = BuildStage( - id: UUID(), - name: "test", - base: ImageOperation( - source: .scratch, - platform: nil, - pullPolicy: .ifNotPresent, - verification: nil, - metadata: OperationMetadata() - ), - nodes: [], - platform: nil - ) - - let graph = try BuildGraph( - stages: [stage], - buildArgs: [:], - targetPlatforms: [.linuxAMD64], - metadata: BuildGraphMetadata() - ) - - let mockSnapshotter = MockSnapshotter() - return ExecutionContext( - stage: stage, - graph: graph, - platform: .linuxAMD64, - reporter: Reporter(), - snapshotter: mockSnapshotter - ) - } -} diff --git a/Tests/NativeBuilderTests/ContainerBuildExecutorTests/MockSnapshotter.swift b/Tests/NativeBuilderTests/ContainerBuildExecutorTests/MockSnapshotter.swift deleted file mode 100644 index 1aeb3a567..000000000 --- a/Tests/NativeBuilderTests/ContainerBuildExecutorTests/MockSnapshotter.swift +++ /dev/null @@ -1,103 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import ContainerBuildSnapshotter -import Foundation - -/// Mock snapshotter for testing -public actor MockSnapshotter: Snapshotter { - private var snapshots: [UUID: Snapshot] = [:] - private var mounts: [UUID: URL] = [:] - - public init() {} - - public func create(parent: Snapshot?) async throws -> Snapshot { - let id = UUID() - let digest = try Digest(algorithm: .sha256, bytes: Data(count: 32)) - let mountPoint = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - let snapshot = Snapshot( - id: id, - digest: digest, - size: 0, - parent: parent, - state: .prepared(mountpoint: mountPoint) - ) - snapshots[id] = snapshot - return snapshot - } - - public func prepare(_ snapshot: Snapshot) async throws -> Snapshot { - // Mock implementation - just return the snapshot as prepared - let mountPoint = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - let prepared = Snapshot( - id: snapshot.id, - digest: snapshot.digest, - size: snapshot.size, - parent: snapshot.parent, - state: .prepared(mountpoint: mountPoint) - ) - snapshots[snapshot.id] = prepared - return prepared - } - - public func commit(_ snapshot: Snapshot) async throws -> Snapshot { - let committedDigest = try Digest(algorithm: .sha256, bytes: Data(count: 32)) - let committed = Snapshot( - id: snapshot.id, - digest: committedDigest, - size: snapshot.size, - parent: snapshot.parent, - state: .committed( - layerDigest: committedDigest.stringValue, - layerSize: snapshot.size, - layerMediaType: "application/vnd.oci.image.layer.v1.tar+gzip", - diffKey: nil - ) - ) - snapshots[snapshot.id] = committed - return committed - } - - public func remove(_ snapshot: Snapshot) async throws { - snapshots.removeValue(forKey: snapshot.id) - mounts.removeValue(forKey: snapshot.id) - } - - public func mount(snapshot: Snapshot, at mountPoint: URL) async throws { - mounts[snapshot.id] = mountPoint - } - - public func unmount(snapshot: Snapshot) async throws { - mounts.removeValue(forKey: snapshot.id) - } - - public func getMountPoint(for snapshot: Snapshot) async -> URL? { - mounts[snapshot.id] - } - - public func list() async throws -> [Snapshot] { - Array(snapshots.values) - } - - public func get(id: UUID) async throws -> Snapshot? { - snapshots[id] - } - - public func getByDigest(_ digest: Digest) async throws -> Snapshot? { - snapshots.values.first { $0.digest == digest } - } -} diff --git a/Tests/NativeBuilderTests/ContainerBuildExecutorTests/ReporterTests.swift b/Tests/NativeBuilderTests/ContainerBuildExecutorTests/ReporterTests.swift deleted file mode 100644 index 6daffdfce..000000000 --- a/Tests/NativeBuilderTests/ContainerBuildExecutorTests/ReporterTests.swift +++ /dev/null @@ -1,212 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import ContainerBuildReporting -import Foundation -import Testing - -@testable import ContainerBuildExecutor - -@MainActor -final class EventCollector { - private var events: [BuildEvent] = [] - - func add(_ event: BuildEvent) { - events.append(event) - } - - func getEvents() -> [BuildEvent] { - events - } -} - -struct ReporterTests { - @Test func reporterEmitsEvents() async throws { - let reporter = Reporter() - let eventCollector = EventCollector() - - // Collect events in background - let collectionTask = Task { - for await event in reporter.stream { - await eventCollector.add(event) - } - } - - // Emit test events - await reporter.report(.buildStarted(totalOperations: 5, stages: 2, timestamp: Date())) - - let context = ReportContext( - nodeId: UUID(), - stageId: "test-stage", - description: "RUN echo hello" - ) - - await reporter.report(.operationStarted(context: context)) - await reporter.report(.operationLog(context: context, message: "Hello, world!")) - await reporter.report(.operationFinished(context: context, duration: 1.5)) - - await reporter.report(.buildCompleted(success: true, timestamp: Date())) - await reporter.finish() - - // Wait for collection to complete - await collectionTask.value - - // Verify events - let events = await eventCollector.getEvents() - #expect(events.count == 5) - - if case .buildStarted(let ops, let stages, _) = events[0] { - #expect(ops == 5) - #expect(stages == 2) - } else { - Issue.record("Expected buildStarted event") - } - - if case .operationStarted(let ctx) = events[1] { - #expect(ctx.description == "RUN echo hello") - } else { - Issue.record("Expected operationStarted event") - } - - if case .operationLog(_, let message) = events[2] { - #expect(message == "Hello, world!") - } else { - Issue.record("Expected operationLog event") - } - - if case .operationFinished(_, let duration) = events[3] { - #expect(duration == 1.5) - } else { - Issue.record("Expected operationFinished event") - } - - if case .buildCompleted(let success, _) = events[4] { - #expect(success == true) - } else { - Issue.record("Expected buildCompleted event") - } - } - - @Test func plainProgressConsumer() async throws { - let reporter = Reporter() - - // Start consumer in background - let consumerTask = Task { - let consumer = PlainProgressConsumer(configuration: .init()) - try await consumer.consume(reporter: reporter) - } - - // Emit events - await reporter.report(.buildStarted(totalOperations: 2, stages: 1, timestamp: Date())) - - let nodeId = UUID() - let context = ReportContext( - nodeId: nodeId, - stageId: "main", - description: "RUN apt-get update" - ) - - await reporter.report(.stageStarted(stageName: "main", timestamp: Date())) - await reporter.report(.operationStarted(context: context)) - await reporter.report(.operationLog(context: context, message: "Reading package lists...")) - await reporter.report(.operationLog(context: context, message: "Building dependency tree...")) - await reporter.report(.operationFinished(context: context, duration: 2.3)) - await reporter.report(.stageCompleted(stageName: "main", timestamp: Date())) - await reporter.report(.buildCompleted(success: true, timestamp: Date())) - - await reporter.finish() - try await consumerTask.value - - // Test passes if consumer completes without error - } - - @Test func operationCacheHit() async throws { - let reporter = Reporter() - let eventCollector = EventCollector() - - let collectionTask = Task { - for await event in reporter.stream { - await eventCollector.add(event) - } - } - - let context = ReportContext( - nodeId: UUID(), - stageId: "cached-stage", - description: "COPY src/ /app/" - ) - - await reporter.report(.operationStarted(context: context)) - await reporter.report(.operationCacheHit(context: context)) - await reporter.finish() - - await collectionTask.value - - let events = await eventCollector.getEvents() - #expect(events.count == 2) - - if case .operationCacheHit(let ctx) = events[1] { - #expect(ctx.description == "COPY src/ /app/") - } else { - Issue.record("Expected operationCacheHit event") - } - } - - @Test func operationFailure() async throws { - let reporter = Reporter() - let eventCollector = EventCollector() - - let collectionTask = Task { - for await event in reporter.stream { - await eventCollector.add(event) - } - } - - let context = ReportContext( - nodeId: UUID(), - stageId: "failing-stage", - description: "RUN false" - ) - - let error = BuildEventError( - type: .executionFailed, - description: "Command exited with non-zero status", - diagnostics: [ - "exitCode": "1", - "workingDirectory": "/app", - ] - ) - - await reporter.report(.operationStarted(context: context)) - await reporter.report(.operationFailed(context: context, error: error)) - await reporter.report(.buildCompleted(success: false, timestamp: Date())) - await reporter.finish() - - await collectionTask.value - - let events = await eventCollector.getEvents() - #expect(events.count == 3) - - if case .operationFailed(let ctx, let err) = events[1] { - #expect(ctx.description == "RUN false") - #expect(err.type == .executionFailed) - #expect(err.diagnostics?["exitCode"] == "1") - } else { - Issue.record("Expected operationFailed event") - } - } -} diff --git a/Tests/NativeBuilderTests/ContainerBuildIRTests/AnalysisTests.swift b/Tests/NativeBuilderTests/ContainerBuildIRTests/AnalysisTests.swift deleted file mode 100644 index dc05fdac0..000000000 --- a/Tests/NativeBuilderTests/ContainerBuildIRTests/AnalysisTests.swift +++ /dev/null @@ -1,612 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -@testable import ContainerBuildIR -@testable import ContainerBuildReporting - -struct AnalysisTests { - - // MARK: - SemanticAnalyzer Integration Tests - - @Test func semanticAnalyzerGraphAnalyzerProtocol() throws { - guard let alpineRef = ImageReference(parsing: "alpine:latest") else { - Issue.record("Failed to parse image reference") - return - } - - let originalGraph = try GraphBuilder.singleStage(from: alpineRef) { builder in - try builder - .run("apk add --no-cache curl") - .workdir("/app") - .copyFromContext(paths: ["src/"], to: "/app/") - .user(.uid(1000)) - .entrypoint(.exec(["/app/server"])) - } - - let analyzer = SemanticAnalyzer() - let context = AnalysisContext(reporter: nil, sourceMap: nil) - - let analyzedGraph = try analyzer.analyze(originalGraph, context: context) - - // SemanticAnalyzer should return the graph unchanged - #expect(analyzedGraph.stages.count == originalGraph.stages.count) - #expect(analyzedGraph.buildArgs == originalGraph.buildArgs) - #expect(analyzedGraph.targetPlatforms == originalGraph.targetPlatforms) - } - - @Test func semanticAnalyzerWithReporter() async throws { - guard let ubuntuRef = ImageReference(parsing: "ubuntu:22.04") else { - Issue.record("Failed to parse image reference") - return - } - - // Create a graph with potential issues - let graph = try GraphBuilder.singleStage(from: ubuntuRef) { builder in - try builder - .run("apt-get update") // Separate update (should trigger layer warning) - .run("apt-get install -y curl") // Separate install (should trigger layer warning) - .run("wget https://example.com/script.sh") // Unverified download (security warning) - .workdir("/app") - .copyFromContext(paths: ["src/"], to: "/app/") - // No USER instruction (should trigger security warning) - } - - let reporter = Reporter() - let context = AnalysisContext(reporter: reporter, sourceMap: nil) - - let analyzer = SemanticAnalyzer() - let _ = try analyzer.analyze(graph, context: context) - - // Allow some time for async reporting - try await Task.sleep(nanoseconds: 100_000_000) // 0.1 second - - // Verify that some events were reported - // Note: We can't easily test the exact events without implementing a test reporter - // that captures events, but we can verify the analyze method completed successfully - #expect(Bool(true)) // Analysis completed without throwing - } - - // MARK: - Layer Efficiency Analysis Tests - - @Test func layerEfficiencyMultiplePackageManagers() throws { - guard let ubuntuRef = ImageReference(parsing: "ubuntu:22.04") else { - Issue.record("Failed to parse image reference") - return - } - - // Create a graph with multiple package manager calls - let graph = try GraphBuilder.singleStage(from: ubuntuRef) { builder in - try builder - .run("apt-get update") - .run("apt-get install -y curl") - .run("apt-get install -y wget") - .run("apt-get install -y git") - } - - let analyzer = SemanticAnalyzer() - let context = AnalysisContext(reporter: nil, sourceMap: nil) - - // This should trigger layer efficiency warnings - let _ = try analyzer.analyze(graph, context: context) - - // Verify analysis completed successfully - #expect(Bool(true)) - } - - @Test func layerEfficiencyAddThenRemove() throws { - guard let alpineRef = ImageReference(parsing: "alpine:latest") else { - Issue.record("Failed to parse image reference") - return - } - - // Create a graph that adds then removes files - let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in - try builder - .run("apk add --no-cache build-base") - .copyFromContext(paths: ["src/"], to: "/tmp/build/") - .run("cd /tmp/build && make") - .run("rm -rf /tmp/build") // Remove build files - } - - let analyzer = SemanticAnalyzer() - let context = AnalysisContext(reporter: nil, sourceMap: nil) - - // This should trigger layer efficiency warnings about unnecessary files - let _ = try analyzer.analyze(graph, context: context) - - #expect(Bool(true)) - } - - // MARK: - Security Analysis Tests - - @Test func securityAnalysisRootUser() throws { - guard let alpineRef = ImageReference(parsing: "alpine:latest") else { - Issue.record("Failed to parse image reference") - return - } - - // Create a graph that runs as root (no USER instruction) - let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in - try builder - .run("apk add --no-cache curl") - .workdir("/app") - .copyFromContext(paths: ["app"], to: "/app/") - .entrypoint(.exec(["/app/app"])) - // No USER instruction - should trigger security warning - } - - let analyzer = SemanticAnalyzer() - let context = AnalysisContext(reporter: nil, sourceMap: nil) - - let _ = try analyzer.analyze(graph, context: context) - - #expect(Bool(true)) - } - - @Test func securityAnalysisPrivilegedExecution() throws { - guard let alpineRef = ImageReference(parsing: "alpine:latest") else { - Issue.record("Failed to parse image reference") - return - } - - // Create a graph with privileged execution - let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in - try builder - .run("mount /dev/sda1 /mnt") - .workdir("/app") - .user(.uid(1000)) - } - - let analyzer = SemanticAnalyzer() - let context = AnalysisContext(reporter: nil, sourceMap: nil) - - let _ = try analyzer.analyze(graph, context: context) - - #expect(Bool(true)) - } - - @Test func securityAnalysisUnverifiedDownloads() throws { - guard let alpineRef = ImageReference(parsing: "alpine:latest") else { - Issue.record("Failed to parse image reference") - return - } - - // Create a graph with unverified downloads - let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in - try builder - .run("wget https://example.com/install.sh && sh install.sh") - .run("curl -sSL https://get.docker.com | sh") - .workdir("/app") - .user(.uid(1000)) - } - - let analyzer = SemanticAnalyzer() - let context = AnalysisContext(reporter: nil, sourceMap: nil) - - let _ = try analyzer.analyze(graph, context: context) - - #expect(Bool(true)) - } - - @Test func securityAnalysisUnpinnedDependencies() throws { - guard let ubuntuRef = ImageReference(parsing: "ubuntu:22.04") else { - Issue.record("Failed to parse image reference") - return - } - - // Create a graph with unpinned dependencies - let graph = try GraphBuilder.singleStage(from: ubuntuRef) { builder in - try builder - .run("apt-get update") - .run("apt-get install -y curl") // No version pinning - .run("pip install flask") // No version pinning - .workdir("/app") - .user(.uid(1000)) - } - - let analyzer = SemanticAnalyzer() - let context = AnalysisContext(reporter: nil, sourceMap: nil) - - let _ = try analyzer.analyze(graph, context: context) - - #expect(Bool(true)) - } - - // MARK: - Cache Analysis Tests - - @Test func cacheAnalysisTimestampInvalidation() throws { - guard let alpineRef = ImageReference(parsing: "alpine:latest") else { - Issue.record("Failed to parse image reference") - return - } - - // Create a graph with timestamp-based cache invalidation - let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in - try builder - .run("apk add --no-cache curl") - .run("echo $(date) > /app/build-time.txt") // Timestamp invalidates cache - .workdir("/app") - .user(.uid(1000)) - } - - let analyzer = SemanticAnalyzer() - let context = AnalysisContext(reporter: nil, sourceMap: nil) - - let _ = try analyzer.analyze(graph, context: context) - - #expect(Bool(true)) - } - - @Test func cacheAnalysisRandomInvalidation() throws { - guard let alpineRef = ImageReference(parsing: "alpine:latest") else { - Issue.record("Failed to parse image reference") - return - } - - // Create a graph with random data cache invalidation - let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in - try builder - .run("apk add --no-cache curl") - .run("echo $RANDOM > /app/random.txt") // Random invalidates cache - .run("openssl rand -hex 16 > /app/key.txt") // Random invalidates cache - .workdir("/app") - .user(.uid(1000)) - } - - let analyzer = SemanticAnalyzer() - let context = AnalysisContext(reporter: nil, sourceMap: nil) - - let _ = try analyzer.analyze(graph, context: context) - - #expect(Bool(true)) - } - - // MARK: - Size Optimization Tests - - @Test func sizeOptimizationPackageCache() throws { - guard let ubuntuRef = ImageReference(parsing: "ubuntu:22.04") else { - Issue.record("Failed to parse image reference") - return - } - - // Create a graph without package cache cleanup - let graph = try GraphBuilder.singleStage(from: ubuntuRef) { builder in - try builder - .run("apt-get update") - .run("apt-get install -y curl wget git") - // No cleanup - should trigger size optimization warning - .workdir("/app") - .user(.uid(1000)) - } - - let analyzer = SemanticAnalyzer() - let context = AnalysisContext(reporter: nil, sourceMap: nil) - - let _ = try analyzer.analyze(graph, context: context) - - #expect(Bool(true)) - } - - @Test func sizeOptimizationBuildDependencies() throws { - guard let alpineRef = ImageReference(parsing: "alpine:latest") else { - Issue.record("Failed to parse image reference") - return - } - - // Create a single-stage graph with build dependencies - let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in - try builder - .run("apk add --no-cache build-base gcc-dev") // Build dependencies - .workdir("/app") - .copyFromContext(paths: ["src/"], to: "/app/") - .run("make") - .user(.uid(1000)) - .entrypoint(.exec(["/app/server"])) - } - - let analyzer = SemanticAnalyzer() - let context = AnalysisContext(reporter: nil, sourceMap: nil) - - let _ = try analyzer.analyze(graph, context: context) - - #expect(Bool(true)) - } - - @Test func sizeOptimizationMultiStageComparison() throws { - guard let alpineRef = ImageReference(parsing: "alpine:latest") else { - Issue.record("Failed to parse image reference") - return - } - - // Create a multi-stage graph (should NOT trigger build dependency warnings) - let graph = try GraphBuilder.multiStage { builder in - try builder - .stage(name: "builder", from: alpineRef) - .run("apk add --no-cache build-base") - .workdir("/app") - .copyFromContext(paths: ["src/"], to: "/app/") - .run("make") - - try builder - .stage(from: alpineRef) - .copyFromStage(.named("builder"), paths: ["/app/server"], to: "/app/") - .user(.uid(1000)) - .entrypoint(.exec(["/app/server"])) - } - - let analyzer = SemanticAnalyzer() - let context = AnalysisContext(reporter: nil, sourceMap: nil) - - let _ = try analyzer.analyze(graph, context: context) - - #expect(Bool(true)) - } - - // MARK: - Custom Analyzer Tests - - struct CustomSecurityAnalyzer: GraphAnalyzer { - func analyze(_ graph: BuildGraph, context: AnalysisContext) throws -> BuildGraph { - // Custom analysis: Check for hardcoded secrets - for stage in graph.stages { - for node in stage.nodes { - if let exec = node.operation as? ExecOperation { - if case .shell(let cmd) = exec.command { - if cmd.contains("PASSWORD=") || cmd.contains("SECRET=") { - if let reporter = context.reporter { - Task { - await reporter.report( - .irEvent( - context: ReportContext( - description: "Potential hardcoded secret detected in command", - sourceMap: nil - ), - type: .error - )) - } - } - } - } - } - } - } - - return graph - } - } - - @Test func customAnalyzerExtensibility() throws { - guard let alpineRef = ImageReference(parsing: "alpine:latest") else { - Issue.record("Failed to parse image reference") - return - } - - // Create a graph with hardcoded secrets - let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in - try builder - .run("export PASSWORD=secret123") // Hardcoded secret - .run("SECRET=api_key_123 ./app") // Hardcoded secret - .workdir("/app") - .user(.uid(1000)) - } - - let customAnalyzer = CustomSecurityAnalyzer() - let context = AnalysisContext(reporter: nil, sourceMap: nil) - - let _ = try customAnalyzer.analyze(graph, context: context) - - #expect(Bool(true)) - } - - // MARK: - Analyzer Chain Tests - - @Test func analyzerChain() throws { - guard let alpineRef = ImageReference(parsing: "alpine:latest") else { - Issue.record("Failed to parse image reference") - return - } - - let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in - try builder - .run("apk add --no-cache curl") - .workdir("/app") - .copyFromContext(paths: ["src/"], to: "/app/") - .user(.uid(1000)) - .entrypoint(.exec(["/app/server"])) - } - - let analyzers: [any GraphAnalyzer] = [ - DependencyAnalyzer(), - SemanticAnalyzer(), - CustomSecurityAnalyzer(), - ] - - var currentGraph = graph - let context = AnalysisContext(reporter: nil, sourceMap: nil) - - // Apply analyzers in sequence - for analyzer in analyzers { - currentGraph = try analyzer.analyze(currentGraph, context: context) - } - - // Graph should be preserved through the chain - #expect(currentGraph.stages.count == graph.stages.count) - #expect(currentGraph.buildArgs == graph.buildArgs) - } - - // MARK: - Performance Tests - - @Test func analysisPerformanceLargeGraph() throws { - guard let alpineRef = ImageReference(parsing: "alpine:latest") else { - Issue.record("Failed to parse image reference") - return - } - - // Create a large graph for performance testing - let graph = try GraphBuilder.multiStage { builder in - for stageIndex in 0..<10 { - try builder - .stage(name: "stage\(stageIndex)", from: alpineRef) - .run("apk add --no-cache curl") - .run("apk add --no-cache wget") - .run("apk add --no-cache git") - .workdir("/app") - .copyFromContext(paths: ["file\(stageIndex).txt"], to: "/app/") - .env("STAGE", "\(stageIndex)") - .label("stage.number", "\(stageIndex)") - .user(.uid(1000)) - } - } - - let analyzer = SemanticAnalyzer() - let context = AnalysisContext(reporter: nil, sourceMap: nil) - - let startTime = Date() - let _ = try analyzer.analyze(graph, context: context) - let duration = Date().timeIntervalSince(startTime) - - print("Semantic analysis for large graph (10 stages): \(String(format: "%.3f", duration))s") - #expect(duration < 1.0, "Analysis should complete quickly for large graphs") - } - - // MARK: - Error Handling Tests - - @Test func analysisErrorHandling() throws { - // Test that analysis handles malformed operations gracefully - guard let alpineRef = ImageReference(parsing: "alpine:latest") else { - Issue.record("Failed to parse image reference") - return - } - - // GraphBuilder will throw validation errors for malformed operations - #expect(throws: ValidationError.self) { - try GraphBuilder.singleStage(from: alpineRef) { builder in - try builder - .run("") // Empty command - .workdir("") // Empty workdir - .copyFromContext(paths: [], to: "") // Empty paths and destination - .user(.uid(0)) // Root user - } - } - } - - // MARK: - Reporting Integration Tests - - @Test func analysisReportingIntegration() async throws { - guard let ubuntuRef = ImageReference(parsing: "ubuntu:22.04") else { - Issue.record("Failed to parse image reference") - return - } - - // Create a graph with multiple types of issues - let graph = try GraphBuilder.singleStage(from: ubuntuRef) { builder in - try builder - .run("apt-get update") // Layer efficiency issue - .run("apt-get install -y curl") // Layer efficiency issue - .run("apt-get install -y wget") // Layer efficiency issue - .run("wget https://example.com/script.sh") // Security issue - .run("echo $RANDOM > /app/random.txt") // Cache invalidation issue - .workdir("/app") - // No USER instruction - security issue - // No cleanup - size optimization issue - } - - let reporter = Reporter() - let context = AnalysisContext( - reporter: reporter, - sourceMap: SourceMap( - file: "Dockerfile", - line: 1, - column: 1, - snippet: "FROM ubuntu:22.04" - ) - ) - - let analyzer = SemanticAnalyzer() - let _ = try analyzer.analyze(graph, context: context) - - // Allow time for async reporting - try await Task.sleep(nanoseconds: 100_000_000) // 0.1 second - - #expect(Bool(true)) // Analysis and reporting completed successfully - } - - // MARK: - Real-world Scenario Tests - - @Test func analysisNodeJSApplication() throws { - guard let nodeRef = ImageReference(parsing: "node:18-alpine") else { - Issue.record("Failed to parse image reference") - return - } - - // Realistic Node.js application build - let graph = try GraphBuilder.singleStage(from: nodeRef) { builder in - try builder - .run("apk add --no-cache dumb-init") // Good: single package install - .workdir("/app") - .copyFromContext(paths: ["package*.json"], to: "./") - .run("npm ci --only=production && npm cache clean --force") // Good: cleanup - .copyFromContext(paths: ["src/"], to: "./src/") - .user(.uid(1000)) // Good: non-root user - .expose([PortSpec(port: 3000)]) - .entrypoint(.exec(["dumb-init", "node", "src/index.js"])) - } - - let analyzer = SemanticAnalyzer() - let context = AnalysisContext(reporter: nil, sourceMap: nil) - - let _ = try analyzer.analyze(graph, context: context) - - #expect(Bool(true)) - } - - @Test func analysisGoApplication() throws { - guard let golangRef = ImageReference(parsing: "golang:1.21-alpine"), - let alpineRef = ImageReference(parsing: "alpine:latest") - else { - Issue.record("Failed to parse image references") - return - } - - // Realistic Go multi-stage build - let graph = try GraphBuilder.multiStage { builder in - try builder - .stage(name: "builder", from: golangRef) - .workdir("/src") - .copyFromContext(paths: ["go.mod", "go.sum"], to: "./") - .run("go mod download") - .copyFromContext(paths: [".", "!**/*_test.go"], to: "./") - .run("CGO_ENABLED=0 GOOS=linux go build -ldflags='-w -s' -o /app cmd/main.go") - - try builder - .stage(from: alpineRef) - .run("apk add --no-cache ca-certificates && rm -rf /var/cache/apk/*") // Good: cleanup - .copyFromStage(.named("builder"), paths: ["/app"], to: "/usr/local/bin/") - .user(.uid(65534)) // Good: nobody user - .expose([PortSpec(port: 8080)]) - .entrypoint(.exec(["/usr/local/bin/app"])) - } - - let analyzer = SemanticAnalyzer() - let context = AnalysisContext(reporter: nil, sourceMap: nil) - - let _ = try analyzer.analyze(graph, context: context) - - #expect(Bool(true)) - } -} diff --git a/Tests/NativeBuilderTests/ContainerBuildIRTests/BasicTests.swift b/Tests/NativeBuilderTests/ContainerBuildIRTests/BasicTests.swift deleted file mode 100644 index 330dd12b6..000000000 --- a/Tests/NativeBuilderTests/ContainerBuildIRTests/BasicTests.swift +++ /dev/null @@ -1,315 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerizationOCI -import Foundation -import Testing - -@testable import ContainerBuildIR - -struct BasicTests { - - @Test func digestCreation() throws { - // Test creating digest from bytes - let bytes = Data(repeating: 0xAB, count: 32) - let digest = try Digest(algorithm: .sha256, bytes: bytes) - #expect(digest.algorithm == .sha256) - #expect(digest.bytes == bytes) - - // Test parsing digest string - let digestString = "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" - let parsed = try Digest(parsing: digestString) - #expect(parsed.stringValue == digestString) - - // Test invalid length - #expect(throws: Error.self) { - try Digest(algorithm: .sha256, bytes: Data(count: 16)) - } - } - - @Test func imageReference() throws { - // Test parsing various formats - let refs = [ - ("ubuntu", "ubuntu:latest"), - ("ubuntu:20.04", "ubuntu:20.04"), - ("ghcr.io/owner/repo:tag", "ghcr.io/owner/repo:tag"), - ("localhost:5000/test", "localhost:5000/test:latest"), - ("image@sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", "image@sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"), - ] - - for (input, expected) in refs { - guard let ref = ImageReference(parsing: input) else { - Issue.record("Failed to parse: \(input)") - continue - } - #expect(ref.stringValue == expected, "Failed for input: \(input)") - } - - // Test creation - let ref = try ImageReference( - registry: "docker.io", - repository: "library/nginx", - tag: "alpine" - ) - #expect(ref.stringValue == "docker.io/library/nginx:alpine") - } - - @Test func simpleGraph() throws { - // Create a simple graph - guard let imageRef = ImageReference(parsing: "alpine:latest") else { - Issue.record("Failed to parse image reference") - return - } - let graph = try GraphBuilder.singleStage( - from: imageRef - ) { builder in - try builder - .run("apk add --no-cache curl") - .workdir("/app") - .copyFromContext(paths: ["main.go"], to: "/app/") - .run("go build -o app main.go") - .entrypoint(.exec(["/app/app"])) - } - - #expect(graph.stages.count == 1) - #expect(graph.stages[0].nodes.count == 5) - - // Validate the graph - let validator = StandardValidator() - let result = validator.validate(graph) - #expect(result.isValid == true, "Graph validation failed: \(result.errors)") - } - - @Test func multiStageGraph() throws { - guard let golangRef = ImageReference(parsing: "golang:1.21"), - let alpineRef = ImageReference(parsing: "alpine:latest") - else { - Issue.record("Failed to parse image references") - return - } - - let graph = try GraphBuilder.multiStage { builder in - // Build stage - try builder - .stage(name: "builder", from: golangRef) - .workdir("/src") - .copyFromContext(paths: ["go.mod", "go.sum", "*.go"], to: "./") - .run("go build -o /app") - - // Runtime stage - try builder - .stage(from: alpineRef) - .run("apk add --no-cache ca-certificates") - .copyFromStage(.named("builder"), paths: ["/app"], to: "/usr/local/bin/app") - .user(.uid(1000)) - .entrypoint(Command.exec(["/usr/local/bin/app"])) - } - - #expect(graph.stages.count == 2) - #expect(graph.stages[0].name == "builder") - #expect(graph.stages[1].name == nil) - - // Check stage dependencies - let deps = graph.stages[1].stageDependencies() - #expect(deps.contains(.named("builder")) == true) - } - - @Test func operationTypes() throws { - // Test ExecOperation - let execOp = ExecOperation( - command: .shell("echo 'Hello, World!'"), - environment: Environment([ - (key: "FOO", value: .literal("bar")) - ]), - workingDirectory: "/tmp" - ) - #expect(execOp.command.displayString == "echo 'Hello, World!'") - #expect(execOp.environment.effectiveEnvironment["FOO"] == "bar") - - // Test FilesystemOperation - let fsOp = FilesystemOperation( - action: .copy, - source: .context(ContextSource(paths: ["file.txt"])), - destination: "/app/file.txt", - fileMetadata: FileMetadata( - ownership: Ownership(user: .numeric(id: 1000), group: .numeric(id: 1000)), - permissions: .mode(0o644) - ) - ) - #expect(fsOp.action == .copy) - #expect(fsOp.destination == "/app/file.txt") - - // Test MetadataOperation - let metaOp = MetadataOperation( - action: .setLabel(key: "version", value: "1.0.0") - ) - if case .setLabel(let key, let value) = metaOp.action { - #expect(key == "version") - #expect(value == "1.0.0") - } else { - Issue.record("Wrong metadata action type") - } - } - - @Test func serialization() throws { - // Create a graph using the builder - guard let imageRef = ImageReference(parsing: "ubuntu:22.04") else { - Issue.record("Failed to parse image reference") - return - } - - let originalGraph = try GraphBuilder.singleStage( - from: imageRef, - platform: .linuxAMD64 - ) { builder in - try builder - .run("apt-get update && apt-get install -y curl") - .workdir("/app") - .copyFromContext(paths: ["package.json", "src/"], to: "/app/") - .run("npm install") - .env("NODE_ENV", "production") - .expose([PortSpec(port: 3000)]) - .cmd(Command.exec(["node", "src/index.js"])) - } - - // Serialize to JSON - let coder = JSONIRCoder(prettyPrint: true) - let data = try coder.encode(originalGraph) - - // Deserialize - let decodedGraph = try coder.decode(data) - - // Compare - #expect(originalGraph.stages.count == decodedGraph.stages.count) - #expect(originalGraph.buildArgs == decodedGraph.buildArgs) - #expect(originalGraph.targetPlatforms == decodedGraph.targetPlatforms) - } - - @Test func validation() throws { - // Create a graph with issues - guard let ubuntuRef = ImageReference(parsing: "ubuntu") else { - Issue.record("Failed to parse image reference") - return - } - - let builder = GraphBuilder() - try builder - .stage(from: ubuntuRef) - .run("apt-get update") // Warning: update without install - .copyFromStage(.named("nonexistent"), paths: ["/file"], to: "/") // Error: stage doesn't exist - - // GraphBuilder should throw validation errors during build - #expect(throws: ValidationError.self) { - try builder.build() - } - } - - @Test func semanticAnalysis() throws { - // Create a Python build graph inline - let graph = try BuildGraph( - stages: [ - // Dependencies stage - BuildStage( - name: "dependencies", - base: ImageOperation( - source: .registry(ImageReference(parsing: "python:3.11-slim")!), - platform: .linuxAMD64 - ), - nodes: [ - BuildNode( - operation: MetadataOperation( - action: .setWorkdir(path: "/app") - ) - ), - BuildNode( - operation: FilesystemOperation( - action: .copy, - source: .context(ContextSource(paths: ["requirements.txt"])), - destination: "/app/" - ) - ), - BuildNode( - operation: ExecOperation( - command: .shell("pip install --user --no-cache-dir -r requirements.txt") - ) - ), - ] - ), - - // Application stage - BuildStage( - name: "app", - base: ImageOperation( - source: .registry(ImageReference(parsing: "python:3.11-slim")!), - platform: .linuxAMD64 - ), - nodes: [ - BuildNode( - operation: FilesystemOperation( - action: .copy, - source: .stage(.named("dependencies"), paths: ["/root/.local"]), - destination: "/root/.local" - ) - ), - BuildNode( - operation: MetadataOperation( - action: .setUser(user: .uidGid(uid: 1000, gid: 1000)) - ) - ), - BuildNode( - operation: MetadataOperation( - action: .setCmd(command: .exec(["python", "main.py"])) - ) - ), - ] - ), - ], - targetPlatforms: [.linuxAMD64, .linuxARM64] - ) - - let analyzer = SemanticAnalyzer() - let context = AnalysisContext(reporter: nil, sourceMap: nil) - let analyzedGraph = try analyzer.analyze(graph, context: context) - - // SemanticAnalyzer should return the graph unchanged - #expect(analyzedGraph.stages.count == graph.stages.count) - #expect(analyzedGraph.buildArgs.count == graph.buildArgs.count) - } - - @Test func graphTraversal() throws { - let stage = BuildStage( - name: "test", - base: ImageOperation(source: .scratch), - nodes: [ - BuildNode(id: UUID(), operation: MetadataOperation(action: .setWorkdir(path: "/app")), dependencies: []), - BuildNode(id: UUID(), operation: ExecOperation(command: .shell("echo test")), dependencies: []), - BuildNode(id: UUID(), operation: MetadataOperation(action: .setUser(user: .uid(1000))), dependencies: []), - ] - ) - - // Test topological sort - let sorted = try GraphTraversal.topologicalSort(stage) - #expect(sorted.count == stage.nodes.count) - - // Test finding roots - let roots = GraphTraversal.findRoots(in: stage) - #expect(roots.count == 3) // All nodes are roots in this case - - // Test finding leaves - let leaves = GraphTraversal.findLeaves(in: stage) - #expect(leaves.count == 3) // All nodes are leaves in this case - } -} diff --git a/Tests/NativeBuilderTests/ContainerBuildIRTests/DependencyAnalysisTests.swift b/Tests/NativeBuilderTests/ContainerBuildIRTests/DependencyAnalysisTests.swift deleted file mode 100644 index 98e8dff6e..000000000 --- a/Tests/NativeBuilderTests/ContainerBuildIRTests/DependencyAnalysisTests.swift +++ /dev/null @@ -1,733 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -@testable import ContainerBuildIR -@testable import ContainerBuildReporting - -struct DependencyAnalysisTests { - - // MARK: - DependencyAnalyzer Tests - - @Test func sequentialDependenciesInStage() throws { - // Create a stage with multiple operations that should depend on each other - let baseOp = ImageOperation(source: .registry(ImageReference(parsing: "alpine")!)) - let stage = BuildStage( - name: "test", - base: baseOp, - nodes: [ - BuildNode( - operation: ExecOperation(command: .shell("echo 'step 1'")), - dependencies: [] - ), - BuildNode( - operation: ExecOperation(command: .shell("echo 'step 2'")), - dependencies: [] - ), - BuildNode( - operation: ExecOperation(command: .shell("echo 'step 3'")), - dependencies: [] - ), - ] - ) - - let graph = try BuildGraph(stages: [stage]) - let analyzer = DependencyAnalyzer() - let context = AnalysisContext(reporter: nil, sourceMap: nil) - - let analyzedGraph = try analyzer.analyze(graph, context: context) - let analyzedStage = analyzedGraph.stages[0] - - // First node should have no dependencies - #expect(analyzedStage.nodes[0].dependencies.isEmpty) - - // Second node should depend on first - #expect(analyzedStage.nodes[1].dependencies.contains(analyzedStage.nodes[0].id)) - - // Third node should depend on second - #expect(analyzedStage.nodes[2].dependencies.contains(analyzedStage.nodes[1].id)) - - // Verify chain: node[0] -> node[1] -> node[2] - #expect(analyzedStage.nodes[1].dependencies.count == 1) - #expect(analyzedStage.nodes[2].dependencies.count == 1) - } - - @Test(.disabled()) func crossStageDependenciesWithCopyFrom() throws { - guard let alpineRef = ImageReference(parsing: "alpine"), - let ubuntuRef = ImageReference(parsing: "ubuntu") - else { - Issue.record("Failed to parse image references") - return - } - - // Create multi-stage build with COPY --from dependencies - let buildStage = BuildStage( - name: "builder", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - operation: ExecOperation(command: .shell("echo 'building...'")), - dependencies: [] - ), - BuildNode( - operation: ExecOperation(command: .shell("echo 'app built' > /app/binary")), - dependencies: [] - ), - ] - ) - - let runtimeStage = BuildStage( - name: "runtime", - base: ImageOperation(source: .registry(ubuntuRef)), - nodes: [ - BuildNode( - operation: FilesystemOperation( - action: .copy, - source: .stage(.named("builder"), paths: ["/app/binary"]), - destination: "/usr/local/bin/app" - ), - dependencies: [] - ), - BuildNode( - operation: MetadataOperation(action: .setEntrypoint(command: .exec(["/usr/local/bin/app"]))), - dependencies: [] - ), - ] - ) - - let graph = try BuildGraph(stages: [buildStage, runtimeStage]) - let analyzer = DependencyAnalyzer() - let context = AnalysisContext(reporter: nil, sourceMap: nil) - - let analyzedGraph = try analyzer.analyze(graph, context: context) - - // Verify cross-stage dependency was established - let analyzedRuntime = analyzedGraph.stages[1] - let copyNode = analyzedRuntime.nodes[0] - let lastBuildNode = analyzedGraph.stages[0].nodes.last! - - #expect( - copyNode.dependencies.contains(lastBuildNode.id), - "COPY --from should depend on last operation in source stage") - - // Verify intra-stage dependency in runtime stage - let entrypointNode = analyzedRuntime.nodes[1] - #expect( - entrypointNode.dependencies.contains(copyNode.id), - "Entrypoint should depend on copy operation") - } - - @Test(.disabled()) func stageReferenceResolution() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - let stage1 = BuildStage( - name: "stage1", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - operation: ExecOperation(command: .shell("echo 'stage1' > /file1")), - dependencies: [] - ) - ] - ) - - let stage2 = BuildStage( - name: "stage2", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - operation: ExecOperation(command: .shell("echo 'stage2' > /file2")), - dependencies: [] - ) - ] - ) - - let finalStage = BuildStage( - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - // Test named reference - BuildNode( - operation: FilesystemOperation( - action: .copy, - source: .stage(.named("stage1"), paths: ["/file1"]), - destination: "/final/file1" - ), - dependencies: [] - ), - // Test index reference - BuildNode( - operation: FilesystemOperation( - action: .copy, - source: .stage(.index(1), paths: ["/file2"]), - destination: "/final/file2" - ), - dependencies: [] - ), - // Test previous reference - BuildNode( - operation: FilesystemOperation( - action: .copy, - source: .stage(.previous, paths: ["/file2"]), - destination: "/final/file2-prev" - ), - dependencies: [] - ), - ] - ) - - let graph = try BuildGraph(stages: [stage1, stage2, finalStage]) - let analyzer = DependencyAnalyzer() - let context = AnalysisContext(reporter: nil, sourceMap: nil) - - let analyzedGraph = try analyzer.analyze(graph, context: context) - let analyzedFinal = analyzedGraph.stages[2] - - // Verify named reference dependency - let namedCopyNode = analyzedFinal.nodes[0] - let stage1LastNode = analyzedGraph.stages[0].nodes.last! - #expect(namedCopyNode.dependencies.contains(stage1LastNode.id)) - - // Verify index reference dependency - let indexCopyNode = analyzedFinal.nodes[1] - let stage2LastNode = analyzedGraph.stages[1].nodes.last! - #expect(indexCopyNode.dependencies.contains(stage2LastNode.id)) - - // Verify previous reference dependency - let previousCopyNode = analyzedFinal.nodes[2] - #expect(previousCopyNode.dependencies.contains(stage2LastNode.id)) - } - - @Test func preserveExistingDependencies() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - // Create nodes with existing dependencies - let node1 = BuildNode( - operation: ExecOperation(command: .shell("echo 'node1'")), - dependencies: [] - ) - - let node2 = BuildNode( - operation: ExecOperation(command: .shell("echo 'node2'")), - dependencies: [] - ) - - let node3 = BuildNode( - operation: ExecOperation(command: .shell("echo 'node3'")), - dependencies: Set([node1.id]) // Explicitly depends on node1, not node2 - ) - - let stage = BuildStage( - name: "test", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [node1, node2, node3] - ) - - let graph = try BuildGraph(stages: [stage]) - let analyzer = DependencyAnalyzer() - let context = AnalysisContext(reporter: nil, sourceMap: nil) - - let analyzedGraph = try analyzer.analyze(graph, context: context) - let analyzedStage = analyzedGraph.stages[0] - - // Node1 should have no dependencies - #expect(analyzedStage.nodes[0].dependencies.isEmpty) - - // Node2 should depend on node1 (sequential) - #expect(analyzedStage.nodes[1].dependencies.contains(analyzedStage.nodes[0].id)) - - // Node3 should preserve its explicit dependency on node1 - // and NOT have sequential dependency on node2 - let node3Analyzed = analyzedStage.nodes[2] - #expect(node3Analyzed.dependencies.contains(node1.id)) - #expect(!node3Analyzed.dependencies.contains(node2.id)) - #expect(node3Analyzed.dependencies.count == 1) - } - - // MARK: - GraphTraversal Tests - - @Test func topologicalSort() throws { - // Create a stage with dependencies: A -> B -> C, A -> D -> C - let nodeA = BuildNode( - operation: ExecOperation(command: .shell("echo 'A'")), - dependencies: [] - ) - let nodeD = BuildNode( - operation: ExecOperation(command: .shell("echo 'D'")), - dependencies: Set([nodeA.id]) - ) - let nodeB = BuildNode( - operation: ExecOperation(command: .shell("echo 'B'")), - dependencies: Set([nodeA.id]) - ) - let nodeC = BuildNode( - operation: ExecOperation(command: .shell("echo 'C'")), - dependencies: Set([nodeB.id, nodeD.id]) - ) - - let stage = BuildStage( - name: "test", - base: ImageOperation(source: .scratch), - nodes: [nodeC, nodeB, nodeA, nodeD] // Intentionally out of order - ) - - let sorted = try GraphTraversal.topologicalSort(stage) - - // Find positions in sorted array - let posA = sorted.firstIndex { $0.id == nodeA.id }! - let posB = sorted.firstIndex { $0.id == nodeB.id }! - let posC = sorted.firstIndex { $0.id == nodeC.id }! - let posD = sorted.firstIndex { $0.id == nodeD.id }! - - // Verify ordering constraints - #expect(posA < posB, "A should come before B") - #expect(posA < posD, "A should come before D") - #expect(posB < posC, "B should come before C") - #expect(posD < posC, "D should come before C") - - #expect(sorted.count == 4, "All nodes should be included") - } - - @Test func cycleDetection() throws { - // Create a cycle: A -> B -> C -> A - let nodeA = BuildNode( - operation: ExecOperation(command: .shell("echo 'A'")), - dependencies: [] - ) - let nodeB = BuildNode( - operation: ExecOperation(command: .shell("echo 'B'")), - dependencies: Set([nodeA.id]) - ) - let nodeC = BuildNode( - operation: ExecOperation(command: .shell("echo 'C'")), - dependencies: Set([nodeB.id]) - ) - - // Create cycle by making A depend on C - let nodeACyclic = BuildNode( - id: nodeA.id, - operation: nodeA.operation, - dependencies: Set([nodeC.id]) - ) - - let stage = BuildStage( - name: "cyclic", - base: ImageOperation(source: .scratch), - nodes: [nodeACyclic, nodeB, nodeC] - ) - - #expect(throws: BuildGraphError.self) { - try GraphTraversal.topologicalSort(stage) - } - } - - @Test func findDependentsAndDependencies() throws { - // Create dependency chain: A -> B -> C, A -> D - let nodeA = BuildNode( - operation: ExecOperation(command: .shell("echo 'A'")), - dependencies: [] - ) - let nodeB = BuildNode( - operation: ExecOperation(command: .shell("echo 'B'")), - dependencies: Set([nodeA.id]) - ) - let nodeC = BuildNode( - operation: ExecOperation(command: .shell("echo 'C'")), - dependencies: Set([nodeB.id]) - ) - let nodeD = BuildNode( - operation: ExecOperation(command: .shell("echo 'D'")), - dependencies: Set([nodeA.id]) - ) - - let stage = BuildStage( - name: "test", - base: ImageOperation(source: .scratch), - nodes: [nodeA, nodeB, nodeC, nodeD] - ) - - // Test finding dependents - let aDependents = GraphTraversal.findDependents(of: nodeA.id, in: stage) - #expect(aDependents.contains(nodeB.id)) - #expect(aDependents.contains(nodeC.id)) // Transitive - #expect(aDependents.contains(nodeD.id)) - #expect(aDependents.count == 3) - - let bDependents = GraphTraversal.findDependents(of: nodeB.id, in: stage) - #expect(bDependents.contains(nodeC.id)) - #expect(!bDependents.contains(nodeA.id)) - #expect(bDependents.count == 1) - - // Test finding dependencies - let cDependencies = GraphTraversal.findDependencies(of: nodeC.id, in: stage) - #expect(cDependencies.contains(nodeB.id)) - #expect(cDependencies.contains(nodeA.id)) // Transitive - #expect(!cDependencies.contains(nodeD.id)) - #expect(cDependencies.count == 2) - - let aDependencies = GraphTraversal.findDependencies(of: nodeA.id, in: stage) - #expect(aDependencies.isEmpty, "Root node should have no dependencies") - } - - @Test func findRootsAndLeaves() throws { - // Create graph: A -> B -> C, D -> E - let nodeA = BuildNode( - operation: ExecOperation(command: .shell("echo 'A'")), - dependencies: [] - ) - let nodeB = BuildNode( - operation: ExecOperation(command: .shell("echo 'B'")), - dependencies: Set([nodeA.id]) - ) - let nodeC = BuildNode( - operation: ExecOperation(command: .shell("echo 'C'")), - dependencies: Set([nodeB.id]) - ) - let nodeD = BuildNode( - operation: ExecOperation(command: .shell("echo 'D'")), - dependencies: [] - ) - let nodeE = BuildNode( - operation: ExecOperation(command: .shell("echo 'E'")), - dependencies: Set([nodeD.id]) - ) - - let stage = BuildStage( - name: "test", - base: ImageOperation(source: .scratch), - nodes: [nodeA, nodeB, nodeC, nodeD, nodeE] - ) - - // Test finding roots - let roots = GraphTraversal.findRoots(in: stage) - let rootIds = Set(roots.map { $0.id }) - #expect(rootIds.contains(nodeA.id)) - #expect(rootIds.contains(nodeD.id)) - #expect(rootIds.count == 2) - - // Test finding leaves - let leaves = GraphTraversal.findLeaves(in: stage) - let leafIds = Set(leaves.map { $0.id }) - #expect(leafIds.contains(nodeC.id)) - #expect(leafIds.contains(nodeE.id)) - #expect(leafIds.count == 2) - } - - @Test func criticalPath() throws { - // Create a diamond dependency: A -> B -> D, A -> C -> D - let nodeA = BuildNode( - operation: ExecOperation(command: .shell("echo 'A'")), - dependencies: [] - ) - let nodeB = BuildNode( - operation: ExecOperation(command: .shell("echo 'B'")), - dependencies: Set([nodeA.id]) - ) - let nodeC = BuildNode( - operation: ExecOperation(command: .shell("echo 'C'")), - dependencies: Set([nodeA.id]) - ) - let nodeD = BuildNode( - operation: ExecOperation(command: .shell("echo 'D'")), - dependencies: Set([nodeB.id, nodeC.id]) - ) - - let stage = BuildStage( - name: "test", - base: ImageOperation(source: .scratch), - nodes: [nodeA, nodeB, nodeC, nodeD] - ) - - let criticalPath = GraphTraversal.criticalPath(in: stage) - - #expect(criticalPath.count >= 3, "Critical path should have at least 3 nodes") - - // Verify path contains A and D (start and end) - let pathIds = Set(criticalPath.map { $0.id }) - #expect(pathIds.contains(nodeA.id)) - #expect(pathIds.contains(nodeD.id)) - } - - @Test func depthFirstTraversal() throws { - // Create dependency chain: A -> B -> C - let nodeA = BuildNode( - operation: ExecOperation(command: .shell("echo 'A'")), - dependencies: [] - ) - let nodeB = BuildNode( - operation: ExecOperation(command: .shell("echo 'B'")), - dependencies: Set([nodeA.id]) - ) - let nodeC = BuildNode( - operation: ExecOperation(command: .shell("echo 'C'")), - dependencies: Set([nodeB.id]) - ) - - let stage = BuildStage( - name: "test", - base: ImageOperation(source: .scratch), - nodes: [nodeC, nodeB, nodeA] // Out of order - ) - - var visitOrder: [UUID] = [] - - try GraphTraversal.depthFirst(stage: stage) { node in - visitOrder.append(node.id) - } - - #expect(visitOrder.count == 3) - - // Find positions in visit order - let posA = visitOrder.firstIndex(of: nodeA.id)! - let posB = visitOrder.firstIndex(of: nodeB.id)! - let posC = visitOrder.firstIndex(of: nodeC.id)! - - // DFS should visit dependencies before dependents - #expect(posA < posB, "A should be visited before B") - #expect(posB < posC, "B should be visited before C") - } - - // MARK: - Stage Dependencies Tests - - @Test func findStageDependencies() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - let stage1 = BuildStage( - name: "build", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - operation: ExecOperation(command: .shell("echo 'building...'")), - dependencies: [] - ) - ] - ) - - let stage2 = BuildStage( - name: "test", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - operation: FilesystemOperation( - action: .copy, - source: .stage(.named("build"), paths: ["/app"]), - destination: "/test/app" - ), - dependencies: [] - ) - ] - ) - - let stage3 = BuildStage( - name: "final", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - operation: FilesystemOperation( - action: .copy, - source: .stage(.index(0), paths: ["/app"]), - destination: "/final/app" - ), - dependencies: [] - ), - BuildNode( - operation: FilesystemOperation( - action: .copy, - source: .stage(.previous, paths: ["/test"]), - destination: "/final/test" - ), - dependencies: [] - ), - ] - ) - - let graph = try BuildGraph(stages: [stage1, stage2, stage3]) - - // Test stage 2 dependencies - let stage2Deps = GraphTraversal.findStageDependencies(of: stage2, in: graph) - #expect(stage2Deps.contains("build")) - #expect(stage2Deps.count == 1) - - // Test stage 3 dependencies - let stage3Deps = GraphTraversal.findStageDependencies(of: stage3, in: graph) - #expect(stage3Deps.contains("build")) // From index(0) - #expect(stage3Deps.contains("test")) // From .previous - #expect(stage3Deps.count == 2) - - // Test stage 1 dependencies (should be empty) - let stage1Deps = GraphTraversal.findStageDependencies(of: stage1, in: graph) - #expect(stage1Deps.isEmpty) - } - - // MARK: - Build Graph Analysis Tests - - @Test func buildGraphAnalysis() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - let graph = try GraphBuilder.multiStage { builder in - try builder - .stage(name: "build", from: alpineRef) - .run("apk add --no-cache build-tools") - .workdir("/src") - .copyFromContext(paths: ["src/"], to: "./") - .run("make build") - - try builder - .stage(name: "test", from: alpineRef) - .copyFromStage(.named("build"), paths: ["/src/app"], to: "/test/") - .run("./test/app --test") - - try builder - .stage(from: alpineRef) - .copyFromStage(.named("build"), paths: ["/src/app"], to: "/usr/local/bin/") - .entrypoint(.exec(["/usr/local/bin/app"])) - } - - let analysis = graph.analyze() - - #expect(analysis.stageCount == 3) - #expect(analysis.operationCount > 0) - - // Verify operation types are counted - #expect(analysis.operationsByType[OperationKind.exec] != nil) - #expect(analysis.operationsByType[OperationKind.filesystem] != nil) - #expect(analysis.operationsByType[OperationKind.metadata] != nil) - - // Verify stage dependencies - #expect(analysis.stageDependencies["test"]?.contains("build") == true) - #expect(analysis.stageDependencies.count >= 1) - - #expect(analysis.maxDepth > 0) - #expect(analysis.criticalPathLength > 0) - } - - // MARK: - Error Handling Tests - - @Test func invalidStageReferenceHandling() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - // Create stage with invalid reference - let stage = BuildStage( - name: "test", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - operation: FilesystemOperation( - action: .copy, - source: .stage(.named("nonexistent"), paths: ["/file"]), - destination: "/test/file" - ), - dependencies: [] - ) - ] - ) - - let graph = try BuildGraph(stages: [stage]) - let analyzer = DependencyAnalyzer() - let context = AnalysisContext(reporter: nil, sourceMap: nil) - - // Should not throw - dependency analyzer handles missing references gracefully - let analyzedGraph = try analyzer.analyze(graph, context: context) - - // The copy operation should not have gained any cross-stage dependencies - let copyNode = analyzedGraph.stages[0].nodes[0] - #expect( - copyNode.dependencies.isEmpty, - "Copy from nonexistent stage should not create dependencies") - } - - @Test func complexDependencyChain() throws { - // Test a complex multi-stage build with intricate dependencies - guard let nodeRef = ImageReference(parsing: "node:18"), - let nginxRef = ImageReference(parsing: "nginx:alpine") - else { - Issue.record("Failed to parse image references") - return - } - - let graph = try GraphBuilder.multiStage { builder in - // Dependencies stage - try builder - .stage(name: "deps", from: nodeRef) - .workdir("/app") - .copyFromContext(paths: ["package*.json"], to: "./") - .run("npm ci --only=production") - - // Build stage - try builder - .stage(name: "build", from: nodeRef) - .workdir("/app") - .copyFromStage(.named("deps"), paths: ["/app/node_modules"], to: "/app/node_modules") - .copyFromContext(paths: ["src/", "tsconfig.json"], to: "./") - .run("npm run build") - - // Assets stage - try builder - .stage(name: "assets", from: nodeRef) - .workdir("/app") - .copyFromStage(.named("build"), paths: ["/app/dist"], to: "/app/dist") - .run("npm run optimize-assets") - - // Final stage - try builder - .stage(from: nginxRef) - .copyFromStage(.named("assets"), paths: ["/app/dist"], to: "/usr/share/nginx/html") - .copyFromContext(paths: ["nginx.conf"], to: "/etc/nginx/nginx.conf") - .expose([PortSpec(port: 80)]) - } - - let analyzer = DependencyAnalyzer() - let context = AnalysisContext(reporter: nil, sourceMap: nil) - let analyzedGraph = try analyzer.analyze(graph, context: context) - - // Verify complex dependency chain - let finalStage = analyzedGraph.stages[3] - let copyFromAssetsNode = finalStage.nodes[0] - let assetsStageLastNode = analyzedGraph.stages[2].nodes.last! - - #expect(copyFromAssetsNode.dependencies.contains(assetsStageLastNode.id)) - - // Verify transitive dependencies exist through the chain - let assetsStage = analyzedGraph.stages[2] - let copyFromBuildNode = assetsStage.nodes[1] // Node 1 is the copyFromStage operation - let buildStageLastNode = analyzedGraph.stages[1].nodes.last! - - #expect(copyFromBuildNode.dependencies.contains(buildStageLastNode.id)) - - // Verify build stage dependencies - let buildStage = analyzedGraph.stages[1] - let copyFromDepsNode = buildStage.nodes[1] // Node 1 is the copyFromStage operation - let depsStageLastNode = analyzedGraph.stages[0].nodes.last! - - #expect(copyFromDepsNode.dependencies.contains(depsStageLastNode.id)) - } -} diff --git a/Tests/NativeBuilderTests/ContainerBuildIRTests/DigestAndPlatformTests.swift b/Tests/NativeBuilderTests/ContainerBuildIRTests/DigestAndPlatformTests.swift deleted file mode 100644 index 94135167f..000000000 --- a/Tests/NativeBuilderTests/ContainerBuildIRTests/DigestAndPlatformTests.swift +++ /dev/null @@ -1,323 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerizationOCI -import Foundation -import Testing - -@testable import ContainerBuildIR - -struct DigestAndPlatformTests { - - // MARK: - Digest Algorithm Tests - - @Test func digestSHA256Creation() throws { - let data = "Hello, World!".data(using: .utf8)! - let digest = try Digest.compute(data, using: .sha256) - - #expect(digest.algorithm == .sha256) - #expect(digest.bytes.count == 32) // SHA256 produces 32 bytes - - // Verify deterministic computation - let digest2 = try Digest.compute(data, using: .sha256) - #expect(digest == digest2) - - // Verify string format - let stringValue = digest.stringValue - #expect(stringValue.hasPrefix("sha256:")) - #expect(stringValue.count == "sha256:".count + 64) // 32 bytes = 64 hex chars - } - - @Test func digestSHA384Creation() throws { - let data = "Test data for SHA384".data(using: .utf8)! - let digest = try Digest.compute(data, using: .sha384) - - #expect(digest.algorithm == .sha384) - #expect(digest.bytes.count == 48) // SHA384 produces 48 bytes - - // Verify string format - let stringValue = digest.stringValue - #expect(stringValue.hasPrefix("sha384:")) - #expect(stringValue.count == "sha384:".count + 96) // 48 bytes = 96 hex chars - } - - @Test func digestSHA512Creation() throws { - let data = "Test data for SHA512".data(using: .utf8)! - let digest = try Digest.compute(data, using: .sha512) - - #expect(digest.algorithm == .sha512) - #expect(digest.bytes.count == 64) // SHA512 produces 64 bytes - - // Verify string format - let stringValue = digest.stringValue - #expect(stringValue.hasPrefix("sha512:")) - #expect(stringValue.count == "sha512:".count + 128) // 64 bytes = 128 hex chars - } - - @Test func digestFromValidBytes() throws { - // Create SHA256 digest from valid bytes - let validBytes = Data(repeating: 0xAB, count: 32) - let digest = try Digest(algorithm: .sha256, bytes: validBytes) - - #expect(digest.algorithm == .sha256) - #expect(digest.bytes == validBytes) - - let expectedString = "sha256:" + String(repeating: "ab", count: 32) - #expect(digest.stringValue == expectedString) - } - - @Test func digestFromInvalidBytes() throws { - // Test wrong length for SHA256 - let wrongLengthBytes = Data(repeating: 0xFF, count: 16) // Should be 32 - #expect(throws: DigestError.self) { - try Digest(algorithm: .sha256, bytes: wrongLengthBytes) - } - - // Test wrong length for SHA384 - let wrongLengthBytes384 = Data(repeating: 0xFF, count: 32) // Should be 48 - #expect(throws: DigestError.self) { - try Digest(algorithm: .sha384, bytes: wrongLengthBytes384) - } - - // Test wrong length for SHA512 - let wrongLengthBytes512 = Data(repeating: 0xFF, count: 32) // Should be 64 - #expect(throws: DigestError.self) { - try Digest(algorithm: .sha512, bytes: wrongLengthBytes512) - } - } - - // MARK: - Digest Parsing Tests - - @Test func digestParsingValidFormats() throws { - let testCases = [ - ("sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", Digest.Algorithm.sha256), - ("sha384:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", Digest.Algorithm.sha384), - ("sha512:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", Digest.Algorithm.sha512), - ] - - for (digestString, expectedAlgorithm) in testCases { - let digest = try Digest(parsing: digestString) - #expect(digest.algorithm == expectedAlgorithm) - #expect(digest.stringValue == digestString) - } - } - - @Test func digestParsingInvalidFormats() throws { - let invalidFormats = [ - "no-colon-separator", - "sha256", - "sha256:", - "sha256:invalid-hex", - "sha256:zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", // Invalid hex chars - "unknown:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", - "sha256:short", // Too short - "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789extra", // Too long - ] - - for invalidFormat in invalidFormats { - #expect(throws: DigestError.self) { - try Digest(parsing: invalidFormat) - } - } - } - - @Test func digestParsingMixedCase() throws { - // Test that mixed case hex is handled correctly - let upperCaseDigest = "sha256:ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789" - let lowerCaseDigest = "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" - let mixedCaseDigest = "sha256:AbCdEf0123456789aBcDeF0123456789AbCdEf0123456789aBcDeF0123456789" - - let upperDigest = try Digest(parsing: upperCaseDigest) - let lowerDigest = try Digest(parsing: lowerCaseDigest) - let mixedDigest = try Digest(parsing: mixedCaseDigest) - - // All should parse to the same bytes - #expect(upperDigest.bytes == lowerDigest.bytes) - #expect(lowerDigest.bytes == mixedDigest.bytes) - - // String output should be lowercase - #expect(upperDigest.stringValue == lowerCaseDigest) - #expect(mixedDigest.stringValue == lowerCaseDigest) - } - - // MARK: - Digest Content Hashing Tests - - @Test func digestContentHashing() throws { - // Test that different content produces different digests - let content1 = "First piece of content".data(using: .utf8)! - let content2 = "Second piece of content".data(using: .utf8)! - - let digest1 = try Digest.compute(content1) - let digest2 = try Digest.compute(content2) - - #expect(digest1 != digest2) - #expect(digest1.stringValue != digest2.stringValue) - - // Test that same content produces same digest - let digest1Copy = try Digest.compute(content1) - #expect(digest1 == digest1Copy) - } - - @Test func digestEmptyContent() throws { - let emptyData = Data() - let digest = try Digest.compute(emptyData) - - #expect(digest.algorithm == .sha256) // Default algorithm - #expect(digest.bytes.count == 32) - - // Known SHA256 of empty string - let expectedEmptyDigest = try Digest(parsing: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") - #expect(digest == expectedEmptyDigest) - } - - @Test func digestLargeContent() throws { - // Test with larger content (1MB) - let largeContent = Data(repeating: 0x42, count: 1024 * 1024) - let digest = try Digest.compute(largeContent) - - #expect(digest.algorithm == .sha256) - #expect(digest.bytes.count == 32) - - // Verify deterministic - let digest2 = try Digest.compute(largeContent) - #expect(digest == digest2) - } - - // MARK: - Digest Codable Tests - - @Test func digestCodable() throws { - let originalDigest = try Digest(parsing: "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789") - - // Encode to JSON - let encoder = JSONEncoder() - let data = try encoder.encode(originalDigest) - - // Decode from JSON - let decoder = JSONDecoder() - let decodedDigest = try decoder.decode(Digest.self, from: data) - - #expect(decodedDigest == originalDigest) - #expect(decodedDigest.stringValue == originalDigest.stringValue) - } - - // MARK: - Integration Tests - - @Test func digestAndPlatformIntegration() throws { - // Test that digests and platforms work together in realistic scenarios - let platform = Platform.linuxAMD64 - let operationData = "RUN apt-get update && apt-get install -y curl".data(using: .utf8)! - let operationDigest = try Digest.compute(operationData) - - // Simulate cache key generation (simplified) - let cacheKeyData = "\(operationDigest.stringValue):\(platform.description)".data(using: .utf8)! - let cacheDigest = try Digest.compute(cacheKeyData) - - #expect(cacheDigest.algorithm == .sha256) - #expect(cacheDigest.bytes.count == 32) - - // Different platform should produce different cache key - let differentPlatform = Platform.linuxARM64 - let differentCacheKeyData = "\(operationDigest.stringValue):\(differentPlatform.description)".data(using: .utf8)! - let differentCacheDigest = try Digest.compute(differentCacheKeyData) - - #expect(cacheDigest != differentCacheDigest) - } - - @Test func multiAlgorithmDigestComparison() throws { - let testData = "Container build test data".data(using: .utf8)! - - let sha256Digest = try Digest.compute(testData, using: .sha256) - let sha384Digest = try Digest.compute(testData, using: .sha384) - let sha512Digest = try Digest.compute(testData, using: .sha512) - - // All should be different (different algorithms) - #expect(sha256Digest != sha384Digest) - #expect(sha384Digest != sha512Digest) - #expect(sha256Digest != sha512Digest) - - // Verify byte lengths - #expect(sha256Digest.bytes.count == 32) - #expect(sha384Digest.bytes.count == 48) - #expect(sha512Digest.bytes.count == 64) - - // Verify string prefixes - #expect(sha256Digest.stringValue.hasPrefix("sha256:")) - #expect(sha384Digest.stringValue.hasPrefix("sha384:")) - #expect(sha512Digest.stringValue.hasPrefix("sha512:")) - } - - // MARK: - Error Message Tests - - @Test func digestErrorMessages() throws { - do { - _ = try Digest(algorithm: .sha256, bytes: Data(count: 16)) - Issue.record("Should have thrown an error") - } catch let error as DigestError { - switch error { - case .invalidLength(let expected, let actual): - #expect(expected == 32) - #expect(actual == 16) - #expect(error.errorDescription?.contains("expected 32") == true) - #expect(error.errorDescription?.contains("got 16") == true) - default: - Issue.record("Wrong error type: \(error)") - } - } - - do { - _ = try Digest(parsing: "invalid:format") - Issue.record("Should have thrown an error") - } catch let error as DigestError { - switch error { - case .unsupportedAlgorithm(let algo): - #expect(algo == "invalid") - #expect(error.errorDescription?.contains("Unsupported digest algorithm") == true) - default: - Issue.record("Wrong error type: \(error)") - } - } - - do { - _ = try Digest(parsing: "sha256:invalid-hex") - Issue.record("Should have thrown an error") - } catch let error as DigestError { - switch error { - case .invalidHex(let hex): - #expect(hex == "invalid-hex") - #expect(error.errorDescription?.contains("Invalid hex") == true) - default: - Issue.record("Wrong error type: \(error)") - } - } - } - - // MARK: - Performance Tests - - @Test func digestPerformance() throws { - let testSizes = [1024, 10240, 102400] // 1KB, 10KB, 100KB - - for size in testSizes { - let data = Data(repeating: 0x42, count: size) - - let startTime = Date() - let _ = try Digest.compute(data) - let duration = Date().timeIntervalSince(startTime) - - print("Digest computation for \(size) bytes: \(String(format: "%.3f", duration))s") - #expect(duration < 0.1, "Digest computation should be fast for \(size) bytes") - } - } -} diff --git a/Tests/NativeBuilderTests/ContainerBuildIRTests/GraphBuilderTests.swift b/Tests/NativeBuilderTests/ContainerBuildIRTests/GraphBuilderTests.swift deleted file mode 100644 index c8ee6ff62..000000000 --- a/Tests/NativeBuilderTests/ContainerBuildIRTests/GraphBuilderTests.swift +++ /dev/null @@ -1,655 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerizationOCI -import Foundation -import Testing - -@testable import ContainerBuildIR -@testable import ContainerBuildReporting - -struct GraphBuilderTests { - - // MARK: - Complex Multi-Stage Scenarios - - @Test func complexMultiStageNodeJSBuild() throws { - guard let nodeRef = ImageReference(parsing: "node:18-alpine"), - let nginxRef = ImageReference(parsing: "nginx:alpine") - else { - Issue.record("Failed to parse image references") - return - } - - let graph = try GraphBuilder.multiStage { builder in - // Dependencies stage - install and cache node_modules - try builder - .stage(name: "deps", from: nodeRef) - .workdir("/app") - .copyFromContext(paths: ["package*.json"], to: "./") - .run("npm ci --only=production && npm cache clean --force") - - // Build stage - compile TypeScript and build assets - try builder - .stage(name: "build", from: nodeRef) - .workdir("/app") - .copyFromStage(.named("deps"), paths: ["/app/node_modules"], to: "/app/node_modules") - .copyFromContext(paths: ["tsconfig.json", "webpack.config.js", "src/"], to: "./") - .run("npm run build") - - // Runtime stage - serve with nginx - try builder - .stage(name: "runtime", from: nginxRef) - .copyFromStage(.named("build"), paths: ["/app/dist"], to: "/usr/share/nginx/html") - .copyFromContext(paths: ["nginx.conf"], to: "/etc/nginx/nginx.conf") - .expose([PortSpec(port: 80)]) - .cmd(.exec(["nginx", "-g", "daemon off;"])) - } - - #expect(graph.stages.count == 3) - #expect(graph.stages[0].name == "deps") - #expect(graph.stages[1].name == "build") - #expect(graph.stages[2].name == "runtime") - - // Verify stage dependencies - let buildDeps = graph.stages[1].stageDependencies() - #expect(buildDeps.contains(.named("deps"))) - - let runtimeDeps = graph.stages[2].stageDependencies() - #expect(runtimeDeps.contains(.named("build"))) - - // Validate the complex graph - let validator = StandardValidator() - let result = validator.validate(graph) - #expect(result.isValid, "Complex multi-stage build should be valid: \(result.errors)") - } - - @Test func fourStageGoMicroserviceBuild() throws { - guard let goRef = ImageReference(parsing: "golang:1.21-alpine"), - let scratchRef = ImageReference(parsing: "scratch") - else { - Issue.record("Failed to parse image references") - return - } - - let graph = try GraphBuilder.multiStage { builder in - // Base tools stage - try builder - .stage(name: "tools", from: goRef) - .run("apk add --no-cache git ca-certificates") - .workdir("/tools") - .run("go install github.com/swaggo/swag/cmd/swag@latest") - - // Dependency stage - try builder - .stage(name: "deps", from: goRef) - .workdir("/src") - .copyFromContext(paths: ["go.mod", "go.sum"], to: "./") - .run("go mod download") - - // Build stage - try builder - .stage(name: "build", from: goRef) - .copyFromStage(.named("tools"), paths: ["/go/bin/swag"], to: "/usr/local/bin/") - .workdir("/src") - .copyFromStage(.named("deps"), paths: ["/go/pkg"], to: "/go/pkg") - .copyFromContext(paths: [".", "!**/*_test.go"], to: "./") - .run("swag init -g cmd/server/main.go") - .run("CGO_ENABLED=0 GOOS=linux go build -ldflags='-w -s' -o /app cmd/server/main.go") - - // Runtime stage - try builder - .stage(from: scratchRef) - .copyFromStage(.named("tools"), paths: ["/etc/ssl/certs/ca-certificates.crt"], to: "/etc/ssl/certs/") - .copyFromStage(.named("build"), paths: ["/app"], to: "/app") - .user(.uid(65534)) // nobody user - .expose([PortSpec(port: 8080)]) - .entrypoint(.exec(["/app"])) - } - - #expect(graph.stages.count == 4) - - // Verify complex dependency chain - let buildStage = graph.stages[2] - let buildDeps = buildStage.stageDependencies() - #expect(buildDeps.contains(.named("tools"))) - #expect(buildDeps.contains(.named("deps"))) - - let runtimeStage = graph.stages[3] - let runtimeDeps = runtimeStage.stageDependencies() - #expect(runtimeDeps.contains(.named("tools"))) - #expect(runtimeDeps.contains(.named("build"))) - } - - // MARK: - Build Arguments and Environment - - @Test func buildArgumentPropagation() throws { - guard let nodeRef = ImageReference(parsing: "alpine:latest") else { - Issue.record("Failed to parse image reference") - return - } - - let graph = try GraphBuilder.multiStage { builder in - try builder - .fromOnlyArg("BASE_IMAGE", defaultValue: "alpine:latest") - .fromOnlyArg("BUILD_VERSION", defaultValue: "1.0.0") - .stage(name: "build", from: nodeRef) - .arg("NODE_ENV", defaultValue: "development") - .arg("BUILD_VERSION") - .arg("API_URL") - .workdir("/app") - .copyFromContext(paths: ["package.json"], to: "./") - .run("npm install --omit=dev") - .env("NODE_ENV", "production") - .env("BUILD_VERSION", "1.0.0") - .env("API_URL", "https://api.example.com") - .copyFromContext(paths: ["src/"], to: "./src/") - .run("npm run build") - } - - // Verify global FROM-only ARG values - #expect(graph.buildArgs.count == 2) - #expect(graph.buildArgs["BASE_IMAGE"] == "alpine:latest") - #expect(graph.buildArgs["BUILD_VERSION"] == "1.0.0") - - // Verify stage-local ARG values - #expect(graph.stages.count == 1) - - let stage = graph.stages[0] - #expect(getStageArg(stage, name: "BASE_IMAGE") == nil) - #expect(getStageArg(stage, name: "NODE_ENV") == "development") - #expect(getStageArg(stage, name: "BUILD_VERSION") == nil) - #expect(getStageArg(stage, name: "API_URL") == nil) - - // Verify ENV instructions reference build args - let envOps = stage.nodes.compactMap { node in - node.operation as? MetadataOperation - }.filter { meta in - if case .setEnv = meta.action { return true } - return false - } - #expect(envOps.count == 3) - } - - @Test func testArgSubstitutionWithSimpleVariables() throws { - let graphBuilder = GraphBuilder() - graphBuilder.fromOnlyArg("DEFINED", defaultValue: "value") - graphBuilder.fromOnlyArg("EMPTY", defaultValue: "") - - #expect(graphBuilder.substituteArgs("prefix-$DEFINED-$DEFINED-$DEFINED-suffix", inFromContext: true) == "prefix-value-value-value-suffix") - #expect(graphBuilder.substituteArgs("prefix-$EMPTY-$EMPTY-$EMPTY-suffix", inFromContext: true) == "prefix----suffix") - #expect(graphBuilder.substituteArgs("prefix-$UNDEFINED-$UNDEFINED-$UNDEFINED-suffix", inFromContext: true) == "prefix----suffix") - } - - @Test func testArgSubstitution() throws { - let graphBuilder = GraphBuilder() - graphBuilder.fromOnlyArg("DEFINED", defaultValue: "value") - graphBuilder.fromOnlyArg("EMPTY", defaultValue: "") - - #expect(graphBuilder.substituteArgs("prefix-${DEFINED}-${DEFINED}-${DEFINED}-suffix", inFromContext: true) == "prefix-value-value-value-suffix") - #expect(graphBuilder.substituteArgs("prefix-${EMPTY}-${EMPTY}-${EMPTY}-suffix", inFromContext: true) == "prefix----suffix") - #expect(graphBuilder.substituteArgs("prefix-${UNDEFINED}-${UNDEFINED}-${UNDEFINED}-suffix", inFromContext: true) == "prefix----suffix") - } - - @Test func testArgSubstitutionWithDefaults() throws { - let graphBuilder = GraphBuilder() - graphBuilder.fromOnlyArg("DEFINED", defaultValue: "defined") - graphBuilder.fromOnlyArg("EMPTY", defaultValue: "") - - #expect(graphBuilder.substituteArgs("${UNDEFINED:-default}", inFromContext: true) == "default") - #expect(graphBuilder.substituteArgs("${UNDEFINED:-default with spaces}", inFromContext: true) == "default with spaces") - #expect(graphBuilder.substituteArgs("${UNDEFINED:-}", inFromContext: true) == "") - - #expect(graphBuilder.substituteArgs("${DEFINED:-default}", inFromContext: true) == "defined") - #expect(graphBuilder.substituteArgs("${EMPTY:-default}", inFromContext: true) == "") - - #expect(graphBuilder.substituteArgs("a=${UNDEFINED:-default} b=${DEFINED:-default} c=${EMPTY:-default}", inFromContext: true) == "a=default b=defined c=") - } - - @Test func testArgSubstitutionWithUTF16Characters() throws { - let graphBuilder = GraphBuilder() - - #expect(graphBuilder.substituteArgs("unicorn 🦄 unicorn ${UNDEFINED:-🦄}", inFromContext: true) == "unicorn 🦄 unicorn 🦄") - #expect(graphBuilder.substituteArgs("🦄 ${UNDEFINED:-unicorn 🦄 unicorn}", inFromContext: true) == "🦄 unicorn 🦄 unicorn") - } - - @Test func testArgSubstitutionWithSubstituteValues() throws { - let graphBuilder = GraphBuilder() - graphBuilder.fromOnlyArg("DEFINED", defaultValue: "defined") - graphBuilder.fromOnlyArg("EMPTY", defaultValue: "") - - #expect(graphBuilder.substituteArgs("${DEFINED:+value}", inFromContext: true) == "value") - #expect(graphBuilder.substituteArgs("${DEFINED:+value with spaces}", inFromContext: true) == "value with spaces") - #expect(graphBuilder.substituteArgs("${DEFINED:+}", inFromContext: true) == "") - #expect(graphBuilder.substituteArgs("${UNDEFINED:+value}", inFromContext: true) == "") - #expect(graphBuilder.substituteArgs("${EMPTY:+value}", inFromContext: true) == "") - - #expect(graphBuilder.substituteArgs("a=${UNDEFINED:+value} b=${DEFINED:+value} c=${EMPTY:+value}", inFromContext: true) == "a= b=value c=") - } - - @Test func testPredefinedArgVariables() throws { - let graphBuilder = GraphBuilder() - - var originalValues: [String: String?] = [:] - for varName in GraphBuilder.predefinedArgs { - originalValues[varName] = ProcessInfo.processInfo.environment[varName] - setenv(varName, "test-value-\(varName.lowercased())", 1) - } - - defer { - for (varName, originalValue) in originalValues { - if let originalValue { - setenv(varName, originalValue, 1) - } else { - unsetenv(varName) - } - } - } - - for varName in GraphBuilder.predefinedArgs { - let expectedValue = "test-value-\(varName.lowercased())" - #expect(graphBuilder.substituteArgs("${\(varName)}", inFromContext: true) == expectedValue) - } - } - - // MARK: - Platform-Specific Builds - - @Test func multiPlatformBuild() throws { - guard let baseRef = ImageReference(parsing: "alpine:latest") else { - Issue.record("Failed to parse image reference") - return - } - - let graph = try GraphBuilder.singleStage(from: baseRef, platform: Platform.linuxAMD64) { builder in - try builder - .platforms(Platform.linuxAMD64, Platform.linuxARM64) - .run("apk add --no-cache curl") - .workdir("/app") - .copyFromContext(paths: ["app.sh"], to: "/app/") - .run("chmod +x /app/app.sh") - .entrypoint(.exec(["/app/app.sh"])) - } - - #expect(graph.targetPlatforms.count == 2) - #expect(graph.targetPlatforms.contains(Platform.linuxAMD64)) - #expect(graph.targetPlatforms.contains(Platform.linuxARM64)) - - // Verify base image has platform constraints - let stage = graph.stages[0] - let imageOp = stage.base - #expect(imageOp.platform != nil) - } - - @Test func platformSpecificStages() throws { - guard let alpineRef = ImageReference(parsing: "alpine:latest") else { - Issue.record("Failed to parse image reference") - return - } - - let graph = try GraphBuilder.multiStage { builder in - // Linux AMD64 optimized stage - try builder - .stage(name: "linux-amd64", from: alpineRef, platform: Platform.linuxAMD64) - .run("apk add --no-cache glibc-compat") - .copyFromContext(paths: ["bin/app-linux-amd64"], to: "/usr/local/bin/app") - - // Linux ARM64 stage - try builder - .stage(name: "linux-arm64", from: alpineRef, platform: Platform.linuxARM64) - .run("apk add --no-cache ca-certificates") - .copyFromContext(paths: ["bin/app-linux-arm64"], to: "/usr/local/bin/app") - - // Final stage that copies from platform-specific stage - try builder - .stage(from: alpineRef) - .copyFromStage(.named("linux-amd64"), paths: ["/usr/local/bin/app"], to: "/usr/local/bin/") - .copyFromStage(.named("linux-arm64"), paths: ["/usr/local/bin/app"], to: "/usr/local/bin/") - .entrypoint(.exec(["/usr/local/bin/app"])) - } - - #expect(graph.stages.count == 3) - - // Verify platform-specific stages - #expect(graph.stages[0].name == "linux-amd64") - #expect(graph.stages[1].name == "linux-arm64") - - let amd64ImageOp = graph.stages[0].base - #expect(amd64ImageOp.platform == Platform.linuxAMD64) - - let arm64ImageOp = graph.stages[1].base - #expect(arm64ImageOp.platform == Platform.linuxARM64) - } - - // MARK: - Error Conditions - - @Test func invalidStageReference() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - #expect(throws: Error.self) { - try GraphBuilder.multiStage { builder in - try builder - .stage(name: "base", from: alpineRef) - .run("echo hello") - - try builder - .stage(name: "app", from: alpineRef) - .copyFromStage(.named("nonexistent"), paths: ["/file"], to: "/") - } - } - } - - @Test func circularStageDependency() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - // This should create a circular dependency through stage references - // GraphBuilder should throw BuildGraphError for cyclic dependencies - #expect(throws: BuildGraphError.self) { - try GraphBuilder.multiStage { builder in - try builder - .stage(name: "stage1", from: alpineRef) - .copyFromStage(.named("stage2"), paths: ["/file1"], to: "/file1") - - try builder - .stage(name: "stage2", from: alpineRef) - .copyFromStage(.named("stage1"), paths: ["/file2"], to: "/file2") - } - } - } - - @Test func validEntrypointAndCmdSequence() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - // CMD after ENTRYPOINT is a valid and common Docker pattern - // ENTRYPOINT defines the executable, CMD provides default arguments - let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in - try builder - .entrypoint(.exec(["/app"])) // Set entrypoint first - .cmd(.shell("echo override")) // CMD after ENTRYPOINT is valid - } - - #expect(graph.stages.count == 1) - #expect(graph.stages[0].nodes.count == 2) - - // Verify the operations exist and are in the correct order - let nodes = graph.stages[0].nodes - - let entrypointNode = nodes.first { node in - if let metaOp = node.operation as? MetadataOperation, - case .setEntrypoint = metaOp.action - { - return true - } - return false - } - #expect(entrypointNode != nil, "Should have ENTRYPOINT operation") - - let cmdNode = nodes.first { node in - if let metaOp = node.operation as? MetadataOperation, - case .setCmd = metaOp.action - { - return true - } - return false - } - #expect(cmdNode != nil, "Should have CMD operation") - - // Validate the graph structure - let validator = StandardValidator() - let result = validator.validate(graph) - #expect(result.isValid, "ENTRYPOINT + CMD sequence should be valid: \(result.errors)") - } - - @Test func emptyStage() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - // Empty stage should be valid but might generate warnings - let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in - // Intentionally empty - just the base image - } - - #expect(graph.stages.count == 1) - #expect(graph.stages[0].nodes.isEmpty, "Empty stage should have no nodes") - - let validator = StandardValidator() - let result = validator.validate(graph) - #expect(result.isValid, "Empty stage should be structurally valid") - } - - // MARK: - Advanced GraphBuilder Features - - @Test func conditionalInstructions() throws { - guard let nodeRef = ImageReference(parsing: "node:18") else { - Issue.record("Failed to parse image reference") - return - } - - let isDevelopment = false - - let graph = try GraphBuilder.singleStage(from: nodeRef) { builder in - try builder - .workdir("/app") - .copyFromContext(paths: ["package.json"], to: "./") - .run("npm install" + (isDevelopment ? "" : " --omit=dev")) - .copyFromContext(paths: ["src/"], to: "./src/") - - if isDevelopment { - try builder - .env("NODE_ENV", "development") - .run("npm run test") - .cmd(.shell("npm run dev")) - } else { - try builder - .env("NODE_ENV", "production") - .run("npm run build") - .cmd(.exec(["node", "dist/index.js"])) - } - } - - // Verify production build path was taken - let envOps = graph.stages[0].nodes.compactMap { node in - node.operation as? MetadataOperation - }.compactMap { meta in - if case .setEnv(let key, let value) = meta.action, - key == "NODE_ENV" - { - return value - } - return nil - } - - #expect(envOps.contains(.literal("production"))) - #expect(!envOps.contains(.literal("development"))) - } - - @Test func stageWithCustomMetadata() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in - try builder - .label("version", "1.0.0") - .label("maintainer", "team@example.com") - .label("description", "Sample application") - .workdir("/app") - .user(.named("appuser")) - .expose([PortSpec(port: 8080)]) - } - - // Verify all metadata operations were added - let metadataOps = graph.stages[0].nodes.compactMap { $0.operation as? MetadataOperation } - - let labelOps = metadataOps.filter { - if case .setLabel = $0.action { return true } - return false - } - #expect(labelOps.count == 3) - - let userOps = metadataOps.filter { - if case .setUser = $0.action { return true } - return false - } - #expect(userOps.count == 1) - - let exposeOps = metadataOps.filter { - if case .expose = $0.action { return true } - return false - } - #expect(exposeOps.count == 1) - } - - @Test func complexMountOperations() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - let graph = try GraphBuilder.singleStage(from: alpineRef) { builder in - try builder - .workdir("/app") - .run( - "apk add --no-cache make gcc", - mounts: [ - Mount(type: .cache, target: "/var/cache/apk"), - Mount(type: .secret, target: "/run/secrets/github-token", source: .secret("github-token"), options: MountOptions(mode: 0o400)), - Mount(type: .bind, target: "/app/.cache", source: .local("build-cache")), - ] - ) - .copyFromContext(paths: ["Makefile", "src/"], to: "./") - .run( - "make build", - mounts: [ - Mount(type: .cache, target: "/app/.cache", options: MountOptions(sharing: .shared)), - Mount(type: .tmpfs, target: "/tmp", options: MountOptions(size: 100_000_000)), - ] - ) - } - - // Verify mount operations exist - let execOps = graph.stages[0].nodes.compactMap { $0.operation as? ExecOperation } - - let firstRunOp = execOps.first { op in - op.command.displayString.contains("apk add") - } - #expect(firstRunOp?.mounts.count == 3) - - let secondRunOp = execOps.first { op in - op.command.displayString.contains("make build") - } - #expect(secondRunOp?.mounts.count == 2) - } - - // MARK: - Graph Modification and Builder State - - @Test func builderStatePreservation() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - let graph1 = try GraphBuilder.singleStage(from: alpineRef) { stageBuilder in - try stageBuilder - .arg("VERSION", defaultValue: "1.0.0") - .platforms(Platform.linuxAMD64) - .run("echo 'Build 1'") - } - - // Verify first graph has our settings - #expect(graph1.targetPlatforms == [Platform.linuxAMD64]) - #expect(graph1.stages.count == 1) - #expect(getStageArg(graph1.stages[0], name: "VERSION") == "1.0.0") - - // Create second graph with different settings - let graph2 = try GraphBuilder.singleStage(from: alpineRef) { stageBuilder in - try stageBuilder - .arg("VERSION", defaultValue: "2.0.0") - .platforms(Platform.linuxARM64) - .run("echo 'Build 2'") - } - - // Verify second graph has updated settings - #expect(graph2.targetPlatforms == [Platform.linuxARM64]) - #expect(graph2.stages.count == 1) - #expect(getStageArg(graph2.stages[0], name: "VERSION") == "2.0.0") - - // Verify first graph unchanged - #expect(graph1.targetPlatforms == [Platform.linuxAMD64]) - #expect(graph1.stages.count == 1) - #expect(getStageArg(graph1.stages[0], name: "VERSION") == "1.0.0") - } - - @Test func incrementalGraphBuilding() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - let graph = try GraphBuilder.multiStage { builder in - // Build base stage - try builder - .stage(name: "base", from: alpineRef) - .run("apk add --no-cache ca-certificates") - .workdir("/app") - - // Add dependency stage - try builder - .stage(name: "deps", from: alpineRef) - .copyFromStage(.named("base"), paths: ["/etc/ssl"], to: "/etc/ssl") - .run("apk add --no-cache curl") - - // Add final stage - try builder - .stage(from: alpineRef) - .copyFromStage(.named("base"), paths: ["/app"], to: "/app") - .copyFromStage(.named("deps"), paths: ["/usr/bin/curl"], to: "/usr/local/bin/") - .entrypoint(.exec(["/app/start.sh"])) - } - - #expect(graph.stages.count == 3) - #expect(graph.stages[0].name == "base") - #expect(graph.stages[1].name == "deps") - #expect(graph.stages[2].name == nil) // Final stage - - // Verify dependencies are correct - let finalStageDeps = graph.stages[2].stageDependencies() - #expect(finalStageDeps.contains(.named("base"))) - #expect(finalStageDeps.contains(.named("deps"))) - } -} - -func getStageArg(_ stage: BuildStage, name: String, globalBuildArgs: [String: String] = [:]) -> String? { - for node in stage.nodes { - if let metadataOp = node.operation as? MetadataOperation, - case .declareArg(let argName, let defaultValue) = metadataOp.action, - argName == name - { - return defaultValue ?? globalBuildArgs[name] - } - } - return nil -} diff --git a/Tests/NativeBuilderTests/ContainerBuildIRTests/OperationTests.swift b/Tests/NativeBuilderTests/ContainerBuildIRTests/OperationTests.swift deleted file mode 100644 index 89e6c026a..000000000 --- a/Tests/NativeBuilderTests/ContainerBuildIRTests/OperationTests.swift +++ /dev/null @@ -1,843 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -@testable import ContainerBuildIR - -struct OperationTests { - - // MARK: - ExecOperation Tests - - @Test func execOperationBasic() throws { - let operation = ExecOperation( - command: .shell("echo 'Hello, World!'") - ) - - #expect(operation.operationKind == .exec) - #expect(operation.command.displayString == "echo 'Hello, World!'") - #expect(operation.environment.variables.isEmpty) - #expect(operation.mounts.isEmpty) - #expect(operation.workingDirectory == nil) - #expect(operation.user == nil) - #expect(operation.network == .default) - #expect(!operation.security.privileged) - } - - @Test func execOperationWithEnvironment() throws { - let environment = Environment([ - (key: "NODE_ENV", value: .literal("production")), - (key: "PORT", value: .buildArg("HTTP_PORT")), - (key: "DEBUG", value: .literal("false")), - ]) - - let operation = ExecOperation( - command: .exec(["node", "server.js"]), - environment: environment - ) - - #expect(operation.environment.variables.count == 3) - - let effectiveEnv = operation.environment.effectiveEnvironment - #expect(effectiveEnv["NODE_ENV"] == "production") - #expect(effectiveEnv["DEBUG"] == "false") - // BUILD_ARG references don't resolve without context - } - - @Test func execOperationWithMounts() throws { - let mounts = [ - Mount( - type: .cache, - target: "/var/cache/apt", - source: .local("apt-cache"), - options: MountOptions(sharing: .shared) - ), - Mount( - type: .secret, - target: "/run/secrets/github-token", - source: .secret("github-token"), - options: MountOptions(readOnly: true, mode: 0o400) - ), - Mount( - type: .tmpfs, - target: "/tmp", - source: nil, - options: MountOptions(size: 100 * 1024 * 1024) // 100MB - ), - ] - - let operation = ExecOperation( - command: .shell("apt-get update && apt-get install -y git"), - mounts: mounts - ) - - #expect(operation.mounts.count == 3) - - let cacheMount = operation.mounts[0] - #expect(cacheMount.type == .cache) - #expect(cacheMount.target == "/var/cache/apt") - #expect(cacheMount.options.sharing == .shared) - - let secretMount = operation.mounts[1] - #expect(secretMount.type == .secret) - #expect(secretMount.options.readOnly == true) - #expect(secretMount.options.mode == 0o400) - - let tmpfsMount = operation.mounts[2] - #expect(tmpfsMount.type == .tmpfs) - #expect(tmpfsMount.options.size == UInt32(100 * 1024 * 1024)) - } - - @Test func execOperationWithUser() throws { - let users = [ - User.named("appuser"), - User.uid(1000), - User.userGroup(user: "app", group: "app"), - User.uidGid(uid: 1000, gid: 1000), - ] - - for user in users { - let operation = ExecOperation( - command: .shell("whoami"), - user: user - ) - - #expect(operation.user == user) - } - } - - @Test func execOperationWithSecurity() throws { - let capabilities = SecurityCapabilities( - add: ["NET_ADMIN", "SYS_TIME"], - drop: ["ALL"] - ) - - let security = SecurityOptions( - privileged: true, - capabilities: capabilities, - seccompProfile: "custom.json", - apparmorProfile: "custom-profile", - noNewPrivileges: false - ) - - let operation = ExecOperation( - command: .shell("mount /dev/sda1 /mnt"), - security: security - ) - - #expect(operation.security.privileged == true) - #expect(operation.security.capabilities?.add.contains("NET_ADMIN") == true) - #expect(operation.security.capabilities?.drop.contains("ALL") == true) - #expect(operation.security.seccompProfile == "custom.json") - #expect(operation.security.apparmorProfile == "custom-profile") - #expect(operation.security.noNewPrivileges == false) - } - - @Test func execOperationWithNetwork() throws { - let networkModes: [NetworkMode] = [.default, .none, .host] - - for networkMode in networkModes { - let operation = ExecOperation( - command: .shell("curl https://example.com"), - network: networkMode - ) - - #expect(operation.network == networkMode) - } - } - - @Test func commandTypes() throws { - let shellCommand = Command.shell("echo 'test' && ls -la") - let execCommand = Command.exec(["ls", "-la", "/app"]) - - #expect(shellCommand.displayString == "echo 'test' && ls -la") - #expect(execCommand.displayString == "ls -la /app") - - // Test with empty exec array - let emptyExecCommand = Command.exec([]) - #expect(emptyExecCommand.displayString == "") - - // Test with single command - let singleExecCommand = Command.exec(["whoami"]) - #expect(singleExecCommand.displayString == "whoami") - } - - // MARK: - FilesystemOperation Tests - - @Test func filesystemOperationBasic() throws { - let operation = FilesystemOperation( - action: .copy, - source: .context(ContextSource(paths: ["src/"])), - destination: "/app/src/" - ) - - #expect(operation.operationKind == .filesystem) - #expect(operation.action == .copy) - #expect(operation.destination == "/app/src/") - - if case .context(let contextSource) = operation.source { - #expect(contextSource.paths == ["src/"]) - } else { - Issue.record("Expected context source") - } - } - - @Test func filesystemOperationFromStage() throws { - let operation = FilesystemOperation( - action: .copy, - source: .stage(.named("builder"), paths: ["/app/dist"]), - destination: "/usr/share/nginx/html/" - ) - - if case .stage(let stageRef, let paths) = operation.source { - if case .named(let name) = stageRef { - #expect(name == "builder") - } else { - Issue.record("Expected named stage reference") - } - #expect(paths == ["/app/dist"]) - } else { - Issue.record("Expected stage source") - } - } - - @Test func filesystemOperationWithMetadata() throws { - let expectedOwnership = Ownership(user: .numeric(id: 1000), group: .numeric(id: 1000)) - let fileMetadata = FileMetadata( - ownership: expectedOwnership, - permissions: .mode(0o644), - timestamps: Timestamps( - created: Date(), - modified: Date() - ) - ) - - let operation = FilesystemOperation( - action: .copy, - source: .context( - ContextSource( - paths: ["config.json"] - )), - destination: "/etc/app/config.json", - fileMetadata: fileMetadata - ) - - #expect(operation.fileMetadata.ownership == expectedOwnership) - - if case .mode(let mode) = operation.fileMetadata.permissions { - #expect(mode == 0o644) - } else { - Issue.record("Expected mode permissions") - } - } - - @Test func filesystemSourceTypes() throws { - let contextSource = FilesystemSource.context( - ContextSource( - paths: ["*.txt", "docs/"] - )) - - let stageSource = FilesystemSource.stage(.index(0), paths: ["/app/binary"]) - let imageSource = FilesystemSource.image( - ImageReference(parsing: "alpine:latest")!, - paths: ["/etc/ssl/certs/"] - ) - let urlSource = FilesystemSource.url(URL(string: "https://releases.example.com/v1.0.0/app.tar.gz")!) - - // Verify each source type can be created - let sources = [contextSource, stageSource, imageSource, urlSource] - for source in sources { - let operation = FilesystemOperation( - action: .copy, - source: source, - destination: "/test/" - ) - #expect(operation.source == source) - } - } - - // MARK: - MetadataOperation Tests - - @Test func metadataOperationEnvironment() throws { - let setEnvOperation = MetadataOperation( - action: .setEnv(key: "NODE_ENV", value: .literal("production")) - ) - - #expect(setEnvOperation.operationKind == .metadata) - - if case .setEnv(let key, let value) = setEnvOperation.action { - #expect(key == "NODE_ENV") - if case .literal(let literalValue) = value { - #expect(literalValue == "production") - } else { - Issue.record("Expected literal value") - } - } else { - Issue.record("Expected setEnv action") - } - } - - @Test func metadataOperationBatchEnvironment() throws { - let envVars = [ - (key: "NODE_ENV", value: EnvironmentValue.literal("production")), - (key: "PORT", value: EnvironmentValue.buildArg("HTTP_PORT")), - (key: "DEBUG", value: EnvironmentValue.literal("false")), - ] - - let operation = MetadataOperation( - action: .setEnvBatch(envVars) - ) - - if case .setEnvBatch(let vars) = operation.action { - #expect(vars.count == 3) - #expect(vars[0].key == "NODE_ENV") - #expect(vars[1].key == "PORT") - #expect(vars[2].key == "DEBUG") - } else { - Issue.record("Expected setEnvBatch action") - } - } - - @Test func metadataOperationLabels() throws { - let setLabelOperation = MetadataOperation( - action: .setLabel(key: "version", value: "1.0.0") - ) - - if case .setLabel(let key, let value) = setLabelOperation.action { - #expect(key == "version") - #expect(value == "1.0.0") - } else { - Issue.record("Expected setLabel action") - } - - let batchLabels = [ - "version": "1.0.0", - "maintainer": "team@example.com", - "description": "Sample application", - ] - - let batchLabelOperation = MetadataOperation( - action: .setLabelBatch(batchLabels) - ) - - if case .setLabelBatch(let labels) = batchLabelOperation.action { - #expect(labels.count == 3) - #expect(labels["version"] == "1.0.0") - #expect(labels["maintainer"] == "team@example.com") - } else { - Issue.record("Expected setLabelBatch action") - } - } - - @Test func metadataOperationArguments() throws { - let operation = MetadataOperation( - action: .declareArg(name: "BUILD_VERSION", defaultValue: "dev") - ) - - if case .declareArg(let name, let defaultValue) = operation.action { - #expect(name == "BUILD_VERSION") - #expect(defaultValue == "dev") - } else { - Issue.record("Expected declareArg action") - } - } - - @Test func metadataOperationExpose() throws { - let operation = MetadataOperation( - action: .expose(ports: [PortSpec(port: 8080, protocol: .tcp)]) - ) - - if case .expose(let ports) = operation.action { - #expect(!ports.isEmpty) - let port = ports[0] - #expect(port.port == 8080) - #expect(port.protocol == .tcp) - } else { - Issue.record("Expected expose action") - } - } - - @Test func metadataOperationWorkdir() throws { - let operation = MetadataOperation( - action: .setWorkdir(path: "/app") - ) - - if case .setWorkdir(let path) = operation.action { - #expect(path == "/app") - } else { - Issue.record("Expected setWorkdir action") - } - } - - @Test func metadataOperationUser() throws { - let userOperations = [ - MetadataOperation(action: .setUser(user: .named("appuser"))), - MetadataOperation(action: .setUser(user: .uid(1000))), - MetadataOperation(action: .setUser(user: .uidGid(uid: 1000, gid: 1000))), - ] - - for operation in userOperations { - if case .setUser(let user) = operation.action { - // Just verify the user is preserved - switch user { - case .named(let name): - #expect(name == "appuser") - case .uid(let uid): - #expect(uid == 1000) - case .uidGid(let uid, let gid): - #expect(uid == 1000) - #expect(gid == 1000) - default: - Issue.record("Unexpected user type") - } - } else { - Issue.record("Expected setUser action") - } - } - } - - @Test func metadataOperationCommands() throws { - let entrypointOperation = MetadataOperation( - action: .setEntrypoint(command: .exec(["/app/server"])) - ) - - let cmdOperation = MetadataOperation( - action: .setCmd(command: .shell("./start.sh")) - ) - - if case .setEntrypoint(let command) = entrypointOperation.action { - if case .exec(let args) = command { - #expect(args == ["/app/server"]) - } else { - Issue.record("Expected exec command") - } - } else { - Issue.record("Expected setEntrypoint action") - } - - if case .setCmd(let command) = cmdOperation.action { - if case .shell(let cmd) = command { - #expect(cmd == "./start.sh") - } else { - Issue.record("Expected shell command") - } - } else { - Issue.record("Expected setCmd action") - } - } - - @Test func metadataOperationHealthcheck() throws { - let healthcheck = Healthcheck( - test: .command(.exec(["curl", "-f", "http://localhost:8080/health"])), - interval: 30, - timeout: 5, - startPeriod: 10, - retries: 3 - ) - - let operation = MetadataOperation( - action: .setHealthcheck(healthcheck: healthcheck) - ) - - if case .setHealthcheck(let hc) = operation.action { - #expect(hc?.interval == 30) - #expect(hc?.timeout == 5) - #expect(hc?.startPeriod == 10) - #expect(hc?.retries == 3) - - if case .command(let cmd) = hc!.test, - case .exec(let args) = cmd - { - #expect(args == ["curl", "-f", "http://localhost:8080/health"]) - } else { - Issue.record("Expected command healthcheck test") - } - } else { - Issue.record("Expected setHealthcheck action") - } - } - - @Test func metadataOperationMiscellaneous() throws { - let stopSignalOp = MetadataOperation(action: .setStopSignal(signal: "SIGTERM")) - let volumeOp = MetadataOperation(action: .addVolume(path: "/data")) - let shellOp = MetadataOperation(action: .setShell(shell: ["/bin/bash", "-c"])) - let onBuildOp = MetadataOperation(action: .addOnBuild(instruction: "RUN npm install")) - - let operations = [stopSignalOp, volumeOp, shellOp, onBuildOp] - - for operation in operations { - #expect(operation.operationKind == .metadata) - - switch operation.action { - case .setStopSignal(let signal): - #expect(signal == "SIGTERM") - case .addVolume(let path): - #expect(path == "/data") - case .setShell(let shell): - #expect(shell == ["/bin/bash", "-c"]) - case .addOnBuild(let instruction): - #expect(instruction == "RUN npm install") - default: - Issue.record("Unexpected action type") - } - } - } - - // MARK: - ImageOperation Tests - - @Test func imageOperationFromRegistry() throws { - let imageRef = ImageReference(parsing: "alpine:3.18")! - let operation = ImageOperation( - source: .registry(imageRef), - platform: .linuxAMD64 - ) - - #expect(operation.operationKind == .image) - #expect(operation.platform == .linuxAMD64) - - if case .registry(let ref) = operation.source { - #expect(ref.stringValue == "alpine:3.18") - } else { - Issue.record("Expected registry source") - } - } - - @Test func imageOperationFromScratch() throws { - let operation = ImageOperation(source: .scratch) - - #expect(operation.platform == nil) // Default platform - - if case .scratch = operation.source { - // Expected - } else { - Issue.record("Expected scratch source") - } - } - - @Test func imageOperationFromOCILayout() throws { - let operation = ImageOperation( - source: .ociLayout(path: "/path/to/build/context", tag: "custom"), - platform: .linuxARM64 - ) - - if case .ociLayout(let path, let tag) = operation.source { - #expect(path == "/path/to/build/context") - #expect(tag == "custom") - } else { - Issue.record("Expected ociLayout source") - } - } - - // MARK: - Operation Visitor Pattern Tests - - class TestOperationVisitor: OperationVisitor { - typealias Result = String - - var visitedOperations: [String] = [] - - func visit(_ operation: ExecOperation) throws -> String { - visitedOperations.append("exec") - return "Exec: \(operation.command.displayString)" - } - - func visit(_ operation: FilesystemOperation) throws -> String { - visitedOperations.append("filesystem") - return "Filesystem: \(operation.action) to \(operation.destination)" - } - - func visit(_ operation: MetadataOperation) throws -> String { - visitedOperations.append("metadata") - return "Metadata: \(operation.action)" - } - - func visit(_ operation: ImageOperation) throws -> String { - visitedOperations.append("image") - return "Image: \(operation.source)" - } - - func visitUnknown(_ operation: any ContainerBuildIR.Operation) throws -> String { - visitedOperations.append("unknown") - return "Unknown: \(operation.operationKind)" - } - } - - @Test func operationVisitorPattern() throws { - let operations: [any ContainerBuildIR.Operation] = [ - ExecOperation(command: .shell("echo test")), - FilesystemOperation( - action: .copy, - source: .context(ContextSource(paths: ["file.txt"])), - destination: "/app/" - ), - MetadataOperation(action: .setEnv(key: "TEST", value: .literal("value"))), - ImageOperation(source: .registry(ImageReference(parsing: "alpine")!)), - ] - - let visitor = TestOperationVisitor() - var results: [String] = [] - - for operation in operations { - let result = try operation.accept(visitor) - results.append(result) - } - - #expect(visitor.visitedOperations == ["exec", "filesystem", "metadata", "image"]) - #expect(results.count == 4) - #expect(results[0].hasPrefix("Exec:")) - #expect(results[1].hasPrefix("Filesystem:")) - #expect(results[2].hasPrefix("Metadata:")) - #expect(results[3].hasPrefix("Image:")) - } - - // MARK: - Operation Serialization Tests - - @Test func operationSerialization() throws { - let operations: [any ContainerBuildIR.Operation] = [ - ExecOperation( - command: .exec(["npm", "install"]), - environment: Environment([(key: "NODE_ENV", value: .literal("production"))]), - user: .uid(1000) - ), - FilesystemOperation( - action: .copy, - source: .context(ContextSource(paths: ["src/"])), - destination: "/app/src/", - fileMetadata: FileMetadata( - ownership: Ownership(user: .numeric(id: 1000), group: .numeric(id: 1000)), - permissions: .mode(0o755) - ) - ), - MetadataOperation( - action: .setLabel(key: "version", value: "1.0.0") - ), - ImageOperation( - source: .registry(ImageReference(parsing: "node:18")!), - platform: .linuxAMD64 - ), - ] - - let encoder = JSONEncoder() - let decoder = JSONDecoder() - - for operation in operations { - // Test that each operation type can be encoded and decoded - let data: Data - - switch operation { - case let execOp as ExecOperation: - data = try encoder.encode(execOp) - let decoded = try decoder.decode(ExecOperation.self, from: data) - #expect(decoded.command.displayString == execOp.command.displayString) - #expect(decoded.user == execOp.user) - - case let fsOp as FilesystemOperation: - data = try encoder.encode(fsOp) - let decoded = try decoder.decode(FilesystemOperation.self, from: data) - #expect(decoded.action == fsOp.action) - #expect(decoded.destination == fsOp.destination) - - case let metaOp as MetadataOperation: - data = try encoder.encode(metaOp) - _ = try decoder.decode(MetadataOperation.self, from: data) - // Note: MetadataAction comparison would need custom implementation - - case let imageOp as ImageOperation: - data = try encoder.encode(imageOp) - let decoded = try decoder.decode(ImageOperation.self, from: data) - #expect(decoded.platform == imageOp.platform) - - default: - Issue.record("Unexpected operation type") - } - } - } - - // MARK: - Operation Metadata Tests - - @Test func operationMetadata() throws { - let sourceLocation = SourceLocation( - file: "Dockerfile", - line: 15, - column: 5 - ) - - let metadata = OperationMetadata( - comment: "Install application dependencies", - sourceLocation: sourceLocation - ) - - let operation = ExecOperation( - command: .shell("npm install"), - metadata: metadata - ) - - #expect(operation.metadata.comment == "Install application dependencies") - #expect(operation.metadata.sourceLocation?.file == "Dockerfile") - #expect(operation.metadata.sourceLocation?.line == 15) - } - - // MARK: - Complex Operation Tests - - @Test func complexExecOperation() throws { - let complexOperation = ExecOperation( - command: .shell("cd /app && npm ci --only=production && npm run build"), - environment: Environment([ - (key: "NODE_ENV", value: .literal("production")), - (key: "BUILD_TARGET", value: .buildArg("TARGET")), - (key: "CACHE_DIR", value: .literal("/tmp/cache")), - ]), - mounts: [ - Mount( - type: .cache, - target: "/tmp/cache", - source: .local("build-cache"), - options: MountOptions(sharing: .shared) - ), - Mount( - type: .secret, - target: "/run/secrets/npmrc", - source: .secret("npmrc"), - options: MountOptions(readOnly: true, mode: 0o600) - ), - ], - workingDirectory: "/app", - user: .uidGid(uid: 1000, gid: 1000), - network: .default, - security: SecurityOptions( - privileged: false, - noNewPrivileges: true - ), - metadata: OperationMetadata( - comment: "Build application with caching and secrets" - ) - ) - - #expect(complexOperation.command.displayString.contains("npm ci")) - #expect(complexOperation.environment.variables.count == 3) - #expect(complexOperation.mounts.count == 2) - #expect(complexOperation.workingDirectory == "/app") - #expect(complexOperation.user != nil) - #expect(complexOperation.security.noNewPrivileges == true) - #expect(complexOperation.metadata.comment?.contains("Build application") == true) - } - - @Test func complexFilesystemOperation() throws { - let complexOperation = FilesystemOperation( - action: .copy, - source: .stage(.named("builder"), paths: ["/app/dist/**/*", "/app/package.json"]), - destination: "/usr/share/nginx/html/", - fileMetadata: FileMetadata( - ownership: Ownership(user: .named(id: "nginx"), group: .named(id: "nginx")), - permissions: .mode(0o644), - timestamps: Timestamps( - created: Date(), - modified: Date() - ) - ), - metadata: OperationMetadata( - comment: "Copy built assets from builder stage", - sourceLocation: SourceLocation(file: "Dockerfile", line: 25, column: 1) - ) - ) - - if case .stage(let stageRef, let paths) = complexOperation.source { - if case .named(let name) = stageRef { - #expect(name == "builder") - } - #expect(paths.count == 2) - #expect(paths.contains("/app/dist/**/*")) - } else { - Issue.record("Expected stage source") - } - - #expect(complexOperation.destination == "/usr/share/nginx/html/") - // Check fileMetadata exists and has expected values - #expect(complexOperation.metadata.comment?.contains("built assets") == true) - } - - // MARK: - Edge Cases and Error Conditions - - @Test func emptyOperations() throws { - // Test operations with minimal/empty configurations - let minimalExec = ExecOperation(command: .exec([])) - #expect(minimalExec.command.displayString == "") - #expect(minimalExec.mounts.isEmpty) - - let minimalFilesystem = FilesystemOperation( - action: .copy, - source: .context(ContextSource(paths: [])), - destination: "" - ) - #expect(minimalFilesystem.destination == "") - - if case .context(let ctx) = minimalFilesystem.source { - #expect(ctx.paths.isEmpty) - } - } - - @Test func operationEquality() throws { - let exec1 = ExecOperation(command: .shell("echo test")) - let exec2 = ExecOperation(command: .shell("echo test")) - let exec3 = ExecOperation(command: .shell("echo different")) - - #expect(exec1 == exec2) - #expect(exec1 != exec3) - - let fs1 = FilesystemOperation( - action: .copy, - source: .context(ContextSource(paths: ["file.txt"])), - destination: "/app/" - ) - let fs2 = FilesystemOperation( - action: .copy, - source: .context(ContextSource(paths: ["file.txt"])), - destination: "/app/" - ) - - #expect(fs1 == fs2) - } - - @Test func operationHashing() throws { - let exec1 = ExecOperation(command: .shell("echo test")) - let exec2 = ExecOperation(command: .shell("echo different")) - let fs1 = FilesystemOperation( - action: .copy, - source: .context(ContextSource(paths: ["file.txt"])), - destination: "/app/" - ) - - let operations: Set = Set([ - AnyHashable(exec1), - AnyHashable(exec2), - AnyHashable(fs1), - ]) - - #expect(operations.count == 3) - - // Test that identical operations hash to the same value - let exec1_copy = ExecOperation(command: .shell("echo test")) - let exec2_copy = ExecOperation(command: .shell("echo test")) - - var hasher1 = Hasher() - var hasher2 = Hasher() - - exec1_copy.hash(into: &hasher1) - exec2_copy.hash(into: &hasher2) - - #expect(hasher1.finalize() == hasher2.finalize()) - } -} diff --git a/Tests/NativeBuilderTests/ContainerBuildIRTests/SerializationTests.swift b/Tests/NativeBuilderTests/ContainerBuildIRTests/SerializationTests.swift deleted file mode 100644 index 971f41df0..000000000 --- a/Tests/NativeBuilderTests/ContainerBuildIRTests/SerializationTests.swift +++ /dev/null @@ -1,622 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -@testable import ContainerBuildIR - -struct SerializationTests { - - // MARK: - JSONIRCoder Tests - - @Test func jsonCoderBasicRoundTrip() throws { - guard let imageRef = ImageReference(parsing: "alpine:latest") else { - Issue.record("Failed to parse image reference") - return - } - - let originalGraph = try GraphBuilder.singleStage(from: imageRef) { builder in - try builder - .run("apk add --no-cache curl") - .workdir("/app") - .copyFromContext(paths: ["src/"], to: "/app/") - .env("NODE_ENV", "production") - .expose([PortSpec(port: 3000)]) - .cmd(.exec(["node", "index.js"])) - } - - let coder = JSONIRCoder() - let encodedData = try coder.encode(originalGraph) - let decodedGraph = try coder.decode(encodedData) - - // Verify structure preservation - #expect(decodedGraph.stages.count == originalGraph.stages.count) - #expect(decodedGraph.buildArgs == originalGraph.buildArgs) - #expect(decodedGraph.targetPlatforms == originalGraph.targetPlatforms) - - // Verify stage content - let originalStage = originalGraph.stages[0] - let decodedStage = decodedGraph.stages[0] - #expect(decodedStage.nodes.count == originalStage.nodes.count) - #expect(decodedStage.name == originalStage.name) - } - - @Test func jsonCoderPrettyPrint() throws { - guard let imageRef = ImageReference(parsing: "ubuntu:22.04") else { - Issue.record("Failed to parse image reference") - return - } - - let graph = try GraphBuilder.singleStage(from: imageRef) { builder in - try builder - .run("apt-get update") - .run("apt-get install -y python3") - } - - let prettyPrintCoder = JSONIRCoder(prettyPrint: true) - let compactCoder = JSONIRCoder(prettyPrint: false) - - let prettyData = try prettyPrintCoder.encode(graph) - let compactData = try compactCoder.encode(graph) - - // Pretty printed should be larger (contains whitespace) - #expect(prettyData.count > compactData.count) - - // Both should decode to the same graph - let prettyGraph = try prettyPrintCoder.decode(prettyData) - let compactGraph = try compactCoder.decode(compactData) - - #expect(prettyGraph.stages.count == compactGraph.stages.count) - #expect(prettyGraph.buildArgs == compactGraph.buildArgs) - - // Verify JSON is actually pretty printed - let prettyJson = String(data: prettyData, encoding: .utf8)! - #expect(prettyJson.contains("\n"), "Pretty printed JSON should contain newlines") - #expect(prettyJson.contains(" "), "Pretty printed JSON should contain indentation") - } - - @Test func jsonCoderMultiStageGraph() throws { - guard let nodeRef = ImageReference(parsing: "node:18"), - let nginxRef = ImageReference(parsing: "nginx:alpine") - else { - Issue.record("Failed to parse image references") - return - } - - let originalGraph = try GraphBuilder.multiStage { builder in - try builder - .stage(name: "build", from: nodeRef) - .workdir("/app") - .copyFromContext(paths: ["package.json"], to: "./") - .run("npm install") - .copyFromContext(paths: ["src/"], to: "./src/") - .run("npm run build") - - try builder - .stage(from: nginxRef) - .copyFromStage(.named("build"), paths: ["/app/dist"], to: "/usr/share/nginx/html") - .expose([PortSpec(port: 80)]) - } - - let coder = JSONIRCoder() - let encodedData = try coder.encode(originalGraph) - let decodedGraph = try coder.decode(encodedData) - - #expect(decodedGraph.stages.count == 2) - #expect(decodedGraph.stages[0].name == "build") - #expect(decodedGraph.stages[1].name == nil) - - // Verify stage dependencies are preserved - let runtimeStage = decodedGraph.stages[1] - let stageDeps = runtimeStage.stageDependencies() - #expect(stageDeps.contains(.named("build"))) - } - - @Test func jsonCoderWithBuildArgs() throws { - guard let nodeRef = ImageReference(parsing: "node:18") else { - Issue.record("Failed to parse image reference") - return - } - - let originalGraph = try GraphBuilder.singleStage(from: nodeRef) { builder in - try builder - .arg("NODE_ENV", defaultValue: "development") - .arg("BUILD_VERSION") - .env("NODE_ENV", "production") - .env("BUILD_VERSION", "1.2.3") - .run("npm install") - } - - let coder = JSONIRCoder() - let encodedData = try coder.encode(originalGraph) - let decodedGraph = try coder.decode(encodedData) - - // Verify build args preservation - #expect(decodedGraph.buildArgs.count >= 0) - - // Verify target platforms preservation - #expect(decodedGraph.targetPlatforms.count >= 0) - } - - // MARK: - BinaryIRCoder Tests - - @Test func binaryCoderBasicRoundTrip() throws { - guard let imageRef = ImageReference(parsing: "alpine:latest") else { - Issue.record("Failed to parse image reference") - return - } - - let originalGraph = try GraphBuilder.singleStage(from: imageRef) { builder in - try builder - .run("apk add --no-cache git") - .workdir("/app") - .copyFromContext(paths: ["main.go"], to: "/app/") - .run("go build -o app main.go") - .entrypoint(.exec(["/app/app"])) - } - - let coder = BinaryIRCoder() - let encodedData = try coder.encode(originalGraph) - let decodedGraph = try coder.decode(encodedData) - - // Verify structure preservation - #expect(decodedGraph.stages.count == originalGraph.stages.count) - #expect(decodedGraph.buildArgs == originalGraph.buildArgs) - #expect(decodedGraph.targetPlatforms == originalGraph.targetPlatforms) - - // Verify stage content - let originalStage = originalGraph.stages[0] - let decodedStage = decodedGraph.stages[0] - #expect(decodedStage.nodes.count == originalStage.nodes.count) - } - - @Test func binaryCoderCompression() throws { - guard let ubuntuRef = ImageReference(parsing: "ubuntu:22.04") else { - Issue.record("Failed to parse image reference") - return - } - - // Create a larger graph to test compression effectiveness - let originalGraph = try GraphBuilder.multiStage { builder in - for i in 0..<3 { - try builder - .stage(name: "stage\(i)", from: ubuntuRef) - .run("apt-get update") - .run("apt-get install -y curl wget git vim") - .workdir("/app") - .copyFromContext(paths: ["file\(i).txt"], to: "/app/") - .env("STAGE_NUMBER", "\(i)") - .label("stage.number", "\(i)") - .label("stage.description", "This is stage number \(i) with detailed information") - } - } - - let jsonCoder = JSONIRCoder(prettyPrint: false) - let binaryCoder = BinaryIRCoder() - - let jsonData = try jsonCoder.encode(originalGraph) - let binaryData = try binaryCoder.encode(originalGraph) - - // Binary format should typically be smaller than JSON - #expect( - binaryData.count <= jsonData.count, - "Binary format should be more compact (JSON: \(jsonData.count), Binary: \(binaryData.count))") - - // Verify both decode correctly - let jsonGraph = try jsonCoder.decode(jsonData) - let binaryGraph = try binaryCoder.decode(binaryData) - - #expect(jsonGraph.stages.count == binaryGraph.stages.count) - #expect(jsonGraph.buildArgs == binaryGraph.buildArgs) - } - - @Test func binaryCoderComplexOperations() throws { - guard let alpineRef = ImageReference(parsing: "alpine:latest") else { - Issue.record("Failed to parse image reference") - return - } - - let originalGraph = try GraphBuilder.singleStage(from: alpineRef) { builder in - try builder - .run( - "apk add --no-cache build-tools", - mounts: [ - Mount(type: .cache, target: "/var/cache/apk", source: .local("apk-cache")), - Mount(type: .secret, target: "/run/secrets/token", source: .secret("github-token")), - ] - ) - .copyFromContext( - paths: ["src/**/*.c", "include/**/*.h"], - to: "/app/" - ) - .healthcheck( - test: .command(.exec(["./healthcheck.sh"])), - interval: 30, - timeout: 5, - retries: 3 - ) - .user(.uidGid(uid: 1000, gid: 1000)) - } - - let coder = BinaryIRCoder() - let encodedData = try coder.encode(originalGraph) - let decodedGraph = try coder.decode(encodedData) - - // Verify complex operations are preserved - let stage = decodedGraph.stages[0] - - // Check for RUN operation with mounts - let runOps = stage.nodes.compactMap { $0.operation as? ExecOperation } - let runWithMounts = runOps.first { !$0.mounts.isEmpty } - #expect(runWithMounts != nil, "RUN operation with mounts should be preserved") - #expect(runWithMounts!.mounts.count == 2, "Both mounts should be preserved") - - // Check for COPY operation with metadata - let copyOps = stage.nodes.compactMap { $0.operation as? FilesystemOperation } - let copyWithMetadata = copyOps.first { - $0.fileMetadata.permissions != nil - } - #expect(copyWithMetadata != nil, "COPY operation should preserve file permissions by default") - - // Check for metadata operations - let metaOps = stage.nodes.compactMap { $0.operation as? MetadataOperation } - let hasHealthcheck = metaOps.contains { - if case .setHealthcheck = $0.action { return true } - return false - } - #expect(hasHealthcheck, "Healthcheck metadata should be preserved") - } - - // MARK: - Format Comparison Tests - - @Test func formatSizeComparison() throws { - guard let golangRef = ImageReference(parsing: "golang:1.21"), - let alpineRef = ImageReference(parsing: "alpine:latest") - else { - Issue.record("Failed to parse image references") - return - } - - // Create a realistic build graph - let graph = try GraphBuilder.multiStage { builder in - try builder - .stage(name: "builder", from: golangRef) - .workdir("/src") - .copyFromContext(paths: ["go.mod", "go.sum"], to: "./") - .run("go mod download") - .copyFromContext(paths: ["cmd/", "internal/", "pkg/"], to: "./") - .run("CGO_ENABLED=0 GOOS=linux go build -ldflags='-w -s' -o /app cmd/main.go") - - try builder - .stage(from: alpineRef) - .run("apk add --no-cache ca-certificates") - .copyFromStage(.named("builder"), paths: ["/app"], to: "/usr/local/bin/") - .user(.uid(65534)) - .expose([PortSpec(port: 8080)]) - .entrypoint(.exec(["/usr/local/bin/app"])) - } - - let jsonCompactCoder = JSONIRCoder(prettyPrint: false) - let jsonPrettyCoder = JSONIRCoder(prettyPrint: true) - let binaryCoder = BinaryIRCoder() - - let jsonCompactData = try jsonCompactCoder.encode(graph) - let jsonPrettyData = try jsonPrettyCoder.encode(graph) - let binaryData = try binaryCoder.encode(graph) - - print("Format size comparison:") - print("- JSON (compact): \(jsonCompactData.count) bytes") - print("- JSON (pretty): \(jsonPrettyData.count) bytes") - print("- Binary: \(binaryData.count) bytes") - - // Expected size ordering - #expect(binaryData.count <= jsonCompactData.count) - #expect(jsonCompactData.count < jsonPrettyData.count) - - // All should decode to equivalent graphs - let graphs = [ - try jsonCompactCoder.decode(jsonCompactData), - try jsonPrettyCoder.decode(jsonPrettyData), - try binaryCoder.decode(binaryData), - ] - - for graph in graphs { - #expect(graph.stages.count == 2) - #expect(graph.stages[0].name == "builder") - } - } - - // MARK: - Error Handling Tests - - @Test func corruptedDataHandling() throws { - let coder = JSONIRCoder() - - // Test completely invalid data - let invalidData = "not valid json".data(using: .utf8)! - #expect(throws: Error.self) { - try coder.decode(invalidData) - } - - // Test valid JSON but invalid structure - let invalidStructure = """ - { - "version": "1.0", - "graph": { - "stages": "this should be an array" - } - } - """.data(using: .utf8)! - - #expect(throws: Error.self) { - try coder.decode(invalidStructure) - } - } - - @Test func binaryCorruptedDataHandling() throws { - let coder = BinaryIRCoder() - - // Test invalid binary data - let invalidData = Data(repeating: 0xFF, count: 100) - #expect(throws: Error.self) { - try coder.decode(invalidData) - } - - // Test truncated data - let validGraph: BuildGraph - do { - validGraph = try GraphBuilder.singleStage( - from: ImageReference(parsing: "alpine")! - ) { builder in - try builder.run("echo test") - } - } catch { - Issue.record("Failed to create test graph") - return - } - - let validData = try coder.encode(validGraph) - let truncatedData = validData.prefix(validData.count / 2) - - #expect(throws: Error.self) { - try coder.decode(Data(truncatedData)) - } - } - - @Test func versionHandling() throws { - // Test that we can detect version information in JSON format - let graph: BuildGraph - do { - graph = try GraphBuilder.singleStage( - from: ImageReference(parsing: "alpine")! - ) { builder in - try builder.run("echo version test") - } - } catch { - Issue.record("Failed to create test graph") - return - } - - let coder = JSONIRCoder(prettyPrint: true) - let data = try coder.encode(graph) - let jsonString = String(data: data, encoding: .utf8)! - - #expect(jsonString.contains("\"version\""), "JSON should contain version information") - #expect(jsonString.contains("\"1.0\""), "Should use version 1.0") - - // Should decode correctly - let decodedGraph = try coder.decode(data) - #expect(decodedGraph.stages.count == graph.stages.count) - } - - // MARK: - File I/O Tests - - @Test func saveAndLoadGraph() throws { - guard let imageRef = ImageReference(parsing: "python:3.11") else { - Issue.record("Failed to parse image reference") - return - } - - let originalGraph = try GraphBuilder.singleStage(from: imageRef) { builder in - try builder - .workdir("/app") - .copyFromContext(paths: ["requirements.txt"], to: "./") - .run("pip install -r requirements.txt") - .copyFromContext(paths: ["src/"], to: "./src/") - .cmd(.exec(["python", "src/main.py"])) - } - - let tempDir = FileManager.default.temporaryDirectory - let jsonURL = tempDir.appendingPathComponent("test-graph.json") - let binaryURL = tempDir.appendingPathComponent("test-graph.bin") - - // Save using different formats - try originalGraph.save(to: jsonURL, using: JSONIRCoder(prettyPrint: true)) - try originalGraph.save(to: binaryURL, using: BinaryIRCoder()) - - // Verify files exist and have content - #expect(FileManager.default.fileExists(atPath: jsonURL.path)) - #expect(FileManager.default.fileExists(atPath: binaryURL.path)) - - let jsonFileSize = try FileManager.default.attributesOfItem(atPath: jsonURL.path)[.size] as! Int - let binaryFileSize = try FileManager.default.attributesOfItem(atPath: binaryURL.path)[.size] as! Int - - #expect(jsonFileSize > 0) - #expect(binaryFileSize > 0) - - // Load and verify - let jsonGraph = try BuildGraph.load(from: jsonURL, using: JSONIRCoder()) - let binaryGraph = try BuildGraph.load(from: binaryURL, using: BinaryIRCoder()) - - #expect(jsonGraph.stages.count == originalGraph.stages.count) - #expect(binaryGraph.stages.count == originalGraph.stages.count) - - // Cleanup - try? FileManager.default.removeItem(at: jsonURL) - try? FileManager.default.removeItem(at: binaryURL) - } - - // MARK: - Performance Tests - - @Test func serializationPerformance() throws { - // Create a large graph for performance testing - guard let baseRef = ImageReference(parsing: "ubuntu:22.04") else { - Issue.record("Failed to parse image reference") - return - } - - var stages: [BuildStage] = [] - - // Create 5 stages with 10 operations each - for stageIndex in 0..<5 { - var nodes: [BuildNode] = [] - - for opIndex in 0..<10 { - let operation: any ContainerBuildIR.Operation - switch opIndex % 4 { - case 0: - operation = ExecOperation(command: .shell("echo 'stage \(stageIndex) op \(opIndex)'")) - case 1: - operation = FilesystemOperation( - action: .copy, - source: .context(ContextSource(paths: ["file\(opIndex).txt"])), - destination: "/app/file\(opIndex).txt" - ) - case 2: - operation = MetadataOperation(action: .setEnv(key: "VAR\(opIndex)", value: .literal("value\(opIndex)"))) - default: - operation = MetadataOperation(action: .setLabel(key: "label\(opIndex)", value: "value\(opIndex)")) - } - - nodes.append(BuildNode(operation: operation, dependencies: [])) - } - - stages.append( - BuildStage( - name: "stage\(stageIndex)", - base: ImageOperation(source: .registry(baseRef)), - nodes: nodes - )) - } - - let largeGraph = try BuildGraph(stages: stages) - - let jsonCoder = JSONIRCoder() - let binaryCoder = BinaryIRCoder() - - // Measure JSON encoding time - let jsonStartTime = Date() - let jsonData = try jsonCoder.encode(largeGraph) - let jsonEncodeTime = Date().timeIntervalSince(jsonStartTime) - - // Measure binary encoding time - let binaryStartTime = Date() - let binaryData = try binaryCoder.encode(largeGraph) - let binaryEncodeTime = Date().timeIntervalSince(binaryStartTime) - - print("Encoding performance for large graph:") - print("- JSON: \(String(format: "%.3f", jsonEncodeTime))s (\(jsonData.count) bytes)") - print("- Binary: \(String(format: "%.3f", binaryEncodeTime))s (\(binaryData.count) bytes)") - - // Both should complete in reasonable time (under 1 second for this size) - #expect(jsonEncodeTime < 1.0, "JSON encoding should be fast") - #expect(binaryEncodeTime < 1.0, "Binary encoding should be fast") - - // Measure decoding performance - let jsonDecodeStart = Date() - let _ = try jsonCoder.decode(jsonData) - let jsonDecodeTime = Date().timeIntervalSince(jsonDecodeStart) - - let binaryDecodeStart = Date() - let _ = try binaryCoder.decode(binaryData) - let binaryDecodeTime = Date().timeIntervalSince(binaryDecodeStart) - - print("Decoding performance for large graph:") - print("- JSON: \(String(format: "%.3f", jsonDecodeTime))s") - print("- Binary: \(String(format: "%.3f", binaryDecodeTime))s") - - #expect(jsonDecodeTime < 1.0, "JSON decoding should be fast") - #expect(binaryDecodeTime < 1.0, "Binary decoding should be fast") - } - - // MARK: - Edge Cases - - @Test func emptyGraphSerialization() throws { - // Test edge case of empty graph - let emptyGraph = try BuildGraph(stages: []) - - let jsonCoder = JSONIRCoder() - let binaryCoder = BinaryIRCoder() - - let jsonData = try jsonCoder.encode(emptyGraph) - let binaryData = try binaryCoder.encode(emptyGraph) - - let jsonDecoded = try jsonCoder.decode(jsonData) - let binaryDecoded = try binaryCoder.decode(binaryData) - - #expect(jsonDecoded.stages.isEmpty) - #expect(binaryDecoded.stages.isEmpty) - #expect(jsonDecoded.buildArgs.isEmpty) - #expect(binaryDecoded.buildArgs.isEmpty) - } - - @Test func graphWithSpecialCharacters() throws { - guard let baseRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - let graph = try GraphBuilder.singleStage(from: baseRef) { builder in - try builder - .run("echo 'Special chars: 中文 émojis 🚀 newlines\n and quotes \"test\"'") - .env("UNICODE", "Contains unicode: ñáéíóú 中文字符 🌍") - .label("description", "Multi-line\ndescription with\ttabs and spaces") - .workdir("/app with spaces/and-symbols!@#$%") - } - - let jsonCoder = JSONIRCoder(prettyPrint: true) - let binaryCoder = BinaryIRCoder() - - let jsonData = try jsonCoder.encode(graph) - let binaryData = try binaryCoder.encode(graph) - - let jsonDecoded = try jsonCoder.decode(jsonData) - let binaryDecoded = try binaryCoder.decode(binaryData) - - #expect(jsonDecoded.stages.count == 1) - #expect(binaryDecoded.stages.count == 1) - - // Check that the operations contain the expected special characters - let stage = jsonDecoded.stages[0] - let execOp = stage.nodes.compactMap { $0.operation as? ExecOperation }.first! - let envOp = stage.nodes.compactMap { $0.operation as? MetadataOperation }.first { op in - if case .setEnv(let key, _) = op.action, key == "UNICODE" { return true } - return false - }! - - // Verify special characters are preserved - if case .shell(let command) = execOp.command { - #expect(command.contains("中文"), "Unicode characters should be preserved in command") - #expect(command.contains("🚀"), "Emoji should be preserved in command") - #expect(command.contains("\n"), "Newlines should be preserved in command") - } - - if case .setEnv(_, let value) = envOp.action, case .literal(let envValue) = value { - #expect(envValue.contains("中文字符"), "Unicode characters should be preserved in env") - #expect(envValue.contains("🌍"), "Emoji should be preserved in env") - } - } -} diff --git a/Tests/NativeBuilderTests/ContainerBuildIRTests/ValidationTests.swift b/Tests/NativeBuilderTests/ContainerBuildIRTests/ValidationTests.swift deleted file mode 100644 index a96211f5e..000000000 --- a/Tests/NativeBuilderTests/ContainerBuildIRTests/ValidationTests.swift +++ /dev/null @@ -1,928 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -@testable import ContainerBuildIR - -struct ValidationTests { - - // MARK: - StructuralValidator Tests - - @Test func duplicateNodeIDs() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - let duplicateId = UUID() - let stage = BuildStage( - name: "test", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - id: duplicateId, - operation: ExecOperation(command: .shell("echo 'first'")), - dependencies: [] - ), - BuildNode( - id: duplicateId, // Duplicate ID - operation: ExecOperation(command: .shell("echo 'second'")), - dependencies: [] - ), - ] - ) - - let graph = try BuildGraph(stages: [stage]) - let validator = StructuralValidator() - let result = validator.validate(graph) - - #expect(!result.isValid) - - let hasDuplicateError = result.errors.contains { error in - if case .duplicateNodeID(let id, _) = error { - return id == duplicateId - } - return false - } - #expect(hasDuplicateError, "Should detect duplicate node ID") - } - - @Test func missingDependency() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - let missingId = UUID() - let stage = BuildStage( - name: "test", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - operation: ExecOperation(command: .shell("echo 'dependent'")), - dependencies: Set([missingId]) // References non-existent node - ) - ] - ) - - // BuildGraph constructor should throw for invalid dependencies - #expect(throws: BuildGraphError.self) { - try BuildGraph(stages: [stage]) - } - } - - @Test func validStructuralGraph() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - let node1 = BuildNode( - operation: ExecOperation(command: .shell("echo 'first'")), - dependencies: [] - ) - let node2 = BuildNode( - operation: ExecOperation(command: .shell("echo 'second'")), - dependencies: Set([node1.id]) - ) - - let stage = BuildStage( - name: "test", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [node1, node2] - ) - - let graph = try BuildGraph(stages: [stage]) - let validator = StructuralValidator() - let result = validator.validate(graph) - - #expect(result.isValid) - #expect(result.errors.isEmpty) - } - - // MARK: - ReferenceValidator Tests - - @Test func undefinedStageReference() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - let stage = BuildStage( - name: "test", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - operation: FilesystemOperation( - action: .copy, - source: .stage(.named("nonexistent"), paths: ["/file"]), - destination: "/test/file" - ), - dependencies: [] - ) - ] - ) - - let graph = try BuildGraph(stages: [stage]) - let validator = ReferenceValidator() - let result = validator.validate(graph) - - #expect(!result.isValid) - - let hasUndefinedStageError = result.errors.contains { error in - if case .undefinedStageReference(let name, _) = error { - return name == "nonexistent" - } - return false - } - #expect(hasUndefinedStageError, "Should detect undefined stage reference") - } - - @Test func stageIndexOutOfBounds() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - let stage = BuildStage( - name: "test", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - operation: FilesystemOperation( - action: .copy, - source: .stage(.index(99), paths: ["/file"]), // Out of bounds - destination: "/test/file" - ), - dependencies: [] - ) - ] - ) - - let graph = try BuildGraph(stages: [stage]) - let validator = ReferenceValidator() - let result = validator.validate(graph) - - #expect(!result.isValid) - - let hasOutOfBoundsError = result.errors.contains { error in - if case .stageIndexOutOfBounds(let index, _) = error { - return index == 99 - } - return false - } - #expect(hasOutOfBoundsError, "Should detect stage index out of bounds") - } - - @Test func invalidPreviousReference() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - // First stage cannot reference "previous" - let stage = BuildStage( - name: "first", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - operation: FilesystemOperation( - action: .copy, - source: .stage(.previous, paths: ["/file"]), - destination: "/test/file" - ), - dependencies: [] - ) - ] - ) - - let graph = try BuildGraph(stages: [stage]) - let validator = ReferenceValidator() - let result = validator.validate(graph) - - #expect(!result.isValid) - - let hasInvalidPrevError = result.errors.contains { error in - if case .invalidPreviousReference = error { - return true - } - return false - } - #expect(hasInvalidPrevError, "Should detect invalid previous reference in first stage") - } - - @Test func forwardStageReferenceWarnings() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - let stage1 = BuildStage( - name: "first", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - operation: FilesystemOperation( - action: .copy, - source: .stage(.named("second"), paths: ["/file"]), // Forward reference - destination: "/test/file" - ), - dependencies: [] - ) - ] - ) - - let stage2 = BuildStage( - name: "second", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - operation: ExecOperation(command: .shell("echo 'second stage'")), - dependencies: [] - ) - ] - ) - - let graph = try BuildGraph(stages: [stage1, stage2]) - let validator = ReferenceValidator() - let result = validator.validate(graph) - - // Should be structurally valid but have warnings - #expect(result.isValid) - #expect(!result.warnings.isEmpty) - - let hasForwardRefWarning = result.warnings.contains { warning in - if case .forwardStageReferenceByName(let name, _) = warning { - return name == "second" - } - return false - } - #expect(hasForwardRefWarning, "Should warn about forward stage reference") - } - - // MARK: - PathValidator Tests - - @Test func emptyDestinationPath() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - let stage = BuildStage( - name: "test", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - operation: FilesystemOperation( - action: .copy, - source: .context(ContextSource(paths: ["file.txt"])), - destination: "" // Empty destination - ), - dependencies: [] - ) - ] - ) - - let graph = try BuildGraph(stages: [stage]) - let validator = PathValidator() - let result = validator.validate(graph) - - #expect(!result.isValid) - - let hasEmptyDestError = result.errors.contains { error in - if case .emptyDestinationPath = error { - return true - } - return false - } - #expect(hasEmptyDestError, "Should detect empty destination path") - } - - @Test func absoluteContextPath() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - let stage = BuildStage( - name: "test", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - operation: FilesystemOperation( - action: .copy, - source: .context(ContextSource(paths: ["/absolute/path"])), // Absolute path - destination: "/app/" - ), - dependencies: [] - ) - ] - ) - - let graph = try BuildGraph(stages: [stage]) - let validator = PathValidator() - let result = validator.validate(graph) - - #expect(!result.isValid) - - let hasAbsolutePathError = result.errors.contains { error in - if case .absoluteContextPath(let path, _) = error { - return path == "/absolute/path" - } - return false - } - #expect(hasAbsolutePathError, "Should detect absolute context path") - } - - @Test func pathWithDotDotWarning() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - let stage = BuildStage( - name: "test", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - operation: FilesystemOperation( - action: .copy, - source: .context(ContextSource(paths: ["../outside/file.txt"])), - destination: "/app/" - ), - dependencies: [] - ) - ] - ) - - let graph = try BuildGraph(stages: [stage]) - let validator = PathValidator() - let result = validator.validate(graph) - - #expect(result.isValid) // Should be valid but have warnings - #expect(!result.warnings.isEmpty) - - let hasDotDotWarning = result.warnings.contains { warning in - if case .pathContainsDotDot(let path, _) = warning { - return path == "../outside/file.txt" - } - return false - } - #expect(hasDotDotWarning, "Should warn about path containing '..'") - } - - @Test func emptyMountTarget() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - let stage = BuildStage( - name: "test", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - operation: ExecOperation( - command: .shell("echo 'test'"), - mounts: [ - Mount( - type: .cache, - target: nil, // Empty target - source: .local("cache-vol"), - options: MountOptions() - ) - ] - ), - dependencies: [] - ) - ] - ) - - let graph = try BuildGraph(stages: [stage]) - let validator = PathValidator() - let result = validator.validate(graph) - - #expect(!result.isValid) - - let hasEmptyMountError = result.errors.contains { error in - if case .emptyMountTarget = error { - return true - } - return false - } - #expect(hasEmptyMountError, "Should detect empty mount target") - } - - // MARK: - SecurityValidator Tests - - @Test func privilegedExecutionWarning() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - let stage = BuildStage( - name: "test", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - operation: ExecOperation( - command: .shell("mount /dev/sda1 /mnt"), - security: SecurityOptions(privileged: true) - ), - dependencies: [] - ) - ] - ) - - let graph = try BuildGraph(stages: [stage]) - let validator = SecurityValidator() - let result = validator.validate(graph) - - #expect(result.isValid) // Should be valid but have warnings - #expect(!result.warnings.isEmpty) - - let hasPrivilegedWarning = result.warnings.contains { warning in - if case .privilegedExecution = warning { - return true - } - return false - } - #expect(hasPrivilegedWarning, "Should warn about privileged execution") - } - - @Test func runningAsRootWarning() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - let stage = BuildStage( - name: "test", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - operation: ExecOperation( - command: .shell("apt-get update"), - user: nil // Running as root - ), - dependencies: [] - ) - ] - ) - - let graph = try BuildGraph(stages: [stage]) - let validator = SecurityValidator() - let result = validator.validate(graph) - - #expect(result.isValid) // Should be valid but have warnings - #expect(!result.warnings.isEmpty) - - let hasRootWarning = result.warnings.contains { warning in - if case .runningAsRoot = warning { - return true - } - return false - } - #expect(hasRootWarning, "Should warn about running as root") - } - - @Test func readWriteSecretMountWarning() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - let stage = BuildStage( - name: "test", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - operation: ExecOperation( - command: .shell("cat /run/secrets/token"), - mounts: [ - Mount( - type: .secret, - target: "/run/secrets/token", - source: .secret("api-token"), - options: MountOptions(readOnly: false) // Read-write secret - ) - ] - ), - dependencies: [] - ) - ] - ) - - let graph = try BuildGraph(stages: [stage]) - let validator = SecurityValidator() - let result = validator.validate(graph) - - #expect(result.isValid) // Should be valid but have warnings - #expect(!result.warnings.isEmpty) - - let hasSecretWarning = result.warnings.contains { warning in - if case .readWriteSecretMount = warning { - return true - } - return false - } - #expect(hasSecretWarning, "Should warn about read-write secret mount") - } - - // MARK: - BestPracticesValidator Tests - - @Test func aptUpdateWithoutInstallWarning() throws { - guard let ubuntuRef = ImageReference(parsing: "ubuntu") else { - Issue.record("Failed to parse image reference") - return - } - - let stage = BuildStage( - name: "test", - base: ImageOperation(source: .registry(ubuntuRef)), - nodes: [ - BuildNode( - operation: ExecOperation(command: .shell("apt-get update")), - dependencies: [] - ) - ] - ) - - let graph = try BuildGraph(stages: [stage]) - let validator = BestPracticesValidator() - let result = validator.validate(graph) - - #expect(result.isValid) // Should be valid but have warnings - #expect(!result.warnings.isEmpty) - - let hasAptUpdateWarning = result.warnings.contains { warning in - if case .aptGetUpdateWithoutInstall = warning { - return true - } - return false - } - #expect(hasAptUpdateWarning, "Should warn about apt-get update without install") - } - - @Test func missingHealthcheckWarning() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - // Build without healthcheck in target stage - let stage = BuildStage( - name: "app", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - operation: ExecOperation(command: .shell("apk add --no-cache curl")), - dependencies: [] - ), - BuildNode( - operation: MetadataOperation(action: .setEntrypoint(command: .exec(["./app"]))), - dependencies: [] - ), - ] - ) - - let graph = try BuildGraph(stages: [stage]) - let validator = BestPracticesValidator() - let result = validator.validate(graph) - - #expect(result.isValid) // Should be valid but have warnings - #expect(!result.warnings.isEmpty) - - let hasMissingHealthcheckWarning = result.warnings.contains { warning in - if case .missingHealthcheck = warning { - return true - } - return false - } - #expect(hasMissingHealthcheckWarning, "Should warn about missing healthcheck") - } - - @Test func validBestPractices() throws { - guard let ubuntuRef = ImageReference(parsing: "ubuntu") else { - Issue.record("Failed to parse image reference") - return - } - - let stage = BuildStage( - name: "app", - base: ImageOperation(source: .registry(ubuntuRef)), - nodes: [ - BuildNode( - operation: ExecOperation(command: .shell("apt-get update && apt-get install -y curl")), - dependencies: [] - ), - BuildNode( - operation: MetadataOperation( - action: .setHealthcheck( - healthcheck: Healthcheck( - test: .command(.exec(["curl", "-f", "http://localhost:8080/health"])), - interval: 30, - timeout: 5, - startPeriod: nil, - retries: 3 - ) - ) - ), - dependencies: [] - ), - BuildNode( - operation: MetadataOperation(action: .setEntrypoint(command: .exec(["./app"]))), - dependencies: [] - ), - ] - ) - - let graph = try BuildGraph(stages: [stage]) - let validator = BestPracticesValidator() - let result = validator.validate(graph) - - #expect(result.isValid) - #expect(result.warnings.isEmpty, "Should not have warnings when following best practices") - } - - // MARK: - CompositeValidator Tests - - @Test func compositeValidatorCombinesResults() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - // Create a graph with multiple types of issues - let duplicateId = UUID() - let stage = BuildStage( - name: "problematic", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - id: duplicateId, - operation: ExecOperation(command: .shell("echo 'first'")), - dependencies: [] - ), - BuildNode( - id: duplicateId, // Duplicate ID (structural error) - operation: FilesystemOperation( - action: .copy, - source: .stage(.named("missing"), paths: ["/file"]), // Missing stage (reference error) - destination: "" // Empty destination (path error) - ), - dependencies: [] - ), - BuildNode( - operation: ExecOperation( - command: .shell("apt-get update"), // Update without install (best practices warning) - security: SecurityOptions(privileged: true) // Privileged (security warning) - ), - dependencies: [] - ), - ] - ) - - let graph = try BuildGraph(stages: [stage]) - - let compositeValidator = CompositeValidator(validators: [ - StructuralValidator(), - ReferenceValidator(), - PathValidator(), - SecurityValidator(), - BestPracticesValidator(), - ]) - - let result = compositeValidator.validate(graph) - - #expect(!result.isValid) - #expect(result.errors.count >= 3) // At least structural, reference, and path errors - #expect(result.warnings.count >= 2) // At least security and best practices warnings - - // Verify we have errors from different validators - let hasStructuralError = result.errors.contains { - if case .duplicateNodeID = $0 { return true } - return false - } - let hasReferenceError = result.errors.contains { - if case .undefinedStageReference = $0 { return true } - return false - } - let hasPathError = result.errors.contains { - if case .emptyDestinationPath = $0 { return true } - return false - } - - #expect(hasStructuralError) - #expect(hasReferenceError) - #expect(hasPathError) - } - - // MARK: - StandardValidator Tests - - @Test func standardValidatorIncludesAllValidators() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - let stage = BuildStage( - name: "test", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - operation: ExecOperation(command: .shell("echo 'test'")), - dependencies: [] - ) - ] - ) - - let graph = try BuildGraph(stages: [stage]) - let validator = StandardValidator() - let result = validator.validate(graph) - - #expect(result.isValid) - #expect(result.errors.isEmpty) - - // StandardValidator should include all built-in validators - // This is tested implicitly by verifying it produces the same comprehensive results - } - - // MARK: - ValidationResult Tests - - @Test func validationResultCombining() throws { - let result1 = ValidationResult( - errors: [.duplicateNodeID(id: UUID(), location: .stage(name: "test1"))], - warnings: [.forwardStageReferenceByName(name: "stage2", location: .stage(name: "test1"))] - ) - - let result2 = ValidationResult( - errors: [.missingDependency(dependencyID: UUID(), location: .stage(name: "test2"))], - warnings: [.privilegedExecution(location: .stage(name: "test2"))] - ) - - let combined = ValidationResult.combine([result1, result2]) - - #expect(combined.errors.count == 2) - #expect(combined.warnings.count == 2) - #expect(!combined.isValid) - } - - @Test func validationResultEmpty() throws { - let result = ValidationResult() - - #expect(result.isValid) - #expect(result.errors.isEmpty) - #expect(result.warnings.isEmpty) - } - - // MARK: - Validation Error and Warning Messages Tests - - @Test func validationErrorMessages() throws { - let testId = UUID() - let errors: [ValidationError] = [ - .duplicateNodeID(id: testId, location: .stage(name: "test")), - .cyclicDependency(location: .stage(name: "test")), - .missingDependency(dependencyID: testId, location: .stage(name: "test")), - .undefinedStageReference(name: "missing", location: .stage(name: "test")), - .stageIndexOutOfBounds(index: 99, location: .stage(name: "test")), - .invalidPreviousReference(location: .stage(name: "test")), - .emptyDestinationPath(location: .stage(name: "test")), - .absoluteContextPath(path: "/absolute", location: .stage(name: "test")), - .emptyMountTarget(location: .stage(name: "test")), - ] - - for error in errors { - let description = error.errorDescription - #expect(description != nil, "Error should have description: \(error)") - #expect(!description!.isEmpty, "Error description should not be empty: \(error)") - } - } - - @Test func validationWarningMessages() throws { - let warnings: [ValidationWarning] = [ - .forwardStageReferenceByName(name: "future", location: .stage(name: "test")), - .forwardStageReferenceByIndex(index: 5, location: .stage(name: "test")), - .pathContainsDotDot(path: "../outside", location: .stage(name: "test")), - .privilegedExecution(location: .stage(name: "test")), - .runningAsRoot(location: .stage(name: "test")), - .readWriteSecretMount(location: .stage(name: "test")), - .aptGetUpdateWithoutInstall(location: .stage(name: "test")), - .missingHealthcheck(location: .stage(name: "test")), - ] - - for warning in warnings { - let message = warning.message - #expect(!message.isEmpty, "Warning should have message: \(warning)") - - let suggestion = warning.suggestion - #expect(suggestion != nil, "Warning should have suggestion: \(warning)") - #expect(!suggestion!.isEmpty, "Warning suggestion should not be empty: \(warning)") - } - } - - // MARK: - Performance Tests - - @Test func validationPerformanceWithLargeGraph() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - // Create a large graph with many stages and nodes - var stages: [BuildStage] = [] - - for stageIndex in 0..<10 { - var nodes: [BuildNode] = [] - - // Create 20 nodes per stage - for nodeIndex in 0..<20 { - let operation = ExecOperation(command: .shell("echo 'stage \(stageIndex) node \(nodeIndex)'")) - let node = BuildNode(operation: operation, dependencies: []) - nodes.append(node) - } - - let stage = BuildStage( - name: "stage\(stageIndex)", - base: ImageOperation(source: .registry(alpineRef)), - nodes: nodes - ) - stages.append(stage) - } - - let graph = try BuildGraph(stages: stages) - let validator = StandardValidator() - - // Measure validation time - let startTime = Date() - let result = validator.validate(graph) - let duration = Date().timeIntervalSince(startTime) - - #expect(result.isValid) - #expect(duration < 1.0, "Validation should complete quickly for large graphs (took \(duration)s)") - } - - // MARK: - Custom Validator Tests - - struct CustomSecurityValidator: BuildValidator { - func validate(_ graph: BuildGraph) -> ValidationResult { - var warnings: [ValidationWarning] = [] - - // Custom rule: warn if any stage downloads from HTTP - for stage in graph.stages { - for node in stage.nodes { - if let exec = node.operation as? ExecOperation { - if case .shell(let cmd) = exec.command, - cmd.contains("https://") - { - warnings.append(.privilegedExecution(location: .stage(name: stage.name))) - } - } - } - } - - return ValidationResult(warnings: warnings) - } - } - - @Test func customValidatorExtension() throws { - guard let alpineRef = ImageReference(parsing: "alpine") else { - Issue.record("Failed to parse image reference") - return - } - - let stage = BuildStage( - name: "insecure", - base: ImageOperation(source: .registry(alpineRef)), - nodes: [ - BuildNode( - operation: ExecOperation(command: .shell("curl http://example.com/script.sh | sh")), - dependencies: [] - ) - ] - ) - - let graph = try BuildGraph(stages: [stage]) - let customValidator = CustomSecurityValidator() - let result = customValidator.validate(graph) - - #expect(result.isValid) // No errors, just warnings - #expect(!result.warnings.isEmpty, "Custom validator should detect HTTP usage") - } -} diff --git a/Tests/NativeBuilderTests/ContainerBuildParserTests/ParserTests.swift b/Tests/NativeBuilderTests/ContainerBuildParserTests/ParserTests.swift deleted file mode 100644 index f15dcc779..000000000 --- a/Tests/NativeBuilderTests/ContainerBuildParserTests/ParserTests.swift +++ /dev/null @@ -1,1131 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import Foundation -import Testing - -@testable import ContainerBuildParser - -@Suite class ParserTest { - @Test func testSimpleDockerfile() throws { - let dockerfile = - #""" - FROM alpine:latest AS build - """# - let parser = DockerfileParser() - let actualGraph = try parser.parse(dockerfile) - - // check the image reference - #expect(!actualGraph.stages.isEmpty) - #expect(actualGraph.stages.count == 1, "expected 1 stage, instead got \(actualGraph.stages.count)") - #expect(actualGraph.stages[0].name == "build", "expected stage name build, got \(actualGraph.stages[0].name)") - } - - @Test func testSimpleDockerfileLowercase() throws { - let dockerfile = - #""" - from alpine:latest as base - """# - let parser = DockerfileParser() - let actualGraph = try parser.parse(dockerfile) - - // check the image reference - #expect(!actualGraph.stages.isEmpty) - #expect(actualGraph.stages.count == 1, "expected 1 stage, instead got \(actualGraph.stages.count)") - #expect(actualGraph.stages[0].name == "base", "expected stage name build, got \(actualGraph.stages[0].name)") - } - - @Test func testDockerfileWithContinuation() throws { - let dockerfile = - #""" - FROM alpine:latest \ - AS \ - build - """# - let parser = DockerfileParser() - let actualGraph = try parser.parse(dockerfile) - - // check the image reference - #expect(!actualGraph.stages.isEmpty) - #expect(actualGraph.stages.count == 1, "expected 1 stage, instead got \(actualGraph.stages.count)") - #expect(actualGraph.stages[0].name == "build", "expected stage name build, got \(actualGraph.stages[0].name)") - } - - static let invalidDockerfileFROM: [String] = [ - #""" - FROM alpine:latest build - """#, - #"FROM alpine:latest build"#, - #"FROM alpine:latest AS"#, - #"FROM AS alpine:latest"#, - #"FROM "" AS build"#, - #"FROM"#, - #""" - FROM alpine:latest \ - build - """#, - ] - - @Test("Invalid FROM instruction throws ParseError", arguments: invalidDockerfileFROM) - func invalidFromDockerfile(_ dockerfile: String) throws { - let parser = DockerfileParser() - #expect(throws: ParseError.self) { - try parser.parse(dockerfile) - } - } - - @Test func testSimpleDockerfileWithCopy() throws { - let dockerfile = - #""" - FROM alpine AS build-context - - FROM alpine AS stage-two - COPY --from=build-context /test /test - """# - let parser = DockerfileParser() - let actualGraph = try parser.parse(dockerfile) - - #expect(!actualGraph.stages.isEmpty) - let stages = actualGraph.stages - - #expect(stages.count == 2, "expected 2 stages, instead got \(stages.count)") - #expect(stages[0].name == "build-context", "expected stage name build-context, got \(stages[0].name)") - #expect(stages[1].name == "stage-two", "expected stage name stage-two, got \(stages[1].name)") - #expect(stages[1].nodes.count == 1, "expected 1 node, instead got \(stages[1].nodes.count)") - - let node = stages[1].nodes[0] - #expect(node.operation is FilesystemOperation) - - let copy = node.operation as! FilesystemOperation - #expect(copy.action == .copy) - } - - @Test func testSimpleDockerfileRun() throws { - let dockerfile = - #""" - FROM alpine:latest AS build - - RUN ["ls", "-la"] - """# - let parser = DockerfileParser() - let actualGraph = try parser.parse(dockerfile) - - #expect(!actualGraph.stages.isEmpty) - - let stages = actualGraph.stages - #expect(stages.count == 1, "expected 1 stage, instead got \(actualGraph.stages.count)") - - let stage = stages[0] - #expect(stage.nodes.count == 1, "expected 1 node, instead got \(stage.nodes.count)") - - let run = stage.nodes[0].operation as! ExecOperation - #expect(run.command.displayString == "ls -la") - } - - @Test func testSimpleDockerfileRunShell() throws { - let dockerfile = - #""" - FROM alpine:latest AS build - - RUN build.sh --verbose - """# - let parser = DockerfileParser() - let actualGraph = try parser.parse(dockerfile) - - #expect(!actualGraph.stages.isEmpty) - - let stages = actualGraph.stages - #expect(stages.count == 1, "expected 1 stage, instead got \(actualGraph.stages.count)") - - let stage = stages[0] - #expect(stage.nodes.count == 1, "expected 1 node, instead got \(stage.nodes.count)") - - let run = stage.nodes[0].operation as! ExecOperation - #expect(run.command.displayString == "build.sh --verbose") - - } - - @Test func testSimpleDockerfileLabel() throws { - let dockerfile = - #""" - FROM alpine:latest AS build - - LABEL label1=value1 - """# - let parser = DockerfileParser() - let actualGraph = try parser.parse(dockerfile) - - #expect(!actualGraph.stages.isEmpty) - - let stages = actualGraph.stages - #expect(stages.count == 1, "expected 1 stage, instead got \(actualGraph.stages.count)") - - let stage = stages[0] - #expect(stage.nodes.count == 1, "expected 1 node, instead got \(stage.nodes.count)") - - let label = stage.nodes[0].operation as! MetadataOperation - #expect(label.action == .setLabelBatch(["label1": "value1"])) - } - - @Test func testSimpleDockerfileCMD() throws { - let dockerfile = - #""" - FROM alpine:latest AS build - - CMD ["./test.sh", "--verbose"] - """# - let parser = DockerfileParser() - let actualGraph = try parser.parse(dockerfile) - - #expect(!actualGraph.stages.isEmpty) - - let stages = actualGraph.stages - #expect(stages.count == 1, "expected 1 stage, instead got \(actualGraph.stages.count)") - - let stage = stages[0] - #expect(stage.nodes.count == 1, "expected 1 node, instead got \(stage.nodes.count)") - - let cmd = stage.nodes[0].operation as! MetadataOperation - switch cmd.action { - case .setCmd(let command): - #expect(command.displayString == "./test.sh --verbose") - default: - Issue.record("expected .setCmd action type, instead got \(cmd.action)") - return - } - } - - @Test func testSimpleDockerfileExpose() throws { - let dockerfile = - #""" - FROM alpine:latest AS build - - EXPOSE 90 \ - 80/udp - """# - let parser = DockerfileParser() - let actualGraph = try parser.parse(dockerfile) - - #expect(!actualGraph.stages.isEmpty) - - let stages = actualGraph.stages - #expect(stages.count == 1, "expected 1 stage, instead got \(actualGraph.stages.count)") - - let stage = stages[0] - #expect(stage.nodes.count == 1, "expected 1 nodes, instead got \(stage.nodes.count)") - - let exposeOp = stage.nodes[0].operation as! MetadataOperation - switch exposeOp.action { - case .expose(let ports): - #expect(ports.count == 2) - let portSpec1 = ports[0] - let portSpec2 = ports[1] - - #expect(portSpec1.port == 90) - #expect(portSpec1.protocol == .tcp) - #expect(portSpec2.port == 80) - #expect(portSpec2.protocol == .udp) - default: - Issue.record("unexpected action type \(exposeOp.action)") - return - } - } - - @Test func testSimpleDockerfileArg() throws { - let dockerfile = - #""" - ARG BASE_IMAGE=alpine:latest - ARG BUILD_VERSION=1.0.0 - - FROM ${BASE_IMAGE} AS stage1 - ARG NODE_ENV=development - ARG BUILD_VERSION - ARG API_URL - - FROM ${BASE_IMAGE} AS stage2 - ARG BUILD_VERSION=2.0.0 - ARG API_URL=https://api.example.com - ARG EMPTY= - """# - let parser = DockerfileParser() - let graph = try parser.parse(dockerfile) - - // Verify global FROM-only ARG values - #expect(graph.buildArgs.count == 2) - #expect(graph.buildArgs["BASE_IMAGE"] == "alpine:latest") - #expect(graph.buildArgs["BUILD_VERSION"] == "1.0.0") - - // Verify stage-local ARG values - #expect(graph.stages.count == 2) - - let stage1 = graph.stages[0] - #expect(stage1.nodes.count == 3) - #expect(getStageArg(stage1, name: "BASE_IMAGE") == nil) - #expect(getStageArg(stage1, name: "NODE_ENV") == "development") - #expect(getStageArg(stage1, name: "BUILD_VERSION") == nil) - #expect(getStageArg(stage1, name: "API_URL") == nil) - #expect(getStageArg(stage1, name: "EMPTY") == nil) - - let stage2 = graph.stages[1] - #expect(stage2.nodes.count == 3) - #expect(getStageArg(stage2, name: "BASE_IMAGE") == nil) - #expect(getStageArg(stage2, name: "NODE_ENV") == nil) - #expect(getStageArg(stage2, name: "BUILD_VERSION") == "2.0.0") - #expect(getStageArg(stage2, name: "API_URL") == "https://api.example.com") - #expect(getStageArg(stage2, name: "EMPTY") == "") - } - - @Test func testSimpleDockerfileArgInInstructions() throws { - // TODO: Add these instructions when they are supported: - // - ADD - // - ENV - // - STOPSIGNAL - // - USER - // - VOLUME - // - WORKDIR - // - ONBUILD - // - ENTRYPOINT - let dockerfile = - #""" - FROM alpine:latest AS build - - ARG RUN=/bin/sh - RUN ${RUN} - - ARG SRC=source - ARG DST=destination - COPY ${SRC} ${DST} - - ARG ECHO="Hello, World!" - CMD ["echo", "${ECHO}"] - - ARG LABEL=whatever - LABEL label=${LABEL} - - ARG PORT=80 - EXPOSE ${PORT} - - ARG VAR1=world - ARG VAR2=hello-${VAR1} - """# - let parser = DockerfileParser() - let graph = try parser.parse(dockerfile) - - #expect(graph.stages.count == 1) - - // Verify ARG substitution - let stage = graph.stages[0] - if case .registry(let imageRef) = stage.base.source { - #expect(imageRef.stringValue == "alpine:latest") - } else { - #expect(Bool(false), "Expected registry image source") - } - - if let run = stage.nodes[1].operation as? ExecOperation { - #expect(run.command.displayString == "/bin/sh") - } else { - #expect(Bool(false), "Expected RUN instruction") - } - - if let copy = stage.nodes[4].operation as? FilesystemOperation { - #expect(copy.action == .copy) - switch copy.source { - case .context(let contextSource): - #expect(contextSource.paths.count == 1) - #expect(contextSource.paths[0] == "source") - default: - #expect(Bool(false), "Expected context source") - } - #expect(copy.destination == "destination") - } else { - #expect(Bool(false), "Expected COPY instruction") - } - - if let cmd = stage.nodes[6].operation as? MetadataOperation { - switch cmd.action { - case .setCmd(let command): - #expect(command.displayString == "echo Hello, World!") - default: - #expect(Bool(false), "Expected .setCmd action") - } - } else { - #expect(Bool(false), "Expected CMD instruction") - } - - if let label = stage.nodes[8].operation as? MetadataOperation { - switch label.action { - case .setLabelBatch(let labels): - #expect(labels["label"] == "whatever") - default: - #expect(Bool(false), "Expected setLabelBatch action") - } - } else { - #expect(Bool(false), "Expected LABEL instruction") - } - - if let expose = stage.nodes[10].operation as? MetadataOperation { - switch expose.action { - case .expose(let ports): - #expect(ports.count == 1) - #expect(ports[0].port == 80) - default: - #expect(Bool(false), "Expected expose action") - } - } else { - #expect(Bool(false), "Expected EXPOSE instruction") - } - - #expect(getStageArg(stage, name: "VAR1") == "world") - #expect(getStageArg(stage, name: "VAR2") == "hello-world") - } -} - -// tests for parsing options for the different instructions -extension ParserTest { - struct RunMountTestCase { - let rawMount: String - let expectedRunMount: RunMount? - - init(rawMount: String, expectedRunMount: RunMount? = nil) { - self.rawMount = rawMount - self.expectedRunMount = expectedRunMount - } - } - - static let runMountTestCases = [ - // bind - // basic bind mount with different target option names - RunMountTestCase( - rawMount: "type=bind,dst=/container/dst", - expectedRunMount: RunMount(type: .bind, source: "/", target: "/container/dst", options: RunMountOptions(readonly: true)) - ), - RunMountTestCase( - rawMount: "type=bind,target=/container/target", - expectedRunMount: RunMount(type: .bind, source: "/", target: "/container/target", options: RunMountOptions(readonly: true)) - ), - RunMountTestCase( - rawMount: "type=bind,destination=/container/destination", - expectedRunMount: RunMount(type: .bind, source: "/", target: "/container/destination", options: RunMountOptions(readonly: true)) - ), - // defaults to bind type if none provided - RunMountTestCase( - rawMount: "dst=/container/dst", - expectedRunMount: RunMount(type: .bind, source: "/", target: "/container/dst", options: RunMountOptions(readonly: true)) - ), - // with source - RunMountTestCase( - rawMount: "dst=/container/dst,source=/source", - expectedRunMount: RunMount(type: .bind, source: "/source", target: "/container/dst", options: RunMountOptions(readonly: true)) - ), - // with from - RunMountTestCase( - rawMount: "dst=/container/dst,from=earlierstage", - expectedRunMount: RunMount(type: .bind, source: "/", from: "earlierstage", target: "/container/dst", options: RunMountOptions(readonly: true)) - ), - // with readwrite explicitly set - RunMountTestCase( - rawMount: "type=bind,dst=/container/dst,readwrite=true", - expectedRunMount: RunMount(type: .bind, source: "/", target: "/container/dst", options: RunMountOptions(readonly: false)) - ), - RunMountTestCase( - rawMount: "type=bind,dst=/container/dst,rw=true", - expectedRunMount: RunMount(type: .bind, source: "/", target: "/container/dst", options: RunMountOptions(readonly: false)) - ), - RunMountTestCase( - rawMount: "type=bind,dst=/container/dst,readwrite=false", - expectedRunMount: RunMount(type: .bind, source: "/", target: "/container/dst", options: RunMountOptions(readonly: true)) - ), - RunMountTestCase( - rawMount: "type=bind,dst=/container/dst,readwrite=false", - expectedRunMount: RunMount(type: .bind, source: "/", target: "/container/dst", options: RunMountOptions(readonly: true)) - ), - - /* Cache cases */ - // minimal valid cache - RunMountTestCase( - rawMount: "type=cache,target=/mycache", - expectedRunMount: RunMount( - type: .cache, - source: "/", - from: "", - id: "/mycache", - target: "/mycache", - options: RunMountOptions( - readonly: false, - uid: 0, - gid: 0, - mode: 0755, - sharing: .shared - ) - ) - ), - // normal with all options - RunMountTestCase( - rawMount: "type=cache,id=0987087,target=/target,readonly=false,sharing=private,from=build,source=/source,mode=0,uid=0,gid=0", - expectedRunMount: RunMount( - type: .cache, - source: "/source", - from: "build", - id: "0987087", - target: "/target", - options: RunMountOptions( - readonly: false, - uid: 0, - gid: 0, - mode: 0, - sharing: .private - ) - ) - ), - // cache with sharing - RunMountTestCase( - rawMount: "type=cache,target=/mycache,sharing=shared", - expectedRunMount: RunMount( - type: .cache, - source: "/", - from: "", - id: "/mycache", - target: "/mycache", - options: RunMountOptions( - readonly: false, - uid: 0, - gid: 0, - mode: 0755, - sharing: .shared - ) - ) - ), - RunMountTestCase( - rawMount: "type=cache,target=/mycache,sharing=private", - expectedRunMount: RunMount( - type: .cache, - source: "/", - from: "", - id: "/mycache", - target: "/mycache", - options: RunMountOptions( - readonly: false, - uid: 0, - gid: 0, - mode: 0755, - sharing: .private - ) - ) - ), - RunMountTestCase( - rawMount: "type=cache,target=/mycache,sharing=locked", - expectedRunMount: RunMount( - type: .cache, - source: "/", - from: "", - id: "/mycache", - target: "/mycache", - options: RunMountOptions( - readonly: false, - uid: 0, - gid: 0, - mode: 0755, - sharing: .locked - ) - ) - ), - - // readonly - RunMountTestCase( - rawMount: "type=cache,target=/mycache,readonly=true", - expectedRunMount: RunMount( - type: .cache, - source: "/", - from: "", - id: "/mycache", - target: "/mycache", - options: RunMountOptions( - readonly: true, - uid: 0, - gid: 0, - mode: 0755, - sharing: .shared - ) - ) - ), - - // cache with id, mode, uid, gid - RunMountTestCase( - rawMount: "type=cache,target=/cache,id=cacheid,mode=0444,uid=1001,gid=1002", - expectedRunMount: RunMount( - type: .cache, - source: "/", - from: "", - id: "cacheid", - target: "/cache", - options: RunMountOptions( - readonly: false, - uid: 1001, - gid: 1002, - mode: 0444, - sharing: .shared - ) - ) - ), - - /* tmpfs cases */ - // minimal tmpfs - RunMountTestCase( - rawMount: "type=tmpfs,target=/tmpfs", - expectedRunMount: RunMount( - type: .tmpfs, - target: "/tmpfs", - options: RunMountOptions(readonly: false) - ) - ), - - // size - RunMountTestCase( - rawMount: "type=tmpfs,target=/tmpfs,size=1000", - expectedRunMount: RunMount( - type: .tmpfs, - target: "/tmpfs", - options: RunMountOptions(readonly: false, size: 1000) - ) - ), - - /* secret cases */ - // minimal secret case - RunMountTestCase( - rawMount: "type=secret,target=/run/secrets/mysecret", - expectedRunMount: RunMount( - type: .secret, - id: "mysecret", - target: "/run/secrets/mysecret", - options: RunMountOptions( - readonly: true, - required: false, - uid: 0, - gid: 0, - mode: 0400, - ) - ) - ), - - // secret with id - RunMountTestCase( - rawMount: "type=secret,id=mysecret,target=/run/secrets/mysecret", - expectedRunMount: RunMount( - type: .secret, - id: "mysecret", - target: "/run/secrets/mysecret", - options: RunMountOptions( - readonly: true, - required: false, - uid: 0, - gid: 0, - mode: 0400, - ) - ) - ), - - // secret using env - RunMountTestCase( - rawMount: "type=secret,id=mysecret,target=/run/secrets/mysecret,env=TEST", - expectedRunMount: RunMount( - type: .secret, - id: "mysecret", - env: "TEST", - target: "/run/secrets/mysecret", - options: RunMountOptions( - readonly: true, - required: false, - uid: 0, - gid: 0, - mode: 0400, - ) - ) - ), - - // secret with required, uid/gid/mode - RunMountTestCase( - rawMount: "type=secret,id=mysecret,target=/run/secrets/mysecret,required=true,uid=1000,gid=1001,mode=0400", - expectedRunMount: RunMount( - type: .secret, - id: "mysecret", - target: "/run/secrets/mysecret", - options: RunMountOptions( - readonly: true, - required: true, - uid: 1000, - gid: 1001, - mode: 0400 - ) - ) - ), - - /* ssh cases */ - // minimal ssh mount - RunMountTestCase( - rawMount: "type=ssh", - expectedRunMount: RunMount( - type: .ssh, - id: "default", - target: "/run/buildkit/ssh_agent", - options: RunMountOptions( - readonly: true, - required: false, - uid: 0, - gid: 0, - mode: 0600 - ) - ) - ), - - // ssh with id - RunMountTestCase( - rawMount: "type=ssh,id=myssh,target=/run/ssh", - expectedRunMount: RunMount( - type: .ssh, - id: "myssh", - target: "/run/ssh", - options: RunMountOptions( - readonly: true, - required: false, - uid: 0, - gid: 0, - mode: 0600 - ) - ) - ), - - // ssh with required and id - RunMountTestCase( - rawMount: "type=ssh,id=deploykey,target=/run/ssh,required=true", - expectedRunMount: RunMount( - type: .ssh, - id: "deploykey", - target: "/run/ssh", - options: RunMountOptions( - readonly: true, - required: true, - uid: 0, - gid: 0, - mode: 0600 - ) - ) - ), - ] - - @Test("Run mounts are parsed correctly", arguments: runMountTestCases) - func runParseMount(_ testCase: RunMountTestCase) throws { - let actual = try RunInstruction.parseMount(testCase.rawMount) - #expect(actual == testCase.expectedRunMount) - } - - static let invalidRunMounts: [String] = [ - /* Common cases */ - // missing or mispelled - "type=", - "tyep=bind,target=/container", - "type=bind,targte=/container", - - // duplicate keys - "type=bind,target=/one,target=/two", - "type=bind,dst=/one,destination=/two", - "type=bind,readwrite=true,readwrite=false", - - /* Bind cases */ - // missing or empty values - "type=bind", - "type=bind,target=", - "type=bind,destination", - - // invalid readwrite - "type=bind,target=/target,rw=Not false", - "type=bind,destination=/destination,rw=Totally false", - "type=bind,target=/target,readwrite=yes", - "type=bind,destination=/destination,readwrite=0", - - // malformed key-value format - "type=bind,target=/container,readwrite", - "type=bind,target=/container,readwrite==true", - "type=bind,target=/container,=true", - "type=bind,=true,target=/container", - - // uses options that bind does not support - "type=bind,destination=/destination,mode=0", - "type=bind,target=/destination,gid=0", - "type=bind,dst=/destination,uid=0", - "type=bind,destination=/destination,sharing=private", - "type=bind,target=/destination,required=true", - "type=bind,dst=/destination,size=10", - "type=bind,target=/destination,id=780707", - "type=bind,destination=/destination,env=TEST", - - // uses options that do not exist - "type=bind,target=/target,foo=bar", - "type=bind,target=/target,readwrite=true,invalid-key=value", - - /* Cache cases */ - // missing required target - "type=cache", - "type=cache,target=", - "type=cache,destination", - - // invalid readonly - "type=cache,target=/cache,readonly=", - "type=cache,target=/cache,readonly=Totally", - "type=cache,target=/cache,ro=Forsure", - - // invalid sharing type - "type=cache,target=/cache,sharing=cache", - "type=cache,target=/cache,sharing=none", - - // invalid mode,uid, or gid - "type=cache,target=/cache,mode=-0777", - "type=cache,target=/cache,uid=-1001", - "type=cache,target=/cache,gid=-100", - - // unsupported options - "type=cache,target=/cache,env=TEST", - "type=cache,target=/cache,required=true", - - /* tmpfs cases */ - // tmpfs missing target - "type=tmpfs", - "type=tmpfs,target=", - "type=tmpfs,dst", - - // invalid size - "type=tmpfs,target=/tmpfs,size=", - "type=tmpfs,target=/tmpfs,size=abc", - "type=tmpfs,target=/tmpfs,size=-100", - "type=tmpfs,target=/tmpfs,size=1.5", - - // unsupported options - "type=tmpfs,target=/tmpfs,mode=0755", - "type=tmpfs,target=/tmpfs,uid=0", - "type=tmpfs,target=/tmpfs,gid=0", - "type=tmpfs,target=/tmpfs,id=myid", - "type=tmpfs,target=/tmpfs,from=build", - "type=tmpfs,target=/tmpfs,env=TEST", - "type=tmpfs,target=/tmpfs,required=true", - "type=tmpfs,target=/tmpfs,sharing=private", - - /* secret cases */ - // invalid mode, uid, gid - "type=secret,target=/secret,mode=-0777", - "type=secret,target=/secret,uid=-1001", - "type=secret,target=/secret,gid=-100", - - // missing a target, env, AND id - "type=secret,readonly=true", - - // env set but no target or id - "type=secret,env=TEST,readonly=true", - - // unsupported options - "type=secret,target=/secret,from=build", - "type=secret,target=/secret,source=/", - "type=secret,target=/secret,sharing=private", - - /* ssh cases */ - // invalid mode,uid,gid - "type=ssh,mode=-0755", - "type=ssh,uid=-079", - "type=ssh,gid=", - - // unsupported options - "type=ssh,target=/ssh,from=build", - "type=ssh,target=/ssh,source=/", - "type=ssh,target=/ssh,env=SSH_TEST", - "type=ssh,target=/ssh,sharing=private", - ] - - @Test("Invalid run mount configuration throws error", arguments: invalidRunMounts) - func testInvalidRunMounts(_ testCase: String) throws { - #expect(throws: ParseError.self) { - let _ = try RunInstruction.parseMount(testCase) - } - } - - struct RunNetworkTest { - let rawNetwork: String? - let expectedNetwork: NetworkMode? - - init(rawNetwork: String?, expectedNetwork: NetworkMode? = nil) { - self.rawNetwork = rawNetwork - self.expectedNetwork = expectedNetwork - } - } - - static let runNetworkTests: [RunNetworkTest] = [ - RunNetworkTest(rawNetwork: "default", expectedNetwork: .default), - RunNetworkTest(rawNetwork: "none", expectedNetwork: NetworkMode.none), - RunNetworkTest(rawNetwork: "host", expectedNetwork: .host), - RunNetworkTest(rawNetwork: nil, expectedNetwork: .default), - ] - - @Test("Successful run network parsing", arguments: runNetworkTests) - func runNetworkParseTest(_ testCase: RunNetworkTest) throws { - let actual = try RunInstruction.parseNetworkMode(mode: testCase.rawNetwork) - #expect(actual == testCase.expectedNetwork) - } - - @Test func invalidNetworkParse() throws { - let invalidMode = "fake" - #expect(throws: ParseError.self) { - let _ = try RunInstruction.parseNetworkMode(mode: invalidMode) - } - } - - struct CopyOwnershipTest { - let rawOwnership: String? - let expectedOwnership: Ownership? - } - - static let copyOwnershipTests = [ - CopyOwnershipTest( - rawOwnership: "55:mygroup", - expectedOwnership: Ownership(user: .numeric(id: 55), group: .named(id: "mygroup")) - ), - CopyOwnershipTest( - rawOwnership: "bin", - expectedOwnership: Ownership(user: .named(id: "bin"), group: nil) - ), - CopyOwnershipTest( - rawOwnership: "1", - expectedOwnership: Ownership(user: .numeric(id: 1), group: nil) - ), - CopyOwnershipTest( - rawOwnership: "10:11", - expectedOwnership: Ownership(user: .numeric(id: 10), group: .numeric(id: 11)) - ), - CopyOwnershipTest( - rawOwnership: "myuser:mygroup", - expectedOwnership: Ownership(user: .named(id: "myuser"), group: .named(id: "mygroup")) - ), - CopyOwnershipTest( - rawOwnership: "", - expectedOwnership: Ownership(user: .numeric(id: 0), group: .numeric(id: 0)) - ), - CopyOwnershipTest( - rawOwnership: nil, - expectedOwnership: Ownership(user: .numeric(id: 0), group: .numeric(id: 0)) - ), - CopyOwnershipTest( - rawOwnership: ":mygroup", - expectedOwnership: Ownership(user: nil, group: .named(id: "mygroup")) - ), - ] - - @Test("Expected parsing of chown options", arguments: copyOwnershipTests) - func testCopyParseOwnership(_ testcase: CopyOwnershipTest) throws { - let actual = try CopyInstruction.parseOwnership(input: testcase.rawOwnership) - #expect(actual == testcase.expectedOwnership) - } - - @Test func testCopyParseOwnershipInvalid() throws { - let rawInput = "myuser:mygroup:extra" - #expect(throws: ParseError.self) { - let _ = try CopyInstruction.parseOwnership(input: rawInput) - } - } - - @Test func testCopyParsePermissions() throws { - let rawPermission = "777" - let actual = try CopyInstruction.parsePermissions(input: rawPermission) - #expect(actual == .mode(777)) - } - - @Test func testCopyParsePermissionsInvalid() throws { - let rawPermission = "u+x" - #expect(throws: ParseError.self) { - let _ = try CopyInstruction.parsePermissions(input: rawPermission) - } - } - - static let invalidPorts = [ - "80//tcp", - "78292939273", - "udp", - "90-100-110", - "8080/fake", - "/fake2", - "80\\tcp", - "0", - "0/tcp", - "8000-0", - "0-6000", - ] - - @Test("Invalid ports throw error", arguments: invalidPorts) - func testInvalidPortParse(_ testCase: String) throws { - #expect(throws: ParseError.self) { - try parsePort(testCase) - } - } - - static let invalidArgs = [ - "", - "=", - "=value", - " ", - "MISSING_QUOTE=\"value", - "MISSING_SINGLE_QUOTE='value", - ] - - @Test("Invalid ARG throw error", arguments: invalidArgs) - func testInvalidArgParse(_ testCase: String) throws { - #expect(throws: ParseError.self) { - let tokens: [Token] = [.stringLiteral("ARG"), .stringLiteral(testCase)] - let _ = try DockerfileParser().tokensToArgInstruction(tokens: tokens) - } - } - - @Test func testArgWithSpacesAndQuotes() throws { - let dockerfile = - #""" - FROM alpine:latest AS build - ARG MESSAGE=Hello! - ARG QUOTED="Hello, World!" - ARG SINGLE='Hello, World!' - """# - let parser = DockerfileParser() - let graph = try parser.parse(dockerfile) - - #expect(graph.stages.count == 1) - let stage = graph.stages[0] - - #expect(getStageArg(stage, name: "MESSAGE") == "Hello!") - #expect(getStageArg(stage, name: "QUOTED") == "Hello, World!") - #expect(getStageArg(stage, name: "SINGLE") == "Hello, World!") - } - - @Test func testMultipleArgsInSingleInstruction() throws { - let dockerfile = - #""" - FROM alpine:latest AS build - ARG TEST1A TEST1B TEST1C - ARG TEST2A TEST2B=unquoted TEST2C="quoted" - ARG TEST3A=unquoted TEST3B TEST3C="quoted" - ARG TEST4A=unquoted TEST4B="quoted" TEST4C - ARG TEST5A=value TEST5B="value" TEST5C='value' - """# - let parser = DockerfileParser() - let graph = try parser.parse(dockerfile) - - #expect(graph.stages.count == 1) - let stage = graph.stages[0] - - #expect(getStageArg(stage, name: "TEST1A") == nil) - #expect(getStageArg(stage, name: "TEST1B") == nil) - #expect(getStageArg(stage, name: "TEST1C") == nil) - - #expect(getStageArg(stage, name: "TEST2A") == nil) - #expect(getStageArg(stage, name: "TEST2B") == "unquoted") - #expect(getStageArg(stage, name: "TEST2C") == "quoted") - - #expect(getStageArg(stage, name: "TEST3A") == "unquoted") - #expect(getStageArg(stage, name: "TEST3B") == nil) - #expect(getStageArg(stage, name: "TEST3C") == "quoted") - - #expect(getStageArg(stage, name: "TEST4A") == "unquoted") - #expect(getStageArg(stage, name: "TEST4B") == "quoted") - #expect(getStageArg(stage, name: "TEST4C") == nil) - - #expect(getStageArg(stage, name: "TEST5A") == "value") - #expect(getStageArg(stage, name: "TEST5B") == "value") - #expect(getStageArg(stage, name: "TEST5C") == "value") - } - - @Test func testArgWhitespaceHandling() throws { - let dockerfile = - #""" - FROM alpine:latest AS build - ARG BEFORE_EQ =value - ARG AFTER_EQ= value - ARG BOTH_EQ = value - ARG TRAILING_SPACE=value - ARG BEFORE_QUOTE= "value" - ARG AFTER_QUOTE="value" - ARG BOTH_QUOTE= "value" - """# - let parser = DockerfileParser() - let graph = try parser.parse(dockerfile) - - #expect(graph.stages.count == 1) - let stage = graph.stages[0] - - #expect(getStageArg(stage, name: "BEFORE_EQ") == "value") - #expect(getStageArg(stage, name: "AFTER_EQ") == "value") - #expect(getStageArg(stage, name: "BOTH_EQ") == "value") - - #expect(getStageArg(stage, name: "TRAILING_SPACE") == "value") - - #expect(getStageArg(stage, name: "BEFORE_QUOTE") == "value") - #expect(getStageArg(stage, name: "AFTER_QUOTE") == "value") - #expect(getStageArg(stage, name: "BOTH_QUOTE") == "value") - } - - @Test func testArgEscaping() throws { - let dockerfile = - #""" - FROM alpine:latest AS build - ARG BACKSLASH="back\\slash" - ARG QUOTE="say \"hello\"" - ARG SINGLE_IN_DOUBLE="single'quote" - ARG DOUBLE_IN_SINGLE ='double"quote' - ARG SINGLE_IN_VALUE=single'quote - ARG QUOTE_IN_VALUE=double"quote - """# - let parser = DockerfileParser() - let graph = try parser.parse(dockerfile) - - #expect(graph.stages.count == 1) - let stage = graph.stages[0] - - #expect(getStageArg(stage, name: "BACKSLASH") == "back\\slash") - #expect(getStageArg(stage, name: "QUOTE") == "say \"hello\"") - #expect(getStageArg(stage, name: "SINGLE_IN_DOUBLE") == "single'quote") - #expect(getStageArg(stage, name: "DOUBLE_IN_SINGLE") == "double\"quote") - #expect(getStageArg(stage, name: "SINGLE_IN_VALUE") == "single'quote") - #expect(getStageArg(stage, name: "QUOTE_IN_VALUE") == "double\"quote") - } - - @Test func testArgWithMissingClosingQuote() throws { - let dockerfile = - #""" - FROM alpine:latest AS build - ARG MISSING_QUOTE="unterminated value - """# - let parser = DockerfileParser() - - #expect(throws: ParseError.self) { - try parser.parse(dockerfile) - } - } - - @Test func testArgWithMissingClosingSingleQuote() throws { - let dockerfile = - #""" - FROM alpine:latest AS build - ARG SINGLE_MISSING='unterminated value - """# - let parser = DockerfileParser() - - #expect(throws: ParseError.self) { - try parser.parse(dockerfile) - } - } -} diff --git a/Tests/NativeBuilderTests/ContainerBuildParserTests/TokenizerTests.swift b/Tests/NativeBuilderTests/ContainerBuildParserTests/TokenizerTests.swift deleted file mode 100644 index be228fe71..000000000 --- a/Tests/NativeBuilderTests/ContainerBuildParserTests/TokenizerTests.swift +++ /dev/null @@ -1,652 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import Testing - -@testable import ContainerBuildParser - -@Suite class TokenizerTest { - struct tokenizerTestInput { - let input: String - let expectedTokens: [Token] - } - - let tokenizerTestCases: [tokenizerTestInput] = [ - tokenizerTestInput( - input: "FROM alpine AS build", - expectedTokens: [ - .stringLiteral("FROM"), - .stringLiteral("alpine"), - .stringLiteral("AS"), - .stringLiteral("build"), - ] - ), - tokenizerTestInput( - input: "FROM alpine", - expectedTokens: [ - .stringLiteral("FROM"), - .stringLiteral("alpine"), - ] - ), - tokenizerTestInput( - input: "RUN --mount=type=cache /app", - expectedTokens: [ - .stringLiteral("RUN"), - .stringLiteral("--mount=type=cache"), - .stringLiteral("/app"), - ] - ), - tokenizerTestInput( - input: "RUN --network=default /app", - expectedTokens: [ - .stringLiteral("RUN"), - .stringLiteral("--network=default"), - .stringLiteral("/app"), - ] - ), - tokenizerTestInput( - input: "RUN --mount=type=bind,target=/target --network=host build.sh", - expectedTokens: [ - .stringLiteral("RUN"), - .stringLiteral("--mount=type=bind,target=/target"), - .stringLiteral("--network=host"), - .stringLiteral("build.sh"), - ] - ), - tokenizerTestInput( - input: "RUN --mount type=cache /app", - expectedTokens: [ - .stringLiteral("RUN"), - .stringLiteral("--mount"), - .stringLiteral("type=cache"), - .stringLiteral("/app"), - ] - ), - tokenizerTestInput( - input: - """ - RUN --mount=type=cache build.sh --input hello - """, - expectedTokens: [ - .stringLiteral("RUN"), - .stringLiteral("--mount=type=cache"), - .stringLiteral("build.sh"), - .stringLiteral("--input"), - .stringLiteral("hello"), - ] - ), - tokenizerTestInput( - input: - #""" - RUN --mount=type=cache ["build.sh", "--input", "hello"] - """#, - expectedTokens: [ - .stringLiteral("RUN"), - .stringLiteral("--mount=type=cache"), - .stringList(["build.sh", "--input", "hello"]), - ] - ), - tokenizerTestInput( - input: - #""" - RUN --mount=type=cache [ "build.sh", "--input", "hello" ] - """#, - expectedTokens: [ - .stringLiteral("RUN"), - .stringLiteral("--mount=type=cache"), - .stringList(["build.sh", "--input", "hello"]), - ] - ), - tokenizerTestInput( - input: - #""" - RUN --mount=type=cache "build.sh --input hello" - """#, - expectedTokens: [ - .stringLiteral("RUN"), - .stringLiteral("--mount=type=cache"), - .stringLiteral("build.sh --input hello"), - ] - ), - tokenizerTestInput( - input: - #""" - RUN --mount=type=cache "build.sh --input hello" - # this is a full line comment - """#, - expectedTokens: [ - .stringLiteral("RUN"), - .stringLiteral("--mount=type=cache"), - .stringLiteral("build.sh --input hello"), - ] - ), - tokenizerTestInput( - input: - #""" - RUN --mount=type=cache "build.sh --input hello" # this is an end line comment - """#, - expectedTokens: [ - .stringLiteral("RUN"), - .stringLiteral("--mount=type=cache"), - .stringLiteral("build.sh --input hello"), - ] - ), - tokenizerTestInput( - input: "COPY --from=alpine src /dest", - expectedTokens: [ - .stringLiteral("COPY"), - .stringLiteral("--from=alpine"), - .stringLiteral("src"), - .stringLiteral("/dest"), - ] - ), - - tokenizerTestInput( - input: "COPY --from=alpine src src1 src2 src3 /dest", - expectedTokens: [ - .stringLiteral("COPY"), - .stringLiteral("--from=alpine"), - .stringLiteral("src"), - .stringLiteral("src1"), - .stringLiteral("src2"), - .stringLiteral("src3"), - .stringLiteral("/dest"), - ] - ), - tokenizerTestInput( - input: "COPY --chown=10:11 src /dest", - expectedTokens: [ - .stringLiteral("COPY"), - .stringLiteral("--chown=10:11"), - .stringLiteral("src"), - .stringLiteral("/dest"), - ] - ), - tokenizerTestInput( - input: "COPY --chown=bin stuff.txt /stuffdest/", - expectedTokens: [ - .stringLiteral("COPY"), - .stringLiteral("--chown=bin"), - .stringLiteral("stuff.txt"), - .stringLiteral("/stuffdest/"), - ] - ), - tokenizerTestInput( - input: "COPY --chown=1 source /destination", - expectedTokens: [ - .stringLiteral("COPY"), - .stringLiteral("--chown=1"), - .stringLiteral("source"), - .stringLiteral("/destination"), - ] - ), - tokenizerTestInput( - input: "COPY --chmod=440 src /dest/", - expectedTokens: [ - .stringLiteral("COPY"), - .stringLiteral("--chmod=440"), - .stringLiteral("src"), - .stringLiteral("/dest/"), - ] - ), - tokenizerTestInput( - input: "COPY --link=false src /dest/", - expectedTokens: [ - .stringLiteral("COPY"), - .stringLiteral("--link=false"), - .stringLiteral("src"), - .stringLiteral("/dest/"), - ] - ), - tokenizerTestInput( - input: "LABEL key=value", - expectedTokens: [ - .stringLiteral("LABEL"), - .stringLiteral("key=value"), - ] - ), - tokenizerTestInput( - input: - #""" - LABEL key=value anotherkey=anothervalue quoted="quotelabel" - """#, - expectedTokens: [ - .stringLiteral("LABEL"), - .stringLiteral("key=value"), - .stringLiteral("anotherkey=anothervalue"), - .stringLiteral("quoted=\"quotelabel\""), - ] - ), - tokenizerTestInput( - input: - #""" - CMD ["executable", "param1", "param2"] - """#, - expectedTokens: [ - .stringLiteral("CMD"), - .stringList(["executable", "param1", "param2"]), - ] - ), - tokenizerTestInput( - input: - #""" - CMD command param1 param2 - """#, - expectedTokens: [ - .stringLiteral("CMD"), - .stringLiteral("command"), - .stringLiteral("param1"), - .stringLiteral("param2"), - ] - ), - tokenizerTestInput( - input: "EXPOSE 8080", - expectedTokens: [ - .stringLiteral("EXPOSE"), - .stringLiteral("8080"), - ] - ), - tokenizerTestInput( - input: "EXPOSE 80/udp 80/tcp 80/sctp", - expectedTokens: [ - .stringLiteral("EXPOSE"), - .stringLiteral("80/udp"), - .stringLiteral("80/tcp"), - .stringLiteral("80/sctp"), - ] - ), - tokenizerTestInput( - input: "EXPOSE 7000-8000/udp", - expectedTokens: [ - .stringLiteral("EXPOSE"), - .stringLiteral("7000-8000/udp"), - ] - ), - tokenizerTestInput( - input: "ARG NODE_ENV=development", - expectedTokens: [ - .stringLiteral("ARG"), - .stringLiteral("NODE_ENV=development"), - ] - ), - tokenizerTestInput( - input: "ARG BUILD_VERSION=", - expectedTokens: [ - .stringLiteral("ARG"), - .stringLiteral("BUILD_VERSION="), - ] - ), - tokenizerTestInput( - input: "ARG API_URL", - expectedTokens: [ - .stringLiteral("ARG"), - .stringLiteral("API_URL"), - ] - ), - ] - - @Test func testTokenization() throws { - for testCase: tokenizerTestInput in tokenizerTestCases { - var tokenizer = DockerfileTokenizer(testCase.input) - let tokens = try tokenizer.getTokens() - #expect(!tokens.isEmpty) - #expect(TokenizerTest.isEqual(actual: tokens, expected: testCase.expectedTokens)) - } - } - - static private func isEqual(actual: [Token], expected: [Token]) -> Bool { - if actual.count != expected.count { - return false - } - var index = 0 - while index < actual.count { - if actual[index] != expected[index] { - return false - } - index += 1 - } - return true - } - - struct TokenTest: Sendable { - let tokens: [Token] - let expectedInstruction: any DockerInstruction - } - - @Test func tokenTranslationFrom() throws { - let fromTokenTestInputs: [TokenTest] = [ - TokenTest( - tokens: [ - .stringLiteral("FROM"), - .stringLiteral("alpine"), - ], - expectedInstruction: try FromInstruction(image: "alpine") - ), - TokenTest( - tokens: [ - .stringLiteral("FROM"), - .stringLiteral("alpine"), - .stringLiteral("AS"), - .stringLiteral("build"), - ], - expectedInstruction: try FromInstruction(image: "alpine", stageName: "build") - ), - TokenTest( - tokens: [ - .stringLiteral("FROM"), - .stringLiteral("--platform=linux/arm64"), - .stringLiteral("alpine"), - ], - expectedInstruction: try FromInstruction(image: "alpine", platform: "linux/arm64") - ), - ] - - for testCase in fromTokenTestInputs { - let buildParser = DockerfileParser() - let actualInstruction = try buildParser.tokensToFromInstruction(tokens: testCase.tokens) - guard let expected = testCase.expectedInstruction as? FromInstruction else { - Issue.record("unexpected instruction type \(testCase.expectedInstruction)") - return - } - #expect(actualInstruction == expected) - } - } - - @Test func testTokensToRunWithShellCommand() throws { - let tokens: [Token] = [ - .stringLiteral("RUN"), - .stringLiteral("--mount=type=cache,target=/cache"), - .stringLiteral("build.sh --input hello"), - ] - - let parser = DockerfileParser() - let actual = try parser.tokensToRunInstruction(tokens: tokens) - - #expect(actual.command.displayString == "build.sh --input hello") - } - - @Test func testTokensToRunWithoutShell() throws { - let command = ["build.sh", "--input", "hello"] - let tokens: [Token] = [ - .stringLiteral("RUN"), - .stringLiteral("--mount=type=cache,target=/mytarget"), - .stringList(command), - ] - - let parser = DockerfileParser() - let actual = try parser.tokensToRunInstruction(tokens: tokens) - - #expect(actual.command.displayString == command.joined(separator: " ")) - } - - static let extraTokensTests: [[Token]] = [ - [ - .stringLiteral("RUN"), - .stringLiteral("--mount=type=tmpfs,size=1000"), - .stringList(["build.sh", "--input", "hello"]), - .stringLiteral("extra"), - ], - [ - .stringLiteral("RUN"), - .stringLiteral("--mount=type=bind,target=/target"), - .stringLiteral("build.sh"), - .stringLiteral("--input"), - .stringLiteral("hello"), - .stringList(["extra"]), - ], - ] - - @Test("Parsing to run instruction fails when there's extra tokens", arguments: extraTokensTests) - func testTokensToRunExtraTokens(tokens: [Token]) throws { - #expect(throws: ParseError.self) { - let parser = DockerfileParser() - let _ = try parser.tokensToRunInstruction(tokens: tokens) - } - } - - @Test func testTokensToCopyInstruction() throws { - let copyTokenTests = [ - TokenTest( - tokens: [ - .stringLiteral("COPY"), - .stringLiteral("--link=false"), - .stringLiteral("src"), - .stringLiteral("/dest/"), - ], - expectedInstruction: try CopyInstruction( - sources: ["src"], - destination: "/dest/", - ) - ), - TokenTest( - tokens: [ - .stringLiteral("COPY"), - .stringLiteral("--chmod=440"), - .stringLiteral("src"), - .stringLiteral("/dest"), - ], - expectedInstruction: try CopyInstruction( - sources: ["src"], - destination: "/dest", - permissions: .mode(440) - ) - ), - TokenTest( - tokens: [ - .stringLiteral("COPY"), - .stringLiteral("--chown"), - .stringLiteral("11:mygroup"), - .stringLiteral("source"), - .stringLiteral("destination"), - ], - expectedInstruction: try CopyInstruction( - sources: ["source"], - destination: "destination", - ownership: Ownership(user: .numeric(id: 11), group: .named(id: "mygroup")) - ) - ), - TokenTest( - tokens: [ - .stringLiteral("COPY"), - .stringLiteral("--from=alpine"), - .stringLiteral("src"), - .stringLiteral("src1"), - .stringLiteral("src2"), - .stringLiteral("src3"), - .stringLiteral("/dest"), - ], - expectedInstruction: try CopyInstruction( - sources: ["src", "src1", "src2", "src3"], - destination: "/dest", - from: "alpine", - ) - ), - TokenTest( - tokens: [ - .stringLiteral("COPY"), - .stringLiteral("--from"), - .stringLiteral("base"), - .stringLiteral("Source"), - .stringLiteral("Dest"), - ], - expectedInstruction: try CopyInstruction( - sources: ["Source"], - destination: "Dest", - from: "base", - ) - ), - ] - - for test in copyTokenTests { - let parser = DockerfileParser() - let actual = try parser.tokensToCopyInstruction(tokens: test.tokens) - guard let expected = test.expectedInstruction as? CopyInstruction else { - Issue.record("unexpected instruction type \(test.expectedInstruction)") - return - } - #expect(actual == expected) - } - } - - static let invalidCopyTokens: [[Token]] = [ - [ - // no destination - .stringLiteral("COPY"), - .stringLiteral("Source"), - ], - [ - // no sources - .stringLiteral("COPY"), - .stringLiteral("--from=alpine"), - ], - ] - - @Test("Invalid copy tokens throw an error", arguments: invalidCopyTokens) - func testInvalidCopyTokens(_ tokens: [Token]) throws { - let parser = DockerfileParser() - #expect(throws: ParseError.self) { - let _ = try parser.tokensToCopyInstruction(tokens: tokens) - } - } - - static let cmdTokenTests: [TokenTest] = [ - TokenTest( - tokens: [ - .stringLiteral("CMD"), - .stringList(["executable", "param1", "param2"]), - ], - expectedInstruction: CMDInstruction(command: .exec(["executable", "param1", "param2"])) - ), - TokenTest( - tokens: [ - .stringLiteral("CMD"), - .stringLiteral("command"), - .stringLiteral("param1"), - .stringLiteral("param2"), - ], expectedInstruction: CMDInstruction(command: .shell("command param1 param2")) - ), - ] - - @Test("Successful tokens to CMD Instruction conversion", arguments: cmdTokenTests) - func testTokensToCMDInstruction(_ testCase: TokenTest) throws { - let parser = DockerfileParser() - let actual = try parser.tokensToCMDInstruction(tokens: testCase.tokens) - guard let expected = testCase.expectedInstruction as? CMDInstruction else { - Issue.record("Instruction is not the correct type, \(testCase.expectedInstruction)") - return - } - #expect(actual == expected) - } - - @Test func testTokensToLabelInstruction() throws { - let tokens: [Token] = [ - .stringLiteral("LABEL"), - .stringLiteral("key=value"), - .stringLiteral("anotherkey=anothervalue"), - .stringLiteral("quoted=\"quotelabel\""), - ] - let expectedLabels: [String: String] = [ - "key": "value", - "anotherkey": "anothervalue", - "quoted": "\"quotelabel\"", - ] - let expectedInstruction = LabelInstruction(labels: expectedLabels) - let actual = try DockerfileParser().tokensToLabelInstruction(tokens: tokens) - #expect(actual == expectedInstruction) - } - - static let exposeTests: [TokenTest] = [ - TokenTest( - tokens: [ - .stringLiteral("EXPOSE"), - .stringLiteral("80"), - ], - expectedInstruction: ExposeInstruction(["80"]) - ), - TokenTest( - tokens: [ - .stringLiteral("EXPOSE"), - .stringLiteral("90/sctp"), - .stringLiteral("8080/udp"), - ], - expectedInstruction: ExposeInstruction(ports: [ - PortSpec(port: 90, protocol: .sctp), - PortSpec(port: 8080, protocol: .udp), - ]) - ), - TokenTest( - tokens: [ - .stringLiteral("EXPOSE"), - .stringLiteral("7000-8000/udp"), - ], - expectedInstruction: ExposeInstruction(ports: [ - PortSpec(port: 7000, endPort: 8000, protocol: .udp) - ]) - ), - ] - - @Test("Successful tokens to EXPOSE instruction", arguments: exposeTests) - func testTokensToExposeInstruction(_ testCase: TokenTest) throws { - let actual = try DockerfileParser().tokensToExposeInstruction(tokens: testCase.tokens) - guard let expected = testCase.expectedInstruction as? ExposeInstruction else { - Issue.record("Instruction is not the correct type, \(testCase.expectedInstruction)") - return - } - #expect(actual == expected) - - } - - static var argTests: [TokenTest] { - get throws { - [ - TokenTest( - tokens: [ - .stringLiteral("ARG"), - .stringLiteral("NODE_ENV=development"), - ], - expectedInstruction: try ArgInstruction(args: [ArgDefinition(name: "NODE_ENV", defaultValue: "development")]) - ), - TokenTest( - tokens: [ - .stringLiteral("ARG"), - .stringLiteral("BUILD_VERSION="), - ], - expectedInstruction: try ArgInstruction(args: [ArgDefinition(name: "BUILD_VERSION", defaultValue: "")]) - ), - TokenTest( - tokens: [ - .stringLiteral("ARG"), - .stringLiteral("API_URL"), - ], - expectedInstruction: try ArgInstruction(args: [ArgDefinition(name: "API_URL", defaultValue: nil)]) - ), - ] - } - } - - @Test func testTokensToArgInstruction() throws { - let testCases = try Self.argTests - for testCase in testCases { - let actual = try DockerfileParser().tokensToArgInstruction(tokens: testCase.tokens) - guard let expected = testCase.expectedInstruction as? ArgInstruction else { - Issue.record("Instruction is not the correct type, \(String(describing: testCase.expectedInstruction))") - return - } - #expect(actual == expected) - } - } -} diff --git a/Tests/NativeBuilderTests/ContainerBuildParserTests/VisitorTests.swift b/Tests/NativeBuilderTests/ContainerBuildParserTests/VisitorTests.swift deleted file mode 100644 index 7b12d86b4..000000000 --- a/Tests/NativeBuilderTests/ContainerBuildParserTests/VisitorTests.swift +++ /dev/null @@ -1,117 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import ContainerizationOCI -import Testing - -@testable import ContainerBuildParser - -/// VisitorTest covers testing that visiting instructions is successful and graphs -/// made from those instructions are structured as expected. Tests are added for -/// instructions that have complex logic in their `visit` function. -@Suite class VisitorTest { - @Test func simpleVisitFrom() throws { - let imageName = "alpine" - let expectedImageRef = ImageReference(parsing: imageName) - let stageName = "build" - let platformString = "linux/arm64" - let expectedPlatform = try Platform(from: platformString) - - let from = try FromInstruction(image: imageName, platform: platformString, stageName: stageName) - let visitor = DockerInstructionVisitor() - try visitor.visit(from) - - let graph = try visitor.graphBuilder.build() - #expect(graph.stages.count == 1) - let stage = graph.stages[0] - #expect(stage.name == stageName) - #expect(stage.platform! == expectedPlatform) - #expect(stage.base.source == .registry(expectedImageRef!)) - } - - @Test func simpleVisitRun() throws { - let from = try FromInstruction(image: "scratch") - let command: Command = .exec(["sh", "-c", "top"]) - let network = "default" - let run = try RunInstruction( - command: command, - rawMounts: [], - network: network - ) - - let visitor = DockerInstructionVisitor() - try visitor.visit(from) - try visitor.visit(run) - - let graph = try visitor.graphBuilder.build() - - #expect(graph.stages.count == 1) - let stage = graph.stages[0] - - #expect(stage.nodes.count == 1) - let node = stage.nodes[0] - - #expect(node.operation is ExecOperation) - - let exec = node.operation as! ExecOperation - #expect(exec.command == command) - #expect(exec.mounts.isEmpty) - #expect(exec.network == .default) - } - - @Test func simpleVisitCopy() throws { - let from = try FromInstruction(image: "scratch") - let sources = ["src", "src1", "src2"] - let dest = "/dest" - let copy = try CopyInstruction( - sources: sources, - destination: dest, - from: nil, - ownership: "10:10", - permissions: "777" - ) - - let visitor = DockerInstructionVisitor() - try visitor.visit(from) - try visitor.visit(copy) - - let graph = try visitor.graphBuilder.build() - - #expect(graph.stages.count == 1) - let stage = graph.stages[0] - - #expect(stage.nodes.count == 1) - let node = stage.nodes[0] - - #expect(node.operation is FilesystemOperation) - - let copyNode = node.operation as! FilesystemOperation - #expect(copyNode.action == .copy) - - let expectedSource = ContextSource( - name: "default", - paths: sources) - #expect(copyNode.source == .context(expectedSource)) - #expect(copyNode.destination == dest) - - let expectedOwnership = Ownership(user: .numeric(id: 10), group: .numeric(id: 10)) - #expect(copyNode.fileMetadata.ownership == expectedOwnership) - - let expectedPerms: Permissions = .mode(777) - #expect(copyNode.fileMetadata.permissions == expectedPerms) - } -} diff --git a/Tests/NativeBuilderTests/ContainerBuildReportingTests/BaseProgressConsumerTests.swift b/Tests/NativeBuilderTests/ContainerBuildReportingTests/BaseProgressConsumerTests.swift deleted file mode 100644 index 478f87bed..000000000 --- a/Tests/NativeBuilderTests/ContainerBuildReportingTests/BaseProgressConsumerTests.swift +++ /dev/null @@ -1,679 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -@testable import ContainerBuildReporting - -@Suite -struct BaseProgressConsumerTests { - - // MARK: - Test Configuration - - struct TestConfiguration: Sendable { - let name: String - let bufferSize: Int - - init(name: String = "test", bufferSize: Int = 100) { - self.name = name - self.bufferSize = bufferSize - } - } - - // Test concrete implementation of BaseProgressConsumer - @MainActor - final class TestProgressConsumer: BaseProgressConsumer, @unchecked Sendable { - private var formattedEvents: [String] = [] - - override func formatAndOutput(_ event: BuildEvent) async throws { - formattedEvents.append("Formatted: \(event)") - } - - func getFormattedEvents() -> [String] { - formattedEvents - } - - func clearFormattedEvents() { - formattedEvents.removeAll() - } - } - - // MARK: - Initialization Tests - - @Test("Initialization with configuration") - @MainActor - func initializationWithConfiguration() { - let config = TestConfiguration(name: "test-consumer", bufferSize: 200) - let consumer = TestProgressConsumer(configuration: config) - - #expect(consumer.configuration.name == "test-consumer") - #expect(consumer.configuration.bufferSize == 200) - #expect(consumer.getEvents().isEmpty) - #expect(consumer.getFormattedEvents().isEmpty) - } - - @Test("Initialization with default configuration") - @MainActor - func initializationWithDefaultConfiguration() { - let consumer = TestProgressConsumer(configuration: TestConfiguration()) - - #expect(consumer.configuration.name == "test") - #expect(consumer.configuration.bufferSize == 100) - #expect(consumer.getEvents().isEmpty) - } - - // MARK: - Event Handling Tests - - @Test("Handles build started event") - @MainActor - func handlesBuildStartedEvent() async throws { - let consumer = TestProgressConsumer(configuration: TestConfiguration()) - let event = BuildEvent.buildStarted(totalOperations: 10, stages: 1, timestamp: Date()) - - try await consumer.handle(event) - - let events = consumer.getEvents() - #expect(events.count == 1) - - let statistics = consumer.getStatistics() - #expect(statistics.startTime != nil) - #expect(statistics.totalOperations == 10) - #expect(statistics.endTime == nil) - #expect(statistics.success == nil) - } - - @Test("Handles build completed event") - @MainActor - func handlesBuildCompletedEvent() async throws { - let consumer = TestProgressConsumer(configuration: TestConfiguration()) - let event = BuildEvent.buildCompleted(success: true, timestamp: Date()) - - try await consumer.handle(event) - - let events = consumer.getEvents() - #expect(events.count == 1) - - let statistics = consumer.getStatistics() - #expect(statistics.endTime != nil) - #expect(statistics.success == true) - } - - @Test("Handles stage started event") - @MainActor - func handlesStageStartedEvent() async throws { - let consumer = TestProgressConsumer(configuration: TestConfiguration()) - let event = BuildEvent.stageStarted(stageName: "stage1", timestamp: Date()) - - try await consumer.handle(event) - - let statistics = consumer.getStatistics() - #expect(statistics.totalStages == 1) - #expect(statistics.stageStatistics["stage1"] != nil) - #expect(statistics.stageStatistics["stage1"]?.startTime != nil) - #expect(statistics.stageStatistics["stage1"]?.endTime == nil) - } - - @Test("Handles stage completed event") - @MainActor - func handlesStageCompletedEvent() async throws { - let consumer = TestProgressConsumer(configuration: TestConfiguration()) - - // Start stage first - let startEvent = BuildEvent.stageStarted(stageName: "stage1", timestamp: Date()) - try await consumer.handle(startEvent) - - // Complete stage - let completeEvent = BuildEvent.stageCompleted(stageName: "stage1", timestamp: Date()) - try await consumer.handle(completeEvent) - - let statistics = consumer.getStatistics() - #expect(statistics.totalStages == 1) - #expect(statistics.stageStatistics["stage1"]?.startTime != nil) - #expect(statistics.stageStatistics["stage1"]?.endTime != nil) - } - - @Test("Handles operation started event") - @MainActor - func handlesOperationStartedEvent() async throws { - let consumer = TestProgressConsumer(configuration: TestConfiguration()) - let context = ReportContext(nodeId: UUID(), stageId: "stage1", description: "Operation 1") - let event = BuildEvent.operationStarted(context: context) - - try await consumer.handle(event) - - let statistics = consumer.getStatistics() - #expect(statistics.stageStatistics["stage1"]?.operationCount == 1) - #expect(statistics.stageStatistics["stage1"]?.cacheHits == 0) - #expect(statistics.stageStatistics["stage1"]?.failures == 0) - } - - @Test("Handles operation finished event") - @MainActor - func handlesOperationFinishedEvent() async throws { - let consumer = TestProgressConsumer(configuration: TestConfiguration()) - let event = BuildEvent.operationFinished(context: ReportContext(nodeId: UUID(), description: "Operation 1"), duration: 1.0) - - try await consumer.handle(event) - - let statistics = consumer.getStatistics() - #expect(statistics.executedOperations == 1) - #expect(statistics.failedOperations == 0) - #expect(statistics.cacheHits == 0) - } - - @Test("Handles operation failed event") - @MainActor - func handlesOperationFailedEvent() async throws { - let consumer = TestProgressConsumer(configuration: TestConfiguration()) - let context = ReportContext(nodeId: UUID(), stageId: "stage1", description: "Operation 1") - let error = BuildEventError(type: .executionFailed, description: "Test failure") - let event = BuildEvent.operationFailed(context: context, error: error) - - try await consumer.handle(event) - - let statistics = consumer.getStatistics() - #expect(statistics.failedOperations == 1) - #expect(statistics.executedOperations == 0) - #expect(statistics.stageStatistics["stage1"]?.failures == 1) - } - - @Test("Handles operation cache hit event") - @MainActor - func handlesOperationCacheHitEvent() async throws { - let consumer = TestProgressConsumer(configuration: TestConfiguration()) - let context = ReportContext(nodeId: UUID(), stageId: "stage1", description: "Operation 1") - let event = BuildEvent.operationCacheHit(context: context) - - try await consumer.handle(event) - - let statistics = consumer.getStatistics() - #expect(statistics.cacheHits == 1) - #expect(statistics.stageStatistics["stage1"]?.cacheHits == 1) - } - - @Test("Handles operation progress event") - @MainActor - func handlesOperationProgressEvent() async throws { - let consumer = TestProgressConsumer(configuration: TestConfiguration()) - let event = BuildEvent.operationProgress(context: ReportContext(nodeId: UUID(), description: "Operation 1"), fraction: 0.5) - - try await consumer.handle(event) - - let events = consumer.getEvents() - #expect(events.count == 1) - - // Progress events don't affect statistics - let statistics = consumer.getStatistics() - #expect(statistics.executedOperations == 0) - #expect(statistics.failedOperations == 0) - #expect(statistics.cacheHits == 0) - } - - @Test("Handles operation log event") - @MainActor - func handlesOperationLogEvent() async throws { - let consumer = TestProgressConsumer(configuration: TestConfiguration()) - let event = BuildEvent.operationLog(context: ReportContext(nodeId: UUID(), description: "Operation 1"), message: "Log message") - - try await consumer.handle(event) - - let events = consumer.getEvents() - #expect(events.count == 1) - - // Log events don't affect statistics - let statistics = consumer.getStatistics() - #expect(statistics.executedOperations == 0) - #expect(statistics.failedOperations == 0) - #expect(statistics.cacheHits == 0) - } - - @Test("Handles IR event") - @MainActor - func handlesIREvent() async throws { - let consumer = TestProgressConsumer(configuration: TestConfiguration()) - let event = BuildEvent.irEvent(context: ReportContext(nodeId: UUID(), description: "IR event"), type: .graphStarted) - - try await consumer.handle(event) - - let events = consumer.getEvents() - #expect(events.count == 1) - - // IR events don't affect statistics - let statistics = consumer.getStatistics() - #expect(statistics.executedOperations == 0) - #expect(statistics.failedOperations == 0) - #expect(statistics.cacheHits == 0) - } - - // MARK: - Statistics Accumulation Tests - - @Test("Statistics accumulation with complex sequence") - @MainActor - func statisticsAccumulationWithComplexSequence() async throws { - let consumer = TestProgressConsumer(configuration: TestConfiguration()) - - // Build sequence: start -> 2 stages -> multiple operations -> complete - let events = [ - BuildEvent.buildStarted(totalOperations: 6, stages: 2, timestamp: Date()), - BuildEvent.stageStarted(stageName: "stage1", timestamp: Date()), - BuildEvent.operationStarted(context: ReportContext(nodeId: UUID(), stageId: "stage1", description: "Operation 1")), - BuildEvent.operationFinished(context: ReportContext(nodeId: UUID(), stageId: "stage1", description: "Operation 1"), duration: 1.0), - BuildEvent.operationStarted(context: ReportContext(nodeId: UUID(), stageId: "stage1", description: "Operation 2")), - BuildEvent.operationCacheHit(context: ReportContext(nodeId: UUID(), stageId: "stage1", description: "Operation 2")), - BuildEvent.operationStarted(context: ReportContext(nodeId: UUID(), stageId: "stage1", description: "Operation 3")), - BuildEvent.operationFailed( - context: ReportContext(nodeId: UUID(), stageId: "stage1", description: "Operation 3"), error: BuildEventError(type: .executionFailed, description: "Failed")), - BuildEvent.stageCompleted(stageName: "stage1", timestamp: Date()), - BuildEvent.stageStarted(stageName: "stage2", timestamp: Date()), - BuildEvent.operationStarted(context: ReportContext(nodeId: UUID(), stageId: "stage2", description: "Operation 4")), - BuildEvent.operationFinished(context: ReportContext(nodeId: UUID(), stageId: "stage2", description: "Operation 4"), duration: 1.0), - BuildEvent.operationStarted(context: ReportContext(nodeId: UUID(), stageId: "stage2", description: "Operation 5")), - BuildEvent.operationCacheHit(context: ReportContext(nodeId: UUID(), stageId: "stage2", description: "Operation 5")), - BuildEvent.operationStarted(context: ReportContext(nodeId: UUID(), stageId: "stage2", description: "Operation 6")), - BuildEvent.operationFinished(context: ReportContext(nodeId: UUID(), stageId: "stage2", description: "Operation 6"), duration: 1.0), - BuildEvent.stageCompleted(stageName: "stage2", timestamp: Date()), - BuildEvent.buildCompleted(success: false, timestamp: Date()), - ] - - for event in events { - try await consumer.handle(event) - } - - let statistics = consumer.getStatistics() - - // Verify build-level statistics - #expect(statistics.totalOperations == 6) - #expect(statistics.executedOperations == 3) // op1, op4, op6 - #expect(statistics.cacheHits == 2) // op2, op5 - #expect(statistics.failedOperations == 1) // op3 - #expect(statistics.success == false) - #expect(statistics.totalStages == 2) - #expect(statistics.startTime != nil) - #expect(statistics.endTime != nil) - #expect(statistics.events.count == 18) - - // Verify stage1 statistics - let stage1Stats = statistics.stageStatistics["stage1"] - #expect(stage1Stats != nil) - #expect(stage1Stats?.operationCount == 3) - #expect(stage1Stats?.cacheHits == 1) - #expect(stage1Stats?.failures == 1) - #expect(stage1Stats?.startTime != nil) - #expect(stage1Stats?.endTime != nil) - - // Verify stage2 statistics - let stage2Stats = statistics.stageStatistics["stage2"] - #expect(stage2Stats != nil) - #expect(stage2Stats?.operationCount == 3) - #expect(stage2Stats?.cacheHits == 1) - #expect(stage2Stats?.failures == 0) - #expect(stage2Stats?.startTime != nil) - #expect(stage2Stats?.endTime != nil) - } - - @Test("Statistics with multiple stages") - @MainActor - func statisticsWithMultipleStages() async throws { - let consumer = TestProgressConsumer(configuration: TestConfiguration()) - - // Create multiple stages with different characteristics - let stages = [ - ("stage1", 5, 2, 1), // 5 ops, 2 cache hits, 1 failure - ("stage2", 3, 1, 0), // 3 ops, 1 cache hit, 0 failures - ("stage3", 2, 0, 2), // 2 ops, 0 cache hits, 2 failures - ("stage4", 1, 1, 0), // 1 op, 1 cache hit, 0 failures - ] - - for (stageName, opCount, cacheHits, failures) in stages { - let stageStartEvent = BuildEvent.stageStarted(stageName: stageName, timestamp: Date()) - try await consumer.handle(stageStartEvent) - - // Add operations - for i in 0..?" - let stageStats = StageStatistics(name: name) - - #expect(stageStats.name == name) - } - - @Test("StageStatistics with unicode characters in name") - func testStageStatisticsWithUnicodeCharactersInName() { - let name = "测试阶段-🚀-étape-тест" - let stageStats = StageStatistics(name: name) - - #expect(stageStats.name == name) - } - - @Test("StageStatistics with zero operations") - func testStageStatisticsWithZeroOperations() { - let stageStats = StageStatistics(name: "empty-stage", operationCount: 0, cacheHits: 0, failures: 0) - - #expect(stageStats.operationCount == 0) - #expect(stageStats.cacheHits == 0) - #expect(stageStats.failures == 0) - } - - @Test("StageStatistics with high operation counts") - func testStageStatisticsWithHighOperationCounts() { - let stageStats = StageStatistics(name: "large-stage", operationCount: 10000, cacheHits: 5000, failures: 100) - - #expect(stageStats.operationCount == 10000) - #expect(stageStats.cacheHits == 5000) - #expect(stageStats.failures == 100) - } - - @Test("StageStatistics with all cache hits") - func testStageStatisticsWithAllCacheHits() { - let stageStats = StageStatistics(name: "cached-stage", operationCount: 10, cacheHits: 10, failures: 0) - - #expect(stageStats.operationCount == 10) - #expect(stageStats.cacheHits == 10) - #expect(stageStats.failures == 0) - } - - @Test("StageStatistics with all failures") - func testStageStatisticsWithAllFailures() { - let stageStats = StageStatistics(name: "failed-stage", operationCount: 5, cacheHits: 0, failures: 5) - - #expect(stageStats.operationCount == 5) - #expect(stageStats.cacheHits == 0) - #expect(stageStats.failures == 5) - } - - // MARK: - Edge Cases and Performance Tests - - @Test("BuildStatistics with extremely long duration") - func testBuildStatisticsWithExtremelyLongDuration() { - let startTime = Date(timeIntervalSince1970: 0) - let endTime = Date() - - let stats = BuildStatistics(startTime: startTime, endTime: endTime) - - #expect(stats.duration != nil) - #expect(stats.duration! > 0) - } - - @Test("BuildStatistics with very short duration") - func testBuildStatisticsWithVeryShortDuration() { - let startTime = Date() - let endTime = Date(timeInterval: 0.001, since: startTime) - - let stats = BuildStatistics(startTime: startTime, endTime: endTime) - - #expect(abs(stats.duration! - 0.001) < 0.0001) - } - - @Test("BuildStatistics memory usage with large data set") - func testBuildStatisticsMemoryUsageWithLargeDataSet() { - let eventCount = 100000 - let stageCount = 1000 - - let events = (0.. (FileHandle, FileHandle) { - let pipe = Pipe() - return (pipe.fileHandleForWriting, pipe.fileHandleForReading) - } - - private func readOutputAsString(from readHandle: FileHandle) -> String { - let data = readHandle.readDataToEndOfFile() - return String(data: data, encoding: .utf8) ?? "" - } - - private func parseJSONLines(_ output: String) -> [[String: Any]] { - let lines = output.split(separator: "\n").map(String.init) - return lines.compactMap { line in - guard let data = line.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - else { - return nil - } - return json - } - } - - // MARK: - Initialization Tests - - @Test("Default initialization sets correct configuration values") - func testDefaultInitialization() { - let consumer = JSONProgressConsumer(configuration: JSONProgressConsumer.Configuration()) - - #expect(!consumer.configuration.prettyPrint) - #expect(consumer.configuration.output == FileHandle.standardOutput) - } - - @Test("Initialization with custom configuration applies settings correctly") - func testInitializationWithCustomConfiguration() { - let (writeHandle, _) = createTestPipe() - let config = JSONProgressConsumer.Configuration( - output: writeHandle, - prettyPrint: true - ) - let consumer = JSONProgressConsumer(configuration: config) - - #expect(consumer.configuration.prettyPrint) - #expect(consumer.configuration.output == writeHandle) - } - - // MARK: - Build Event JSON Output Tests - - @Test("Build started event produces correct JSON output") - func testBuildStartedEvent() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) - let consumer = JSONProgressConsumer(configuration: config) - - let event = BuildEvent.buildStarted(totalOperations: 5, stages: 2, timestamp: Date()) - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let jsonLines = parseJSONLines(output) - - #expect(jsonLines.count == 1) - let json = jsonLines[0] - #expect(json["type"] as? String == "build_started") - #expect(json["total_operations"] as? Int == 5) - #expect(json["stages"] as? Int == 2) - #expect(json["timestamp"] != nil) - } - - @Test("Build completed event produces correct JSON output") - func testBuildCompletedEvent() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) - let consumer = JSONProgressConsumer(configuration: config) - - let event = BuildEvent.buildCompleted(success: true, timestamp: Date()) - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let jsonLines = parseJSONLines(output) - - #expect(jsonLines.count == 1) - let json = jsonLines[0] - #expect(json["type"] as? String == "build_completed") - #expect(json["success"] as? Bool == true) - #expect(json["timestamp"] != nil) - } - - @Test("Stage started event produces correct JSON output") - func testStageStartedEvent() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) - let consumer = JSONProgressConsumer(configuration: config) - - let event = BuildEvent.stageStarted(stageName: "stage1", timestamp: Date()) - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let jsonLines = parseJSONLines(output) - - #expect(jsonLines.count == 1) - let json = jsonLines[0] - #expect(json["type"] as? String == "stage_started") - #expect(json["stage"] as? String == "stage1") - #expect(json["timestamp"] != nil) - } - - @Test("Stage completed event produces correct JSON output") - func testStageCompletedEvent() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) - let consumer = JSONProgressConsumer(configuration: config) - - let event = BuildEvent.stageCompleted(stageName: "stage1", timestamp: Date()) - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let jsonLines = parseJSONLines(output) - - #expect(jsonLines.count == 1) - let json = jsonLines[0] - #expect(json["type"] as? String == "stage_completed") - #expect(json["stage"] as? String == "stage1") - #expect(json["timestamp"] != nil) - } - - @Test("Operation started event produces correct JSON output with operation number") - func testOperationStartedEvent() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) - let consumer = JSONProgressConsumer(configuration: config) - - let nodeId = UUID() - let context = ReportContext(nodeId: nodeId, stageId: "stage1", description: "Test operation") - let event = BuildEvent.operationStarted(context: context) - - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let jsonLines = parseJSONLines(output) - - #expect(jsonLines.count == 1) - let json = jsonLines[0] - #expect(json["type"] as? String == "operation_started") - #expect(json["operation"] as? String == "#1") - #expect(json["description"] as? String == "Test operation") - #expect(json["stage"] as? String == "stage1") - #expect(json["timestamp"] != nil) - } - - @Test("Operation finished event produces correct JSON output with duration") - func testOperationFinishedEvent() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) - let consumer = JSONProgressConsumer(configuration: config) - - let nodeId = UUID() - let context = ReportContext(nodeId: nodeId, description: "Test operation") - - // First start the operation to assign a number - let startEvent = BuildEvent.operationStarted(context: context) - try await consumer.handle(startEvent) - - // Then finish it - let finishEvent = BuildEvent.operationFinished(context: context, duration: 1.5) - try await consumer.handle(finishEvent) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let jsonLines = parseJSONLines(output) - - #expect(jsonLines.count == 2) - let finishJson = jsonLines[1] - #expect(finishJson["type"] as? String == "operation_finished") - #expect(finishJson["operation"] as? String == "#1") - #expect(finishJson["duration"] as? Double == 1.5) - #expect(finishJson["timestamp"] != nil) - } - - @Test("Operation failed event produces correct JSON output with error details") - func testOperationFailedEvent() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) - let consumer = JSONProgressConsumer(configuration: config) - - let nodeId = UUID() - let context = ReportContext(nodeId: nodeId, description: "Test operation") - let error = BuildEventError(type: .executionFailed, description: "Test error") - - // First start the operation to assign a number - let startEvent = BuildEvent.operationStarted(context: context) - try await consumer.handle(startEvent) - - // Then fail it - let failEvent = BuildEvent.operationFailed(context: context, error: error) - try await consumer.handle(failEvent) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let jsonLines = parseJSONLines(output) - - #expect(jsonLines.count == 2) - let failJson = jsonLines[1] - #expect(failJson["type"] as? String == "operation_failed") - #expect(failJson["operation"] as? String == "#1") - #expect(failJson["error"] as? String == "Test error") - #expect(failJson["error_type"] as? String == "executionFailed") - #expect(failJson["timestamp"] != nil) - } - - @Test("Operation cache hit event produces correct JSON output") - func testOperationCacheHitEvent() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) - let consumer = JSONProgressConsumer(configuration: config) - - let nodeId = UUID() - let context = ReportContext(nodeId: nodeId, description: "Test operation") - let event = BuildEvent.operationCacheHit(context: context) - - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let jsonLines = parseJSONLines(output) - - #expect(jsonLines.count == 1) - let json = jsonLines[0] - #expect(json["type"] as? String == "operation_cache_hit") - #expect(json["operation"] as? String == "#1") - #expect(json["description"] as? String == "Test operation") - #expect(json["timestamp"] != nil) - } - - @Test("Operation progress event produces correct JSON output with progress fraction") - func testOperationProgressEvent() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) - let consumer = JSONProgressConsumer(configuration: config) - - let nodeId = UUID() - let context = ReportContext(nodeId: nodeId, description: "Test operation") - - // First start the operation to assign a number - let startEvent = BuildEvent.operationStarted(context: context) - try await consumer.handle(startEvent) - - // Then progress it - let progressEvent = BuildEvent.operationProgress(context: context, fraction: 0.75) - try await consumer.handle(progressEvent) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let jsonLines = parseJSONLines(output) - - #expect(jsonLines.count == 2) - let progressJson = jsonLines[1] - #expect(progressJson["type"] as? String == "operation_progress") - #expect(progressJson["operation"] as? String == "#1") - #expect(progressJson["progress"] as? Double == 0.75) - #expect(progressJson["timestamp"] != nil) - } - - @Test("Operation log event produces correct JSON output with message") - func testOperationLogEvent() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) - let consumer = JSONProgressConsumer(configuration: config) - - let nodeId = UUID() - let context = ReportContext(nodeId: nodeId, description: "Test operation") - - // First start the operation to assign a number - let startEvent = BuildEvent.operationStarted(context: context) - try await consumer.handle(startEvent) - - // Then log a message - let logEvent = BuildEvent.operationLog(context: context, message: "Test log message") - try await consumer.handle(logEvent) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let jsonLines = parseJSONLines(output) - - #expect(jsonLines.count == 2) - let logJson = jsonLines[1] - #expect(logJson["type"] as? String == "operation_log") - #expect(logJson["operation"] as? String == "#1") - #expect(logJson["message"] as? String == "Test log message") - #expect(logJson["timestamp"] != nil) - } - - @Test("IR event produces correct JSON output with source map information") - func testIREvent() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) - let consumer = JSONProgressConsumer(configuration: config) - - let nodeId = UUID() - let sourceMap = SourceMap(file: "test.swift", line: 42, column: 10, snippet: "let x = 1") - let context = ReportContext(nodeId: nodeId, stageId: "stage1", description: "IR test", sourceMap: sourceMap) - let event = BuildEvent.irEvent(context: context, type: .graphStarted) - - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let jsonLines = parseJSONLines(output) - - #expect(jsonLines.count == 1) - let json = jsonLines[0] - #expect(json["type"] as? String == "ir_event") - #expect(json["event_type"] as? String == "graph_started") - #expect(json["description"] as? String == "IR test") - #expect(json["node_id"] as? String == nodeId.uuidString) - #expect(json["stage_id"] as? String == "stage1") - #expect(json["timestamp"] != nil) - - // Check source map - let sourceMapJson = json["source_map"] as? [String: Any] - #expect(sourceMapJson != nil) - #expect(sourceMapJson?["file"] as? String == "test.swift") - #expect(sourceMapJson?["line"] as? Int == 42) - #expect(sourceMapJson?["column"] as? Int == 10) - #expect(sourceMapJson?["snippet"] as? String == "let x = 1") - } - - // MARK: - Operation Number Assignment Tests - - @Test("Operation number assignment assigns sequential numbers to operations") - func testOperationNumberAssignment() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) - let consumer = JSONProgressConsumer(configuration: config) - - let nodeId1 = UUID() - let nodeId2 = UUID() - let nodeId3 = UUID() - - let events = [ - BuildEvent.operationStarted(context: ReportContext(nodeId: nodeId1, description: "Op 1")), - BuildEvent.operationStarted(context: ReportContext(nodeId: nodeId2, description: "Op 2")), - BuildEvent.operationStarted(context: ReportContext(nodeId: nodeId3, description: "Op 3")), - BuildEvent.operationFinished(context: ReportContext(nodeId: nodeId1, description: "Op 1"), duration: 1.0), - BuildEvent.operationFinished(context: ReportContext(nodeId: nodeId2, description: "Op 2"), duration: 2.0), - BuildEvent.operationFinished(context: ReportContext(nodeId: nodeId3, description: "Op 3"), duration: 3.0), - ] - - for event in events { - try await consumer.handle(event) - } - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let jsonLines = parseJSONLines(output) - - #expect(jsonLines.count == 6) - - // Check operation numbers are assigned sequentially - #expect(jsonLines[0]["operation"] as? String == "#1") - #expect(jsonLines[1]["operation"] as? String == "#2") - #expect(jsonLines[2]["operation"] as? String == "#3") - #expect(jsonLines[3]["operation"] as? String == "#1") - #expect(jsonLines[4]["operation"] as? String == "#2") - #expect(jsonLines[5]["operation"] as? String == "#3") - } - - @Test("Operation number consistency maintains same number across operation lifecycle") - func testOperationNumberConsistency() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) - let consumer = JSONProgressConsumer(configuration: config) - - let nodeId = UUID() - let context = ReportContext(nodeId: nodeId, description: "Test operation") - - let events = [ - BuildEvent.operationStarted(context: context), - BuildEvent.operationProgress(context: context, fraction: 0.5), - BuildEvent.operationLog(context: context, message: "Progress update"), - BuildEvent.operationFinished(context: context, duration: 1.0), - ] - - for event in events { - try await consumer.handle(event) - } - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let jsonLines = parseJSONLines(output) - - #expect(jsonLines.count == 4) - - // All events should have the same operation number - for json in jsonLines { - #expect(json["operation"] as? String == "#1") - } - } - - // MARK: - Pretty Print Tests - - @Test("Pretty print formatting produces indented JSON with newlines") - func testPrettyPrintFormatting() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: true) - let consumer = JSONProgressConsumer(configuration: config) - - let event = BuildEvent.buildStarted(totalOperations: 5, stages: 2, timestamp: Date()) - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - - // Pretty printed JSON should contain newlines and indentation - #expect(output.contains("{\n")) - #expect(output.contains(" ")) - #expect(output.contains("\"stages\" : 2")) - #expect(output.contains("\"total_operations\" : 5")) - #expect(output.contains("\"type\" : \"build_started\"")) - } - - @Test("Compact formatting produces single-line JSON without indentation") - func testCompactFormatting() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) - let consumer = JSONProgressConsumer(configuration: config) - - let event = BuildEvent.buildStarted(totalOperations: 5, stages: 2, timestamp: Date()) - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - - // Compact JSON should be on a single line - let lines = output.split(separator: "\n") - #expect(lines.count == 1) - #expect(!output.contains(" ")) - } - - // MARK: - Edge Cases and Error Handling - - @Test("Operation event without node ID produces no output") - func testOperationEventWithoutNodeId() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) - let consumer = JSONProgressConsumer(configuration: config) - - let context = ReportContext(nodeId: nil, description: "Test operation") - let event = BuildEvent.operationStarted(context: context) - - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - - // Should produce no output when nodeId is nil - #expect(output.isEmpty) - } - - @Test("Operation finished without prior start produces no output") - func testOperationFinishedWithoutPriorStart() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) - let consumer = JSONProgressConsumer(configuration: config) - - let nodeId = UUID() - let context = ReportContext(nodeId: nodeId, description: "Test operation") - let event = BuildEvent.operationFinished(context: context, duration: 1.0) - - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - - // Should produce no output when operation wasn't started - #expect(output.isEmpty) - } - - @Test("IR event with minimal context produces basic JSON output") - func testIREventWithMinimalContext() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) - let consumer = JSONProgressConsumer(configuration: config) - - let context = ReportContext(nodeId: nil, description: "Minimal IR event") - let event = BuildEvent.irEvent(context: context, type: .graphStarted) - - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let jsonLines = parseJSONLines(output) - - #expect(jsonLines.count == 1) - let json = jsonLines[0] - #expect(json["type"] as? String == "ir_event") - #expect(json["event_type"] as? String == "graph_started") - #expect(json["description"] as? String == "Minimal IR event") - #expect(json["node_id"] == nil) - #expect(json["stage_id"] == nil) - #expect(json["source_map"] == nil) - } - - @Test("IR event with partial source map includes only available fields") - func testIREventWithPartialSourceMap() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) - let consumer = JSONProgressConsumer(configuration: config) - - let sourceMap = SourceMap(file: "test.swift", line: nil, column: nil, snippet: nil) - let context = ReportContext(nodeId: nil, description: "Partial source map", sourceMap: sourceMap) - let event = BuildEvent.irEvent(context: context, type: .graphStarted) - - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let jsonLines = parseJSONLines(output) - - #expect(jsonLines.count == 1) - let json = jsonLines[0] - let sourceMapJson = json["source_map"] as? [String: Any] - #expect(sourceMapJson != nil) - #expect(sourceMapJson?["file"] as? String == "test.swift") - #expect(sourceMapJson?["line"] == nil) - #expect(sourceMapJson?["column"] == nil) - #expect(sourceMapJson?["snippet"] == nil) - } - - // MARK: - Thread Safety Tests - - @Test("Thread safety with concurrent operations maintains unique operation numbers") - func testThreadSafetyWithConcurrentOperations() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) - let consumer = JSONProgressConsumer(configuration: config) - - let operationCount = 100 - let nodeIds = (0.. (FileHandle, FileHandle) { - let pipe = Pipe() - return (pipe.fileHandleForWriting, pipe.fileHandleForReading) - } - - private func readOutputAsString(from readHandle: FileHandle) -> String { - let data = readHandle.readDataToEndOfFile() - return String(data: data, encoding: .utf8) ?? "" - } - - private func parseLines(_ output: String) -> [String] { - output.split(separator: "\n").map(String.init).filter { !$0.isEmpty } - } - - // MARK: - Initialization Tests - - @Test("Default initialization uses standard output") - func defaultInitialization() { - let consumer = PlainProgressConsumer(configuration: PlainProgressConsumer.Configuration()) - - #expect(consumer.configuration.output == FileHandle.standardOutput) - } - - @Test("Initialization with custom configuration") - func initializationWithCustomConfiguration() { - let (writeHandle, _) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - #expect(consumer.configuration.output == writeHandle) - } - - // MARK: - Build Event Formatting Tests - - @Test("Build started event produces no output") - func buildStartedEvent() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let event = BuildEvent.buildStarted(totalOperations: 5, stages: 2, timestamp: Date()) - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - - // BuildKit doesn't show explicit build start, so should be empty - #expect(output.isEmpty) - } - - @Test("Build completed event produces no output") - func buildCompletedEvent() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let event = BuildEvent.buildCompleted(success: true, timestamp: Date()) - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - - // BuildKit doesn't show explicit build completion in plain output - #expect(output.isEmpty) - } - - @Test("Stage events produce no output") - func stageEvents() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let startEvent = BuildEvent.stageStarted(stageName: "stage1", timestamp: Date()) - let completeEvent = BuildEvent.stageCompleted(stageName: "stage1", timestamp: Date()) - - try await consumer.handle(startEvent) - try await consumer.handle(completeEvent) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - - // Stages are implicit in operation descriptions - #expect(output.isEmpty) - } - - @Test("Operation started event formats correctly") - func operationStartedEvent() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let nodeId = UUID() - let context = ReportContext(nodeId: nodeId, stageId: "stage1", description: "RUN apk add git") - let event = BuildEvent.operationStarted(context: context) - - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 1) - #expect(lines[0] == "#1 [stage1] RUN apk add git") - } - - @Test("Operation finished event shows duration") - func operationFinishedEvent() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let nodeId = UUID() - let startTime = Date() - let endTime = Date(timeInterval: 1.5, since: startTime) - let context = ReportContext(nodeId: nodeId, description: "RUN apk add git", timestamp: startTime) - let endContext = ReportContext(nodeId: nodeId, description: "RUN apk add git", timestamp: endTime) - - // Start operation first - let startEvent = BuildEvent.operationStarted(context: context) - try await consumer.handle(startEvent) - - // Then finish it - let finishEvent = BuildEvent.operationFinished(context: endContext, duration: 1.5) - try await consumer.handle(finishEvent) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 2) - #expect(lines[0] == "#1 [stage] RUN apk add git") - #expect(lines[1] == "#1 DONE 1.5s") - } - - @Test("Operation failed event shows error message") - func operationFailedEvent() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let nodeId = UUID() - let context = ReportContext(nodeId: nodeId, description: "RUN apk add git") - let error = BuildEventError(type: .executionFailed, description: "Package not found") - - // Start operation first - let startEvent = BuildEvent.operationStarted(context: context) - try await consumer.handle(startEvent) - - // Then fail it - let failEvent = BuildEvent.operationFailed(context: context, error: error) - try await consumer.handle(failEvent) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 2) - #expect(lines[0] == "#1 [stage] RUN apk add git") - #expect(lines[1] == "#1 ERROR: Package not found") - } - - @Test("Operation cache hit event shows CACHED status") - func operationCacheHitEvent() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let nodeId = UUID() - let context = ReportContext(nodeId: nodeId, stageId: "stage1", description: "RUN apk add git") - let event = BuildEvent.operationCacheHit(context: context) - - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 2) - #expect(lines[0] == "#1 [stage1] RUN apk add git") - #expect(lines[1] == "#1 CACHED") - } - - @Test("Operation progress event shows percentage") - func operationProgressEvent() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let nodeId = UUID() - let context = ReportContext(nodeId: nodeId, description: "RUN apk add git") - - // Start operation first - let startEvent = BuildEvent.operationStarted(context: context) - try await consumer.handle(startEvent) - - // Then show progress - let progressEvent = BuildEvent.operationProgress(context: context, fraction: 0.75) - try await consumer.handle(progressEvent) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 2) - #expect(lines[0] == "#1 [stage] RUN apk add git") - #expect(lines[1] == "#1 75% complete") - } - - @Test("Operation log event shows timestamped message") - func operationLogEvent() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let nodeId = UUID() - let startTime = Date() - let logTime = Date(timeInterval: 0.245, since: startTime) - let context = ReportContext(nodeId: nodeId, description: "RUN apk add git", timestamp: startTime) - let logContext = ReportContext(nodeId: nodeId, description: "RUN apk add git", timestamp: logTime) - - // Start operation first - let startEvent = BuildEvent.operationStarted(context: context) - try await consumer.handle(startEvent) - - // Then log a message - let logEvent = BuildEvent.operationLog(context: logContext, message: "fetch https://dl-cdn.alpinelinux.org/alpine/...") - try await consumer.handle(logEvent) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 2) - #expect(lines[0] == "#1 [stage] RUN apk add git") - #expect(lines[1] == "#1 0.245 fetch https://dl-cdn.alpinelinux.org/alpine/...") - } - - // MARK: - Operation Description Formatting Tests - - @Test("FROM operation description formatting") - func fromOperationDescriptionFormatting() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let nodeId = UUID() - let context = ReportContext(nodeId: nodeId, description: "FROM alpine:latest") - let event = BuildEvent.operationStarted(context: context) - - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 1) - #expect(lines[0] == "#1 [internal] load metadata for alpine:latest") - } - - @Test("BaseImage operation description formatting") - func baseImageOperationDescriptionFormatting() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let nodeId = UUID() - let context = ReportContext(nodeId: nodeId, description: "BaseImage: ubuntu:20.04") - let event = BuildEvent.operationStarted(context: context) - - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 1) - #expect(lines[0] == "#1 [internal] load metadata for ubuntu:20.04") - } - - @Test("Stage name formatting") - func stageNameFormatting() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let nodeId = UUID() - let context = ReportContext(nodeId: nodeId, stageId: "build-stage", description: "COPY . /app") - let event = BuildEvent.operationStarted(context: context) - - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 1) - #expect(lines[0] == "#1 [build-stage] COPY . /app") - } - - @Test("UUID stage name formatting") - func uuidStageNameFormatting() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let nodeId = UUID() - let context = ReportContext(nodeId: nodeId, stageId: "stage-\(UUID().uuidString)", description: "RUN make") - let event = BuildEvent.operationStarted(context: context) - - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 1) - #expect(lines[0] == "#1 [stage] RUN make") - } - - @Test("Operation without stage ID") - func operationWithoutStageId() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let nodeId = UUID() - let context = ReportContext(nodeId: nodeId, description: "COPY . /app") - let event = BuildEvent.operationStarted(context: context) - - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 1) - #expect(lines[0] == "#1 [stage] COPY . /app") - } - - // MARK: - Duration Formatting Tests - - @Test("Duration formatting for sub-second durations") - func durationFormattingSubSecond() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let nodeId = UUID() - let startTime = Date() - let endTime = Date(timeInterval: 0.123, since: startTime) - let context = ReportContext(nodeId: nodeId, description: "Test", timestamp: startTime) - let endContext = ReportContext(nodeId: nodeId, description: "Test", timestamp: endTime) - - let startEvent = BuildEvent.operationStarted(context: context) - try await consumer.handle(startEvent) - - let finishEvent = BuildEvent.operationFinished(context: endContext, duration: 0.123) - try await consumer.handle(finishEvent) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 2) - #expect(lines[1] == "#1 DONE 0.1s") - } - - @Test("Duration formatting for seconds") - func durationFormattingSeconds() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let nodeId = UUID() - let startTime = Date() - let endTime = Date(timeInterval: 15.7, since: startTime) - let context = ReportContext(nodeId: nodeId, description: "Test", timestamp: startTime) - let endContext = ReportContext(nodeId: nodeId, description: "Test", timestamp: endTime) - - let startEvent = BuildEvent.operationStarted(context: context) - try await consumer.handle(startEvent) - - let finishEvent = BuildEvent.operationFinished(context: endContext, duration: 15.7) - try await consumer.handle(finishEvent) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 2) - #expect(lines[1] == "#1 DONE 15.7s") - } - - @Test("Duration formatting for minutes") - func durationFormattingMinutes() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let nodeId = UUID() - let startTime = Date() - let endTime = Date(timeInterval: 125.0, since: startTime) // 2m5s - let context = ReportContext(nodeId: nodeId, description: "Test", timestamp: startTime) - let endContext = ReportContext(nodeId: nodeId, description: "Test", timestamp: endTime) - - let startEvent = BuildEvent.operationStarted(context: context) - try await consumer.handle(startEvent) - - let finishEvent = BuildEvent.operationFinished(context: endContext, duration: 125.0) - try await consumer.handle(finishEvent) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 2) - #expect(lines[1] == "#1 DONE 2m5s") - } - - // MARK: - IR Event Formatting Tests - - @Test("IR event analyzing") - func irEventAnalyzing() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let context = ReportContext(nodeId: UUID(), description: "Analyzing build dependencies") - let event = BuildEvent.irEvent(context: context, type: .analyzing) - - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 1) - #expect(lines[0] == "=> Analyzing build dependencies") - } - - @Test("IR event validating") - func irEventValidating() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let context = ReportContext(nodeId: UUID(), description: "Validating build graph") - let event = BuildEvent.irEvent(context: context, type: .validating) - - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 1) - #expect(lines[0] == "=> Validating build graph") - } - - @Test("IR event error") - func irEventError() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let context = ReportContext(nodeId: UUID(), description: "Invalid syntax") - let event = BuildEvent.irEvent(context: context, type: .error) - - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 1) - #expect(lines[0] == "ERROR: Invalid syntax") - } - - @Test("IR event error with source map") - func irEventErrorWithSourceMap() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let sourceMap = SourceMap(file: "Dockerfile", line: 15, column: 5, snippet: "RUN invalid-command") - let context = ReportContext(nodeId: UUID(), description: "Command not found", sourceMap: sourceMap) - let event = BuildEvent.irEvent(context: context, type: .error) - - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 1) - #expect(lines[0] == "ERROR: Command not found at Dockerfile:15") - } - - @Test("IR event warning") - func irEventWarning() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let context = ReportContext(nodeId: UUID(), description: "Deprecated instruction") - let event = BuildEvent.irEvent(context: context, type: .warning) - - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 1) - #expect(lines[0] == "WARNING: Deprecated instruction") - } - - @Test("IR event warning with source map") - func irEventWarningWithSourceMap() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let sourceMap = SourceMap(file: "Dockerfile", line: 8, column: nil, snippet: nil) - let context = ReportContext(nodeId: UUID(), description: "MAINTAINER is deprecated", sourceMap: sourceMap) - let event = BuildEvent.irEvent(context: context, type: .warning) - - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 1) - #expect(lines[0] == "WARNING: MAINTAINER is deprecated at Dockerfile:8") - } - - @Test("IR event graph events produce no output") - func irEventGraphEvents() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let events = [ - BuildEvent.irEvent(context: ReportContext(nodeId: UUID(), description: "Graph started"), type: .graphStarted), - BuildEvent.irEvent(context: ReportContext(nodeId: UUID(), description: "Graph completed"), type: .graphCompleted), - BuildEvent.irEvent(context: ReportContext(nodeId: UUID(), description: "Stage added"), type: .stageAdded), - BuildEvent.irEvent(context: ReportContext(nodeId: UUID(), description: "Node added"), type: .nodeAdded), - ] - - for event in events { - try await consumer.handle(event) - } - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - - // These events should not produce output in plain format - #expect(output.isEmpty) - } - - // MARK: - Operation Number Assignment Tests - - @Test("Operation number assignment") - func operationNumberAssignment() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let nodeId1 = UUID() - let nodeId2 = UUID() - let nodeId3 = UUID() - - let events = [ - BuildEvent.operationStarted(context: ReportContext(nodeId: nodeId1, description: "Op 1")), - BuildEvent.operationStarted(context: ReportContext(nodeId: nodeId2, description: "Op 2")), - BuildEvent.operationStarted(context: ReportContext(nodeId: nodeId3, description: "Op 3")), - ] - - for event in events { - try await consumer.handle(event) - } - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 3) - #expect(lines[0].hasPrefix("#1 ")) - #expect(lines[1].hasPrefix("#2 ")) - #expect(lines[2].hasPrefix("#3 ")) - } - - @Test("Operation number consistency") - func operationNumberConsistency() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let nodeId = UUID() - let startTime = Date() - let progressTime = Date(timeInterval: 0.5, since: startTime) - let logTime = Date(timeInterval: 1.0, since: startTime) - let endTime = Date(timeInterval: 2.0, since: startTime) - - let events = [ - BuildEvent.operationStarted(context: ReportContext(nodeId: nodeId, description: "Test", timestamp: startTime)), - BuildEvent.operationProgress(context: ReportContext(nodeId: nodeId, description: "Test", timestamp: progressTime), fraction: 0.5), - BuildEvent.operationLog(context: ReportContext(nodeId: nodeId, description: "Test", timestamp: logTime), message: "Log message"), - BuildEvent.operationFinished(context: ReportContext(nodeId: nodeId, description: "Test", timestamp: endTime), duration: 2.0), - ] - - for event in events { - try await consumer.handle(event) - } - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 4) - #expect(lines[0].hasPrefix("#1 ")) - #expect(lines[1].hasPrefix("#1 ")) - #expect(lines[2].hasPrefix("#1 ")) - #expect(lines[3].hasPrefix("#1 ")) - } - - // MARK: - Edge Cases and Error Handling - - @Test("Operation event without node ID produces no output") - func operationEventWithoutNodeId() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let context = ReportContext(nodeId: nil, description: "Test operation") - let event = BuildEvent.operationStarted(context: context) - - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - - // Should produce no output when nodeId is nil - #expect(output.isEmpty) - } - - @Test("Operation finished without prior start produces no output") - func operationFinishedWithoutPriorStart() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let nodeId = UUID() - let context = ReportContext(nodeId: nodeId, description: "Test operation") - let event = BuildEvent.operationFinished(context: context, duration: 1.0) - - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - - // Should produce no output when operation wasn't started - #expect(output.isEmpty) - } - - @Test("Operation progress without prior start produces no output") - func operationProgressWithoutPriorStart() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let nodeId = UUID() - let context = ReportContext(nodeId: nodeId, description: "Test operation") - let event = BuildEvent.operationProgress(context: context, fraction: 0.5) - - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - - // Should produce no output when operation wasn't started - #expect(output.isEmpty) - } - - @Test("Operation log without prior start produces no output") - func operationLogWithoutPriorStart() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let nodeId = UUID() - let context = ReportContext(nodeId: nodeId, description: "Test operation") - let event = BuildEvent.operationLog(context: context, message: "Log message") - - try await consumer.handle(event) - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - - // Should produce no output when operation wasn't started - #expect(output.isEmpty) - } - - @Test("Multiple operations with same node ID") - func multipleOperationsWithSameNodeId() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let nodeId = UUID() - let context = ReportContext(nodeId: nodeId, description: "Test operation") - - let events = [ - BuildEvent.operationStarted(context: context), - BuildEvent.operationStarted(context: context), // Same nodeId - BuildEvent.operationFinished(context: context, duration: 1.0), - ] - - for event in events { - try await consumer.handle(event) - } - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 3) - #expect(lines[0].hasPrefix("#1 ")) - #expect(lines[1].hasPrefix("#1 ")) // Same number reused - #expect(lines[2].hasPrefix("#1 ")) - } - - // MARK: - Thread Safety Tests - - @Test("Thread safety with concurrent operations") - func threadSafetyWithConcurrentOperations() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let operationCount = 100 - let nodeIds = (0.. Analyzing build graph") - #expect(lines[1] == "#1 [internal] load metadata for alpine:latest") - #expect(lines[2] == "#1 CACHED") - #expect(lines[3] == "#2 [stage1] RUN apk add --no-cache git") - #expect(lines[4] == "#2 0.245 fetch https://dl-cdn.alpinelinux.org/alpine/...") - #expect(lines[5] == "#2 DONE 1.2s") - #expect(lines[6] == "#3 [stage2] COPY . /app") - #expect(lines[7] == "#3 ERROR: No such file or directory") - - // Note: The failure line is now included in the count - } - - @Test("BuildKit-style output") - func buildKitStyleOutput() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - - let nodeId1 = UUID() - let nodeId2 = UUID() - - let events = [ - BuildEvent.operationStarted(context: ReportContext(nodeId: nodeId1, description: "BaseImage: alpine:latest")), - BuildEvent.operationCacheHit(context: ReportContext(nodeId: nodeId1, description: "BaseImage: alpine:latest")), - BuildEvent.operationStarted(context: ReportContext(nodeId: nodeId2, stageId: "base", description: "RUN apk add --no-cache git")), - BuildEvent.operationLog( - context: ReportContext(nodeId: nodeId2, description: "RUN apk add --no-cache git", timestamp: Date(timeInterval: 0.245, since: Date())), - message: "fetch https://dl-cdn.alpinelinux.org/alpine/..."), - BuildEvent.operationFinished( - context: ReportContext(nodeId: nodeId2, description: "RUN apk add --no-cache git", timestamp: Date(timeInterval: 1.2, since: Date())), duration: 1.2), - ] - - for event in events { - try await consumer.handle(event) - } - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 5) - - // Verify BuildKit-style output - #expect(lines[0] == "#1 [internal] load metadata for alpine:latest") - #expect(lines[1] == "#1 CACHED") - #expect(lines[2] == "#2 [base] RUN apk add --no-cache git") - #expect(lines[3] == "#2 0.245 fetch https://dl-cdn.alpinelinux.org/alpine/...") - #expect(lines[4] == "#2 DONE 1.2s") - } - - @Test("Consumer with reporter") - func consumerWithReporter() async throws { - let (writeHandle, readHandle) = createTestPipe() - let config = PlainProgressConsumer.Configuration(output: writeHandle) - let consumer = PlainProgressConsumer(configuration: config) - let reporter = Reporter(bufferSize: 100) - - // Start consuming in background - let consumeTask = Task { - do { - try await consumer.consume(reporter: reporter) - } catch { - Issue.record("Consumer failed: \(error)") - } - } - - // Report some events - let nodeId1 = UUID() - let nodeId2 = UUID() - let startTime = Date() - let finishTime = Date(timeInterval: 1.0, since: startTime) - let events = [ - BuildEvent.buildStarted(totalOperations: 2, stages: 1, timestamp: Date()), - BuildEvent.operationStarted(context: ReportContext(nodeId: nodeId1, description: "FROM alpine:latest")), - BuildEvent.operationCacheHit(context: ReportContext(nodeId: nodeId1, description: "FROM alpine:latest")), - BuildEvent.operationStarted(context: ReportContext(nodeId: nodeId2, description: "RUN apk add git", timestamp: startTime)), - BuildEvent.operationFinished(context: ReportContext(nodeId: nodeId2, description: "RUN apk add git", timestamp: finishTime), duration: 1.0), - BuildEvent.buildCompleted(success: true, timestamp: Date()), - ] - - for event in events { - await reporter.report(event) - } - - await reporter.finish() - await consumeTask.value - - writeHandle.closeFile() - let output = readOutputAsString(from: readHandle) - let lines = parseLines(output) - - #expect(lines.count == 4) - #expect(lines[0] == "#1 [internal] load metadata for alpine:latest") - #expect(lines[1] == "#1 CACHED") - #expect(lines[2] == "#2 [stage] RUN apk add git") - #expect(lines[3] == "#2 DONE 1.0s") - } - -} diff --git a/Tests/NativeBuilderTests/ContainerBuildReportingTests/ReportableErrorTests.swift b/Tests/NativeBuilderTests/ContainerBuildReportingTests/ReportableErrorTests.swift deleted file mode 100644 index 394f6e9f0..000000000 --- a/Tests/NativeBuilderTests/ContainerBuildReportingTests/ReportableErrorTests.swift +++ /dev/null @@ -1,521 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -@testable import ContainerBuildReporting - -@Suite("ReportableError Tests") -struct ReportableErrorTests { - - // MARK: - Test Error Types - - struct TestExecutionError: ReportableError { - let message: String - let category: ErrorCategory = .executionFailed - let errorDiagnostics: ErrorDiagnostics? - - var errorCategory: ErrorCategory { category } - var shortDescription: String { message } - var detailedDescription: String? { nil } - var diagnostics: ErrorDiagnostics { errorDiagnostics ?? ErrorDiagnostics() } - var underlyingError: Error? { nil } - - var description: String { message } - - func toBuildEventError() -> BuildEventError { - BuildEventError( - type: .executionFailed, - description: message, - diagnostics: diagnostics.toDictionary() - ) - } - } - - struct TestNetworkError: ReportableError { - let url: String - let statusCode: Int - - var errorCategory: ErrorCategory { .networkError } - var shortDescription: String { "Network request to \(url) failed with status code \(statusCode)" } - var detailedDescription: String? { "HTTP request failed" } - var underlyingError: Error? { nil } - - var diagnostics: ErrorDiagnostics { - ErrorDiagnostics( - operation: "HTTP Request", - path: url, - exitCode: statusCode - ) - } - - var description: String { - "Network request to \(url) failed with status code \(statusCode)" - } - - func toBuildEventError() -> BuildEventError { - BuildEventError( - type: .executionFailed, - description: description, - diagnostics: diagnostics.toDictionary() - ) - } - } - - // MARK: - Error Category Tests - - @Test("Error categories map to correct failure types") - func testErrorCategoryMapping() { - // Test the mapping logic based on the actual default implementation - let _ = TestExecutionError(message: "test", errorDiagnostics: nil) - - // Test execution failed category - let execFailedError = TestExecutionError(message: "test", errorDiagnostics: nil) - let buildError = execFailedError.toBuildEventError() - #expect(buildError.type == .executionFailed) - - // Test that the mapping works through the actual interface - let categories: [ErrorCategory] = [ - .executionFailed, - .commandNotFound, - .permissionDenied, - .timeout, - .cancelled, - .invalidConfiguration, - .missingDependency, - .incompatiblePlatform, - .resourceExhausted, - .diskFull, - .memoryExhausted, - .fileNotFound, - .fileAccessDenied, - .networkError, - .syntaxError, - .validationError, - .unsupportedFeature, - .cacheCorrupted, - .cacheMiss, - .unknown, - ] - - // Just verify that all categories are valid - for category in categories { - #expect(category.rawValue.isEmpty == false) - } - } - - @Test("Error categories have meaningful descriptions") - func testErrorCategoryDescriptions() { - let categories: [ErrorCategory] = [ - .executionFailed, - .commandNotFound, - .permissionDenied, - .timeout, - .cancelled, - .invalidConfiguration, - .missingDependency, - .incompatiblePlatform, - .resourceExhausted, - .diskFull, - .memoryExhausted, - .fileNotFound, - .fileAccessDenied, - .networkError, - .syntaxError, - .validationError, - .unsupportedFeature, - .cacheCorrupted, - .cacheMiss, - .unknown, - ] - - for category in categories { - let description = String(describing: category) - #expect(!description.isEmpty) - #expect(description != "ErrorCategory") - } - } - - // MARK: - ErrorDiagnostics Tests - - @Test("ErrorDiagnostics creation with all parameters") - func testErrorDiagnosticsFullCreation() { - let diagnostics = ErrorDiagnostics( - operation: "docker build", - path: "/path/to/Dockerfile", - exitCode: 127, - environment: ["PATH": "/usr/bin", "USER": "root"] - ) - - #expect(diagnostics.operation == "docker build") - #expect(diagnostics.path == "/path/to/Dockerfile") - #expect(diagnostics.exitCode == 127) - #expect(diagnostics.environment?["PATH"] == "/usr/bin") - #expect(diagnostics.environment?["USER"] == "root") - // Note: suggestion is no longer a separate property - } - - @Test("ErrorDiagnostics toDictionary conversion") - func testErrorDiagnosticsToDictionary() { - let diagnostics = ErrorDiagnostics( - operation: "container run", - path: "/var/lib/containers/image.tar", - exitCode: 1, - environment: ["CONTAINER_RUNTIME": "podman"] - ) - - let dict = diagnostics.toDictionary() - - #expect(dict["operation"] == "container run") - #expect(dict["path"] == "/var/lib/containers/image.tar") - #expect(dict["exitCode"] == "1") - #expect(dict["env.CONTAINER_RUNTIME"] == "podman") - } - - @Test("ErrorDiagnostics with nil values") - func testErrorDiagnosticsWithNilValues() { - let diagnostics = ErrorDiagnostics( - operation: nil, - path: nil, - exitCode: nil, - environment: nil - ) - - let dict = diagnostics.toDictionary() - #expect(dict.isEmpty) - } - - @Test("ErrorDiagnostics with partial values") - func testErrorDiagnosticsPartialValues() { - let diagnostics = ErrorDiagnostics( - operation: "build step", - path: nil, - exitCode: 0, - environment: nil - ) - - let dict = diagnostics.toDictionary() - #expect(dict.count == 2) - #expect(dict["operation"] == "build step") - #expect(dict["exitCode"] == "0") - #expect(dict["path"] == nil) - } - - @Test("ErrorDiagnostics with multiple environment variables") - func testErrorDiagnosticsMultipleEnvVars() { - let diagnostics = ErrorDiagnostics( - operation: "compile", - environment: [ - "CC": "clang", - "CFLAGS": "-O2", - "LDFLAGS": "-lm", - ] - ) - - let dict = diagnostics.toDictionary() - - #expect(dict["env.CC"] == "clang") - #expect(dict["env.CFLAGS"] == "-O2") - #expect(dict["env.LDFLAGS"] == "-lm") - } - - // MARK: - ReportableError Protocol Tests - - @Test("Custom error implements ReportableError") - func testCustomReportableError() { - let error = TestExecutionError( - message: "Command failed", - errorDiagnostics: ErrorDiagnostics( - operation: "docker build", - exitCode: 1 - ) - ) - - #expect(error.errorCategory == ErrorCategory.executionFailed) - #expect(error.description == "Command failed") - - let buildError = error.toBuildEventError() - #expect(buildError.description == "Command failed") - #expect(buildError.type == BuildEventError.FailureType.executionFailed) - #expect(buildError.diagnostics?["operation"] == "docker build") - #expect(buildError.diagnostics?["exitCode"] == "1") - } - - @Test("Network error with diagnostics") - func testNetworkErrorWithDiagnostics() { - let error = TestNetworkError( - url: "https://registry.example.com/v2/", - statusCode: 404 - ) - - #expect(error.errorCategory == .networkError) - // Note: suggestion is no longer a separate property - - let buildError = error.toBuildEventError() - #expect(buildError.type == .executionFailed) - #expect(buildError.diagnostics?["path"] == "https://registry.example.com/v2/") - #expect(buildError.diagnostics?["exitCode"] == "404") - } - - // MARK: - GenericReportableError Tests - - @Test("GenericReportableError wraps standard errors") - func testGenericReportableError() { - let nsError = NSError( - domain: "com.example.container", - code: 42, - userInfo: [NSLocalizedDescriptionKey: "Container not found"] - ) - - let reportableError = nsError.asReportableError() - - #expect(reportableError.errorCategory == .unknown) - #expect(reportableError.shortDescription.contains("Container not found")) - - let buildError = reportableError.toBuildEventError() - #expect(buildError.type == .executionFailed) - #expect(buildError.description.contains("Container not found")) - } - - @Test("Simple Swift error conversion") - func testSimpleErrorConversion() { - enum TestError: Error { - case simpleError - } - - let error = TestError.simpleError - let reportableError = error.asReportableError() - - #expect(reportableError.errorCategory == .unknown) - #expect(reportableError.shortDescription.contains("TestError")) - } - - @Test("LocalizedError conversion preserves message") - func testLocalizedErrorConversion() { - struct CustomError: LocalizedError { - var errorDescription: String? { - "Custom localized error message" - } - } - - let error = CustomError() - let reportableError = error.asReportableError() - - #expect(reportableError.shortDescription == "Custom localized error message") - } - - // MARK: - BuildEventError Conversion Tests - - @Test("BuildEventError preserves all diagnostics") - func testBuildEventErrorFullConversion() { - let diagnostics = ErrorDiagnostics( - operation: "layer extraction", - path: "/tmp/layer.tar.gz", - exitCode: 2, - environment: ["TMPDIR": "/tmp"] - ) - - let error = TestExecutionError( - message: "Failed to extract layer", - errorDiagnostics: diagnostics - ) - - let buildError = error.toBuildEventError() - - #expect(buildError.description == "Failed to extract layer") - #expect(buildError.type == .executionFailed) - #expect(buildError.diagnostics?.count == 4) - #expect(buildError.diagnostics?["operation"] == "layer extraction") - #expect(buildError.diagnostics?["path"] == "/tmp/layer.tar.gz") - #expect(buildError.diagnostics?["exitCode"] == "2") - #expect(buildError.diagnostics?["env.TMPDIR"] == "/tmp") - } - - @Test("BuildEventError with nil diagnostics") - func testBuildEventErrorNilDiagnostics() { - let error = TestExecutionError( - message: "Generic failure", - errorDiagnostics: nil - ) - - let buildError = error.toBuildEventError() - - #expect(buildError.description == "Generic failure") - #expect(buildError.type == .executionFailed) - #expect(buildError.diagnostics?.isEmpty == true) - } - - // MARK: - Edge Cases - - @Test("Empty diagnostics produces empty dictionary") - func testEmptyDiagnostics() { - let diagnostics = ErrorDiagnostics() - let dict = diagnostics.toDictionary() - - #expect(dict.isEmpty) - } - - @Test("Complex error hierarchy") - func testComplexErrorHierarchy() { - struct OuterError: Error { - let inner: Error - } - - struct InnerError: ReportableError { - var errorCategory: ErrorCategory { .validationError } - var diagnostics: ErrorDiagnostics { ErrorDiagnostics() } - - var shortDescription: String { "Inner validation error" } - - var description: String { shortDescription } - - func toBuildEventError() -> BuildEventError { - BuildEventError( - type: .invalidConfiguration, - description: description, - diagnostics: nil - ) - } - } - - let innerError = InnerError() - let outerError = OuterError(inner: innerError) - - let reportableError = outerError.asReportableError() - #expect(reportableError.errorCategory == .unknown) - #expect(reportableError.shortDescription.contains("OuterError")) - } - - // MARK: - Error Categorization Tests - - @Test("Proper category assignment for file errors") - func testFileCategoryAssignment() { - struct FileError: ReportableError { - let path: String - let isNotFound: Bool - - var errorCategory: ErrorCategory { - isNotFound ? .fileNotFound : .fileAccessDenied - } - - var diagnostics: ErrorDiagnostics { - ErrorDiagnostics(path: path) - } - - var shortDescription: String { - isNotFound ? "File not found: \(path)" : "Access denied: \(path)" - } - - var description: String { - shortDescription - } - - func toBuildEventError() -> BuildEventError { - BuildEventError( - type: .executionFailed, - description: description, - diagnostics: diagnostics.toDictionary() - ) - } - } - - let notFoundError = FileError(path: "/missing/file", isNotFound: true) - #expect(notFoundError.errorCategory == .fileNotFound) - #expect(notFoundError.toBuildEventError().type == .executionFailed) - - let accessError = FileError(path: "/protected/file", isNotFound: false) - #expect(accessError.errorCategory == .fileAccessDenied) - #expect(accessError.toBuildEventError().type == .executionFailed) - } - - @Test("Unknown category fallback") - func testUnknownCategoryFallback() { - struct UnknownError: ReportableError { - var errorCategory: ErrorCategory { .unknown } - var diagnostics: ErrorDiagnostics { ErrorDiagnostics() } - - var shortDescription: String { "Something went wrong" } - - var description: String { shortDescription } - - func toBuildEventError() -> BuildEventError { - BuildEventError( - type: .executionFailed, - description: description, - diagnostics: nil - ) - } - } - - let error = UnknownError() - let buildError = error.toBuildEventError() - - #expect(buildError.type == .executionFailed) - } - - @Test("Resource exhaustion errors") - func testResourceExhaustionErrors() { - struct ResourceError: ReportableError { - enum ResourceType { - case disk - case memory - case cpu - } - - let resourceType: ResourceType - let usage: String - - var errorCategory: ErrorCategory { - switch resourceType { - case .disk: return .diskFull - case .memory: return .memoryExhausted - case .cpu: return .resourceExhausted - } - } - - var diagnostics: ErrorDiagnostics { - ErrorDiagnostics( - operation: "resource check" - ) - } - - var shortDescription: String { - "\(resourceType) exhausted: \(usage)" - } - - var description: String { - shortDescription - } - - func toBuildEventError() -> BuildEventError { - BuildEventError( - type: .resourceExhausted, - description: description, - diagnostics: diagnostics.toDictionary() - ) - } - } - - let diskError = ResourceError(resourceType: .disk, usage: "99%") - #expect(diskError.errorCategory == .diskFull) - #expect(diskError.toBuildEventError().type == .resourceExhausted) - - let memoryError = ResourceError(resourceType: .memory, usage: "8GB/8GB") - #expect(memoryError.errorCategory == .memoryExhausted) - #expect(memoryError.toBuildEventError().type == .resourceExhausted) - } -} diff --git a/Tests/NativeBuilderTests/ContainerBuildReportingTests/SimpleNewTests.swift b/Tests/NativeBuilderTests/ContainerBuildReportingTests/SimpleNewTests.swift deleted file mode 100644 index 62f1ffb95..000000000 --- a/Tests/NativeBuilderTests/ContainerBuildReportingTests/SimpleNewTests.swift +++ /dev/null @@ -1,79 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -@testable import ContainerBuildReporting - -@Suite("Simple New Tests") -struct SimpleNewTests { - - @Test("BuildStatistics basic initialization") - func testBuildStatisticsBasic() { - let stats = BuildStatistics() - #expect(stats.startTime == nil) - #expect(stats.endTime == nil) - #expect(stats.duration == nil) - #expect(stats.totalOperations == 0) - } - - @Test("StageStatistics basic initialization") - func testStageStatisticsBasic() { - let stageStats = StageStatistics(name: "test") - #expect(stageStats.name == "test") - #expect(stageStats.startTime == nil) - #expect(stageStats.endTime == nil) - #expect(stageStats.duration == nil) - #expect(stageStats.operationCount == 0) - } - - @Test("Basic JSON output functionality") - func testBasicJSONOutput() async throws { - let pipe = Pipe() - let config = JSONProgressConsumer.Configuration(output: pipe.fileHandleForWriting, prettyPrint: false) - let consumer = JSONProgressConsumer(configuration: config) - - let event = BuildEvent.buildStarted(totalOperations: 1, stages: 1, timestamp: Date()) - try await consumer.handle(event) - - pipe.fileHandleForWriting.closeFile() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - let output = String(data: data, encoding: .utf8) ?? "" - - #expect(output.contains("\"type\":\"build_started\"")) - #expect(output.contains("\"total_operations\":1")) - } - - @Test("Basic plain output functionality") - func testBasicPlainOutput() async throws { - let pipe = Pipe() - let config = PlainProgressConsumer.Configuration(output: pipe.fileHandleForWriting) - let consumer = PlainProgressConsumer(configuration: config) - - let nodeId = UUID() - let context = ReportContext(nodeId: nodeId, description: "Test operation") - let event = BuildEvent.operationStarted(context: context) - - try await consumer.handle(event) - - pipe.fileHandleForWriting.closeFile() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - let output = String(data: data, encoding: .utf8) ?? "" - - #expect(output.contains("#1 [stage] Test operation")) - } -} diff --git a/Tests/NativeBuilderTests/ContainerBuildSnapshotterTests/BinaryPathTests.swift b/Tests/NativeBuilderTests/ContainerBuildSnapshotterTests/BinaryPathTests.swift deleted file mode 100644 index 6b450e7b4..000000000 --- a/Tests/NativeBuilderTests/ContainerBuildSnapshotterTests/BinaryPathTests.swift +++ /dev/null @@ -1,532 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -@testable import ContainerBuildSnapshotter - -@Suite struct BinaryPathTests { - - // MARK: - Initialization Tests - - @Test func InitFromString() { - let path = BinaryPath(string: "/usr/local/bin") - #expect(path.stringValue == "/usr/local/bin") - #expect(path.isValidUTF8) - #expect(path.rawBytes == Data("/usr/local/bin".utf8)) - } - - @Test func InitFromBytes() { - let bytes = Data([0x2F, 0x75, 0x73, 0x72]) // "/usr" in bytes - let path = BinaryPath(bytes: bytes) - #expect(path.stringValue == "/usr") - #expect(path.rawBytes == bytes) - } - - @Test func InitFromNonUTF8Bytes() { - // Invalid UTF-8 sequence - let bytes = Data([0x2F, 0xFF, 0xFE, 0x00]) - let path = BinaryPath(bytes: bytes) - #expect(path.stringValue == nil) - #expect(!path.isValidUTF8) - #expect(path.rawBytes == bytes) - - // requireString should provide a fallback with replacement character - let requiredString = path.requireString - #expect(requiredString.contains("\u{FFFD}")) - } - - @Test func InitFromCString() { - let cString = "/tmp/test".cString(using: .utf8)! - cString.withUnsafeBufferPointer { buffer in - let path = BinaryPath(cString: buffer.baseAddress!) - #expect(path.stringValue == "/tmp/test") - } - } - - @Test func InitFromURL() { - let url = URL(fileURLWithPath: "/var/log/system.log") - let path = BinaryPath(url: url) - #expect(path.stringValue == "/var/log/system.log") - #expect(path.isValidUTF8) - } - - @Test func InitFromStringLiteral() { - let path: BinaryPath = "/home/user/documents" - #expect(path.stringValue == "/home/user/documents") - } - - // MARK: - Empty Path Tests - - @Test func emptyPath() { - let empty = BinaryPath(bytes: Data()) - #expect(empty.isEmpty) - #expect(empty.stringValue == "") - #expect(empty.components == []) - } - - @Test func emptyStringPath() { - let empty = BinaryPath(string: "") - #expect(empty.isEmpty) - #expect(empty.stringValue == "") - } - - // MARK: - Path Component Tests - - @Test func LastPathComponent() { - let path = BinaryPath(string: "/usr/local/bin") - #expect(path.lastPathComponent.stringValue == "bin") - - let rootPath = BinaryPath(string: "/") - #expect(rootPath.lastPathComponent.stringValue == "") - - let noSlash = BinaryPath(string: "filename") - #expect(noSlash.lastPathComponent.stringValue == "filename") - } - - @Test func DeletingLastPathComponent() { - let path = BinaryPath(string: "/usr/local/bin") - let parent = path.deletingLastPathComponent() - #expect(parent.stringValue == "/usr/local") - - let rootPath = BinaryPath(string: "/usr") - let rootParent = rootPath.deletingLastPathComponent() - #expect(rootParent.stringValue == "/") - - let justRoot = BinaryPath(string: "/") - let justRootParent = justRoot.deletingLastPathComponent() - #expect(justRootParent.stringValue == "/") - - let noSlash = BinaryPath(string: "filename") - let noSlashParent = noSlash.deletingLastPathComponent() - #expect(noSlashParent.isEmpty) - } - - @Test func AppendingPathComponent() { - let base = BinaryPath(string: "/usr/local") - let appended = base.appending(BinaryPath(string: "bin")) - #expect(appended.stringValue == "/usr/local/bin") - - let baseWithSlash = BinaryPath(string: "/usr/local/") - let appendedToSlash = baseWithSlash.appending(BinaryPath(string: "bin")) - #expect(appendedToSlash.stringValue == "/usr/local/bin") - - let empty = BinaryPath(string: "") - let appendedToEmpty = empty.appending(BinaryPath(string: "test")) - #expect(appendedToEmpty.stringValue == "test") - - let appendEmpty = base.appending(BinaryPath(string: "")) - #expect(appendEmpty.stringValue == "/usr/local") - } - - @Test func PathComponents() { - let path = BinaryPath(string: "/usr/local/bin") - let components = path.components - #expect(components.count == 3) - #expect(components[0].stringValue == "usr") - #expect(components[1].stringValue == "local") - #expect(components[2].stringValue == "bin") - - let multiSlash = BinaryPath(string: "//usr//local//") - let multiComponents = multiSlash.components - #expect(multiComponents.count == 2) - #expect(multiComponents[0].stringValue == "usr") - #expect(multiComponents[1].stringValue == "local") - - let noSlash = BinaryPath(string: "filename") - let noSlashComponents = noSlash.components - #expect(noSlashComponents.count == 1) - #expect(noSlashComponents[0].stringValue == "filename") - } - - // MARK: - Relative Path Tests - - @Test func RelativePath() { - let base = BinaryPath(string: "/usr/local") - let full = BinaryPath(string: "/usr/local/bin/test") - let relative = full.relativePath(from: base) - #expect(relative?.stringValue == "bin/test") - - let sameBase = BinaryPath(string: "/usr/local") - let sameFull = BinaryPath(string: "/usr/local") - let sameRelative = sameFull.relativePath(from: sameBase) - #expect(sameRelative?.stringValue == "") - - let differentBase = BinaryPath(string: "/var") - let differentFull = BinaryPath(string: "/usr/local") - let differentRelative = differentFull.relativePath(from: differentBase) - #expect(differentRelative == nil) - } - - @Test func HasPrefix() { - let path = BinaryPath(string: "/usr/local/bin") - #expect(path.hasPrefix(BinaryPath(string: "/usr"))) - #expect(path.hasPrefix(BinaryPath(string: "/usr/local"))) - #expect(!path.hasPrefix(BinaryPath(string: "/var"))) - #expect(path.hasPrefix(path)) - } - - @Test func HasSuffix() { - let path = BinaryPath(string: "/usr/local/bin") - #expect(path.hasSuffix(BinaryPath(string: "bin"))) - #expect(path.hasSuffix(BinaryPath(string: "local/bin"))) - #expect(!path.hasSuffix(BinaryPath(string: "usr"))) - #expect(path.hasSuffix(path)) - } - - // MARK: - Comparison Tests - - @Test func Equality() { - let path1 = BinaryPath(string: "/usr/local") - let path2 = BinaryPath(string: "/usr/local") - let path3 = BinaryPath(string: "/usr/bin") - - #expect(path1 == path2) - #expect(path1 != path3) - - // Test with byte initialization - let bytePath1 = BinaryPath(bytes: Data("/usr/local".utf8)) - #expect(path1 == bytePath1) - } - - @Test func Comparison() { - let path1 = BinaryPath(string: "/usr/bin") - let path2 = BinaryPath(string: "/usr/local") - - #expect(path1 < path2) - #expect(!(path2 < path1)) - #expect(!(path1 < path1)) - } - - @Test func Hashable() { - let path1 = BinaryPath(string: "/usr/local") - let path2 = BinaryPath(string: "/usr/local") - let path3 = BinaryPath(string: "/usr/bin") - - var set = Set() - set.insert(path1) - set.insert(path2) - set.insert(path3) - - #expect(set.count == 2) // path1 and path2 are equal - #expect(set.contains(path1)) - #expect(set.contains(path3)) - } - - // MARK: - Interop Tests - - @Test func WithCString() { - let path = BinaryPath(string: "/usr/local/bin") - path.withCString { cString in - let length = strlen(cString) - #expect(length == 14) - #expect(String(cString: cString) == "/usr/local/bin") - } - - // Test with non-UTF8 path - let nonUTF8 = BinaryPath(bytes: Data([0x2F, 0xFF, 0xFE])) - nonUTF8.withCString { cString in - // Should still work as C string (bytes with null termination) - let length = strlen(cString) - #expect(length == 3) - } - } - - @Test func URLConversion() { - let path = BinaryPath(string: "/usr/local/bin") - let url = path.url - #expect(url != nil) - #expect(url?.path == "/usr/local/bin") - - // Non-UTF8 path should not convert to URL - let nonUTF8 = BinaryPath(bytes: Data([0x2F, 0xFF, 0xFE])) - #expect(nonUTF8.url == nil) - } - - // MARK: - Codable Tests - - @Test func CodableWithValidUTF8() throws { - let original = BinaryPath(string: "/usr/local/bin") - - let encoder = JSONEncoder() - let data = try encoder.encode(original) - - let decoder = JSONDecoder() - let decoded = try decoder.decode(BinaryPath.self, from: data) - - #expect(original == decoded) - #expect(decoded.stringValue == "/usr/local/bin") - } - - @Test func CodableWithNonUTF8() throws { - let nonUTF8Bytes = Data([0x2F, 0xFF, 0xFE, 0x00]) - let original = BinaryPath(bytes: nonUTF8Bytes) - - let encoder = JSONEncoder() - let data = try encoder.encode(original) - - // When non-UTF8 is encoded, it becomes base64 - // JSONDecoder will decode the base64 string as a String first - // This means the decoded path will contain the base64 string as UTF-8 bytes - // not the original non-UTF8 bytes - - let decoder = JSONDecoder() - let decoded = try decoder.decode(BinaryPath.self, from: data) - - // The base64 representation becomes the new path content - // This is a limitation of the current Codable implementation - // For true binary preservation, a different encoding strategy would be needed - #expect(decoded.stringValue != nil) // It's now a valid UTF-8 string (the base64) - - // Alternative test: ensure original path with valid UTF-8 round-trips correctly - let utf8Path = BinaryPath(string: "/usr/local/bin") - let utf8Data = try encoder.encode(utf8Path) - let utf8Decoded = try decoder.decode(BinaryPath.self, from: utf8Data) - #expect(utf8Path == utf8Decoded) - } - - // MARK: - Description Tests - - @Test func Description() { - let utf8Path = BinaryPath(string: "/usr/local") - #expect(utf8Path.description == "/usr/local") - - let nonUTF8 = BinaryPath(bytes: Data([0x2F, 0xFF, 0xFE])) - #expect(nonUTF8.description.contains("non-UTF8")) - #expect(nonUTF8.description.contains("3 bytes")) - } - - // MARK: - Edge Cases - - @Test func RootPath() { - let root = BinaryPath(string: "/") - #expect(root.stringValue == "/") - #expect(root.lastPathComponent.stringValue == "") - #expect(root.deletingLastPathComponent().stringValue == "/") - #expect(root.components == []) - } - - @Test func PathWithTrailingSlash() { - let path = BinaryPath(string: "/usr/local/") - #expect(path.lastPathComponent.stringValue == "") - #expect(path.components.count == 2) - } - - @Test func PathWithMultipleSlashes() { - let path = BinaryPath(string: "//usr///local//bin//") - let components = path.components - #expect(components.count == 3) - #expect(components[0].stringValue == "usr") - #expect(components[1].stringValue == "local") - #expect(components[2].stringValue == "bin") - } - - @Test func LongPath() { - let longComponent = String(repeating: "a", count: 255) - let longPath = "/usr/local/\(longComponent)/bin" - let path = BinaryPath(string: longPath) - #expect(path.stringValue == longPath) - #expect(path.components.count == 4) - } - - @Test func AppendingWithLeadingSlash() { - let base = BinaryPath(string: "/usr") - let component = BinaryPath(string: "/local") - let result = base.appending(component) - #expect(result.stringValue == "/usr/local") - } - - @Test func RequireStringWithEmptyPath() { - let empty = BinaryPath(bytes: Data()) - // Empty Data is valid UTF-8 (empty string), so requireString returns it directly - #expect(empty.stringValue == "") - #expect(empty.requireString == "") - } - - @Test func RequireStringWithComplexNonUTF8() { - // Mix of valid and invalid UTF-8 - let bytes = Data([ - 0x2F, // / - 0x75, 0x73, 0x72, // usr - 0x2F, // / - 0xFF, 0xFE, // Invalid UTF-8 - 0x2F, // / - 0x62, 0x69, 0x6E, // bin - ]) - let path = BinaryPath(bytes: bytes) - let required = path.requireString - - // The algorithm processes bytes sequentially and may not preserve all text - // when invalid UTF-8 is encountered in the middle - #expect(required.contains("\u{FFFD}")) - - // Additional test with simpler invalid UTF-8 - let simpleInvalid = BinaryPath(bytes: Data([0xFF, 0xFE])) - let simpleRequired = simpleInvalid.requireString - #expect(simpleRequired.contains("\u{FFFD}")) - } - - // MARK: - Special Character Path Tests - - @Test func SpecialCharacterPaths() { - // Test paths with special characters, quotes, and Unicode - let specialPaths = [ - " (@vec) {càraçt#èrë} $épêcial", - "Char ;059090 to quote", - "DIR�", // Contains replacement character - "Fichier @ ", - "Fichier avec non asci char Évelyne Mère.txt", - "Répertoire (@vec) {càraçt#èrë} $épêcial", - "Répertoire Existant", - "test\\test", // Backslash in filename - "이루마 YIRUMA - River Flows in You.mp3", // Korean characters - ] - - for pathString in specialPaths { - let path = BinaryPath(string: pathString) - #expect(path.stringValue == pathString, "Path should preserve special characters: \(pathString)") - #expect(path.isValidUTF8, "Path should be valid UTF-8: \(pathString)") - - // Test round-trip through bytes - let bytes = path.rawBytes - let reconstructed = BinaryPath(bytes: bytes) - #expect(reconstructed.stringValue == pathString, "Round-trip should preserve path: \(pathString)") - } - } - - @Test func SpecialCharacterPathComponents() { - // Test path component operations with special characters - let basePath = BinaryPath(string: "/tmp") - let specialComponent = BinaryPath(string: "Répertoire (@vec) {càraçt#èrë} $épêcial") - let fullPath = basePath.appending(specialComponent) - - #expect(fullPath.stringValue == "/tmp/Répertoire (@vec) {càraçt#èrë} $épêcial") - #expect(fullPath.lastPathComponent.stringValue == "Répertoire (@vec) {càraçt#èrë} $épêcial") - #expect(fullPath.deletingLastPathComponent().stringValue == "/tmp") - } - - @Test func UnicodePathOperations() { - // Test with Korean characters - let koreanPath = BinaryPath(string: "/music/이루마 YIRUMA - River Flows in You.mp3") - #expect(koreanPath.lastPathComponent.stringValue == "이루마 YIRUMA - River Flows in You.mp3") - - let components = koreanPath.components - #expect(components.count == 2) - #expect(components[0].stringValue == "music") - #expect(components[1].stringValue == "이루마 YIRUMA - River Flows in You.mp3") - } - - @Test func PathsWithQuotesAndSpecialChars() { - // Test paths that would need shell escaping - let quotePath = BinaryPath(string: "Char ;090 to quote") - #expect(quotePath.stringValue == "Char ;090 to quote") - - let atSymbolPath = BinaryPath(string: "Fichier @ ") - #expect(atSymbolPath.stringValue == "Fichier @ ") - - let dollarPath = BinaryPath(string: "file$with$dollars") - #expect(dollarPath.stringValue == "file$with$dollars") - } - - @Test func BackslashInFilename() { - // Test backslash in filename (not as path separator) - let backslashPath = BinaryPath(string: "test\\test") - #expect(backslashPath.stringValue == "test\\test") - #expect(backslashPath.lastPathComponent.stringValue == "test\\test") - - // When used as a component in a path - let fullPath = BinaryPath(string: "/tmp/test\\test/some data") - let components = fullPath.components - #expect(components.count == 3) - #expect(components[0].stringValue == "tmp") - #expect(components[1].stringValue == "test\\test") - #expect(components[2].stringValue == "some data") - } - - @Test func PathsWithReplacementCharacter() { - // Test path containing Unicode replacement character (�) - let replacementPath = BinaryPath(string: "DIR�") - #expect(replacementPath.stringValue == "DIR�") - #expect(replacementPath.isValidUTF8) - - // Test that it can be used in path operations - let basePath = BinaryPath(string: "/tmp") - let fullPath = basePath.appending(replacementPath) - #expect(fullPath.stringValue == "/tmp/DIR�") - } - - @Test func AccentedCharacterPaths() { - // Test various accented characters - let accentedPaths = [ - "Foldèr with éncodïng", - "Évelyne Mère.txt", - "càraçt#èrë", - "épêcial", - ] - - for pathString in accentedPaths { - let path = BinaryPath(string: pathString) - #expect(path.stringValue == pathString) - #expect(path.isValidUTF8) - - // Test in a full path context - let fullPath = BinaryPath(string: "/home/user/\(pathString)") - #expect(fullPath.lastPathComponent.stringValue == pathString) - } - } - - @Test func SpecialCharacterPathComparison() { - // Test that paths with special characters can be compared and used in sets - let path1 = BinaryPath(string: "Répertoire (@vec) {càraçt#èrë} $épêcial") - let path2 = BinaryPath(string: "Répertoire (@vec) {càraçt#èrë} $épêcial") - let path3 = BinaryPath(string: "이루마 YIRUMA - River Flows in You.mp3") - - #expect(path1 == path2) - #expect(path1 != path3) - - var pathSet = Set() - pathSet.insert(path1) - pathSet.insert(path2) - pathSet.insert(path3) - - #expect(pathSet.count == 2) // path1 and path2 are equal - #expect(pathSet.contains(path1)) - #expect(pathSet.contains(path3)) - } - - @Test func SpecialCharacterPathCoding() throws { - // Test that special character paths can be encoded/decoded - let specialPaths = [ - "Répertoire (@vec) {càraçt#èrë} $épêcial", - "이루마 YIRUMA - River Flows in You.mp3", - "test\\test", - "DIR�", - ] - - let encoder = JSONEncoder() - let decoder = JSONDecoder() - - for pathString in specialPaths { - let original = BinaryPath(string: pathString) - let data = try encoder.encode(original) - let decoded = try decoder.decode(BinaryPath.self, from: data) - - #expect(original == decoded, "Coding round-trip failed for: \(pathString)") - #expect(decoded.stringValue == pathString, "String value not preserved for: \(pathString)") - } - } -} diff --git a/Tests/NativeBuilderTests/ContainerBuildSnapshotterTests/DiffKeyUnitTests.swift b/Tests/NativeBuilderTests/ContainerBuildSnapshotterTests/DiffKeyUnitTests.swift deleted file mode 100644 index ec31dc423..000000000 --- a/Tests/NativeBuilderTests/ContainerBuildSnapshotterTests/DiffKeyUnitTests.swift +++ /dev/null @@ -1,323 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 ContainerBuildIR -import Foundation -import Testing - -@testable import ContainerBuildSnapshotter - -/// Pure unit tests for DiffKey that don't require DirectoryDiffer or filesystem operations. -/// These tests use mock Diff data to test DiffKey.computeFromDiffs() in isolation. -@Suite struct DiffKeyUnitTests { - - // MARK: - Mock Data Helpers - - /// Create a mock Added diff entry - private func mockAdded( - path: String, - node: Diff.Modified.Node = .regular, - permissions: FilePermissions? = FilePermissions(rawValue: 0o644), - size: Int64? = 100, - uid: UInt32? = 1000, - gid: UInt32? = 1000, - xattrs: [String: Data]? = nil - ) -> Diff { - .added( - .init( - path: BinaryPath(string: path), - node: node, - permissions: permissions, - size: size, - modificationTime: Date(timeIntervalSince1970: 1_000_000), - linkTarget: nil, - uid: uid, - gid: gid, - xattrs: xattrs, - devMajor: nil, - devMinor: nil, - nlink: nil - ) - ) - } - - /// Create a mock Modified diff entry - private func mockModified( - path: String, - kind: Diff.Modified.Kind = .contentChanged, - node: Diff.Modified.Node = .regular, - permissions: FilePermissions? = FilePermissions(rawValue: 0o644), - size: Int64? = 100, - uid: UInt32? = 1000, - gid: UInt32? = 1000, - xattrs: [String: Data]? = nil - ) -> Diff { - .modified( - .init( - path: BinaryPath(string: path), - kind: kind, - node: node, - permissions: permissions, - size: size, - modificationTime: Date(timeIntervalSince1970: 1_000_000), - linkTarget: nil, - uid: uid, - gid: gid, - xattrs: xattrs, - devMajor: nil, - devMinor: nil, - nlink: nil - ) - ) - } - - /// Create a mock Deleted diff entry - private func mockDeleted(path: String) -> Diff { - .deleted(path: BinaryPath(string: path)) - } - - // MARK: - Tests - - @Test func emptyDiffProducesConsistentKey() async throws { - // Empty diffs should produce a consistent key - let key1 = try await DiffKey.computeFromDiffs( - [], - targetMount: URL(fileURLWithPath: "/tmp") - ) - let key2 = try await DiffKey.computeFromDiffs( - [], - targetMount: URL(fileURLWithPath: "/tmp") - ) - - #expect(key1 == key2) - #expect(key1.stringValue.hasPrefix("sha256:")) - } - - @Test func singleAddedFileProducesKey() async throws { - let diffs = [mockAdded(path: "file.txt")] - - let key = try await DiffKey.computeFromDiffs( - diffs, - targetMount: URL(fileURLWithPath: "/tmp") - ) - - #expect(key.stringValue.hasPrefix("sha256:")) - #expect(key.rawHex.count == 64) - } - - @Test func orderIndependentForSameChanges() async throws { - // Same changes in different order should produce the same key - let diffs1 = [ - mockAdded(path: "a.txt"), - mockAdded(path: "b.txt"), - mockAdded(path: "c.txt"), - ] - - let diffs2 = [ - mockAdded(path: "c.txt"), - mockAdded(path: "a.txt"), - mockAdded(path: "b.txt"), - ] - - let key1 = try await DiffKey.computeFromDiffs( - diffs1, - targetMount: URL(fileURLWithPath: "/tmp") - ) - let key2 = try await DiffKey.computeFromDiffs( - diffs2, - targetMount: URL(fileURLWithPath: "/tmp") - ) - - #expect(key1 == key2) - } - - @Test func differentOperationTypesProduceDifferentKeys() async throws { - let added = [mockAdded(path: "file.txt")] - let modified = [mockModified(path: "file.txt")] - let deleted = [mockDeleted(path: "file.txt")] - - let keyAdded = try await DiffKey.computeFromDiffs( - added, - targetMount: URL(fileURLWithPath: "/tmp") - ) - let keyModified = try await DiffKey.computeFromDiffs( - modified, - targetMount: URL(fileURLWithPath: "/tmp") - ) - let keyDeleted = try await DiffKey.computeFromDiffs( - deleted, - targetMount: URL(fileURLWithPath: "/tmp") - ) - - #expect(keyAdded != keyModified) - #expect(keyModified != keyDeleted) - #expect(keyAdded != keyDeleted) - } - - @Test func differentModificationKindsProduceDifferentKeys() async throws { - let contentChange = [mockModified(path: "file.txt", kind: .contentChanged)] - let metadataOnly = [mockModified(path: "file.txt", kind: .metadataOnly)] - let typeChange = [mockModified(path: "file.txt", kind: .typeChanged)] - - let keyContent = try await DiffKey.computeFromDiffs( - contentChange, - targetMount: URL(fileURLWithPath: "/tmp") - ) - let keyMetadata = try await DiffKey.computeFromDiffs( - metadataOnly, - targetMount: URL(fileURLWithPath: "/tmp") - ) - let keyType = try await DiffKey.computeFromDiffs( - typeChange, - targetMount: URL(fileURLWithPath: "/tmp") - ) - - #expect(keyContent != keyMetadata) - #expect(keyMetadata != keyType) - #expect(keyContent != keyType) - } - - @Test func differentPermissionsProduceDifferentKeys() async throws { - let perm644 = [mockAdded(path: "file.txt", permissions: FilePermissions(rawValue: 0o644))] - let perm755 = [mockAdded(path: "file.txt", permissions: FilePermissions(rawValue: 0o755))] - - let key644 = try await DiffKey.computeFromDiffs( - perm644, - targetMount: URL(fileURLWithPath: "/tmp") - ) - let key755 = try await DiffKey.computeFromDiffs( - perm755, - targetMount: URL(fileURLWithPath: "/tmp") - ) - - #expect(key644 != key755) - } - - @Test func xattrsAffectKey() async throws { - let noXattrs = [mockAdded(path: "file.txt")] - let withXattrs = [ - mockAdded( - path: "file.txt", - xattrs: ["user.test": Data("value".utf8)] - ) - ] - - let keyNoXattrs = try await DiffKey.computeFromDiffs( - noXattrs, - targetMount: URL(fileURLWithPath: "/tmp") - ) - let keyWithXattrs = try await DiffKey.computeFromDiffs( - withXattrs, - targetMount: URL(fileURLWithPath: "/tmp") - ) - - #expect(keyNoXattrs != keyWithXattrs) - } - - @Test func baseDigestAffectsKey() async throws { - let diffs = [mockAdded(path: "file.txt")] - let base1 = try Digest.compute(Data("base1".utf8), using: .sha256) - let base2 = try Digest.compute(Data("base2".utf8), using: .sha256) - - let keyNoBase = try await DiffKey.computeFromDiffs( - diffs, - baseDigest: nil, - targetMount: URL(fileURLWithPath: "/tmp") - ) - let keyBase1 = try await DiffKey.computeFromDiffs( - diffs, - baseDigest: base1, - targetMount: URL(fileURLWithPath: "/tmp") - ) - let keyBase2 = try await DiffKey.computeFromDiffs( - diffs, - baseDigest: base2, - targetMount: URL(fileURLWithPath: "/tmp") - ) - - #expect(keyNoBase != keyBase1) - #expect(keyBase1 != keyBase2) - #expect(keyNoBase != keyBase2) - } - - @Test func coupleToBaseParameter() async throws { - let diffs = [mockAdded(path: "file.txt")] - let base = try Digest.compute(Data("base".utf8), using: .sha256) - - let keyCoupled = try await DiffKey.computeFromDiffs( - diffs, - baseDigest: base, - targetMount: URL(fileURLWithPath: "/tmp"), - coupleToBase: true - ) - let keyUncoupled = try await DiffKey.computeFromDiffs( - diffs, - baseDigest: base, - targetMount: URL(fileURLWithPath: "/tmp"), - coupleToBase: false - ) - - #expect(keyCoupled != keyUncoupled) - } - - @Test func complexDiffSetProducesConsistentKey() async throws { - let diffs = [ - mockAdded(path: "new/file1.txt"), - mockAdded(path: "new/file2.txt", node: .directory), - mockModified(path: "existing/file.txt", kind: .contentChanged), - mockModified(path: "existing/dir", kind: .metadataOnly, node: .directory), - mockDeleted(path: "old/file.txt"), - mockDeleted(path: "old/dir"), - mockAdded(path: "link", node: .symlink), - ] - - let key1 = try await DiffKey.computeFromDiffs( - diffs, - targetMount: URL(fileURLWithPath: "/tmp") - ) - let key2 = try await DiffKey.computeFromDiffs( - diffs, - targetMount: URL(fileURLWithPath: "/tmp") - ) - - #expect(key1 == key2) - } - - @Test func socketsAndDevicesAreExcluded() async throws { - // Sockets and device nodes should be excluded from the key - let withSocketAndDevice = [ - mockAdded(path: "file.txt"), - mockAdded(path: "socket", node: .socket), - mockAdded(path: "device", node: .device), - ] - - let withoutSocketAndDevice = [ - mockAdded(path: "file.txt") - ] - - let key1 = try await DiffKey.computeFromDiffs( - withSocketAndDevice, - targetMount: URL(fileURLWithPath: "/tmp") - ) - let key2 = try await DiffKey.computeFromDiffs( - withoutSocketAndDevice, - targetMount: URL(fileURLWithPath: "/tmp") - ) - - // Keys should be the same since sockets and devices are excluded - #expect(key1 == key2) - } -} diff --git a/Tests/NativeBuilderTests/ContainerBuildSnapshotterTests/FileContentDifferTests.swift b/Tests/NativeBuilderTests/ContainerBuildSnapshotterTests/FileContentDifferTests.swift deleted file mode 100644 index 2a8ef2051..000000000 --- a/Tests/NativeBuilderTests/ContainerBuildSnapshotterTests/FileContentDifferTests.swift +++ /dev/null @@ -1,86 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -@testable import ContainerBuildSnapshotter - -@Suite struct FileContentDifferTests { - - @Test func attributesOnlyShortCircuit() throws { - let d = FileContentDiffer() - let r = try d.diff(oldURL: nil, newURL: nil, attributesOnly: true) - #expect(r == .attributeOnly) - } - - @Test func nilSidesMeanContentChanged() throws { - let d = FileContentDiffer() - // old only - #expect(try d.diff(oldURL: URL(fileURLWithPath: "/tmp/missing"), newURL: nil) == .contentChanged) - // new only - #expect(try d.diff(oldURL: nil, newURL: URL(fileURLWithPath: "/tmp/missing")) == .contentChanged) - } - - @Test func equalContentAttributeOnly() throws { - try TestUtils.withTempDir { dir in - let a = dir.appendingPathComponent("a.txt") - let b = dir.appendingPathComponent("b.txt") - try TestUtils.writeString(a, "same") - try TestUtils.writeString(b, "same") - - let d = FileContentDiffer() - let r = try d.diff(oldURL: a, newURL: b) - #expect(r == .attributeOnly) - } - } - - @Test func changedContentDetected() throws { - try TestUtils.withTempDir { dir in - let a = dir.appendingPathComponent("a.txt") - let b = dir.appendingPathComponent("b.txt") - try TestUtils.writeString(a, "hello") - try TestUtils.writeString(b, "world") - - let d = FileContentDiffer() - let r = try d.diff(oldURL: a, newURL: b) - #expect(r == .contentChanged) - } - } - - @Test func largeFileChunking() throws { - try TestUtils.withTempDir { dir in - let a = dir.appendingPathComponent("a.bin") - let b = dir.appendingPathComponent("b.bin") - // ~10MB payload - let block = Data(repeating: 0xAB, count: 1024 * 1024) // 1MiB - var payload = Data() - for _ in 0..<10 { payload.append(block) } - try TestUtils.write(a, contents: payload) - try TestUtils.write(b, contents: payload) - - let d = FileContentDiffer() - #expect(try d.diff(oldURL: a, newURL: b) == .attributeOnly) - - // flip one byte - var changed = payload - changed[changed.count / 2] = 0xCD - try TestUtils.write(b, contents: changed) - - #expect(try d.diff(oldURL: a, newURL: b) == .contentChanged) - } - } -} diff --git a/Tests/NativeBuilderTests/ContainerBuildSnapshotterTests/TestUtils.swift b/Tests/NativeBuilderTests/ContainerBuildSnapshotterTests/TestUtils.swift deleted file mode 100644 index e6b6d67ce..000000000 --- a/Tests/NativeBuilderTests/ContainerBuildSnapshotterTests/TestUtils.swift +++ /dev/null @@ -1,82 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -// Simple temp-directory helper for tests -enum TestUtils { - static func withTempDir(_ body: (URL) throws -> T) throws -> T { - let dir = FileManager.default.temporaryDirectory.appendingPathComponent("cb-snap-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: dir) } - return try body(dir) - } - - static func withTempDirAsync(_ body: (URL) async throws -> T) async throws -> T { - let dir = FileManager.default.temporaryDirectory.appendingPathComponent("cb-snap-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: dir) } - return try await body(dir) - } - - @discardableResult - static func write(_ path: URL, contents: Data, permissions: UInt16? = nil) throws -> URL { - let fm = FileManager.default - try fm.createDirectory(at: path.deletingLastPathComponent(), withIntermediateDirectories: true) - fm.createFile(atPath: path.path, contents: contents) - if let mode = permissions { - try fm.setAttributes([.posixPermissions: NSNumber(value: mode)], ofItemAtPath: path.path) - } - return path - } - - @discardableResult - static func writeString(_ path: URL, _ string: String, permissions: UInt16? = nil) throws -> URL { - try write(path, contents: Data(string.utf8), permissions: permissions) - } - - static func readString(_ path: URL) throws -> String { - String(decoding: try Data(contentsOf: path), as: UTF8.self) - } - - static func makeSymlink(at: URL, to relativeTarget: String) throws { - let fm = FileManager.default - try fm.createDirectory(at: at.deletingLastPathComponent(), withIntermediateDirectories: true) - try fm.createSymbolicLink(atPath: at.path, withDestinationPath: relativeTarget) - } - - static func chmod(_ path: URL, mode: UInt16) throws { - try FileManager.default.setAttributes([.posixPermissions: NSNumber(value: mode)], ofItemAtPath: path.path) - } - - static func fileExists(_ path: URL) -> Bool { - FileManager.default.fileExists(atPath: path.path) - } - - static func mkdir(_ path: URL) throws { - try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true) - } - - static func listAll(relativeTo root: URL) throws -> [String] { - guard let en = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil) else { return [] } - var out: [String] = [] - while let u = en.nextObject() as? URL { - let rel = u.path.replacingOccurrences(of: root.path + "/", with: "") - out.append(rel) - } - return out.sorted() - } -} From 06eab455c8dc967cd8a75874011cac3ac0034d53 Mon Sep 17 00:00:00 2001 From: J Logan Date: Mon, 22 Sep 2025 11:05:06 -0700 Subject: [PATCH 008/491] Clarify memory units in help and documentation. (#657) Closes #519. ## Type of Change - [ ] Bug fix - [ ] New feature - [ ] Breaking change - [x] Documentation update ## Motivation and Context Clarifies memory units. ## Testing - [x] Tested locally - [x] Added/updated tests - [x] Added/updated docs --- Sources/ContainerClient/Flags.swift | 2 +- .../ContainerClient/Measurement+Parse.swift | 34 +-- Sources/ContainerCommands/BuildCommand.swift | 2 +- .../Measurement+ParseTests.swift | 209 ++++++++++++++++++ docs/how-to.md | 4 +- docs/technical-overview.md | 2 +- 6 files changed, 233 insertions(+), 20 deletions(-) create mode 100644 Tests/ContainerClientTests/Measurement+ParseTests.swift diff --git a/Sources/ContainerClient/Flags.swift b/Sources/ContainerClient/Flags.swift index 1364157f6..a2b081ca1 100644 --- a/Sources/ContainerClient/Flags.swift +++ b/Sources/ContainerClient/Flags.swift @@ -65,7 +65,7 @@ public struct Flags { @Option( name: [.customLong("memory"), .customShort("m")], help: - "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" + "Amount of memory (1MiByte granularity), with optional K, M, G, T, or P suffix" ) public var memory: String? } diff --git a/Sources/ContainerClient/Measurement+Parse.swift b/Sources/ContainerClient/Measurement+Parse.swift index e28bd9027..8333a7656 100644 --- a/Sources/ContainerClient/Measurement+Parse.swift +++ b/Sources/ContainerClient/Measurement+Parse.swift @@ -16,7 +16,7 @@ import Foundation -private let units: [Character: UnitInformationStorage] = [ +private let binaryUnits: [Character: UnitInformationStorage] = [ "b": .bytes, "k": .kibibytes, "m": .mebibytes, @@ -40,20 +40,22 @@ extension Measurement { } } - /// parse the provided string into a measurement that is able to be converted to various byte sizes + /// parseMemory the provided string into a measurement that is able to be converted to various byte sizes using binary exponents public static func parse(parsing: String) throws -> Measurement { - let check = "01234567890. " - let i = parsing.lastIndex { - check.contains($0) - } - guard let i else { + let check = "01234567890." + let trimmed = parsing.trimmingCharacters(in: .whitespaces).lowercased() + guard !trimmed.isEmpty else { throw ParseError.invalidSize } - let after = parsing.index(after: i) - let rawValue = parsing[.. Character { let s = unit.dropFirst() + let unitSymbol = unit.first ?? "b" + switch s { - case "", "b", "ib": - return unit.first ?? "b" + case "", "ib", "b": + return unitSymbol default: throw ParseError.invalidSymbol(unit) } diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift index ab6bcf66a..ec44fdc9b 100644 --- a/Sources/ContainerCommands/BuildCommand.swift +++ b/Sources/ContainerCommands/BuildCommand.swift @@ -44,7 +44,7 @@ extension Application { @Option( name: [.customLong("memory"), .customShort("m")], help: - "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" + "Amount of builder container memory (1MiByte granularity), with optional K, M, G, T, or P suffix" ) var memory: String = "2048MB" diff --git a/Tests/ContainerClientTests/Measurement+ParseTests.swift b/Tests/ContainerClientTests/Measurement+ParseTests.swift new file mode 100644 index 000000000..d34318e41 --- /dev/null +++ b/Tests/ContainerClientTests/Measurement+ParseTests.swift @@ -0,0 +1,209 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// 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 + +@testable import ContainerClient + +struct MeasurementParseTests { + + @Test("Parse binary units - bare unit symbols") + func testBinaryUnits() throws { + let result1 = try Measurement.parse(parsing: "4k") + #expect(result1.value == 4.0) + #expect(result1.unit == .kibibytes) + + let result2 = try Measurement.parse(parsing: "2m") + #expect(result2.value == 2.0) + #expect(result2.unit == .mebibytes) + + let result3 = try Measurement.parse(parsing: "1g") + #expect(result3.value == 1.0) + #expect(result3.unit == .gibibytes) + + let result4 = try Measurement.parse(parsing: "512b") + #expect(result4.value == 512.0) + #expect(result4.unit == .bytes) + } + + @Test("Parse binary units - ib suffix") + func testBinaryUnitsWithIbSuffix() throws { + let result1 = try Measurement.parse(parsing: "4kib") + #expect(result1.value == 4.0) + #expect(result1.unit == .kibibytes) + + let result2 = try Measurement.parse(parsing: "2mib") + #expect(result2.value == 2.0) + #expect(result2.unit == .mebibytes) + + let result3 = try Measurement.parse(parsing: "1gib") + #expect(result3.value == 1.0) + #expect(result3.unit == .gibibytes) + + let result4 = try Measurement.parse(parsing: "3tib") + #expect(result4.value == 3.0) + #expect(result4.unit == .tebibytes) + + let result5 = try Measurement.parse(parsing: "1pib") + #expect(result5.value == 1.0) + #expect(result5.unit == .pebibytes) + } + + @Test("Parse binary units - all suffixes now use binary") + func testAllSuffixesUseBinary() throws { + let result1 = try Measurement.parse(parsing: "4kb") + #expect(result1.value == 4.0) + #expect(result1.unit == .kibibytes) + + let result2 = try Measurement.parse(parsing: "2mb") + #expect(result2.value == 2.0) + #expect(result2.unit == .mebibytes) + + let result3 = try Measurement.parse(parsing: "1gb") + #expect(result3.value == 1.0) + #expect(result3.unit == .gibibytes) + + let result4 = try Measurement.parse(parsing: "3tb") + #expect(result4.value == 3.0) + #expect(result4.unit == .tebibytes) + + let result5 = try Measurement.parse(parsing: "1pb") + #expect(result5.value == 1.0) + #expect(result5.unit == .pebibytes) + } + + @Test("Parse with whitespace") + func testParsingWithWhitespace() throws { + let result1 = try Measurement.parse(parsing: " 4k ") + #expect(result1.value == 4.0) + #expect(result1.unit == .kibibytes) + + let result2 = try Measurement.parse(parsing: " 2.5mb ") + #expect(result2.value == 2.5) + #expect(result2.unit == .mebibytes) + } + + @Test("Parse decimal values") + func testDecimalValues() throws { + let result1 = try Measurement.parse(parsing: "4.5k") + #expect(result1.value == 4.5) + #expect(result1.unit == .kibibytes) + + let result2 = try Measurement.parse(parsing: "1.25gb") + #expect(result2.value == 1.25) + #expect(result2.unit == .gibibytes) + + let result3 = try Measurement.parse(parsing: "0.5mib") + #expect(result3.value == 0.5) + #expect(result3.unit == .mebibytes) + } + + @Test("Parse case insensitive") + func testCaseInsensitive() throws { + let result1 = try Measurement.parse(parsing: "4K") + #expect(result1.value == 4.0) + #expect(result1.unit == .kibibytes) + + let result2 = try Measurement.parse(parsing: "2GB") + #expect(result2.value == 2.0) + #expect(result2.unit == .gibibytes) + + let result3 = try Measurement.parse(parsing: "1MIB") + #expect(result3.value == 1.0) + #expect(result3.unit == .mebibytes) + } + + @Test("Parse bytes unit") + func testBytesUnit() throws { + let result1 = try Measurement.parse(parsing: "1024") + #expect(result1.value == 1024.0) + #expect(result1.unit == .bytes) + + let result2 = try Measurement.parse(parsing: "512b") + #expect(result2.value == 512.0) + #expect(result2.unit == .bytes) + } + + @Test("Parse invalid size throws error") + func testInvalidSizeThrowsError() { + #expect { + _ = try Measurement.parse(parsing: "abc") + } throws: { error in + guard let parseError = error as? Measurement.ParseError else { + return false + } + return parseError.description == "invalid size" + } + + #expect { + _ = try Measurement.parse(parsing: "k4") + } throws: { error in + guard let parseError = error as? Measurement.ParseError else { + return false + } + return parseError.description == "invalid size" + } + } + + @Test("Parse invalid symbol throws error") + func testInvalidSymbolThrowsError() { + #expect { + _ = try Measurement.parse(parsing: "4x") + } throws: { error in + guard let parseError = error as? Measurement.ParseError else { + return false + } + return parseError.description == "invalid symbol: x" + } + + #expect { + _ = try Measurement.parse(parsing: "4kx") + } throws: { error in + guard let parseError = error as? Measurement.ParseError else { + return false + } + return parseError.description == "invalid symbol: kx" + } + } + + @Test("Parse empty string throws error") + func testEmptyStringThrowsError() { + #expect { + _ = try Measurement.parse(parsing: "") + } throws: { error in + guard let parseError = error as? Measurement.ParseError else { + return false + } + return parseError.description == "invalid size" + } + } + + @Test("Verify all suffixes now use binary units") + func testAllSuffixesUseBinaryUnits() throws { + let bareK = try Measurement.parse(parsing: "1k") + let kib = try Measurement.parse(parsing: "1kib") + let kb = try Measurement.parse(parsing: "1kb") + + #expect(bareK.unit == .kibibytes) + #expect(kib.unit == .kibibytes) + #expect(kb.unit == .kibibytes) + + let allInBytes = bareK.converted(to: .bytes).value + + #expect(allInBytes == 1024.0) + } +} diff --git a/docs/how-to.md b/docs/how-to.md index 7d55c9659..a1c40844b 100644 --- a/docs/how-to.md +++ b/docs/how-to.md @@ -4,7 +4,7 @@ How to use the features of `container`. ## Configure memory and CPUs for your containers -Since the containers created by `container` are lightweight virtual machines, consider the needs of your containerized application when you use `container run`. The `--memory` and `--cpus` options allow you to override the default memory and CPU limits for the virtual machine. The default values are 1 gigabyte of RAM and 4 CPUs. You can use abbreviations for memory units; for example, to run a container for image `big` with 8 CPUs and 32 gigabytes of memory, use: +Since the containers created by `container` are lightweight virtual machines, consider the needs of your containerized application when you use `container run`. The `--memory` and `--cpus` options allow you to override the default memory and CPU limits for the virtual machine. The default values are 1 gigabyte of RAM and 4 CPUs. You can use abbreviations for memory units; for example, to run a container for image `big` with 8 CPUs and 32 GiBytes of memory, use: ```bash container run --rm --cpus 8 --memory 32g big @@ -14,7 +14,7 @@ container run --rm --cpus 8 --memory 32g big When you first run `container build`, `container` starts a *builder*, which is a utility container that builds images from your `Dockerfile`s. As with anything you run with `container run`, the builder runs in a lightweight virtual machine, so for resource-intensive builds, you may need to increase the memory and CPU limits for the builder VM. -By default, the builder VM receives 2 gigabytes of RAM and 2 CPUs. You can change these limits by starting the builder container before running `container build`: +By default, the builder VM receives 2 GiBytes of RAM and 2 CPUs. You can change these limits by starting the builder container before running `container build`: ```bash container builder start --cpus 8 --memory 32g diff --git a/docs/technical-overview.md b/docs/technical-overview.md index 728e9b210..83962af08 100644 --- a/docs/technical-overview.md +++ b/docs/technical-overview.md @@ -61,7 +61,7 @@ socat TCP-LISTEN:8000,fork,bind=192.168.64.1 TCP:127.0.0.1:8000 ### Releasing container memory to macOS -The macOS Virtualization framework implements only partial support for memory ballooning, which is a technology that allows virtual machines to dynamically use and relinquish host memory. When you create a container, the underlying virtual machine only uses the amount of memory that the containerized application needs. For example, you might start a container using the option `--memory 16g`, but see that the application is only using 2 gigabytes of RAM in the macOS Activity Monitor. +The macOS Virtualization framework implements only partial support for memory ballooning, which is a technology that allows virtual machines to dynamically use and relinquish host memory. When you create a container, the underlying virtual machine only uses the amount of memory that the containerized application needs. For example, you might start a container using the option `--memory 16g`, but see that the application is only using 2 GiBytes of RAM in the macOS Activity Monitor. Currently, memory pages freed to the Linux operating system by processes running in the container's VM are not relinquished to the host. If you run many memory-intensive containers, you may need to occasionally restart them to reduce memory utilization. From f86befafc7c9203613c37c649b9778383f8dc14b Mon Sep 17 00:00:00 2001 From: J Logan Date: Mon, 22 Sep 2025 11:05:48 -0700 Subject: [PATCH 009/491] Cleans up system subcommand options. (#662) - Part of #515. - Order options alphabetically. - Make descriptions more concise and consistent. ## Type of Change - [ ] Bug fix - [ ] New feature - [ ] Breaking change - [ ] Documentation update ## Motivation and Context See #515. ## Testing - [ ] Tested locally - [ ] Added/updated tests - [ ] Added/updated docs --- .../System/DNS/DNSCreate.swift | 8 ++++-- .../System/DNS/DNSDelete.swift | 8 ++++-- .../System/DNS/DNSList.swift | 6 +++- .../System/Kernel/KernelSet.swift | 28 +++++++++++-------- .../System/Property/PropertyClear.swift | 6 ++-- .../System/Property/PropertyGet.swift | 6 ++-- .../System/Property/PropertyList.swift | 10 +++---- .../System/Property/PropertySet.swift | 8 +++--- .../ContainerCommands/System/SystemLogs.swift | 12 ++++---- .../System/SystemStart.swift | 16 ++++++----- .../System/SystemStatus.swift | 8 ++++-- .../ContainerCommands/System/SystemStop.swift | 9 ++++-- 12 files changed, 75 insertions(+), 50 deletions(-) diff --git a/Sources/ContainerCommands/System/DNS/DNSCreate.swift b/Sources/ContainerCommands/System/DNS/DNSCreate.swift index 22e27401e..b4b03bd95 100644 --- a/Sources/ContainerCommands/System/DNS/DNSCreate.swift +++ b/Sources/ContainerCommands/System/DNS/DNSCreate.swift @@ -22,15 +22,19 @@ import Foundation extension Application { public struct DNSCreate: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "create", abstract: "Create a local DNS domain for containers (must run as an administrator)" ) - @Argument(help: "the local domain name") + @OptionGroup + var global: Flags.Global + + @Argument(help: "The local domain name") var domainName: String + public init() {} + public func run() async throws { let resolver: HostDNSResolver = HostDNSResolver() do { diff --git a/Sources/ContainerCommands/System/DNS/DNSDelete.swift b/Sources/ContainerCommands/System/DNS/DNSDelete.swift index 689254e81..5dcce1b6f 100644 --- a/Sources/ContainerCommands/System/DNS/DNSDelete.swift +++ b/Sources/ContainerCommands/System/DNS/DNSDelete.swift @@ -21,16 +21,20 @@ import Foundation extension Application { public struct DNSDelete: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "delete", abstract: "Delete a local DNS domain (must run as an administrator)", aliases: ["rm"] ) - @Argument(help: "the local domain name") + @OptionGroup + var global: Flags.Global + + @Argument(help: "The local domain name") var domainName: String + public init() {} + public func run() async throws { let resolver = HostDNSResolver() do { diff --git a/Sources/ContainerCommands/System/DNS/DNSList.swift b/Sources/ContainerCommands/System/DNS/DNSList.swift index 87e669339..268e81a99 100644 --- a/Sources/ContainerCommands/System/DNS/DNSList.swift +++ b/Sources/ContainerCommands/System/DNS/DNSList.swift @@ -20,13 +20,17 @@ import Foundation extension Application { public struct DNSList: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "list", abstract: "List local DNS domains", aliases: ["ls"] ) + @OptionGroup + var global: Flags.Global + + public init() {} + public func run() async throws { let resolver: HostDNSResolver = HostDNSResolver() let domains = resolver.listDomains() diff --git a/Sources/ContainerCommands/System/Kernel/KernelSet.swift b/Sources/ContainerCommands/System/Kernel/KernelSet.swift index 4fd3df394..9079a18ef 100644 --- a/Sources/ContainerCommands/System/Kernel/KernelSet.swift +++ b/Sources/ContainerCommands/System/Kernel/KernelSet.swift @@ -26,26 +26,30 @@ import TerminalProgress extension Application { public struct KernelSet: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "set", abstract: "Set the default kernel" ) - @Option(name: .customLong("binary"), help: "Path to the binary to set as the default kernel. If used with --tar, this points to a location inside the tar") - var binaryPath: String? = nil + @Option(name: .long, help: "The architecture of the kernel binary (values: amd64, arm64)") + var arch: String = ContainerizationOCI.Platform.current.architecture.description - @Option(name: .customLong("tar"), help: "Filesystem path or remote URL to a tar ball that contains the kernel to use") - var tarPath: String? = nil + @Option(name: .customLong("binary"), help: "Path to the kernel file (or archive member, if used with --tar)") + var binaryPath: String? = nil - @Option(name: .customLong("arch"), help: "The architecture of the kernel binary. One of (amd64, arm64)") - var architecture: String = ContainerizationOCI.Platform.current.architecture.description + @Flag(name: .long, help: "Overwrites an existing kernel with the same name") + var force: Bool = false - @Flag(name: .customLong("recommended"), help: "Download and install the recommended kernel as the default. This flag ignores any other arguments") + @Flag(name: .long, help: "Download and install the recommended kernel as the default (takes precedence over all other flags)") var recommended: Bool = false - @Flag(name: .long, help: "Force install of kernel. If a kernel exists with the same name, it will be overwritten.") - var force: Bool = false + @Option(name: .customLong("tar"), help: "Filesystem path or remote URL to a tar archive containing a kernel file") + var tarPath: String? = nil + + @OptionGroup + var global: Flags.Global + + public init() {} public func run() async throws { if recommended { @@ -91,13 +95,13 @@ extension Application { } private func getSystemPlatform() throws -> SystemPlatform { - switch architecture { + switch arch { case "arm64": return .linuxArm case "amd64": return .linuxAmd default: - throw ContainerizationError(.unsupported, message: "Unsupported architecture \(architecture)") + throw ContainerizationError(.unsupported, message: "Unsupported architecture \(arch)") } } diff --git a/Sources/ContainerCommands/System/Property/PropertyClear.swift b/Sources/ContainerCommands/System/Property/PropertyClear.swift index 6c69e3786..e2d0daccd 100644 --- a/Sources/ContainerCommands/System/Property/PropertyClear.swift +++ b/Sources/ContainerCommands/System/Property/PropertyClear.swift @@ -22,8 +22,6 @@ import Foundation extension Application { public struct PropertyClear: AsyncParsableCommand { - public init() {} - public static let configuration = CommandConfiguration( commandName: "clear", abstract: "Clear a property value" @@ -32,9 +30,11 @@ extension Application { @OptionGroup var global: Flags.Global - @Argument(help: "the property ID") + @Argument(help: "The property ID") var id: String + public init() {} + public func run() async throws { guard let key = DefaultsStore.Keys(rawValue: id) else { throw ContainerizationError(.invalidArgument, message: "invalid property ID: \(id)") diff --git a/Sources/ContainerCommands/System/Property/PropertyGet.swift b/Sources/ContainerCommands/System/Property/PropertyGet.swift index 909f2cd47..883a20c9a 100644 --- a/Sources/ContainerCommands/System/Property/PropertyGet.swift +++ b/Sources/ContainerCommands/System/Property/PropertyGet.swift @@ -22,8 +22,6 @@ import Foundation extension Application { public struct PropertyGet: AsyncParsableCommand { - public init() {} - public static let configuration = CommandConfiguration( commandName: "get", abstract: "Retrieve a property value" @@ -32,9 +30,11 @@ extension Application { @OptionGroup var global: Flags.Global - @Argument(help: "the property ID") + @Argument(help: "The property ID") var id: String + public init() {} + public func run() async throws { let value = DefaultsStore.allValues() .filter { id == $0.id } diff --git a/Sources/ContainerCommands/System/Property/PropertyList.swift b/Sources/ContainerCommands/System/Property/PropertyList.swift index 312d109f7..cbf8e0b16 100644 --- a/Sources/ContainerCommands/System/Property/PropertyList.swift +++ b/Sources/ContainerCommands/System/Property/PropertyList.swift @@ -21,23 +21,23 @@ import Foundation extension Application { public struct PropertyList: AsyncParsableCommand { - public init() {} - public static let configuration = CommandConfiguration( commandName: "list", abstract: "List system properties", aliases: ["ls"] ) - @Flag(name: .shortAndLong, help: "Only output the network name") - var quiet = false - @Option(name: .long, help: "Format of the output") var format: ListFormat = .table + @Flag(name: .shortAndLong, help: "Only output the property ID") + var quiet = false + @OptionGroup var global: Flags.Global + public init() {} + public func run() async throws { let vals = DefaultsStore.allValues() try printValues(vals, format: format) diff --git a/Sources/ContainerCommands/System/Property/PropertySet.swift b/Sources/ContainerCommands/System/Property/PropertySet.swift index 490de2215..dd8f4e03a 100644 --- a/Sources/ContainerCommands/System/Property/PropertySet.swift +++ b/Sources/ContainerCommands/System/Property/PropertySet.swift @@ -24,8 +24,6 @@ import Foundation extension Application { public struct PropertySet: AsyncParsableCommand { - public init() {} - public static let configuration = CommandConfiguration( commandName: "set", abstract: "Set a property value" @@ -34,12 +32,14 @@ extension Application { @OptionGroup var global: Flags.Global - @Argument(help: "the property ID") + @Argument(help: "The property ID") var id: String - @Argument(help: "the property value") + @Argument(help: "The property value") var value: String + public init() {} + public func run() async throws { guard let key = DefaultsStore.Keys(rawValue: id) else { throw ContainerizationError(.invalidArgument, message: "invalid property ID: \(id)") diff --git a/Sources/ContainerCommands/System/SystemLogs.swift b/Sources/ContainerCommands/System/SystemLogs.swift index c1f606a89..6f342c4fd 100644 --- a/Sources/ContainerCommands/System/SystemLogs.swift +++ b/Sources/ContainerCommands/System/SystemLogs.swift @@ -23,8 +23,6 @@ import OSLog extension Application { public struct SystemLogs: AsyncParsableCommand { - public init() {} - public static let subsystem = "com.apple.container" public static let configuration = CommandConfiguration( @@ -32,8 +30,8 @@ extension Application { abstract: "Fetch system logs for `container` services" ) - @OptionGroup - var global: Flags.Global + @Flag(name: .shortAndLong, help: "Follow log output") + var follow: Bool = false @Option( name: .long, @@ -41,8 +39,10 @@ extension Application { ) var last: String = "5m" - @Flag(name: .shortAndLong, help: "Follow log output") - var follow: Bool = false + @OptionGroup + var global: Flags.Global + + public init() {} public func run() async throws { let process = Process() diff --git a/Sources/ContainerCommands/System/SystemStart.swift b/Sources/ContainerCommands/System/SystemStart.swift index 929bb6069..77c9a9d33 100644 --- a/Sources/ContainerCommands/System/SystemStart.swift +++ b/Sources/ContainerCommands/System/SystemStart.swift @@ -24,7 +24,6 @@ import TerminalProgress extension Application { public struct SystemStart: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "start", abstract: "Start `container` services" @@ -42,14 +41,17 @@ extension Application { transform: { URL(filePath: $0) }) var installRoot = InstallRoot.defaultURL - @Flag(name: .long, help: "Enable debug logging for the runtime daemon.") - var debug = false - @Flag( - name: .long, inversion: .prefixedEnableDisable, - help: "Specify whether the default kernel should be installed or not. The default behavior is to prompt the user for a response.") + name: .long, + inversion: .prefixedEnableDisable, + help: "Specify whether the default kernel should be installed or not (default: prompt user)") var kernelInstall: Bool? + @OptionGroup + var global: Flags.Global + + public init() {} + public func run() async throws { // Without the true path to the binary in the plist, `container-apiserver` won't launch properly. // TODO: Use plugin loader for API server. @@ -60,7 +62,7 @@ extension Application { var args = [executableUrl.absolutePath()] - if debug { + if global.debug { args.append("--debug") } diff --git a/Sources/ContainerCommands/System/SystemStatus.swift b/Sources/ContainerCommands/System/SystemStatus.swift index c46e28b86..b074e2f2d 100644 --- a/Sources/ContainerCommands/System/SystemStatus.swift +++ b/Sources/ContainerCommands/System/SystemStatus.swift @@ -23,15 +23,19 @@ import Logging extension Application { public struct SystemStatus: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "status", abstract: "Show the status of `container` services" ) - @Option(name: .shortAndLong, help: "Launchd prefix for `container` services") + @Option(name: .shortAndLong, help: "Launchd prefix for services") var prefix: String = "com.apple.container." + @OptionGroup + var global: Flags.Global + + public init() {} + public func run() async throws { let isRegistered = try ServiceManager.isRegistered(fullServiceLabel: "\(prefix)apiserver") if !isRegistered { diff --git a/Sources/ContainerCommands/System/SystemStop.swift b/Sources/ContainerCommands/System/SystemStop.swift index bd60893d0..2ba0069c5 100644 --- a/Sources/ContainerCommands/System/SystemStop.swift +++ b/Sources/ContainerCommands/System/SystemStop.swift @@ -23,8 +23,6 @@ import Logging extension Application { public struct SystemStop: AsyncParsableCommand { - public init() {} - private static let stopTimeoutSeconds: Int32 = 5 private static let shutdownTimeoutSeconds: Int32 = 20 @@ -33,9 +31,14 @@ extension Application { abstract: "Stop all `container` services" ) - @Option(name: .shortAndLong, help: "Launchd prefix for `container` services") + @Option(name: .shortAndLong, help: "Launchd prefix for services") var prefix: String = "com.apple.container." + @OptionGroup + var global: Flags.Global + + public init() {} + public func run() async throws { let log = Logger( label: "com.apple.container.cli", From 7d905bde646d4532ca57f733be757ff09af5515a Mon Sep 17 00:00:00 2001 From: J Logan Date: Mon, 22 Sep 2025 12:17:24 -0700 Subject: [PATCH 010/491] Cleans up network and volume subcommands. (#661) - Part of #515. - Order options alphabetically. - Use consistent delete logic for network and volume delete. ## Motivation and Context See #515 --- .../Network/NetworkCommand.swift | 3 +- .../Network/NetworkCreate.swift | 9 ++- .../Network/NetworkDelete.swift | 9 ++- .../Network/NetworkInspect.swift | 7 +- .../Network/NetworkList.swift | 9 ++- .../Volume/VolumeCommand.swift | 3 +- .../Volume/VolumeCreate.swift | 14 ++-- .../Volume/VolumeDelete.swift | 73 +++++++++++++++++-- .../Volume/VolumeInspect.swift | 10 ++- .../ContainerCommands/Volume/VolumeList.swift | 12 ++- 10 files changed, 113 insertions(+), 36 deletions(-) diff --git a/Sources/ContainerCommands/Network/NetworkCommand.swift b/Sources/ContainerCommands/Network/NetworkCommand.swift index b2d0797c6..736708481 100644 --- a/Sources/ContainerCommands/Network/NetworkCommand.swift +++ b/Sources/ContainerCommands/Network/NetworkCommand.swift @@ -18,7 +18,6 @@ import ArgumentParser extension Application { public struct NetworkCommand: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "network", abstract: "Manage container networks", @@ -30,5 +29,7 @@ extension Application { ], aliases: ["n"] ) + + public init() {} } } diff --git a/Sources/ContainerCommands/Network/NetworkCreate.swift b/Sources/ContainerCommands/Network/NetworkCreate.swift index a759eea50..705ecfcde 100644 --- a/Sources/ContainerCommands/Network/NetworkCreate.swift +++ b/Sources/ContainerCommands/Network/NetworkCreate.swift @@ -23,20 +23,21 @@ import TerminalProgress extension Application { public struct NetworkCreate: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "create", abstract: "Create a new network") + @Option(name: .customLong("label"), help: "Set metadata for a network") + var labels: [String] = [] + @OptionGroup var global: Flags.Global - @Option(name: .customLong("label"), help: "Set metadata on a network") - var labels: [String] = [] - @Argument(help: "Network name") var name: String + public init() {} + public func run() async throws { let parsedLabels = Utility.parseKeyValuePairs(labels) let config = try NetworkConfiguration(id: self.name, mode: .nat, labels: parsedLabels) diff --git a/Sources/ContainerCommands/Network/NetworkDelete.swift b/Sources/ContainerCommands/Network/NetworkDelete.swift index ac3aa02b1..80e545e21 100644 --- a/Sources/ContainerCommands/Network/NetworkDelete.swift +++ b/Sources/ContainerCommands/Network/NetworkDelete.swift @@ -22,21 +22,22 @@ import Foundation extension Application { public struct NetworkDelete: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "delete", abstract: "Delete one or more networks", aliases: ["rm"]) + @Flag(name: .shortAndLong, help: "Delete all networks") + var all = false + @OptionGroup var global: Flags.Global - @Flag(name: .shortAndLong, help: "Remove all networks") - var all = false - @Argument(help: "Network names") var networkNames: [String] = [] + public init() {} + public func validate() throws { if networkNames.count == 0 && !all { throw ContainerizationError(.invalidArgument, message: "no networks specified and --all not supplied") diff --git a/Sources/ContainerCommands/Network/NetworkInspect.swift b/Sources/ContainerCommands/Network/NetworkInspect.swift index 3c4b918e7..d80ddd59d 100644 --- a/Sources/ContainerCommands/Network/NetworkInspect.swift +++ b/Sources/ContainerCommands/Network/NetworkInspect.swift @@ -22,16 +22,17 @@ import SwiftProtobuf extension Application { public struct NetworkInspect: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "inspect", abstract: "Display information about one or more networks") + @Argument(help: "Networks to inspect") + var networks: [String] + @OptionGroup var global: Flags.Global - @Argument(help: "Networks to inspect") - var networks: [String] + public init() {} public func run() async throws { let objects: [any Codable] = try await ClientNetwork.list().filter { diff --git a/Sources/ContainerCommands/Network/NetworkList.swift b/Sources/ContainerCommands/Network/NetworkList.swift index 155e90bcc..4ad71e1e2 100644 --- a/Sources/ContainerCommands/Network/NetworkList.swift +++ b/Sources/ContainerCommands/Network/NetworkList.swift @@ -23,21 +23,22 @@ import SwiftProtobuf extension Application { public struct NetworkList: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "list", abstract: "List networks", aliases: ["ls"]) - @Flag(name: .shortAndLong, help: "Only output the network name") - var quiet = false - @Option(name: .long, help: "Format of the output") var format: ListFormat = .table + @Flag(name: .shortAndLong, help: "Only output the network name") + var quiet = false + @OptionGroup var global: Flags.Global + public init() {} + public func run() async throws { let networks = try await ClientNetwork.list() try printNetworks(networks: networks, format: format) diff --git a/Sources/ContainerCommands/Volume/VolumeCommand.swift b/Sources/ContainerCommands/Volume/VolumeCommand.swift index f4e0f4261..4b8893e4f 100644 --- a/Sources/ContainerCommands/Volume/VolumeCommand.swift +++ b/Sources/ContainerCommands/Volume/VolumeCommand.swift @@ -18,7 +18,6 @@ import ArgumentParser extension Application { public struct VolumeCommand: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "volume", abstract: "Manage container volumes", @@ -30,5 +29,7 @@ extension Application { ], aliases: ["v"] ) + + public init() {} } } diff --git a/Sources/ContainerCommands/Volume/VolumeCreate.swift b/Sources/ContainerCommands/Volume/VolumeCreate.swift index 928abbd92..92a8227ba 100644 --- a/Sources/ContainerCommands/Volume/VolumeCreate.swift +++ b/Sources/ContainerCommands/Volume/VolumeCreate.swift @@ -20,24 +20,28 @@ import Foundation extension Application.VolumeCommand { public struct VolumeCreate: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "create", abstract: "Create a volume" ) - @Option(name: .customShort("s"), help: "Size of the volume (default: 512GB). Examples: 1G, 512MB, 2T") - var size: String? + @Option(name: .customLong("label"), help: "Set metadata for a volume") + var labels: [String] = [] @Option(name: .customLong("opt"), help: "Set driver specific options") var driverOpts: [String] = [] - @Option(name: .customLong("label"), help: "Set metadata on a volume") - var labels: [String] = [] + @Option(name: .short, help: "Size of the volume in bytes, with optional K, M, G, T, or P suffix") + var size: String? + + @OptionGroup + var global: Flags.Global @Argument(help: "Volume name") var name: String + public init() {} + public func run() async throws { var parsedDriverOpts = Utility.parseKeyValuePairs(driverOpts) let parsedLabels = Utility.parseKeyValuePairs(labels) diff --git a/Sources/ContainerCommands/Volume/VolumeDelete.swift b/Sources/ContainerCommands/Volume/VolumeDelete.swift index 80e3408dc..54133d215 100644 --- a/Sources/ContainerCommands/Volume/VolumeDelete.swift +++ b/Sources/ContainerCommands/Volume/VolumeDelete.swift @@ -16,24 +16,83 @@ import ArgumentParser import ContainerClient +import ContainerizationError import Foundation extension Application.VolumeCommand { public struct VolumeDelete: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "delete", - abstract: "Remove one or more volumes", + abstract: "Delete one or more volumes", aliases: ["rm"] ) - @Argument(help: "Volume name(s)") - var names: [String] + @Flag(name: .shortAndLong, help: "Delete all volumes") + var all = false + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Volume names") + var names: [String] = [] + + public init() {} public func run() async throws { - for name in names { - try await ClientVolume.delete(name: name) - print(name) + let uniqueVolumeNames = Set(names) + let volumes: [Volume] + + if all { + volumes = try await ClientVolume.list() + } else { + volumes = try await ClientVolume.list() + .filter { v in + uniqueVolumeNames.contains(v.id) + } + + // If one of the volumes requested isn't present lets throw. We don't need to do + // this for --all as --all should be perfectly usable with no volumes to remove, + // otherwise it'd be quite clunky. + if volumes.count != uniqueVolumeNames.count { + let missing = uniqueVolumeNames.filter { id in + !volumes.contains { v in + v.id == id + } + } + throw ContainerizationError( + .notFound, + message: "failed to delete one or more volumes: \(missing)" + ) + } + } + + var failed = [String]() + try await withThrowingTaskGroup(of: Volume?.self) { group in + for volume in volumes { + group.addTask { + do { + // delete atomically disables the IP allocator, then deletes + // the allocator disable fails if any IPs are still in use + try await ClientVolume.delete(name: volume.id) + print(volume.id) + return nil + } catch { + log.error("failed to delete volume \(volume.id): \(error)") + return volume + } + } + } + + for try await volume in group { + guard let volume else { + continue + } + failed.append(volume.id) + } + } + + if failed.count > 0 { + throw ContainerizationError(.internalError, message: "delete failed for one or more volumes: \(failed)") } } } diff --git a/Sources/ContainerCommands/Volume/VolumeInspect.swift b/Sources/ContainerCommands/Volume/VolumeInspect.swift index f6405e245..c5afbfa43 100644 --- a/Sources/ContainerCommands/Volume/VolumeInspect.swift +++ b/Sources/ContainerCommands/Volume/VolumeInspect.swift @@ -20,15 +20,19 @@ import Foundation extension Application.VolumeCommand { public struct VolumeInspect: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "inspect", - abstract: "Display detailed information on one or more volumes" + abstract: "Display information about one or more volumes" ) - @Argument(help: "Volume name(s)") + @OptionGroup + var global: Flags.Global + + @Argument(help: "Volume names") var names: [String] + public init() {} + public func run() async throws { var volumes: [Volume] = [] diff --git a/Sources/ContainerCommands/Volume/VolumeList.swift b/Sources/ContainerCommands/Volume/VolumeList.swift index 840df9bab..9c7bdd044 100644 --- a/Sources/ContainerCommands/Volume/VolumeList.swift +++ b/Sources/ContainerCommands/Volume/VolumeList.swift @@ -21,19 +21,23 @@ import Foundation extension Application.VolumeCommand { public struct VolumeList: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "list", abstract: "List volumes", aliases: ["ls"] ) - @Flag(name: .shortAndLong, help: "Only display volume names") - var quiet: Bool = false - @Option(name: .long, help: "Format of the output") var format: Application.ListFormat = .table + @Flag(name: .shortAndLong, help: "Only output the volume name") + var quiet: Bool = false + + @OptionGroup + var global: Flags.Global + + public init() {} + public func run() async throws { let volumes = try await ClientVolume.list() try printVolumes(volumes: volumes, format: format) From 1f94a8340a88e956bfa81dc1b28d6f56a4245629 Mon Sep 17 00:00:00 2001 From: J Logan Date: Mon, 22 Sep 2025 12:48:45 -0700 Subject: [PATCH 011/491] Cleans up build subcommand options. (#658) - Part of #515. - Order options alphabetically. - Use `container ls` options for `builder status`. ## Type of Change - [ ] Bug fix - [ ] New feature - [x] Breaking change - Change options and output of `builder status`. - [x] Documentation update ## Motivation and Context See #515. ## Testing - [x] Tested locally - [ ] Added/updated tests - [x] Added/updated docs --- .../Builder/BuilderDelete.swift | 14 +-- .../Builder/BuilderStart.swift | 19 ++-- .../Builder/BuilderStatus.swift | 87 ++++++++++++------- .../Builder/BuilderStop.swift | 12 +-- .../Container/ContainerDelete.swift | 2 +- .../Container/ContainerList.swift | 18 ++-- 6 files changed, 91 insertions(+), 61 deletions(-) diff --git a/Sources/ContainerCommands/Builder/BuilderDelete.swift b/Sources/ContainerCommands/Builder/BuilderDelete.swift index 252d54cce..2229671d3 100644 --- a/Sources/ContainerCommands/Builder/BuilderDelete.swift +++ b/Sources/ContainerCommands/Builder/BuilderDelete.swift @@ -21,22 +21,22 @@ import Foundation extension Application { public struct BuilderDelete: AsyncParsableCommand { - public init() {} - public static var configuration: CommandConfiguration { var config = CommandConfiguration() config.commandName = "delete" config.aliases = ["rm"] - config._superCommandName = "builder" - config.abstract = "Delete builder" - config.usage = "\n\t builder delete [command options]" - config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) + config.abstract = "Delete the builder container" return config } - @Flag(name: .shortAndLong, help: "Force delete builder even if it is running") + @Flag(name: .shortAndLong, help: "Delete the builder even if it is running") var force = false + @OptionGroup + var global: Flags.Global + + public init() {} + public func run() async throws { do { let container = try await ClientContainer.get(id: "buildkit") diff --git a/Sources/ContainerCommands/Builder/BuilderStart.swift b/Sources/ContainerCommands/Builder/BuilderStart.swift index e14acb025..b42213fad 100644 --- a/Sources/ContainerCommands/Builder/BuilderStart.swift +++ b/Sources/ContainerCommands/Builder/BuilderStart.swift @@ -28,28 +28,27 @@ import TerminalProgress extension Application { public struct BuilderStart: AsyncParsableCommand { - public init() {} - public static var configuration: CommandConfiguration { var config = CommandConfiguration() config.commandName = "start" - config._superCommandName = "builder" - config.abstract = "Start builder" - config.usage = "\nbuilder start [command options]" - config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) + config.abstract = "Start the builder container" return config } - @Option(name: [.customLong("cpus"), .customShort("c")], help: "Number of CPUs to allocate to the container") + @Option(name: .shortAndLong, help: "Number of CPUs to allocate to the builder container") var cpus: Int64 = 2 @Option( - name: [.customLong("memory"), .customShort("m")], - help: - "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" + name: .shortAndLong, + help: "Amount of builder container memory (1MiByte granularity), with optional K, M, G, T, or P suffix" ) var memory: String = "2048MB" + @OptionGroup + var global: Flags.Global + + public init() {} + public func run() async throws { let progressConfig = try ProgressConfig( showTasks: true, diff --git a/Sources/ContainerCommands/Builder/BuilderStatus.swift b/Sources/ContainerCommands/Builder/BuilderStatus.swift index 747ee210a..796183487 100644 --- a/Sources/ContainerCommands/Builder/BuilderStatus.swift +++ b/Sources/ContainerCommands/Builder/BuilderStatus.swift @@ -17,51 +17,36 @@ import ArgumentParser import ContainerClient import ContainerizationError +import ContainerizationExtras import Foundation extension Application { public struct BuilderStatus: AsyncParsableCommand { - public init() {} - public static var configuration: CommandConfiguration { var config = CommandConfiguration() config.commandName = "status" - config._superCommandName = "builder" - config.abstract = "Print builder status" - config.usage = "\n\t builder status [command options]" - config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) + config.abstract = "Display the builder container status" return config } - @Flag(name: .long, help: ArgumentHelp("Display detailed status in json format")) - var json: Bool = false + @Option(name: .long, help: "Format of the output") + var format: ListFormat = .table - public func run() async throws { - do { - let container = try await ClientContainer.get(id: "buildkit") - if json { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let jsonData = try encoder.encode(container) + @Flag(name: .shortAndLong, help: "Only output the container ID") + var quiet = false - guard let jsonString = String(data: jsonData, encoding: .utf8) else { - throw ContainerizationError(.internalError, message: "failed to encode BuildKit container as json") - } - print(jsonString) - return - } + @OptionGroup + var global: Flags.Global - let image = container.configuration.image.reference - let resources = container.configuration.resources - let cpus = resources.cpus - let memory = resources.memoryInBytes / (1024 * 1024) // bytes to MB - let addr = "" + public init() {} - print("ID IMAGE STATE ADDR CPUS MEMORY") - print("\(container.id) \(image) \(container.status.rawValue.uppercased()) \(addr) \(cpus) \(memory) MB") + public func run() async throws { + do { + let container = try await ClientContainer.get(id: "buildkit") + try printContainers(containers: [container], format: format) } catch { if error is ContainerizationError { - if (error as? ContainerizationError)?.code == .notFound { + if (error as? ContainerizationError)?.code == .notFound && !quiet { print("builder is not running") return } @@ -69,5 +54,49 @@ extension Application { throw error } } + + private func createHeader() -> [[String]] { + [["ID", "IMAGE", "STATE", "ADDR", "CPUS", "MEMORY"]] + } + + private func printContainers(containers: [ClientContainer], format: ListFormat) throws { + if format == .json { + let printables = containers.map { + PrintableContainer($0) + } + let data = try JSONEncoder().encode(printables) + print(String(data: data, encoding: .utf8)!) + + return + } + + if self.quiet { + containers + .filter { $0.status == .running } + .forEach { print($0.id) } + return + } + + var rows = createHeader() + for container in containers { + rows.append(container.asRow) + } + + let formatter = TableOutput(rows: rows) + print(formatter.format()) + } + } +} + +extension ClientContainer { + fileprivate var asRow: [String] { + [ + self.id, + self.configuration.image.reference, + self.status.rawValue, + self.networks.compactMap { try? CIDRAddress($0.address).address.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 ae46474cd..cfdea2a18 100644 --- a/Sources/ContainerCommands/Builder/BuilderStop.swift +++ b/Sources/ContainerCommands/Builder/BuilderStop.swift @@ -21,18 +21,18 @@ import Foundation extension Application { public struct BuilderStop: AsyncParsableCommand { - public init() {} - public static var configuration: CommandConfiguration { var config = CommandConfiguration() config.commandName = "stop" - config._superCommandName = "builder" - config.abstract = "Stop builder" - config.usage = "\n\t builder stop" - config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) + config.abstract = "Stop the builder container" return config } + @OptionGroup + var global: Flags.Global + + public init() {} + public func run() async throws { do { let container = try await ClientContainer.get(id: "buildkit") diff --git a/Sources/ContainerCommands/Container/ContainerDelete.swift b/Sources/ContainerCommands/Container/ContainerDelete.swift index 63794da4a..3719c04d1 100644 --- a/Sources/ContainerCommands/Container/ContainerDelete.swift +++ b/Sources/ContainerCommands/Container/ContainerDelete.swift @@ -28,7 +28,7 @@ extension Application { abstract: "Delete one or more containers", aliases: ["rm"]) - @Flag(name: .shortAndLong, help: "Force the removal of one or more running containers") + @Flag(name: .shortAndLong, help: "Delete containers even if they are running") var force = false @Flag(name: .shortAndLong, help: "Remove all containers") diff --git a/Sources/ContainerCommands/Container/ContainerList.swift b/Sources/ContainerCommands/Container/ContainerList.swift index 403e7a503..a66dabc3f 100644 --- a/Sources/ContainerCommands/Container/ContainerList.swift +++ b/Sources/ContainerCommands/Container/ContainerList.swift @@ -23,32 +23,32 @@ import SwiftProtobuf extension Application { public struct ContainerList: AsyncParsableCommand { - public init() {} - public static let configuration = CommandConfiguration( commandName: "list", abstract: "List containers", aliases: ["ls"]) - @Flag(name: .shortAndLong, help: "Show stopped containers as well") + @Flag(name: .shortAndLong, help: "Include containers that are not running") var all = false - @Flag(name: .shortAndLong, help: "Only output the container ID") - var quiet = false - @Option(name: .long, help: "Format of the output") var format: ListFormat = .table + @Flag(name: .shortAndLong, help: "Only output the container ID") + var quiet = false + @OptionGroup var global: Flags.Global + public init() {} + public func run() async throws { let containers = try await ClientContainer.list() try printContainers(containers: containers, format: format) } private func createHeader() -> [[String]] { - [["ID", "IMAGE", "OS", "ARCH", "STATE", "ADDR"]] + [["ID", "IMAGE", "OS", "ARCH", "STATE", "ADDR", "CPUS", "MEMORY"]] } private func printContainers(containers: [ClientContainer], format: ListFormat) throws { @@ -87,7 +87,7 @@ extension Application { } extension ClientContainer { - var asRow: [String] { + fileprivate var asRow: [String] { [ self.id, self.configuration.image.reference, @@ -95,6 +95,8 @@ extension ClientContainer { self.configuration.platform.architecture, self.status.rawValue, self.networks.compactMap { try? CIDRAddress($0.address).address.description }.joined(separator: ","), + "\(self.configuration.resources.cpus)", + "\(self.configuration.resources.memoryInBytes / (1024 * 1024)) MB", ] } } From bcfcd5ac195c4f6fc82a092a922fcf77e33ac35c Mon Sep 17 00:00:00 2001 From: J Logan Date: Mon, 22 Sep 2025 13:18:20 -0700 Subject: [PATCH 012/491] Cleans up registry subcommand options. (#660) - Part of #515. - Order options alphabetically. ## Motivation and Context See #515. --- .../ContainerCommands/Network/NetworkDelete.swift | 4 ++-- Sources/ContainerCommands/Registry/Login.swift | 15 +++++++++------ Sources/ContainerCommands/Registry/Logout.swift | 6 +++--- .../Registry/RegistryCommand.swift | 5 +++-- .../ContainerCommands/Volume/VolumeDelete.swift | 2 -- 5 files changed, 17 insertions(+), 15 deletions(-) diff --git a/Sources/ContainerCommands/Network/NetworkDelete.swift b/Sources/ContainerCommands/Network/NetworkDelete.swift index 80e545e21..4df3374bc 100644 --- a/Sources/ContainerCommands/Network/NetworkDelete.swift +++ b/Sources/ContainerCommands/Network/NetworkDelete.swift @@ -90,8 +90,8 @@ extension Application { for network in networks { group.addTask { do { - // delete atomically disables the IP allocator, then deletes - // the allocator disable fails if any IPs are still in use + // Delete atomically disables the IP allocator, then deletes + // the allocator. The disable fails if any IPs are still in use. try await ClientNetwork.delete(id: network.id) print(network.id) return nil diff --git a/Sources/ContainerCommands/Registry/Login.swift b/Sources/ContainerCommands/Registry/Login.swift index 3346f3fe9..018e168d1 100644 --- a/Sources/ContainerCommands/Registry/Login.swift +++ b/Sources/ContainerCommands/Registry/Login.swift @@ -25,20 +25,23 @@ extension Application { public struct Login: AsyncParsableCommand { public init() {} public static let configuration = CommandConfiguration( - abstract: "Login to a registry" + abstract: "Log in to a registry" ) - @Option(name: .shortAndLong, help: "Username") - var username: String = "" + @OptionGroup + var registry: Flags.Registry @Flag(help: "Take the password from stdin") var passwordStdin: Bool = false - @Argument(help: "Registry server name") - var server: String + @Option(name: .shortAndLong, help: "Registry user name") + var username: String = "" @OptionGroup - var registry: Flags.Registry + var global: Flags.Global + + @Argument(help: "Registry server name") + var server: String public func run() async throws { var username = self.username diff --git a/Sources/ContainerCommands/Registry/Logout.swift b/Sources/ContainerCommands/Registry/Logout.swift index ac1155010..1eb274198 100644 --- a/Sources/ContainerCommands/Registry/Logout.swift +++ b/Sources/ContainerCommands/Registry/Logout.swift @@ -25,12 +25,12 @@ extension Application { public static let configuration = CommandConfiguration( abstract: "Log out from a registry") - @Argument(help: "Registry server name") - var registry: String - @OptionGroup var global: Flags.Global + @Argument(help: "Registry server name") + var registry: String + public func run() async throws { let keychain = KeychainHelper(id: Constants.keychainID) let r = Reference.resolveDomain(domain: registry) diff --git a/Sources/ContainerCommands/Registry/RegistryCommand.swift b/Sources/ContainerCommands/Registry/RegistryCommand.swift index 55c2fc1e7..5a4782040 100644 --- a/Sources/ContainerCommands/Registry/RegistryCommand.swift +++ b/Sources/ContainerCommands/Registry/RegistryCommand.swift @@ -18,15 +18,16 @@ import ArgumentParser extension Application { public struct RegistryCommand: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "registry", - abstract: "Manage registry configurations", + abstract: "Manage registry logins", subcommands: [ Login.self, Logout.self, ], aliases: ["r"] ) + + public init() {} } } diff --git a/Sources/ContainerCommands/Volume/VolumeDelete.swift b/Sources/ContainerCommands/Volume/VolumeDelete.swift index 54133d215..021d910d7 100644 --- a/Sources/ContainerCommands/Volume/VolumeDelete.swift +++ b/Sources/ContainerCommands/Volume/VolumeDelete.swift @@ -71,8 +71,6 @@ extension Application.VolumeCommand { for volume in volumes { group.addTask { do { - // delete atomically disables the IP allocator, then deletes - // the allocator disable fails if any IPs are still in use try await ClientVolume.delete(name: volume.id) print(volume.id) return nil From 7df9763aa52435efa31afbe2308a418ef9f91151 Mon Sep 17 00:00:00 2001 From: Danny Canter Date: Mon, 22 Sep 2025 14:17:38 -0700 Subject: [PATCH 013/491] ClientContainer: Remove response timeout from `stop` (#667) After the large reworks, I'm not as worried about having this timeout anymore. Prior to the reworks stop was signaled via an async event sent to the daemon as stop was performed directly on the given runtime-helper instance. If anything errored during that it was common for things to explode, but now that stop is: client -> APIServer -> runtime-helper instance and the logic for stop has been improved quite a bit, in addition to stop no longer holding a lock during it's operation, I'm not sure we need this. In the future it'd also be nice to support no timeout (the timeout until we send SIGKILL, not response timeout) in which case we'd have to have an extra bit of logic here to skip the response timeout in that scenario anyways. --- Package.resolved | 2 +- Sources/ContainerClient/Core/ClientContainer.swift | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/Package.resolved b/Package.resolved index 414623d6c..a82439493 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "23302f085b6239754a405a0a6ec0dcaad1d48086319c3eaa5a5650b4bf2670e5", + "originHash" : "a04bb1d08ef1fca925fcfe3250303675ad17c962fecc35b23972ebb8598bb63f", "pins" : [ { "identity" : "async-http-client", diff --git a/Sources/ContainerClient/Core/ClientContainer.swift b/Sources/ContainerClient/Core/ClientContainer.swift index a110fe50c..b8b0999b4 100644 --- a/Sources/ContainerClient/Core/ClientContainer.swift +++ b/Sources/ContainerClient/Core/ClientContainer.swift @@ -196,13 +196,7 @@ extension ClientContainer { request.set(key: .id, value: self.id) request.set(key: .stopOptions, value: data) - // Stop is somewhat more prone to hanging than other commands given it - // has quite a bit of `wait()`'s down the chain to make sure the container actually - // exited. To combat a potential hang, lets timeout if we don't return in a small - // time period after the actual stop timeout sent via .stopOptions (the time - // until we send SIGKILL after SIGTERM if the container still hasn't exited). - let responseTimeout = Duration(.seconds(Int64(opts.timeoutInSeconds + 3))) - try await client.send(request, responseTimeout: responseTimeout) + try await client.send(request) } catch { throw ContainerizationError( .internalError, From f3b33ffc45434f8a45acbf39b9f25af74dd18703 Mon Sep 17 00:00:00 2001 From: J Logan Date: Mon, 22 Sep 2025 16:36:07 -0700 Subject: [PATCH 014/491] Cleans up option groups, container subcommand help. (#647) - Part of #515. - Add titles to option groups for container subcommands. - Order option groups and container subcommand options alphabetically. - Use `container-id` and `container-ids` consistently as argument names. - Shorten long valueNames to avoid option column overflow in help output where possible. - Replace customShort and customLong with short, long, and shortAndLong where possible. - Always place global options and arguments after alphabetized command-specific options. - Rename RunCommand to ContainerRun and relocate it. - Rename Executable to ContainerCLI. ## Type of Change - [ ] Bug fix - [ ] New feature - [x] Breaking change (if you're depending on `RunCommand`, renamed). - [ ] Documentation update ## Motivation and Context See #385. ## Testing - [ ] Tested locally - [ ] Added/updated tests - [ ] Added/updated docs --- .../{Executable.swift => ContainerCLI.swift} | 2 +- Sources/ContainerClient/Flags.swift | 172 ++++++++----- Sources/ContainerClient/Parser.swift | 2 +- Sources/ContainerCommands/Application.swift | 2 +- Sources/ContainerCommands/BuildCommand.swift | 80 +++--- .../Container/ContainerCreate.swift | 20 +- .../Container/ContainerDelete.swift | 16 +- .../Container/ContainerExec.swift | 8 +- .../Container/ContainerInspect.swift | 6 +- .../Container/ContainerKill.swift | 20 +- .../Container/ContainerLogs.swift | 22 +- .../ContainerRun.swift} | 16 +- .../Container/ContainerStart.swift | 10 +- .../Container/ContainerStop.swift | 16 +- .../Container/ContainersCommand.swift | 40 --- docs/command-reference.md | 238 +++++++++++------- 16 files changed, 362 insertions(+), 308 deletions(-) rename Sources/CLI/{Executable.swift => ContainerCLI.swift} (96%) rename Sources/ContainerCommands/{RunCommand.swift => Container/ContainerRun.swift} (97%) delete mode 100644 Sources/ContainerCommands/Container/ContainersCommand.swift diff --git a/Sources/CLI/Executable.swift b/Sources/CLI/ContainerCLI.swift similarity index 96% rename from Sources/CLI/Executable.swift rename to Sources/CLI/ContainerCLI.swift index 2fd5485f3..62eec52d3 100644 --- a/Sources/CLI/Executable.swift +++ b/Sources/CLI/ContainerCLI.swift @@ -19,7 +19,7 @@ import ContainerClient import ContainerCommands @main -public struct Executable: AsyncParsableCommand { +public struct ContainerCLI: AsyncParsableCommand { public init() {} @Argument(parsing: .captureForPassthrough) diff --git a/Sources/ContainerClient/Flags.swift b/Sources/ContainerClient/Flags.swift index a2b081ca1..4d1d21fba 100644 --- a/Sources/ContainerClient/Flags.swift +++ b/Sources/ContainerClient/Flags.swift @@ -29,43 +29,49 @@ public struct Flags { public struct Process: ParsableArguments { public init() {} - @Option( - name: [.customLong("cwd"), .customShort("w"), .customLong("workdir")], - help: "Current working directory for the container") - public var cwd: String? - - @Option(name: [.customLong("env"), .customShort("e")], help: "Set environment variables") + @Option(name: .shortAndLong, help: "Set environment variables (format: key=value)") public var env: [String] = [] - @Option(name: .customLong("env-file"), help: "Read in a file of environment variables") + @Option( + name: .long, + help: "Read in a file of environment variables (key=value format, ignores # comments and blank lines)" + ) public var envFile: [String] = [] - @Option(name: .customLong("uid"), help: "Set the uid for the process") - public var uid: UInt32? - - @Option(name: .customLong("gid"), help: "Set the gid for the process") + @Option(name: .long, help: "Set the group ID for the process") public var gid: UInt32? - @Flag(name: [.customLong("interactive"), .customShort("i")], help: "Keep Stdin open even if not attached") + @Flag(name: .shortAndLong, help: "Keep the standard input open even if not attached") public var interactive = false - @Flag(name: [.customLong("tty"), .customShort("t")], help: "Open a tty with the process") + @Flag(name: .shortAndLong, help: "Open a TTY with the process") public var tty = false - @Option(name: [.customLong("user"), .customShort("u")], help: "Set the user for the process") + @Option(name: .shortAndLong, help: "Set the user for the process (format: name|uid[:gid])") public var user: String? + + @Option(name: .long, help: "Set the user ID for the process") + public var uid: UInt32? + + @Option( + name: [.customShort("w"), .customLong("workdir"), .long], + help: .init( + "Set the initial working directory inside the container", + valueName: "dir" + ) + ) + public var cwd: String? } public struct Resource: ParsableArguments { public init() {} - @Option(name: [.customLong("cpus"), .customShort("c")], help: "Number of CPUs to allocate to the container") + @Option(name: .shortAndLong, help: "Number of CPUs to allocate to the container") public var cpus: Int64? @Option( - name: [.customLong("memory"), .customShort("m")], - help: - "Amount of memory (1MiByte granularity), with optional K, M, G, T, or P suffix" + name: .shortAndLong, + help: "Amount of memory (1MiByte granularity), with optional K, M, G, T, or P suffix" ) public var memory: String? } @@ -84,83 +90,115 @@ public struct Flags { public struct Management: ParsableArguments { public init() {} - @Flag(name: [.customLong("detach"), .short], help: "Run the container and detach from the process") - public var detach = false - - @Option(name: .customLong("entrypoint"), help: "Override the entrypoint of the image") - public var entryPoint: String? - - @Option(name: .customLong("mount"), help: "Add a mount to the container (type=<>,source=<>,target=<>,readonly)") - public var mounts: [String] = [] - - @Option(name: [.customLong("publish"), .short], help: "Publish a port from container to host (format: [host-ip:]host-port:container-port[/protocol])") - public var publishPorts: [String] = [] - - @Option(name: .customLong("publish-socket"), help: "Publish a socket from container to host (format: host_path:container_path)") - public var publishSockets: [String] = [] + @Option(name: .shortAndLong, help: "Set arch if image can target multiple architectures") + public var arch: String = Arch.hostArchitecture().rawValue - @Option(name: .customLong("tmpfs"), help: "Add a tmpfs mount to the container at the given path") - public var tmpFs: [String] = [] + @Option(name: .long, help: "Write the container ID to the path provided") + public var cidfile = "" - @Option(name: .customLong("name"), help: "Assign a name to the container. If excluded will be a generated UUID") - public var name: String? + @Flag(name: .shortAndLong, help: "Run the container and detach from the process") + public var detach = false - @Flag(name: [.customLong("remove"), .customLong("rm")], help: "Remove the container after it stops") - public var remove = false + @Option( + name: .customLong("dns"), + help: .init("DNS nameserver IP address", valueName: "ip") + ) + public var dnsNameservers: [String] = [] - @Option(name: .customLong("platform"), help: "Platform for the image if it's multi-platform. This takes precedence over --os and --arch") - public var platform: String? + @Option( + name: .long, + help: .init("Default DNS domain", valueName: "domain") + ) + public var dnsDomain: String? = nil - @Option(name: .customLong("os"), help: "Set OS if image can target multiple operating systems") - public var os = "linux" + @Option( + name: .customLong("dns-option"), + help: .init("DNS options", valueName: "option") + ) + public var dnsOptions: [String] = [] @Option( - name: [.long, .short], help: "Set arch if image can target multiple architectures") - public var arch: String = Arch.hostArchitecture().rawValue + name: .customLong("dns-search"), + help: .init("DNS search domains", valueName: "domain") + ) + public var dnsSearchDomains: [String] = [] - @Option(name: [.customLong("volume"), .short], help: "Bind mount a volume into the container") - public var volumes: [String] = [] + @Option( + name: .long, + help: .init( + "Override the entrypoint of the image", + valueName: "cmd" + ) + ) + public var entrypoint: String? @Option( - name: [.customLong("kernel"), .short], help: "Set a custom kernel path", completion: .file(), + name: .shortAndLong, + help: .init("Set a custom kernel path", valueName: "path"), + completion: .file(), transform: { str in URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) - }) + } + ) public var kernel: String? + @Option(name: [.short, .customLong("label")], help: "Add a key=value label to the container") + public var labels: [String] = [] + + @Option(name: .customLong("mount"), help: "Add a mount to the container (format: type=<>,source=<>,target=<>,readonly)") + public var mounts: [String] = [] + + @Option(name: .long, help: "Use the specified name as the container ID") + public var name: String? + @Option(name: [.customLong("network")], help: "Attach the container to a network") public var networks: [String] = [] - @Option(name: .customLong("cidfile"), help: "Write the container ID to the path provided") - public var cidfile = "" - @Flag(name: [.customLong("no-dns")], help: "Do not configure DNS in the container") public var dnsDisabled = false - @Option(name: .customLong("dns"), help: "DNS nameserver IP address") - public var dnsNameservers: [String] = [] + @Option(name: .long, help: "Set OS if image can target multiple operating systems") + public var os = "linux" - @Option(name: .customLong("dns-domain"), help: "Default DNS domain") - public var dnsDomain: String? = nil + @Option( + name: [.customShort("p"), .customLong("publish")], + help: .init( + "Publish a port from container to host (format: [host-ip:]host-port:container-port[/protocol])", + valueName: "spec" + ) + ) + public var publishPorts: [String] = [] - @Option(name: .customLong("dns-search"), help: "DNS search domains") - public var dnsSearchDomains: [String] = [] + @Option(name: .long, help: "Platform for the image if it's multi-platform. This takes precedence over --os and --arch") + public var platform: String? - @Option(name: .customLong("dns-option"), help: "DNS options") - public var dnsOptions: [String] = [] + @Option( + name: .customLong("publish-socket"), + help: .init( + "Publish a socket from container to host (format: host_path:container_path)", + valueName: "spec" + ) + ) + public var publishSockets: [String] = [] - @Option(name: [.customLong("label"), .short], help: "Add a key=value label to the container") - public var labels: [String] = [] + @Flag(name: [.customLong("rm"), .long], help: "Remove the container after it stops") + public var remove = false + + @Flag(name: .long, help: "Forward SSH agent socket to container") + public var ssh = false + + @Option(name: .customLong("tmpfs"), help: "Add a tmpfs mount to the container at the given path") + public var tmpFs: [String] = [] + + @Option(name: [.customLong("volume"), .short], help: "Bind mount a volume into the container") + public var volumes: [String] = [] @Flag( - name: [.customLong("virtualization")], + name: .long, help: - "Expose virtualization capabilities to the container. (Host must have nested virtualization support, and guest kernel must have virtualization capabilities enabled)" + "Expose virtualization capabilities to the container (requires host and guest support)" ) public var virtualization: Bool = false - - @Flag(name: .customLong("ssh"), help: "Forward SSH agent socket to container") - public var ssh = false } public struct Progress: ParsableArguments { @@ -170,7 +208,7 @@ public struct Flags { self.disableProgressUpdates = disableProgressUpdates } - @Flag(name: .customLong("disable-progress-updates"), help: "Disable progress bar updates") + @Flag(name: .long, help: "Disable progress bar updates") public var disableProgressUpdates = false } } diff --git a/Sources/ContainerClient/Parser.swift b/Sources/ContainerClient/Parser.swift index 493bf9fb4..24f844931 100644 --- a/Sources/ContainerClient/Parser.swift +++ b/Sources/ContainerClient/Parser.swift @@ -192,7 +192,7 @@ public struct Parser { var result: [String] = [] var hasEntrypointOverride: Bool = false // ensure the entrypoint is honored if it has been explicitly set by the user - if let entrypoint = managementFlags.entryPoint, !entrypoint.isEmpty { + if let entrypoint = managementFlags.entrypoint, !entrypoint.isEmpty { result = [entrypoint] hasEntrypointOverride = true } else if let entrypoint = config?.entrypoint, !entrypoint.isEmpty { diff --git a/Sources/ContainerCommands/Application.swift b/Sources/ContainerCommands/Application.swift index c04656f97..10ea34047 100644 --- a/Sources/ContainerCommands/Application.swift +++ b/Sources/ContainerCommands/Application.swift @@ -59,7 +59,7 @@ public struct Application: AsyncParsableCommand { ContainerKill.self, ContainerList.self, ContainerLogs.self, - ContainerRunCommand.self, + ContainerRun.self, ContainerStart.self, ContainerStop.self, ] diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift index ec44fdc9b..affa786c8 100644 --- a/Sources/ContainerCommands/BuildCommand.swift +++ b/Sources/ContainerCommands/BuildCommand.swift @@ -38,21 +38,30 @@ extension Application { return config } - @Option(name: [.customLong("cpus"), .customShort("c")], help: "Number of CPUs to allocate to the container") - var cpus: Int64 = 2 - @Option( - name: [.customLong("memory"), .customShort("m")], - help: - "Amount of builder container memory (1MiByte granularity), with optional K, M, G, T, or P suffix" + name: .shortAndLong, + help: ArgumentHelp("Add the architecture type to the build", valueName: "value"), + transform: { val in val.split(separator: ",").map { String($0) } } ) - var memory: String = "2048MB" + var arch: [[String]] = { + [[Arch.hostArchitecture().rawValue]] + }() @Option(name: .long, help: ArgumentHelp("Set build-time variables", valueName: "key=val")) var buildArg: [String] = [] - @Argument(help: "Build directory") - var contextDir: String = "." + @Option(name: .long, help: ArgumentHelp("Cache imports for the build", valueName: "value", visibility: .hidden)) + var cacheIn: [String] = { + [] + }() + + @Option(name: .long, help: ArgumentHelp("Cache exports for the build", valueName: "value", visibility: .hidden)) + var cacheOut: [String] = { + [] + }() + + @Option(name: .shortAndLong, help: "Number of CPUs to allocate to the builder container") + var cpus: Int64 = 2 @Option(name: .shortAndLong, help: ArgumentHelp("Path to Dockerfile", valueName: "path")) var file: String = "Dockerfile" @@ -60,34 +69,24 @@ extension Application { @Option(name: .shortAndLong, help: ArgumentHelp("Set a label", valueName: "key=val")) var label: [String] = [] + @Option( + name: .shortAndLong, + help: + "Amount of builder container memory (1MiByte granularity), with optional K, M, G, T, or P suffix" + ) + var memory: String = "2048MB" + @Flag(name: .long, help: "Do not use cache") var noCache: Bool = false - @Option(name: .shortAndLong, help: ArgumentHelp("Output configuration for the build", valueName: "value")) + @Option(name: .shortAndLong, help: ArgumentHelp("Output configuration for the build (format: type=[,dest=])", valueName: "value")) var output: [String] = { ["type=oci"] }() - @Option(name: .long, help: ArgumentHelp("Cache imports for the build", valueName: "value", visibility: .hidden)) - var cacheIn: [String] = { - [] - }() - - @Option(name: .long, help: ArgumentHelp("Cache exports for the build", valueName: "value", visibility: .hidden)) - var cacheOut: [String] = { - [] - }() - - @Option( - name: .long, - help: "add the platform to the build", - transform: { val in val.split(separator: ",").map { String($0) } } - ) - var platform: [[String]] = [[]] - @Option( name: .long, - help: ArgumentHelp("add the OS type to the build", valueName: "value"), + help: ArgumentHelp("Add the OS type to the build", valueName: "value"), transform: { val in val.split(separator: ",").map { String($0) } } ) var os: [[String]] = { @@ -95,28 +94,29 @@ extension Application { }() @Option( - name: [.long, .short], - help: ArgumentHelp("add the architecture type to the build", valueName: "value"), + name: .long, + help: "Add the platform to the build (takes precedence over --os and --arch)", transform: { val in val.split(separator: ",").map { String($0) } } ) - var arch: [[String]] = { - [[Arch.hostArchitecture().rawValue]] - }() + var platform: [[String]] = [[]] - @Option(name: .long, help: ArgumentHelp("Progress type - one of [auto|plain|tty]", valueName: "type")) + @Option(name: .long, help: ArgumentHelp("Progress type (format: auto|plain|tty)]", valueName: "type")) var progress: String = "auto" - @Option(name: .long, help: ArgumentHelp("Builder-shim vsock port", valueName: "port")) - var vsockPort: UInt32 = 8088 + @Flag(name: .shortAndLong, help: "Suppress build output") + var quiet: Bool = false - @Option(name: [.customShort("t"), .customLong("tag")], help: ArgumentHelp("Name for the built image", valueName: "name")) + @Option(name: [.short, .customLong("tag")], help: ArgumentHelp("Name for the built image", valueName: "name")) var targetImageName: String = UUID().uuidString.lowercased() @Option(name: .long, help: ArgumentHelp("Set the target build stage", valueName: "stage")) var target: String = "" - @Flag(name: .shortAndLong, help: "Suppress build output") - var quiet: Bool = false + @Option(name: .long, help: ArgumentHelp("Builder shim vsock port", valueName: "port")) + var vsockPort: UInt32 = 8088 + + @Argument(help: "Build directory") + var contextDir: String = "." public func run() async throws { do { @@ -257,7 +257,7 @@ extension Application { return results }() group.addTask { [terminal, buildArg, contextDir, label, noCache, target, quiet, cacheIn, cacheOut] in - let config = ContainerBuild.Builder.BuildConfig( + let config = Builder.BuildConfig( buildID: buildID, contentStore: RemoteContentStoreClient(), buildArgs: buildArg, diff --git a/Sources/ContainerCommands/Container/ContainerCreate.swift b/Sources/ContainerCommands/Container/ContainerCreate.swift index 5375f621f..283b43124 100644 --- a/Sources/ContainerCommands/Container/ContainerCreate.swift +++ b/Sources/ContainerCommands/Container/ContainerCreate.swift @@ -28,27 +28,27 @@ extension Application { commandName: "create", abstract: "Create a new container") - @Argument(help: "Image name") - var image: String - - @Argument(parsing: .captureForPassthrough, help: "Container init process arguments") - var arguments: [String] = [] - - @OptionGroup + @OptionGroup(title: "Process options") var processFlags: Flags.Process - @OptionGroup + @OptionGroup(title: "Resource options") var resourceFlags: Flags.Resource - @OptionGroup + @OptionGroup(title: "Management options") var managementFlags: Flags.Management - @OptionGroup + @OptionGroup(title: "Registry options") var registryFlags: Flags.Registry @OptionGroup var global: Flags.Global + @Argument(help: "Image name") + var image: String + + @Argument(parsing: .captureForPassthrough, help: "Container init process arguments") + var arguments: [String] = [] + public func run() async throws { let progressConfig = try ProgressConfig( showTasks: true, diff --git a/Sources/ContainerCommands/Container/ContainerDelete.swift b/Sources/ContainerCommands/Container/ContainerDelete.swift index 3719c04d1..d0826d5ce 100644 --- a/Sources/ContainerCommands/Container/ContainerDelete.swift +++ b/Sources/ContainerCommands/Container/ContainerDelete.swift @@ -28,23 +28,23 @@ extension Application { abstract: "Delete one or more containers", aliases: ["rm"]) - @Flag(name: .shortAndLong, help: "Delete containers even if they are running") - var force = false - @Flag(name: .shortAndLong, help: "Remove all containers") var all = false + @Flag(name: .shortAndLong, help: "Delete containers even if they are running") + var force = false + @OptionGroup var global: Flags.Global - @Argument(help: "Container IDs/names") - var containerIDs: [String] = [] + @Argument(help: "Container IDs") + var containerIds: [String] = [] public func validate() throws { - if containerIDs.count == 0 && !all { + if containerIds.count == 0 && !all { throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied") } - if containerIDs.count > 0 && all { + if containerIds.count > 0 && all { throw ContainerizationError( .invalidArgument, message: "explicitly supplied container ID(s) conflict with the --all flag" @@ -53,7 +53,7 @@ extension Application { } public mutating func run() async throws { - let set = Set(containerIDs) + let set = Set(containerIds) var containers = [ClientContainer]() if all { diff --git a/Sources/ContainerCommands/Container/ContainerExec.swift b/Sources/ContainerCommands/Container/ContainerExec.swift index 123a39ab5..03e9e575d 100644 --- a/Sources/ContainerCommands/Container/ContainerExec.swift +++ b/Sources/ContainerCommands/Container/ContainerExec.swift @@ -28,21 +28,21 @@ extension Application { commandName: "exec", abstract: "Run a new command in a running container") - @OptionGroup + @OptionGroup(title: "Process options") var processFlags: Flags.Process @OptionGroup var global: Flags.Global - @Argument(help: "Running containers ID") - var containerID: String + @Argument(help: "Container ID") + var containerId: String @Argument(parsing: .captureForPassthrough, help: "New process arguments") var arguments: [String] public func run() async throws { var exitCode: Int32 = 127 - let container = try await ClientContainer.get(id: containerID) + let container = try await ClientContainer.get(id: containerId) try ensureRunning(container: container) let stdin = self.processFlags.interactive diff --git a/Sources/ContainerCommands/Container/ContainerInspect.swift b/Sources/ContainerCommands/Container/ContainerInspect.swift index 8de6dbfea..36b23c363 100644 --- a/Sources/ContainerCommands/Container/ContainerInspect.swift +++ b/Sources/ContainerCommands/Container/ContainerInspect.swift @@ -30,12 +30,12 @@ extension Application { @OptionGroup var global: Flags.Global - @Argument(help: "Containers to inspect") - var containers: [String] + @Argument(help: "Container IDs") + var containerIds: [String] public func run() async throws { let objects: [any Codable] = try await ClientContainer.list().filter { - containers.contains($0.id) + containerIds.contains($0.id) }.map { PrintableContainer($0) } diff --git a/Sources/ContainerCommands/Container/ContainerKill.swift b/Sources/ContainerCommands/Container/ContainerKill.swift index 16dd3bb54..b9e4446e6 100644 --- a/Sources/ContainerCommands/Container/ContainerKill.swift +++ b/Sources/ContainerCommands/Container/ContainerKill.swift @@ -26,31 +26,31 @@ extension Application { public static let configuration = CommandConfiguration( commandName: "kill", - abstract: "Kill one or more running containers") + abstract: "Kill or signal one or more running containers") - @Option(name: .shortAndLong, help: "Signal to send the container(s)") - var signal: String = "KILL" - - @Flag(name: .shortAndLong, help: "Kill all running containers") + @Flag(name: .shortAndLong, help: "Kill or signal all running containers") var all = false - @Argument(help: "Container IDs") - var containerIDs: [String] = [] + @Option(name: .shortAndLong, help: "Signal to send to the container(s)") + var signal: String = "KILL" @OptionGroup var global: Flags.Global + @Argument(help: "Container IDs") + var containerIds: [String] = [] + public func validate() throws { - if containerIDs.count == 0 && !all { + if containerIds.count == 0 && !all { throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied") } - if containerIDs.count > 0 && all { + if containerIds.count > 0 && all { throw ContainerizationError(.invalidArgument, message: "explicitly supplied container IDs conflicts with the --all flag") } } public mutating func run() async throws { - let set = Set(containerIDs) + let set = Set(containerIds) var containers = try await ClientContainer.list().filter { c in c.status == .running diff --git a/Sources/ContainerCommands/Container/ContainerLogs.swift b/Sources/ContainerCommands/Container/ContainerLogs.swift index cc60c29db..f2bf468f7 100644 --- a/Sources/ContainerCommands/Container/ContainerLogs.swift +++ b/Sources/ContainerCommands/Container/ContainerLogs.swift @@ -27,27 +27,27 @@ extension Application { public static let configuration = CommandConfiguration( commandName: "logs", - abstract: "Fetch container stdio or boot logs" + abstract: "Fetch container logs" ) - @OptionGroup - var global: Flags.Global + @Flag(name: .long, help: "Display the boot log for the container instead of stdio") + var boot: Bool = false @Flag(name: .shortAndLong, help: "Follow log output") var follow: Bool = false - @Flag(name: .long, help: "Display the boot log for the container instead of stdio") - var boot: Bool = false - - @Option(name: [.customShort("n")], help: "Number of lines to show from the end of the logs. If not provided this will print all of the logs") + @Option(name: .short, help: "Number of lines to show from the end of the logs. If not provided this will print all of the logs") var numLines: Int? - @Argument(help: "Container to fetch logs for") - var container: String + @OptionGroup + var global: Flags.Global + + @Argument(help: "Container ID") + var containerId: String public func run() async throws { do { - let container = try await ClientContainer.get(id: container) + let container = try await ClientContainer.get(id: containerId) let fhs = try await container.logs() let fileHandle = boot ? fhs[1] : fhs[0] @@ -59,7 +59,7 @@ extension Application { } catch { throw ContainerizationError( .invalidArgument, - message: "failed to fetch container logs for \(container): \(error)" + message: "failed to fetch container logs for \(containerId): \(error)" ) } } diff --git a/Sources/ContainerCommands/RunCommand.swift b/Sources/ContainerCommands/Container/ContainerRun.swift similarity index 97% rename from Sources/ContainerCommands/RunCommand.swift rename to Sources/ContainerCommands/Container/ContainerRun.swift index 6d5d65031..aadaa3da1 100644 --- a/Sources/ContainerCommands/RunCommand.swift +++ b/Sources/ContainerCommands/Container/ContainerRun.swift @@ -26,29 +26,29 @@ import NIOPosix import TerminalProgress extension Application { - public struct ContainerRunCommand: AsyncParsableCommand { + public struct ContainerRun: AsyncParsableCommand { public init() {} public static let configuration = CommandConfiguration( commandName: "run", abstract: "Run a container") - @OptionGroup + @OptionGroup(title: "Process options") var processFlags: Flags.Process - @OptionGroup + @OptionGroup(title: "Resource options") var resourceFlags: Flags.Resource - @OptionGroup + @OptionGroup(title: "Management options") var managementFlags: Flags.Management - @OptionGroup + @OptionGroup(title: "Registry options") var registryFlags: Flags.Registry - @OptionGroup - var global: Flags.Global + @OptionGroup(title: "Progress options") + var progressFlags: Flags.Progress @OptionGroup - var progressFlags: Flags.Progress + var global: Flags.Global @Argument(help: "Image name") var image: String diff --git a/Sources/ContainerCommands/Container/ContainerStart.swift b/Sources/ContainerCommands/Container/ContainerStart.swift index 7369c0809..cffedc673 100644 --- a/Sources/ContainerCommands/Container/ContainerStart.swift +++ b/Sources/ContainerCommands/Container/ContainerStart.swift @@ -31,14 +31,14 @@ extension Application { @Flag(name: .shortAndLong, help: "Attach STDOUT/STDERR") var attach = false - @Flag(name: .shortAndLong, help: "Attach container's STDIN") + @Flag(name: .shortAndLong, help: "Attach STDIN") var interactive = false @OptionGroup var global: Flags.Global - @Argument(help: "Container's ID") - var containerID: String + @Argument(help: "Container ID") + var containerId: String public func run() async throws { var exitCode: Int32 = 127 @@ -52,7 +52,7 @@ extension Application { } progress.start() - let container = try await ClientContainer.get(id: containerID) + let container = try await ClientContainer.get(id: containerId) do { let detach = !self.attach && !self.interactive let io = try ProcessIO.create( @@ -70,7 +70,7 @@ extension Application { if detach { try await process.start() try io.closeAfterStart() - print(self.containerID) + print(self.containerId) return } diff --git a/Sources/ContainerCommands/Container/ContainerStop.swift b/Sources/ContainerCommands/Container/ContainerStop.swift index 7ffa28662..d1eed1a1a 100644 --- a/Sources/ContainerCommands/Container/ContainerStop.swift +++ b/Sources/ContainerCommands/Container/ContainerStop.swift @@ -31,30 +31,30 @@ extension Application { @Flag(name: .shortAndLong, help: "Stop all running containers") var all = false - @Option(name: .shortAndLong, help: "Signal to send the container(s)") + @Option(name: .shortAndLong, help: "Signal to send the containers") var signal: String = "SIGTERM" - @Option(name: .shortAndLong, help: "Seconds to wait before killing the container(s)") + @Option(name: .shortAndLong, help: "Seconds to wait before killing the containers") var time: Int32 = 5 - @Argument - var containerIDs: [String] = [] - @OptionGroup var global: Flags.Global + @Argument(help: "Container IDs") + var containerIds: [String] = [] + public func validate() throws { - if containerIDs.count == 0 && !all { + if containerIds.count == 0 && !all { throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied") } - if containerIDs.count > 0 && all { + if containerIds.count > 0 && all { throw ContainerizationError( .invalidArgument, message: "explicitly supplied container IDs conflicts with the --all flag") } } public mutating func run() async throws { - let set = Set(containerIDs) + let set = Set(containerIds) var containers = [ClientContainer]() if self.all { containers = try await ClientContainer.list() diff --git a/Sources/ContainerCommands/Container/ContainersCommand.swift b/Sources/ContainerCommands/Container/ContainersCommand.swift deleted file mode 100644 index 05abcd065..000000000 --- a/Sources/ContainerCommands/Container/ContainersCommand.swift +++ /dev/null @@ -1,40 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. -// -// 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 - -extension Application { - public struct ContainersCommand: AsyncParsableCommand { - public init() {} - - public static let configuration = CommandConfiguration( - commandName: "containers", - abstract: "Manage containers", - subcommands: [ - ContainerCreate.self, - ContainerDelete.self, - ContainerExec.self, - ContainerInspect.self, - ContainerKill.self, - ContainerList.self, - ContainerLogs.self, - ContainerStart.self, - ContainerStop.self, - ], - aliases: ["container", "c"] - ) - } -} diff --git a/docs/command-reference.md b/docs/command-reference.md index 751558d5b..0631c2153 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -16,42 +16,47 @@ container run [OPTIONS] IMAGE [COMMAND] [ARG...] **Options** -* **Process and resources** - * `-w, --cwd, --workdir `: Current working directory for the container - * `-e, --env `: Set environment variables - * `--env-file `: Read in a file of environment variables - * `--uid `: Set the uid for the process - * `--gid `: Set the gid for the process - * `-i, --interactive`: Keep Stdin open even if not attached - * `-t, --tty`: Open a tty with the process - * `-u, --user `: Set the user for the process +* **Process options** + * `-e, --env `: Set environment variables (format: key=value) + * `--env-file `: Read in a file of environment variables (key=value format, ignores # comments and blank lines) + * `--gid `: Set the group ID for the process + * `-i, --interactive`: Keep the standard input open even if not attached + * `-t, --tty`: Open a TTY with the process + * `-u, --user `: Set the user for the process (format: name|uid[:gid]) + * `--uid `: Set the user ID for the process + * `-w, --workdir, --cwd `: Set the initial working directory inside the container +* **Resource options** * `-c, --cpus `: Number of CPUs to allocate to the container - * `-m, --memory `: Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container) -* **Container management** - * `-d, --detach`: Run the container and detach from the process - * `--entrypoint `: Override the entrypoint of the image - * `--mount `: Add a mount to the container (type=<>,source=<>,target=<>,readonly) - * `-p, --publish `: Publish a port from container to host (format: [host-ip:]host-port:container-port[/protocol]) - * `--publish-socket `: Publish a socket from container to host (format: host_path:container_path) - * `--tmpfs `: Add a tmpfs mount to the container at the given path - * `--name `: Assign a name to the container. If excluded will be a generated UUID - * `--remove, --rm`: Remove the container after it stops - * `--os `: Set OS if image can target multiple operating systems (default: linux) + * `-m, --memory `: Amount of memory (1MiByte granularity), with optional K, M, G, T, or P suffix +* **Management options** * `-a, --arch `: Set arch if image can target multiple architectures (default: arm64) - * `-v, --volume `: Bind mount a volume into the container - * `-k, --kernel `: Set a custom kernel path - * `--network `: Attach the container to a network * `--cidfile `: Write the container ID to the path provided - * `--no-dns`: Do not configure DNS in the container - * `--dns `: DNS nameserver IP address - * `--dns-domain `: Default DNS domain - * `--dns-search `: DNS search domains - * `--dns-option `: DNS options + * `-d, --detach`: Run the container and detach from the process + * `--dns `: DNS nameserver IP address + * `--dns-domain `: Default DNS domain + * `--dns-option