Skip to content

Commit e37dcc1

Browse files
authored
Create ResourceLabels and use for ManagedResource, NetworkConfiguration. (apple#1360)
- Closes apple#1359. - Create a ResourceLabels type and extract the label validation from NetworkConfiguration into the new type. - Create a base AppError type that is compatible with structured logging and delegates message presentation to the error receiver. - Define LabelError over AppError for label validation. - Slightly reworks NetworkConfiguration entity migration code in NetworksService.
1 parent af43a8b commit e37dcc1

14 files changed

Lines changed: 251 additions & 126 deletions

File tree

Sources/APIServer/APIServer+Start.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ extension APIServer {
316316
let config = try NetworkConfiguration(
317317
id: ClientNetwork.defaultNetworkName,
318318
mode: .nat,
319-
labels: [ResourceLabelKeys.role: ResourceRoleValues.builtin],
319+
labels: try .init([ResourceLabelKeys.role: ResourceRoleValues.builtin]),
320320
pluginInfo: NetworkPluginInfo(plugin: "container-network-vmnet")
321321
)
322322
_ = try await service.create(configuration: config)

Sources/ContainerCommands/Container/ContainerStart.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ extension Application {
7373

7474
for mount in container.configuration.mounts where mount.isVirtiofs {
7575
if !FileManager.default.fileExists(atPath: mount.source) {
76-
throw ContainerizationError(.invalidState, message: "path '\(mount.source)' is not a directory")
76+
throw ContainerizationError(.invalidState, message: "mount source path '\(mount.source)' does not exist")
7777
}
7878
}
7979

Sources/ContainerCommands/Network/NetworkCreate.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ extension Application {
6363
public init() {}
6464

6565
public func run() async throws {
66-
let parsedLabels = Utility.parseKeyValuePairs(labels)
66+
let parsedLabels = try ResourceLabels(Utility.parseKeyValuePairs(labels))
6767
let mode: NetworkMode = hostOnly ? .hostOnly : .nat
6868
let config = try NetworkConfiguration(
6969
id: self.name,
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the container project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
/// Protocol for errors with a stable code and structured metadata.
18+
/// This allows the client to present the error as it chooses.
19+
20+
import Collections
21+
22+
public protocol AppError: Error {
23+
var code: AppErrorCode { get }
24+
var metadata: OrderedDictionary<String, String> { get }
25+
var underlyingError: Error? { get }
26+
}
27+
28+
public struct AppErrorCode: RawRepresentable, Hashable, Sendable {
29+
public let rawValue: String
30+
31+
public init(rawValue: String) {
32+
self.rawValue = rawValue
33+
}
34+
35+
public static let invalidArgument = AppErrorCode(rawValue: "invalid_argument")
36+
}

Sources/ContainerResource/Common/ManagedResource.swift

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ public protocol ManagedResource: Identifiable, Sendable, Codable {
3232

3333
/// Key-value properties for the resource. The user and system may both
3434
/// make use of labels to read and write annotations or other metadata.
35-
/// A good practice is to use
36-
var labels: [String: String] { get }
35+
/// A good practice when using labels for automation is to use reverse
36+
/// domain name notation (example: `com.example.mytool.role`) for
37+
/// label names.
38+
var labels: ResourceLabels { get }
3739

3840
/// Generates a unique resource ID value.
3941
static func generateId() -> String
@@ -52,7 +54,7 @@ extension ManagedResource {
5254
}
5355
}
5456

55-
// FIXME: This moves to ManagedResource and/or a ResourceLabels typealias eventually.
56-
extension [String: String] {
57+
extension ResourceLabels {
58+
/// Returns true if for a resource that the system automatically manages.
5759
public var isBuiltin: Bool { self.contains { $0 == ResourceLabelKeys.role && $1 == ResourceRoleValues.builtin } }
5860
}

Sources/ContainerResource/Common/ResourceLabels.swift

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,90 @@
1414
// limitations under the License.
1515
//===----------------------------------------------------------------------===//
1616

17+
import Collections
18+
19+
/// Metadata for a managed resource.
20+
public struct ResourceLabels: Sendable, Equatable {
21+
public static let keyLengthMax = 128
22+
23+
public static let labelLengthMax = 4096
24+
25+
public let dictionary: [String: String]
26+
27+
public struct LabelError: AppError {
28+
public var code: AppErrorCode
29+
30+
public var metadata: OrderedDictionary<String, String>
31+
32+
public var underlyingError: (any Error)? { nil }
33+
}
34+
35+
public init() {
36+
dictionary = [:]
37+
}
38+
39+
public init(_ labels: [String: String]) throws {
40+
for (key, value) in labels {
41+
try Self.validateLabel(key: key, value: value)
42+
}
43+
self.dictionary = labels
44+
}
45+
46+
public static func validateLabelKey(_ key: String) throws {
47+
guard key.count <= Self.keyLengthMax else {
48+
throw LabelError(code: .invalidLabelKeyLength, metadata: ["key": key, "maxLength": "\(Self.keyLengthMax)"])
49+
}
50+
let dockerPattern = #/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/#
51+
let ociPattern = #/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*(?:/(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*))*$/#
52+
let dockerMatch = !key.ranges(of: dockerPattern).isEmpty
53+
let ociMatch = !key.ranges(of: ociPattern).isEmpty
54+
guard dockerMatch || ociMatch else {
55+
throw LabelError(code: .invalidLabelKeyContent, metadata: ["key": key])
56+
}
57+
}
58+
59+
public static func validateLabel(key: String, value: String) throws {
60+
try validateLabelKey(key)
61+
let fullLabel = "\(key)=\(value)"
62+
guard fullLabel.count <= labelLengthMax else {
63+
throw LabelError(code: .invalidLabelLength, metadata: ["label": fullLabel, "maxLength": "\(Self.labelLengthMax)"])
64+
}
65+
}
66+
}
67+
68+
extension ResourceLabels: Codable {
69+
public func encode(to encoder: Encoder) throws {
70+
try dictionary.encode(to: encoder)
71+
}
72+
73+
public init(from decoder: Decoder) throws {
74+
let dict = try [String: String](from: decoder)
75+
try self.init(dict)
76+
}
77+
}
78+
79+
extension ResourceLabels: Collection {
80+
public typealias Index = Dictionary<String, String>.Index
81+
public typealias Element = Dictionary<String, String>.Element
82+
83+
public var startIndex: Index { dictionary.startIndex }
84+
public var endIndex: Index { dictionary.endIndex }
85+
86+
public subscript(position: Index) -> Element { dictionary[position] }
87+
public func index(after i: Index) -> Index { dictionary.index(after: i) }
88+
89+
// Direct key access
90+
public subscript(key: String) -> String? {
91+
get { dictionary[key] }
92+
}
93+
}
94+
95+
extension AppErrorCode {
96+
public static let invalidLabelKeyContent = AppErrorCode(rawValue: "invalid_label_key_content")
97+
public static let invalidLabelKeyLength = AppErrorCode(rawValue: "invalid_label_key_length")
98+
public static let invalidLabelLength = AppErrorCode(rawValue: "invalid_label_length")
99+
}
100+
17101
/// System-defined keys for resource labels.
18102
public struct ResourceLabelKeys {
19103
/// Indicates a owner of a resource managed by a plugin.

Sources/ContainerResource/Network/NetworkConfiguration.swift

Lines changed: 7 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -46,21 +46,22 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable {
4646
public let ipv6Subnet: CIDRv6?
4747

4848
/// Key-value labels for the network.
49-
public var labels: [String: String] = [:]
49+
/// Resource labels should not be mutated, except while building a network configurations.
50+
public let labels: ResourceLabels
5051

5152
/// Details about the network plugin that manages this network.
5253
/// FIXME: This field only needs to be optional while we wait for the field
5354
/// to be proliferated to most users when they update container.
54-
public var pluginInfo: NetworkPluginInfo?
55+
public let pluginInfo: NetworkPluginInfo?
5556

5657
/// Creates a network configuration
5758
public init(
5859
id: String,
5960
mode: NetworkMode,
6061
ipv4Subnet: CIDRv4? = nil,
6162
ipv6Subnet: CIDRv6? = nil,
62-
labels: [String: String] = [:],
63-
pluginInfo: NetworkPluginInfo
63+
labels: ResourceLabels = .init(),
64+
pluginInfo: NetworkPluginInfo?
6465
) throws {
6566
self.id = id
6667
self.creationDate = Date()
@@ -98,7 +99,8 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable {
9899
ipv4Subnet = try subnetText.map { try CIDRv4($0) }
99100
ipv6Subnet = try container.decodeIfPresent(String.self, forKey: .ipv6Subnet)
100101
.map { try CIDRv6($0) }
101-
labels = try container.decodeIfPresent([String: String].self, forKey: .labels) ?? [:]
102+
let decodedLabels = try container.decodeIfPresent([String: String].self, forKey: .labels) ?? [:]
103+
labels = try .init(decodedLabels)
102104
pluginInfo = try container.decodeIfPresent(NetworkPluginInfo.self, forKey: .pluginInfo)
103105
try validate()
104106
}
@@ -120,28 +122,6 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable {
120122
guard id.isValidNetworkID() else {
121123
throw ContainerizationError(.invalidArgument, message: "invalid network ID: \(id)")
122124
}
123-
124-
for (key, value) in labels {
125-
try validateLabel(key: key, value: value)
126-
}
127-
}
128-
129-
/// TODO: Extract when we clean up client dependencies.
130-
private func validateLabel(key: String, value: String) throws {
131-
let keyLengthMax = 128
132-
let labelLengthMax = 4096
133-
guard key.count <= keyLengthMax else {
134-
throw ContainerizationError(.invalidArgument, message: "invalid label, key length is greater than \(keyLengthMax): \(key)")
135-
}
136-
137-
guard key.isValidLabelKey() else {
138-
throw ContainerizationError(.invalidArgument, message: "invalid label key: \(key)")
139-
}
140-
141-
let fullLabel = "\(key)=\(value)"
142-
guard fullLabel.count <= labelLengthMax else {
143-
throw ContainerizationError(.invalidArgument, message: "invalid label, key length is greater than \(labelLengthMax): \(fullLabel)")
144-
}
145125
}
146126
}
147127

@@ -151,14 +131,4 @@ extension String {
151131
let pattern = #"^[a-z0-9](?:[a-z0-9._-]{0,61}[a-z0-9])?$"#
152132
return self.range(of: pattern, options: .regularExpression) != nil
153133
}
154-
155-
/// Ensure label key conforms to OCI or Docker label guidelines.
156-
/// TODO: Extract when we clean up client dependencies.
157-
fileprivate func isValidLabelKey() -> Bool {
158-
let dockerPattern = #/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/#
159-
let ociPattern = #/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*(?:/(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*))*$/#
160-
let dockerMatch = !self.ranges(of: dockerPattern).isEmpty
161-
let ociMatch = !self.ranges(of: ociPattern).isEmpty
162-
return dockerMatch || ociMatch
163-
}
164134
}

Sources/ContainerResource/Registry/RegistryResource.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,22 +32,22 @@ public struct RegistryResource: ManagedResource {
3232
///
3333
/// This value must be a valid DNS hostname or IPv6 address, optionally
3434
/// followed by a port number (e.g., "docker.io", "localhost:5000", "[::1]:5000").
35-
public var name: String
35+
public let name: String
3636

3737
/// The username used for authentication with this registry.
3838
public let username: String
3939

4040
/// The time at which the system created this registry resource.
41-
public var creationDate: Date
41+
public let creationDate: Date
4242

4343
/// The time at which the registry resource was last modified.
44-
public var modificationDate: Date
44+
public let modificationDate: Date
4545

4646
/// Key-value properties for the resource.
4747
///
4848
/// The user and system may both make use of labels to read and write
4949
/// annotations or other metadata.
50-
public var labels: [String: String]
50+
public let labels: ResourceLabels
5151

5252
/// Validates a registry hostname according to OCI distribution specification.
5353
///
@@ -94,7 +94,7 @@ public struct RegistryResource: ManagedResource {
9494
username: String,
9595
creationDate: Date,
9696
modificationDate: Date,
97-
labels: [String: String] = [:]
97+
labels: ResourceLabels = .init()
9898
) {
9999
self.id = hostname
100100
self.name = hostname

0 commit comments

Comments
 (0)