Skip to content

Commit a54be36

Browse files
authored
Add --labels for networks. (#600)
- Closes #557. - Breaking change: removes `.upToNextOption` for labels on volumes as this is not what is done for containers, and it forces the argument to precede the options if a label is supplied, which is non-intuitive. ## Type of Change - [ ] Bug fix - [x] New feature - [x] Breaking change - [x] Documentation update ## Motivation and Context Consistent features and UX across managed resources. ## Testing - [x] Tested locally - [x] Added/updated tests - [x] Added/updated docs
1 parent 9692d79 commit a54be36

13 files changed

Lines changed: 316 additions & 30 deletions

File tree

Package.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,12 @@ let package = Package(
159159
],
160160
path: "Sources/Services/ContainerNetworkService"
161161
),
162+
.testTarget(
163+
name: "ContainerNetworkServiceTests",
164+
dependencies: [
165+
"ContainerNetworkService"
166+
]
167+
),
162168
.executableTarget(
163169
name: "container-core-images",
164170
dependencies: [

Sources/APIServer/APIServer.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ struct APIServer: AsyncParsableCommand {
236236
.filter { $0.id == ClientNetwork.defaultNetworkName }
237237
.first
238238
if defaultNetwork == nil {
239-
let config = NetworkConfiguration(id: ClientNetwork.defaultNetworkName, mode: .nat)
239+
let config = try NetworkConfiguration(id: ClientNetwork.defaultNetworkName, mode: .nat)
240240
_ = try await service.create(configuration: config)
241241
}
242242

Sources/APIServer/Networks/NetworksService.swift

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,19 +97,20 @@ actor NetworksService {
9797

9898
/// Create a new network from the provided configuration.
9999
public func create(configuration: NetworkConfiguration) async throws -> NetworkState {
100+
log.info(
101+
"network service: create",
102+
metadata: [
103+
"id": "\(configuration.id)"
104+
])
105+
106+
// Ensure nobody is manipulating the network already.
100107
guard !busyNetworks.contains(configuration.id) else {
101108
throw ContainerizationError(.exists, message: "network \(configuration.id) has a pending operation")
102109
}
103110

104111
busyNetworks.insert(configuration.id)
105112
defer { busyNetworks.remove(configuration.id) }
106113

107-
log.info(
108-
"network service: create",
109-
metadata: [
110-
"id": "\(configuration.id)"
111-
])
112-
113114
// Ensure the network doesn't already exist.
114115
guard networkStates[configuration.id] == nil else {
115116
throw ContainerizationError(.exists, message: "network \(configuration.id) already exists")
@@ -118,7 +119,14 @@ actor NetworksService {
118119
// Create and start the network.
119120
try await registerService(configuration: configuration)
120121
let client = NetworkClient(id: configuration.id)
121-
let networkState = try await client.state()
122+
123+
// Ensure the network is running, and set up the persistent network state
124+
// using our configuration data, as the one from the helper doesn't include
125+
// metadata.
126+
guard case .running(_, let status) = try await client.state() else {
127+
throw ContainerizationError(.invalidState, message: "network \(configuration.id) failed to start")
128+
}
129+
let networkState: NetworkState = .running(configuration, status)
122130
networkStates[configuration.id] = networkState
123131

124132
// Persist the configuration data.

Sources/CLI/Network/NetworkCreate.swift

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,18 @@ extension Application {
2727
commandName: "create",
2828
abstract: "Create a new network")
2929

30-
@Argument(help: "Network name")
31-
var name: String
32-
3330
@OptionGroup
3431
var global: Flags.Global
3532

33+
@Option(name: .customLong("label"), help: "Set metadata on a network")
34+
var labels: [String] = []
35+
36+
@Argument(help: "Network name")
37+
var name: String
38+
3639
func run() async throws {
37-
let config = NetworkConfiguration(id: self.name, mode: .nat)
40+
let parsedLabels = Utility.parseKeyValuePairs(labels)
41+
let config = try NetworkConfiguration(id: self.name, mode: .nat, labels: parsedLabels)
3842
let state = try await ClientNetwork.create(configuration: config)
3943
print(state.id)
4044
}

Sources/CLI/Network/NetworkDelete.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ extension Application {
2727
abstract: "Delete one or more networks",
2828
aliases: ["rm"])
2929

30-
@Flag(name: .shortAndLong, help: "Remove all networks")
31-
var all = false
32-
3330
@OptionGroup
3431
var global: Flags.Global
3532

33+
@Flag(name: .shortAndLong, help: "Remove all networks")
34+
var all = false
35+
3636
@Argument(help: "Network names")
3737
var networkNames: [String] = []
3838

Sources/CLI/Network/NetworkList.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,15 @@ extension Application {
2828
abstract: "List networks",
2929
aliases: ["ls"])
3030

31+
@OptionGroup
32+
var global: Flags.Global
33+
3134
@Flag(name: .shortAndLong, help: "Only output the network name")
3235
var quiet = false
3336

3437
@Option(name: .long, help: "Format of the output")
3538
var format: ListFormat = .table
3639

37-
@OptionGroup
38-
var global: Flags.Global
39-
4040
func run() async throws {
4141
let networks = try await ClientNetwork.list()
4242
try printNetworks(networks: networks, format: format)

Sources/CLI/Volume/VolumeCreate.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,18 @@ extension Application.VolumeCommand {
2525
abstract: "Create a volume"
2626
)
2727

28-
@Argument(help: "Volume name")
29-
var name: String
30-
3128
@Option(name: .customShort("s"), help: "Size of the volume (default: 512GB). Examples: 1G, 512MB, 2T")
3229
var size: String?
3330

34-
@Option(name: .customLong("opt"), parsing: .upToNextOption, help: "Set driver specific options")
31+
@Option(name: .customLong("opt"), help: "Set driver specific options")
3532
var driverOpts: [String] = []
3633

37-
@Option(name: .customLong("label"), parsing: .upToNextOption, help: "Set metadata on a volume")
34+
@Option(name: .customLong("label"), help: "Set metadata on a volume")
3835
var labels: [String] = []
3936

37+
@Argument(help: "Volume name")
38+
var name: String
39+
4040
func run() async throws {
4141
var parsedDriverOpts = Utility.parseKeyValuePairs(driverOpts)
4242
let parsedLabels = Utility.parseKeyValuePairs(labels)

Sources/Helpers/NetworkVmnet/NetworkVmnetHelper.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ extension NetworkVmnetHelper {
6565
do {
6666
log.info("configuring XPC server")
6767
let subnet = try self.subnet.map { try CIDRAddress($0) }
68-
let configuration = NetworkConfiguration(id: id, mode: .nat, subnet: subnet?.description)
68+
let configuration = try NetworkConfiguration(id: id, mode: .nat, subnet: subnet?.description)
6969
let network = try Self.createNetwork(configuration: configuration, log: log)
7070
try await network.start()
7171
let server = try await NetworkService(network: network, log: log)

Sources/Services/ContainerNetworkService/NetworkConfiguration.swift

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

17+
import ContainerizationError
18+
import ContainerizationExtras
19+
1720
/// Configuration parameters for network creation.
1821
public struct NetworkConfiguration: Codable, Sendable, Identifiable {
1922
/// A unique identifier for the network
@@ -25,14 +28,89 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable {
2528
/// The preferred CIDR address for the subnet, if specified
2629
public let subnet: String?
2730

31+
/// Key-value labels for the network.
32+
public var labels: [String: String] = [:]
33+
2834
/// Creates a network configuration
2935
public init(
3036
id: String,
3137
mode: NetworkMode,
32-
subnet: String? = nil
33-
) {
38+
subnet: String? = nil,
39+
labels: [String: String] = [:]
40+
) throws {
3441
self.id = id
3542
self.mode = mode
3643
self.subnet = subnet
44+
self.labels = labels
45+
try validate()
46+
}
47+
48+
enum CodingKeys: String, CodingKey {
49+
case id
50+
case mode
51+
case subnet
52+
case labels
53+
}
54+
55+
/// Create a configuration from the supplied Decoder, initializing missing
56+
/// values where possible to reasonable defaults.
57+
public init(from decoder: Decoder) throws {
58+
let container = try decoder.container(keyedBy: CodingKeys.self)
59+
60+
id = try container.decode(String.self, forKey: .id)
61+
mode = try container.decode(NetworkMode.self, forKey: .mode)
62+
subnet = try container.decodeIfPresent(String.self, forKey: .subnet)
63+
labels = try container.decodeIfPresent([String: String].self, forKey: .labels) ?? [:]
64+
try validate()
65+
}
66+
67+
private func validate() throws {
68+
guard id.isValidNetworkID() else {
69+
throw ContainerizationError(.invalidArgument, message: "invalid network ID: \(id)")
70+
}
71+
72+
if let subnet {
73+
_ = try CIDRAddress(subnet)
74+
}
75+
76+
for (key, value) in labels {
77+
try validateLabel(key: key, value: value)
78+
}
79+
}
80+
81+
/// TODO: Extract when we clean up client dependencies.
82+
private func validateLabel(key: String, value: String) throws {
83+
let keyLengthMax = 128
84+
let labelLengthMax = 4096
85+
guard key.count <= keyLengthMax else {
86+
throw ContainerizationError(.invalidArgument, message: "invalid label, key length is greater than \(keyLengthMax): \(key)")
87+
}
88+
89+
guard key.isValidLabelKey() else {
90+
throw ContainerizationError(.invalidArgument, message: "invalid label key: \(key)")
91+
}
92+
93+
let fullLabel = "\(key)=\(value)"
94+
guard fullLabel.count <= labelLengthMax else {
95+
throw ContainerizationError(.invalidArgument, message: "invalid label, key length is greater than \(labelLengthMax): \(fullLabel)")
96+
}
97+
}
98+
}
99+
100+
extension String {
101+
/// Ensure that the network ID has the correct syntax.
102+
fileprivate func isValidNetworkID() -> Bool {
103+
let pattern = #"^[a-z0-9](?:[a-z0-9._-]{0,61}[a-z0-9])?$"#
104+
return self.range(of: pattern, options: .regularExpression) != nil
105+
}
106+
107+
/// Ensure label key conforms to OCI or Docker label guidelines.
108+
/// TODO: Extract when we clean up client dependencies.
109+
fileprivate func isValidLabelKey() -> Bool {
110+
let dockerPattern = #/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/#
111+
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])?)*))*$/#
112+
let dockerMatch = !self.ranges(of: dockerPattern).isEmpty
113+
let ociMatch = !self.ranges(of: ociPattern).isEmpty
114+
return dockerMatch || ociMatch
37115
}
38116
}

Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,4 +128,60 @@ class TestCLINetwork: CLITest {
128128
return
129129
}
130130
}
131+
132+
@available(macOS 26, *)
133+
@Test func testNetworkLabels() async throws {
134+
do {
135+
// prep: delete container and network, ignoring if it doesn't exist
136+
let name = Test.current!.name.trimmingCharacters(in: ["(", ")"])
137+
try? doRemove(name: name)
138+
let networkDeleteArgs = ["network", "delete", name]
139+
_ = try? run(arguments: networkDeleteArgs)
140+
141+
// create our network
142+
let networkCreateArgs = ["network", "create", "--label", "foo=bar", "--label", "baz=qux", name]
143+
let networkCreateResult = try run(arguments: networkCreateArgs)
144+
guard networkCreateResult.status == 0 else {
145+
throw CLIError.executionFailed("command failed: \(networkCreateResult.error)")
146+
}
147+
148+
// ensure it's deleted
149+
defer {
150+
_ = try? run(arguments: networkDeleteArgs)
151+
}
152+
153+
// inspect the network
154+
let networkInspectArgs = ["network", "inspect", name]
155+
let networkInspectResult = try run(arguments: networkInspectArgs)
156+
guard networkInspectResult.status == 0 else {
157+
throw CLIError.executionFailed("command failed: \(networkInspectResult.error)")
158+
}
159+
160+
// decode the JSON result
161+
let networkInspectOutput = networkInspectResult.output
162+
guard let jsonData = networkInspectOutput.data(using: .utf8) else {
163+
throw CLIError.invalidOutput("network inspect output invalid")
164+
}
165+
166+
let decoder = JSONDecoder()
167+
let networks = try decoder.decode([NetworkInspectOutput].self, from: jsonData)
168+
guard networks.count == 1 else {
169+
throw CLIError.invalidOutput("expected exactly one network from inspect, got \(networks.count)")
170+
}
171+
172+
// validate labels
173+
174+
let expectedLabels = [
175+
"foo": "bar",
176+
"baz": "qux",
177+
]
178+
#expect(expectedLabels == networks[0].config.labels)
179+
180+
// delete should succeed
181+
_ = try run(arguments: networkDeleteArgs)
182+
} catch {
183+
Issue.record("failed to safely delete network \(error)")
184+
return
185+
}
186+
}
131187
}

0 commit comments

Comments
 (0)