diff --git a/Sources/ContainerBuild/Builder.swift b/Sources/ContainerBuild/Builder.swift index cc941bb17..8b8e83406 100644 --- a/Sources/ContainerBuild/Builder.swift +++ b/Sources/ContainerBuild/Builder.swift @@ -25,6 +25,8 @@ import NIOHPACK import NIOHTTP2 public struct Builder: Sendable { + public static let builderContainerId = "buildkit" + let client: BuilderClientProtocol let clientAsync: BuilderClientAsyncProtocol let group: EventLoopGroup diff --git a/Sources/ContainerCommands/Builder/BuilderStart.swift b/Sources/ContainerCommands/Builder/BuilderStart.swift index 83fd8b288..278e94dde 100644 --- a/Sources/ContainerCommands/Builder/BuilderStart.swift +++ b/Sources/ContainerCommands/Builder/BuilderStart.swift @@ -201,8 +201,7 @@ extension Application { useRosetta ? nil : "--enable-qemu", ].compactMap { $0 } - let id = "buildkit" - try ContainerAPIClient.Utility.validEntityName(id) + try ContainerAPIClient.Utility.validEntityName(Builder.builderContainerId) let image = try await ClientImage.fetch( reference: builderImage, @@ -244,9 +243,9 @@ extension Application { memory: memory ) - var config = ContainerConfiguration(id: id, image: imageDesc, process: processConfig) + var config = ContainerConfiguration(id: Builder.builderContainerId, image: imageDesc, process: processConfig) config.resources = resources - config.labels = ["com.apple.container.resource.role": "builder"] + config.labels = [ResourceLabelKeys.role: ResourceRoleValues.builder] config.mounts = [ .init( type: .tmpfs, @@ -264,11 +263,15 @@ extension Application { // Enable Rosetta only if the user didn't ask to disable it config.rosetta = useRosetta - let network = try await ClientNetwork.get(id: ClientNetwork.defaultNetworkName) - guard case .running(_, let networkStatus) = network else { + guard let defaultNetwork = try await ClientNetwork.builtin else { + throw ContainerizationError(.invalidState, message: "default network is not present") + } + guard case .running(_, let networkStatus) = defaultNetwork else { throw ContainerizationError(.invalidState, message: "default network is not running") } - config.networks = [AttachmentConfiguration(network: network.id, options: AttachmentOptions(hostname: id))] + config.networks = [ + AttachmentConfiguration(network: defaultNetwork.id, options: AttachmentOptions(hostname: Builder.builderContainerId)) + ] let subnet = networkStatus.ipv4Subnet let nameserver = IPv4Address(subnet.lower.value + 1).description let nameservers = dnsNameservers.isEmpty ? [nameserver] : dnsNameservers diff --git a/Sources/ContainerCommands/Network/NetworkDelete.swift b/Sources/ContainerCommands/Network/NetworkDelete.swift index 795a85c88..979266738 100644 --- a/Sources/ContainerCommands/Network/NetworkDelete.swift +++ b/Sources/ContainerCommands/Network/NetworkDelete.swift @@ -54,20 +54,22 @@ extension Application { let uniqueNetworkNames = Set(networkNames) let networks: [NetworkState] - if uniqueNetworkNames.contains(ClientNetwork.defaultNetworkName) { - throw ContainerizationError( - .invalidArgument, - message: "cannot delete the default network" - ) - } - if all { networks = try await ClientNetwork.list() - .filter { $0.id != ClientNetwork.defaultNetworkName } + .filter { !$0.isBuiltin } } else { networks = try await ClientNetwork.list() .filter { c in - uniqueNetworkNames.contains(c.id) + guard uniqueNetworkNames.contains(c.id) else { + return false + } + guard !c.isBuiltin else { + throw ContainerizationError( + .invalidArgument, + message: "cannot delete a builtin network: \(c.id)" + ) + } + return true } // If one of the networks requested isn't present lets throw. We don't need to do diff --git a/Sources/ContainerCommands/Network/NetworkPrune.swift b/Sources/ContainerCommands/Network/NetworkPrune.swift index 2ba0199fc..b903fb7dd 100644 --- a/Sources/ContainerCommands/Network/NetworkPrune.swift +++ b/Sources/ContainerCommands/Network/NetworkPrune.swift @@ -41,7 +41,7 @@ extension Application.NetworkCommand { } let networksToPrune = allNetworks.filter { network in - network.id != ClientNetwork.defaultNetworkName && !networksInUse.contains(network.id) + !network.isBuiltin && !networksInUse.contains(network.id) } var prunedNetworks = [String]() diff --git a/Sources/ContainerPersistence/DefaultsStore.swift b/Sources/ContainerPersistence/DefaultsStore.swift index 91befa754..8aea8b20c 100644 --- a/Sources/ContainerPersistence/DefaultsStore.swift +++ b/Sources/ContainerPersistence/DefaultsStore.swift @@ -20,7 +20,7 @@ import ContainerizationError import Foundation public enum DefaultsStore { - private static let userDefaultDomain = "com.apple.container.defaults" + public static let userDefaultDomain = "com.apple.container.defaults" public enum Keys: String { case buildRosetta = "build.rosetta" diff --git a/Sources/ContainerResource/Common/ManagedResource.swift b/Sources/ContainerResource/Common/ManagedResource.swift new file mode 100644 index 000000000..4349ebe5e --- /dev/null +++ b/Sources/ContainerResource/Common/ManagedResource.swift @@ -0,0 +1,58 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation + +/// Common properties for all managed resources. +public protocol ManagedResource: Identifiable, Sendable, Codable { + /// A 64 byte hexadecimal string, assigned by the system, that uniquely + /// identifies the resource. + var id: String { get } + + /// A user assigned name that shall be unique within the namespace of + /// the resource category. If the user does not assign a name, this value + /// shall be the same as the system-assigned identifier. + var name: String { get } + + /// The time at which the system created the resource. + var creationDate: Date { get } + + /// Key-value properties for the resource. The user and system may both + /// make use of labels to read and write annotations or other metadata. + /// A good practice is to use + var labels: [String: String] { get } + + /// Generates a unique resource ID value. + static func generateId() -> String + + /// Returns true only if the specified resource name is syntactically valid. + static func nameValid(_ name: String) -> Bool +} + +extension ManagedResource { + /// Generate a random identifier that has the format of an ASCII SHA-256 hash. + public static func randomId() -> String { + (0..<2) + .map { _ in UInt128.random(in: 0...UInt128.max) } + .map { String($0, radix: 16).padding(toLength: 32, withPad: "0", startingAt: 0) } + .joined() + } +} + +// FIXME: This moves to ManagedResource and/or a ResourceLabels typealias eventually. +extension [String: String] { + public var isBuiltin: Bool { self.contains { $0 == ResourceLabelKeys.role && $1 == ResourceRoleValues.builtin } } +} diff --git a/Sources/ContainerResource/Common/ResourceLabels.swift b/Sources/ContainerResource/Common/ResourceLabels.swift new file mode 100644 index 000000000..5f80a713c --- /dev/null +++ b/Sources/ContainerResource/Common/ResourceLabels.swift @@ -0,0 +1,33 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +/// System-defined keys for resource labels. +public struct ResourceLabelKeys { + /// Indicates a owner of a resource managed by a plugin. + public static let plugin = "com.apple.container.plugin" + + /// Indicates a resource with a reserved or dedicated purpose. + public static let role = "com.apple.container.resource.role" +} + +/// System-defined values for resource the resource role label. +public struct ResourceRoleValues { + /// Indicates a container that can build images. + public static let builder = "builder" + + /// Indicates a system-created resource that cannot be deleted by the user. + public static let builtin = "builtin" +} diff --git a/Sources/ContainerResource/Network/NetworkState.swift b/Sources/ContainerResource/Network/NetworkState.swift index 244041485..3430d761d 100644 --- a/Sources/ContainerResource/Network/NetworkState.swift +++ b/Sources/ContainerResource/Network/NetworkState.swift @@ -57,15 +57,19 @@ public enum NetworkState: Codable, Sendable { public var id: String { switch self { - case .created(let configuration): configuration.id - case .running(let configuration, _): configuration.id + case .created(let config), .running(let config, _): config.id } } public var creationDate: Date { switch self { - case .created(let configuration): configuration.creationDate - case .running(let configuration, _): configuration.creationDate + case .created(let config), .running(let config, _): config.creationDate + } + } + + public var isBuiltin: Bool { + switch self { + case .created(let config), .running(let config, _): config.labels.isBuiltin } } } diff --git a/Sources/Helpers/APIServer/APIServer+Start.swift b/Sources/Helpers/APIServer/APIServer+Start.swift index c56fb4f4a..928a9f2cb 100644 --- a/Sources/Helpers/APIServer/APIServer+Start.swift +++ b/Sources/Helpers/APIServer/APIServer+Start.swift @@ -264,10 +264,14 @@ extension APIServer { ) let defaultNetwork = try await service.list() - .filter { $0.id == ClientNetwork.defaultNetworkName } + .filter { $0.isBuiltin } .first if defaultNetwork == nil { - let config = try NetworkConfiguration(id: ClientNetwork.defaultNetworkName, mode: .nat) + let config = try NetworkConfiguration( + id: ClientNetwork.defaultNetworkName, + mode: .nat, + labels: [ResourceLabelKeys.role: ResourceRoleValues.builtin] + ) _ = try await service.create(configuration: config) } diff --git a/Sources/Services/ContainerAPIService/Client/ClientNetwork.swift b/Sources/Services/ContainerAPIService/Client/ClientNetwork.swift index d2521314a..d7f239a28 100644 --- a/Sources/Services/ContainerAPIService/Client/ClientNetwork.swift +++ b/Sources/Services/ContainerAPIService/Client/ClientNetwork.swift @@ -86,4 +86,11 @@ extension ClientNetwork { request.set(key: .networkId, value: id) try await client.send(request) } + + /// Retrieve the builtin network. + public static var builtin: NetworkState? { + get async throws { + try await list().first { $0.isBuiltin } + } + } } diff --git a/Sources/Services/ContainerAPIService/Client/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift index a4e70db0b..4137d9920 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -197,7 +197,12 @@ public struct Utility { } config.networks = [] } else { - config.networks = try getAttachmentConfigurations(containerId: config.id, networks: parsedNetworks) + let builtinNetworkId = try await ClientNetwork.builtin?.id + config.networks = try getAttachmentConfigurations( + containerId: config.id, + builtinNetworkId: builtinNetworkId, + networks: parsedNetworks + ) for attachmentConfiguration in config.networks { let network: NetworkState = try await ClientNetwork.get(id: attachmentConfiguration.network) guard case .running(_, _) = network else { @@ -244,7 +249,11 @@ public struct Utility { return (config, kernel) } - static func getAttachmentConfigurations(containerId: String, networks: [Parser.ParsedNetwork]) throws -> [AttachmentConfiguration] { + static func getAttachmentConfigurations( + containerId: String, + builtinNetworkId: String?, + networks: [Parser.ParsedNetwork] + ) throws -> [AttachmentConfiguration] { // Validate MAC addresses if provided for network in networks { if let mac = network.macAddress { @@ -268,7 +277,7 @@ public struct Utility { guard networks.isEmpty else { // Check if this is only the default network with properties (e.g., MAC address) - let isOnlyDefaultNetwork = networks.count == 1 && networks[0].name == ClientNetwork.defaultNetworkName + let isOnlyDefaultNetwork = networks.count == 1 && networks[0].name == builtinNetworkId // networks may only be specified for macOS 26+ (except for default network with properties) if !isOnlyDefaultNetwork { @@ -292,8 +301,12 @@ public struct Utility { ) } } + // if no networks specified, attach to the default network - return [AttachmentConfiguration(network: ClientNetwork.defaultNetworkName, options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: nil))] + guard let builtinNetworkId else { + throw ContainerizationError(.invalidState, message: "builtin network is not present") + } + return [AttachmentConfiguration(network: builtinNetworkId, options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: nil))] } private static func getKernel(management: Flags.Management) async throws -> Kernel { diff --git a/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift b/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift index 3e9f2e071..5eef5cd04 100644 --- a/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift +++ b/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift @@ -66,7 +66,17 @@ public actor NetworksService { self.networkPlugin = networkPlugin let configurations = try await store.list() - for configuration in configurations { + for var configuration in configurations { + // Ensure the network with id "default" is marked as builtin. + if configuration.id == ClientNetwork.defaultNetworkName { + let role = configuration.labels[ResourceLabelKeys.role] + if role == nil || role != ResourceRoleValues.builtin { + configuration.labels[ResourceLabelKeys.role] = ResourceRoleValues.builtin + try await store.update(configuration) + } + } + + // Start up the network. do { try await registerService(configuration: configuration) } catch { @@ -186,17 +196,17 @@ public actor NetworksService { "id": "\(id)" ]) - // basic sanity checks on network itself - if id == ClientNetwork.defaultNetworkName { - throw ContainerizationError(.invalidArgument, message: "cannot delete system subnet \(ClientNetwork.defaultNetworkName)") - } - guard let networkState = networkStates[id] else { throw ContainerizationError(.notFound, message: "no network for id \(id)") } + // basic sanity checks on network itself + if networkState.isBuiltin { + throw ContainerizationError(.invalidArgument, message: "cannot delete builtin network: \(id)") + } + guard case .running = networkState else { - throw ContainerizationError(.invalidState, message: "cannot delete subnet \(id) in state \(networkState.state)") + throw ContainerizationError(.invalidState, message: "cannot delete network \(id) in state \(networkState.state)") } // prevent container operations while we atomically check and delete diff --git a/Tests/ContainerResourceTests/ManagedResourceTests.swift b/Tests/ContainerResourceTests/ManagedResourceTests.swift new file mode 100644 index 000000000..c6d6f5c7f --- /dev/null +++ b/Tests/ContainerResourceTests/ManagedResourceTests.swift @@ -0,0 +1,75 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +@testable import ContainerResource + +struct ManagedResourceTests { + + // Mock implementation to test the randomId function + struct MockManagedResource: ManagedResource { + var id: String + var name: String + var creationDate: Date + var labels: [String: String] + + static func generateId() -> String { + randomId() + } + + static func nameValid(_ name: String) -> Bool { + true + } + } + + @Test("randomId generates valid hex string SHA256 hash format") + func testRandomIdFormat() { + let id = MockManagedResource.randomId() + + // SHA256 hash is 64 hex characters (256 bits / 4 bits per hex char) + #expect(id.count == 64, "randomId should generate 64 character string") + + // Should only contain valid hexadecimal characters (0-9, a-f) + let hexCharacterSet = CharacterSet(charactersIn: "0123456789abcdef") + let idCharacterSet = CharacterSet(charactersIn: id) + #expect( + hexCharacterSet.isSuperset(of: idCharacterSet), + "randomId should only contain hexadecimal characters (0-9, a-f)") + } + + @Test("randomId generates unique values") + func testRandomIdUniqueness() { + // Generate multiple IDs and verify they're all different + let ids = (0..<100).map { _ in MockManagedResource.randomId() } + let uniqueIds = Set(ids) + + #expect(uniqueIds.count == 100, "All generated IDs should be unique") + } + + @Test("randomId uses lowercase hexadecimal") + func testRandomIdLowercase() { + let id = MockManagedResource.randomId() + + // Should not contain uppercase letters + let uppercaseLetters = CharacterSet.uppercaseLetters + let idCharacterSet = CharacterSet(charactersIn: id) + #expect( + uppercaseLetters.isDisjoint(with: idCharacterSet), + "randomId should use lowercase hexadecimal characters") + } +}