Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ let package = Package(
],
path: "Sources/Services/ContainerNetworkService"
),
.testTarget(
name: "ContainerNetworkServiceTests",
dependencies: [
"ContainerNetworkService"
]
),
.executableTarget(
name: "container-core-images",
dependencies: [
Expand Down
2 changes: 1 addition & 1 deletion Sources/APIServer/APIServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ struct APIServer: AsyncParsableCommand {
.filter { $0.id == ClientNetwork.defaultNetworkName }
.first
if defaultNetwork == nil {
let config = NetworkConfiguration(id: ClientNetwork.defaultNetworkName, mode: .nat)
let config = try NetworkConfiguration(id: ClientNetwork.defaultNetworkName, mode: .nat)
_ = try await service.create(configuration: config)
}

Expand Down
22 changes: 15 additions & 7 deletions Sources/APIServer/Networks/NetworksService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,19 +97,20 @@ actor NetworksService {

/// Create a new network from the provided configuration.
public func create(configuration: NetworkConfiguration) async throws -> NetworkState {
log.info(
"network service: create",
metadata: [
"id": "\(configuration.id)"
])

// Ensure nobody is manipulating the network already.
guard !busyNetworks.contains(configuration.id) else {
throw ContainerizationError(.exists, message: "network \(configuration.id) has a pending operation")
}

busyNetworks.insert(configuration.id)
defer { busyNetworks.remove(configuration.id) }

log.info(
"network service: create",
metadata: [
"id": "\(configuration.id)"
])

// Ensure the network doesn't already exist.
guard networkStates[configuration.id] == nil else {
throw ContainerizationError(.exists, message: "network \(configuration.id) already exists")
Expand All @@ -118,7 +119,14 @@ actor NetworksService {
// Create and start the network.
try await registerService(configuration: configuration)
let client = NetworkClient(id: configuration.id)
let networkState = try await client.state()

// Ensure the network is running, and set up the persistent network state
// using our configuration data, as the one from the helper doesn't include
// metadata.
guard case .running(_, let status) = try await client.state() else {
throw ContainerizationError(.invalidState, message: "network \(configuration.id) failed to start")
}
let networkState: NetworkState = .running(configuration, status)
networkStates[configuration.id] = networkState

// Persist the configuration data.
Expand Down
12 changes: 8 additions & 4 deletions Sources/CLI/Network/NetworkCreate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,18 @@ extension Application {
commandName: "create",
abstract: "Create a new network")

@Argument(help: "Network name")
var name: 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

func run() async throws {
let config = NetworkConfiguration(id: self.name, mode: .nat)
let parsedLabels = Utility.parseKeyValuePairs(labels)
let config = try NetworkConfiguration(id: self.name, mode: .nat, labels: parsedLabels)
let state = try await ClientNetwork.create(configuration: config)
print(state.id)
}
Expand Down
6 changes: 3 additions & 3 deletions Sources/CLI/Network/NetworkDelete.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ extension Application {
abstract: "Delete one or more networks",
aliases: ["rm"])

@Flag(name: .shortAndLong, help: "Remove 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] = []

Expand Down
6 changes: 3 additions & 3 deletions Sources/CLI/Network/NetworkList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,15 @@ extension Application {
abstract: "List networks",
aliases: ["ls"])

@OptionGroup
var global: Flags.Global

@Flag(name: .shortAndLong, help: "Only output the network name")
var quiet = false

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

@OptionGroup
var global: Flags.Global

func run() async throws {
let networks = try await ClientNetwork.list()
try printNetworks(networks: networks, format: format)
Expand Down
10 changes: 5 additions & 5 deletions Sources/CLI/Volume/VolumeCreate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,18 @@ extension Application.VolumeCommand {
abstract: "Create a volume"
)

@Argument(help: "Volume name")
var name: String

@Option(name: .customShort("s"), help: "Size of the volume (default: 512GB). Examples: 1G, 512MB, 2T")
var size: String?

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

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

@Argument(help: "Volume name")
var name: String

func run() async throws {
var parsedDriverOpts = Utility.parseKeyValuePairs(driverOpts)
let parsedLabels = Utility.parseKeyValuePairs(labels)
Expand Down
2 changes: 1 addition & 1 deletion Sources/Helpers/NetworkVmnet/NetworkVmnetHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ extension NetworkVmnetHelper {
do {
log.info("configuring XPC server")
let subnet = try self.subnet.map { try CIDRAddress($0) }
let configuration = NetworkConfiguration(id: id, mode: .nat, subnet: subnet?.description)
let configuration = try NetworkConfiguration(id: id, mode: .nat, subnet: subnet?.description)
let network = try Self.createNetwork(configuration: configuration, log: log)
try await network.start()
let server = try await NetworkService(network: network, log: log)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
// limitations under the License.
//===----------------------------------------------------------------------===//

import ContainerizationError
import ContainerizationExtras

/// Configuration parameters for network creation.
public struct NetworkConfiguration: Codable, Sendable, Identifiable {
/// A unique identifier for the network
Expand All @@ -25,14 +28,89 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable {
/// The preferred CIDR address for the subnet, if specified
public let subnet: String?

/// Key-value labels for the network.
public var labels: [String: String] = [:]

/// Creates a network configuration
public init(
id: String,
mode: NetworkMode,
subnet: String? = nil
) {
subnet: String? = nil,
labels: [String: String] = [:]
) throws {
self.id = id
self.mode = mode
self.subnet = subnet
self.labels = labels
try validate()
}

enum CodingKeys: String, CodingKey {
case id
case mode
case subnet
case labels
}

/// Create a configuration from the supplied Decoder, initializing missing
/// values where possible to reasonable defaults.
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)

id = try container.decode(String.self, forKey: .id)
mode = try container.decode(NetworkMode.self, forKey: .mode)
subnet = try container.decodeIfPresent(String.self, forKey: .subnet)
labels = try container.decodeIfPresent([String: String].self, forKey: .labels) ?? [:]
Comment on lines +58 to +63
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use the CodingKey enum to stay consistent with how ContainerConfiguration.swift handles labels?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

try validate()
}

private func validate() throws {
guard id.isValidNetworkID() else {
throw ContainerizationError(.invalidArgument, message: "invalid network ID: \(id)")
}

if let subnet {
_ = try CIDRAddress(subnet)
}

for (key, value) in labels {
try validateLabel(key: key, value: value)
}
}

/// TODO: Extract when we clean up client dependencies.
private func validateLabel(key: String, value: String) throws {
let keyLengthMax = 128
let labelLengthMax = 4096
guard key.count <= keyLengthMax else {
throw ContainerizationError(.invalidArgument, message: "invalid label, key length is greater than \(keyLengthMax): \(key)")
}

guard key.isValidLabelKey() else {
throw ContainerizationError(.invalidArgument, message: "invalid label key: \(key)")
}

let fullLabel = "\(key)=\(value)"
guard fullLabel.count <= labelLengthMax else {
throw ContainerizationError(.invalidArgument, message: "invalid label, key length is greater than \(labelLengthMax): \(fullLabel)")
}
}
}

extension String {
/// Ensure that the network ID has the correct syntax.
fileprivate func isValidNetworkID() -> Bool {
let pattern = #"^[a-z0-9](?:[a-z0-9._-]{0,61}[a-z0-9])?$"#
return self.range(of: pattern, options: .regularExpression) != nil
}

/// Ensure label key conforms to OCI or Docker label guidelines.
/// TODO: Extract when we clean up client dependencies.
fileprivate func isValidLabelKey() -> Bool {
let dockerPattern = #/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/#
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])?)*))*$/#
let dockerMatch = !self.ranges(of: dockerPattern).isEmpty
let ociMatch = !self.ranges(of: ociPattern).isEmpty
return dockerMatch || ociMatch
}
}
56 changes: 56 additions & 0 deletions Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,60 @@ class TestCLINetwork: CLITest {
return
}
}

@available(macOS 26, *)
@Test func testNetworkLabels() async throws {
do {
// prep: delete container and network, ignoring if it doesn't exist
let name = Test.current!.name.trimmingCharacters(in: ["(", ")"])
try? doRemove(name: name)
let networkDeleteArgs = ["network", "delete", name]
_ = try? run(arguments: networkDeleteArgs)

// create our network
let networkCreateArgs = ["network", "create", "--label", "foo=bar", "--label", "baz=qux", name]
let networkCreateResult = try run(arguments: networkCreateArgs)
guard networkCreateResult.status == 0 else {
throw CLIError.executionFailed("command failed: \(networkCreateResult.error)")
}

// ensure it's deleted
defer {
_ = try? run(arguments: networkDeleteArgs)
}

// inspect the network
let networkInspectArgs = ["network", "inspect", name]
let networkInspectResult = try run(arguments: networkInspectArgs)
guard networkInspectResult.status == 0 else {
throw CLIError.executionFailed("command failed: \(networkInspectResult.error)")
}

// decode the JSON result
let networkInspectOutput = networkInspectResult.output
guard let jsonData = networkInspectOutput.data(using: .utf8) else {
throw CLIError.invalidOutput("network inspect output invalid")
}

let decoder = JSONDecoder()
let networks = try decoder.decode([NetworkInspectOutput].self, from: jsonData)
guard networks.count == 1 else {
throw CLIError.invalidOutput("expected exactly one network from inspect, got \(networks.count)")
}

// validate labels

let expectedLabels = [
"foo": "bar",
"baz": "qux",
]
#expect(expectedLabels == networks[0].config.labels)

// delete should succeed
_ = try run(arguments: networkDeleteArgs)
} catch {
Issue.record("failed to safely delete network \(error)")
return
}
}
}
8 changes: 8 additions & 0 deletions Tests/CLITests/Utilities/CLITest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class CLITest {
let reference: String
}

// These structs need to track their counterpart presentation structs in CLI.
struct ImageInspectOutput: Codable {
let name: String
let variants: [variant]
Expand All @@ -41,6 +42,13 @@ class CLITest {
}
}

struct NetworkInspectOutput: Codable {
let id: String
let state: String
let config: NetworkConfiguration
let status: NetworkStatus?
}

init() throws {}

let testUUID = UUID().uuidString
Expand Down
Loading