mirror of
https://github.com/apple/container.git
synced 2026-08-28 11:26:32 +00:00
Use labels instead of id to discriminate the builtin network. (#1123)
- Closes #1122. - Adds placeholder ManagedResource and unit tests. Nothing is using these yet. - Adds system-defined resource labels for owning plugin and resource role. The system discriminates the builtin network using role "builtin". - Adds builtin role when creating builtin network at startup, and ensures that a preexisting network with ID "default" gets updated with the role label. - Replace all network ID checks for "default" with the builtin role check. - Adds "builder" role to builder VM. ## Type of Change - [ ] Bug fix - [x] New feature - [ ] Breaking change - [ ] Documentation update ## Motivation and Context Role and owner labels should make cross-cutting resource policy easier to implement. ## Testing - [x] Tested locally - [x] Added/updated tests - [ ] Added/updated docs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -54,20 +54,22 @@ extension Application {
|
||||
let uniqueNetworkNames = Set<String>(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
|
||||
|
||||
@@ -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]()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user