From 4d6b40bef7d3dc1d169898c93ca2ce24c7ea5dd8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:44:48 +0000 Subject: [PATCH 1/4] Initial plan From b131facdb4a68326655fe7d2bd7d0b4cf2e3b528 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:50:37 +0000 Subject: [PATCH 2/4] Add advanced networking: port ranges/UDP, DNS search/opt, IPAM config, connection inspection Co-authored-by: djpfs <43576725+djpfs@users.noreply.github.com> --- .../ComposeEngine/ComposeOrchestrator.swift | 106 +++++++++++++-- .../ComposeEngine/Models/ComposeModels.swift | 56 +++++++- .../ComposeEngineTests.swift | 122 +++++++++++++++++- 3 files changed, 263 insertions(+), 21 deletions(-) diff --git a/Sources/ComposeEngine/ComposeOrchestrator.swift b/Sources/ComposeEngine/ComposeOrchestrator.swift index 3e06c5b..5db6535 100644 --- a/Sources/ComposeEngine/ComposeOrchestrator.swift +++ b/Sources/ComposeEngine/ComposeOrchestrator.swift @@ -537,6 +537,43 @@ public struct ComposeOrchestrator: Sendable { return nil } + // MARK: - Connection inspection + + /// A snapshot of a service's network attachments and published ports. + public struct ServiceConnections: Sendable { + /// The service name. + public let service: String + /// Network attachments for the container. + public let attachments: [Attachment] + /// Published ports for the container. + public let publishedPorts: [PublishPort] + + public init(service: String, attachments: [Attachment], publishedPorts: [PublishPort]) { + self.service = service + self.attachments = attachments + self.publishedPorts = publishedPorts + } + } + + /// Inspect the network connections for every running service in the project. + /// + /// Returns one ``ServiceConnections`` entry per service that has a container + /// (running or stopped). Services whose containers do not exist yet are + /// silently omitted. + public func inspectConnections(project: ComposeProject) async throws -> [ServiceConnections] { + var result: [ServiceConnections] = [] + for name in project.services.keys.sorted() { + let containerName = Self.containerName(project: project.name, service: name, config: project.services[name]) + guard let snapshot = try? await service.getContainer(id: containerName) else { continue } + result.append(ServiceConnections( + service: name, + attachments: snapshot.networks, + publishedPorts: snapshot.configuration.publishedPorts + )) + } + return result + } + // MARK: - Images /// The image reference used by each service. @@ -694,9 +731,7 @@ public struct ComposeOrchestrator: Sendable { // Published ports. var publishedPorts: [PublishPort] = [] for spec in service.ports { - if let port = Self.parsePortSpec(spec) { - publishedPorts.append(port) - } + publishedPorts.append(contentsOf: Self.parsePortSpec(spec)) } // Resources. @@ -744,7 +779,13 @@ public struct ComposeOrchestrator: Sendable { publishedPorts: publishedPorts, labels: labels, networks: networks, - dns: service.dns.isEmpty ? nil : .init(nameservers: service.dns), + dns: service.dns.isEmpty && service.dnsSearch.isEmpty && service.dnsOpt.isEmpty + ? nil + : .init( + nameservers: service.dns.isEmpty ? ContainerConfiguration.DNSConfiguration.defaultNameservers : service.dns, + searchDomains: service.dnsSearch, + options: service.dnsOpt + ), resources: resources, readOnly: service.readOnly, useInit: service.initEnabled, @@ -949,21 +990,58 @@ public struct ComposeOrchestrator: Sendable { ) } - /// Parse a compose port spec like `8080:80`, `127.0.0.1:8080:80`, `80`. - static func parsePortSpec(_ spec: String) -> PublishPort? { - let parts = spec.split(separator: ":").map(String.init) + /// Parse a compose port spec like `8080:80`, `127.0.0.1:8080:80`, `80`, + /// `8080:80/udp`, or `8080-8090:80-90`. + static func parsePortSpec(_ spec: String) -> [PublishPort] { + // Strip an optional protocol suffix, e.g. "8080:80/udp". + var remaining = spec + var proto: PublishProtocol = .tcp + if let slashIdx = remaining.lastIndex(of: "/") { + let suffix = String(remaining[remaining.index(after: slashIdx)...]) + if let p = PublishProtocol(suffix) { + proto = p + remaining = String(remaining[.. ClosedRange? { + let rangeParts = s.split(separator: "-").map(String.init) + if rangeParts.count == 2, + let lo = UInt16(rangeParts[0]), let hi = UInt16(rangeParts[1]), + lo <= hi { + return lo...hi + } else if rangeParts.count == 1, let p = UInt16(rangeParts[0]) { + return p...p + } + return nil + } + switch parts.count { case 1: - guard let port = UInt16(parts[0]) else { return nil } - return PublishPort(hostAddress: IPAddress("0.0.0.0"), hostPort: port, containerPort: port, proto: .tcp, count: 1) + guard let hostRange = parsePorts(parts[0]) else { return [] } + return hostRange.map { p in + PublishPort(hostAddress: IPAddress("0.0.0.0"), hostPort: p, containerPort: p, proto: proto, count: 1) + } case 2: - guard let host = UInt16(parts[0]), let container = UInt16(parts[1]) else { return nil } - return PublishPort(hostAddress: IPAddress("0.0.0.0"), hostPort: host, containerPort: container, proto: .tcp, count: 1) + guard let hostRange = parsePorts(parts[0]), + let containerRange = parsePorts(parts[1]), + hostRange.count == containerRange.count else { return [] } + return zip(hostRange, containerRange).map { (h, c) in + PublishPort(hostAddress: IPAddress("0.0.0.0"), hostPort: h, containerPort: c, proto: proto, count: 1) + } case 3: - guard let host = UInt16(parts[1]), let container = UInt16(parts[2]) else { return nil } - return PublishPort(hostAddress: IPAddress(parts[0]), hostPort: host, containerPort: container, proto: .tcp, count: 1) + let address = parts[0] + guard let hostRange = parsePorts(parts[1]), + let containerRange = parsePorts(parts[2]), + hostRange.count == containerRange.count else { return [] } + return zip(hostRange, containerRange).map { (h, c) in + PublishPort(hostAddress: IPAddress(address), hostPort: h, containerPort: c, proto: proto, count: 1) + } default: - return nil + return [] } } diff --git a/Sources/ComposeEngine/Models/ComposeModels.swift b/Sources/ComposeEngine/Models/ComposeModels.swift index bd532ed..1c2ac84 100644 --- a/Sources/ComposeEngine/Models/ComposeModels.swift +++ b/Sources/ComposeEngine/Models/ComposeModels.swift @@ -117,6 +117,10 @@ public struct ServiceConfig: Sendable, Codable, Equatable { public var extraHosts: [String] /// DNS servers. public var dns: [String] + /// DNS search domains. + public var dnsSearch: [String] + /// DNS resolver options (e.g. `["ndots:5"]`). + public var dnsOpt: [String] /// Linux capabilities to add. public var capAdd: [String] /// Linux capabilities to drop. @@ -160,6 +164,8 @@ public struct ServiceConfig: Sendable, Codable, Equatable { stopSignal: String? = nil, extraHosts: [String] = [], dns: [String] = [], + dnsSearch: [String] = [], + dnsOpt: [String] = [], capAdd: [String] = [], capDrop: [String] = [], shmSize: String? = nil, @@ -195,6 +201,8 @@ public struct ServiceConfig: Sendable, Codable, Equatable { self.stopSignal = stopSignal self.extraHosts = extraHosts self.dns = dns + self.dnsSearch = dnsSearch + self.dnsOpt = dnsOpt self.capAdd = capAdd self.capDrop = capDrop self.shmSize = shmSize @@ -232,6 +240,8 @@ public struct ServiceConfig: Sendable, Codable, Equatable { case stopSignal = "stop_signal" case extraHosts = "extra_hosts" case dns + case dnsSearch = "dns_search" + case dnsOpt = "dns_opt" case capAdd = "cap_add" case capDrop = "cap_drop" case shmSize = "shm_size" @@ -270,6 +280,8 @@ public struct ServiceConfig: Sendable, Codable, Equatable { stopSignal = try c.decodeIfPresent(String.self, forKey: .stopSignal) extraHosts = try c.decodeIfPresent([String].self, forKey: .extraHosts) ?? [] dns = try c.decodeIfPresent([String].self, forKey: .dns) ?? [] + dnsSearch = try c.decodeIfPresent([String].self, forKey: .dnsSearch) ?? [] + dnsOpt = try c.decodeIfPresent([String].self, forKey: .dnsOpt) ?? [] capAdd = try c.decodeIfPresent([String].self, forKey: .capAdd) ?? [] capDrop = try c.decodeIfPresent([String].self, forKey: .capDrop) ?? [] shmSize = try c.decodeIfPresent(String.self, forKey: .shmSize) @@ -527,19 +539,58 @@ public struct NetworkConfig: Sendable, Codable, Equatable { public var driver: String? /// Whether the network is external (pre-existing). public var external: Bool - /// The external network's name. + /// The external network's name (or custom internal name). public var name: String? + /// IPAM configuration for custom subnet allocation. + public var ipam: IPAMConfig? + + /// IPAM (IP Address Management) configuration. + public struct IPAMConfig: Sendable, Codable, Equatable { + /// The IPAM driver. + public var driver: String? + /// Per-subnet configuration entries. + public var config: [IPAMPool] + + public init(driver: String? = nil, config: [IPAMPool] = []) { + self.driver = driver + self.config = config + } + } - public init(driver: String? = nil, external: Bool = false, name: String? = nil) { + /// A single IPAM subnet pool entry. + public struct IPAMPool: Sendable, Codable, Equatable { + /// The subnet in CIDR notation (e.g. `"172.28.0.0/16"`). + public var subnet: String? + /// The IP range allocated from the subnet (e.g. `"172.28.5.0/24"`). + public var ipRange: String? + /// The gateway address for the subnet (e.g. `"172.28.5.254"`). + public var gateway: String? + + public init(subnet: String? = nil, ipRange: String? = nil, gateway: String? = nil) { + self.subnet = subnet + self.ipRange = ipRange + self.gateway = gateway + } + + enum CodingKeys: String, CodingKey { + case subnet + case ipRange = "ip_range" + case gateway + } + } + + public init(driver: String? = nil, external: Bool = false, name: String? = nil, ipam: IPAMConfig? = nil) { self.driver = driver self.external = external self.name = name + self.ipam = ipam } enum CodingKeys: String, CodingKey { case driver case external case name + case ipam } public init(from decoder: Decoder) throws { @@ -552,6 +603,7 @@ public struct NetworkConfig: Sendable, Codable, Equatable { external = c.contains(.external) } name = try c.decodeIfPresent(String.self, forKey: .name) + ipam = try c.decodeIfPresent(IPAMConfig.self, forKey: .ipam) } } diff --git a/Tests/ComposeEngineTests/ComposeEngineTests.swift b/Tests/ComposeEngineTests/ComposeEngineTests.swift index 6aa313b..7e297e5 100644 --- a/Tests/ComposeEngineTests/ComposeEngineTests.swift +++ b/Tests/ComposeEngineTests/ComposeEngineTests.swift @@ -204,15 +204,45 @@ final class ComposeEngineTests: XCTestCase { func testParsePortSpec() { let simple = ComposeOrchestrator.parsePortSpec("8080:80") - XCTAssertEqual(simple?.hostPort, 8080) - XCTAssertEqual(simple?.containerPort, 80) + XCTAssertEqual(simple.first?.hostPort, 8080) + XCTAssertEqual(simple.first?.containerPort, 80) let bound = ComposeOrchestrator.parsePortSpec("127.0.0.1:8080:80") - XCTAssertEqual(bound?.hostAddress.rawValue, "127.0.0.1") + XCTAssertEqual(bound.first?.hostAddress.rawValue, "127.0.0.1") let single = ComposeOrchestrator.parsePortSpec("80") - XCTAssertEqual(single?.hostPort, 80) - XCTAssertEqual(single?.containerPort, 80) + XCTAssertEqual(single.first?.hostPort, 80) + XCTAssertEqual(single.first?.containerPort, 80) + } + + func testParsePortSpecUDP() { + let udp = ComposeOrchestrator.parsePortSpec("5353:53/udp") + XCTAssertEqual(udp.count, 1) + XCTAssertEqual(udp.first?.hostPort, 5353) + XCTAssertEqual(udp.first?.containerPort, 53) + XCTAssertEqual(udp.first?.proto, .udp) + } + + func testParsePortSpecRange() { + let range = ComposeOrchestrator.parsePortSpec("8080-8082:80-82") + XCTAssertEqual(range.count, 3) + XCTAssertEqual(range[0].hostPort, 8080) + XCTAssertEqual(range[0].containerPort, 80) + XCTAssertEqual(range[2].hostPort, 8082) + XCTAssertEqual(range[2].containerPort, 82) + XCTAssertEqual(range[0].proto, .tcp) + } + + func testParsePortSpecRangeUDP() { + let range = ComposeOrchestrator.parsePortSpec("9000-9001:9000-9001/udp") + XCTAssertEqual(range.count, 2) + XCTAssertEqual(range[0].proto, .udp) + XCTAssertEqual(range[1].hostPort, 9001) + } + + func testParsePortSpecInvalid() { + XCTAssertTrue(ComposeOrchestrator.parsePortSpec("notaport").isEmpty) + XCTAssertTrue(ComposeOrchestrator.parsePortSpec("8080-8082:80-81").isEmpty) // mismatched range lengths } func testParseMemory() { @@ -344,4 +374,86 @@ final class ComposeEngineTests: XCTestCase { let web = try XCTUnwrap(project.services["web"]) XCTAssertEqual(web.ports, ["8080:80"]) } + + // MARK: - Advanced networking: DNS search / options + + func testDnsSearchAndOptions() throws { + let yaml = """ + services: + web: + image: nginx + dns: + - 8.8.8.8 + dns_search: + - example.com + - local + dns_opt: + - "ndots:5" + """ + let project = try ComposeParser().parse(yaml: yaml) + let web = try XCTUnwrap(project.services["web"]) + XCTAssertEqual(web.dns, ["8.8.8.8"]) + XCTAssertEqual(web.dnsSearch, ["example.com", "local"]) + XCTAssertEqual(web.dnsOpt, ["ndots:5"]) + } + + func testDnsSearchDefaultsEmpty() throws { + let yaml = """ + services: + web: + image: nginx + """ + let project = try ComposeParser().parse(yaml: yaml) + let web = try XCTUnwrap(project.services["web"]) + XCTAssertTrue(web.dnsSearch.isEmpty) + XCTAssertTrue(web.dnsOpt.isEmpty) + } + + // MARK: - Advanced networking: IPAM + + func testNetworkIpamConfig() throws { + let yaml = """ + networks: + backend: + driver: bridge + ipam: + driver: default + config: + - subnet: "172.28.0.0/16" + ip_range: "172.28.5.0/24" + gateway: "172.28.5.254" + services: + app: + image: nginx + networks: + - backend + """ + let project = try ComposeParser().parse(yaml: yaml) + let backend = try XCTUnwrap(project.networks["backend"]) + XCTAssertEqual(backend.driver, "bridge") + let ipam = try XCTUnwrap(backend.ipam) + XCTAssertEqual(ipam.driver, "default") + XCTAssertEqual(ipam.config.count, 1) + XCTAssertEqual(ipam.config[0].subnet, "172.28.0.0/16") + XCTAssertEqual(ipam.config[0].ipRange, "172.28.5.0/24") + XCTAssertEqual(ipam.config[0].gateway, "172.28.5.254") + } + + func testExternalNetworkConfig() throws { + let yaml = """ + networks: + shared: + external: true + name: my-shared-network + services: + app: + image: nginx + networks: + - default + """ + let project = try ComposeParser().parse(yaml: yaml) + let shared = try XCTUnwrap(project.networks["shared"]) + XCTAssertTrue(shared.external) + XCTAssertEqual(shared.name, "my-shared-network") + } } From 38d306729a6665d39f0b6f9288a0db2ca3abec26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:51:57 +0000 Subject: [PATCH 3/4] Address code review: precise error handling in inspectConnections, fix DNS nameservers behavior Co-authored-by: djpfs <43576725+djpfs@users.noreply.github.com> --- .../ComposeEngine/ComposeOrchestrator.swift | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/Sources/ComposeEngine/ComposeOrchestrator.swift b/Sources/ComposeEngine/ComposeOrchestrator.swift index 5db6535..da7bbe4 100644 --- a/Sources/ComposeEngine/ComposeOrchestrator.swift +++ b/Sources/ComposeEngine/ComposeOrchestrator.swift @@ -564,12 +564,17 @@ public struct ComposeOrchestrator: Sendable { var result: [ServiceConnections] = [] for name in project.services.keys.sorted() { let containerName = Self.containerName(project: project.name, service: name, config: project.services[name]) - guard let snapshot = try? await service.getContainer(id: containerName) else { continue } - result.append(ServiceConnections( - service: name, - attachments: snapshot.networks, - publishedPorts: snapshot.configuration.publishedPorts - )) + do { + let snapshot = try await service.getContainer(id: containerName) + result.append(ServiceConnections( + service: name, + attachments: snapshot.networks, + publishedPorts: snapshot.configuration.publishedPorts + )) + } catch BackendError.notFound { + // Container hasn't been created yet — skip silently. + continue + } } return result } @@ -782,7 +787,7 @@ public struct ComposeOrchestrator: Sendable { dns: service.dns.isEmpty && service.dnsSearch.isEmpty && service.dnsOpt.isEmpty ? nil : .init( - nameservers: service.dns.isEmpty ? ContainerConfiguration.DNSConfiguration.defaultNameservers : service.dns, + nameservers: service.dns, searchDomains: service.dnsSearch, options: service.dnsOpt ), From 18e76932f426bf6997876bf15cc773f6e836a880 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:04:02 +0000 Subject: [PATCH 4/4] Resolve PR feedback for network docs and IPAM wiring Co-authored-by: djpfs <43576725+djpfs@users.noreply.github.com> --- .../ComposeEngine/ComposeOrchestrator.swift | 76 ++++++++++++++++--- .../ComposeEngineTests.swift | 34 +++++++++ 2 files changed, 101 insertions(+), 9 deletions(-) diff --git a/Sources/ComposeEngine/ComposeOrchestrator.swift b/Sources/ComposeEngine/ComposeOrchestrator.swift index da7bbe4..588ef3f 100644 --- a/Sources/ComposeEngine/ComposeOrchestrator.swift +++ b/Sources/ComposeEngine/ComposeOrchestrator.swift @@ -90,6 +90,23 @@ public struct ComposeOrchestrator: Sendable { "\(sanitize(project))_\(sanitize(network))" } + /// The runtime network name for a compose network reference. + static func runtimeNetworkName(project: ComposeProject, network: String) -> String { + if network == "default" { + return ContainerAPIClient.defaultNetworkName + } + // If the network wasn't declared, keep the raw key as a best-effort + // runtime name (validation normally rejects undeclared networks). + guard let config = project.networks[network] else { return network } + if let explicitName = config.name, !explicitName.isEmpty { + return explicitName + } + if config.external { + return network + } + return Self.networkName(project: project.name, network: network) + } + /// The platform volume name for a compose volume. public static func volumeName(project: String, volume: String) -> String { "\(sanitize(project))_\(sanitize(volume))" @@ -159,6 +176,10 @@ public struct ComposeOrchestrator: Sendable { progress?.yield("Creating volumes...") try await ensureVolumes(project) + // 2b. Create the project's internal networks. + progress?.yield("Creating networks...") + try await ensureNetworks(project) + // 3. Create containers in dependency order, recreating any whose // config hash changed since the last `up`. for name in order { @@ -555,7 +576,7 @@ public struct ComposeOrchestrator: Sendable { } } - /// Inspect the network connections for every running service in the project. + /// Inspect the network connections for every service in the project. /// /// Returns one ``ServiceConnections`` entry per service that has a container /// (running or stopped). Services whose containers do not exist yet are @@ -766,15 +787,18 @@ public struct ComposeOrchestrator: Sendable { labels[ComposeLabel.configHash] = configHash } - // Networks: attach to the platform's default network with a unique - // hostname (the server rejects empty/duplicate hostnames). The MTU - // must be set explicitly: the CLI defaults to 1280, and omitting it - // makes bootstrap fail with EOPNOTSUPP. + // Networks: attach to configured compose networks, or the platform's + // default network when none are configured. Hostnames must be unique + // and non-empty, and MTU must be set explicitly to avoid EOPNOTSUPP. let hostname = service.hostname ?? containerName - let networks = [AttachmentConfiguration( - network: ContainerAPIClient.defaultNetworkName, - options: AttachmentOptions(hostname: hostname, mtu: 1280) - )] + let configuredNetworks = service.networks.isEmpty ? ["default"] : service.networks + let networks = configuredNetworks.enumerated().map { index, network in + let attachmentHostname = index == 0 ? hostname : "\(hostname)-\(Self.sanitize(network))" + AttachmentConfiguration( + network: Self.runtimeNetworkName(project: project, network: network), + options: AttachmentOptions(hostname: attachmentHostname, mtu: 1280) + ) + } return ContainerConfiguration( id: containerName, @@ -856,6 +880,40 @@ public struct ComposeOrchestrator: Sendable { } } + /// Create the project's internal networks that don't exist yet. + private func ensureNetworks(_ project: ComposeProject) async throws { + let existing = try await service.listNetworks() + let existingNames = Set(existing.map(\.name)) + for (name, config) in project.networks where !config.external { + let runtimeName = Self.runtimeNetworkName(project: project, network: name) + if existingNames.contains(runtimeName) { + continue + } + + let primaryPool = config.ipam?.config.first + var options: [String: String] = [:] + if let ipRange = primaryPool?.ipRange { + options["ip_range"] = ipRange + } + if let gateway = primaryPool?.gateway { + options["gateway"] = gateway + } + if let ipamDriver = config.ipam?.driver { + options["ipam_driver"] = ipamDriver + } + // The backend network model has a first-class subnet field only; + // gateway/ip_range are passed through plugin options. + + let configuration = NetworkConfiguration( + name: runtimeName, + ipv4Subnet: primaryPool?.subnet, + plugin: config.driver ?? "vmnet", + options: options + ) + _ = try await service.createNetwork(configuration: configuration) + } + } + /// Replace the source of named-volume mounts with the volume's host path. /// The server rejects a bare volume name as a mount source (bootstrap /// fails with EOPNOTSUPP); the CLI always sends the resolved path. diff --git a/Tests/ComposeEngineTests/ComposeEngineTests.swift b/Tests/ComposeEngineTests/ComposeEngineTests.swift index 7e297e5..55f0fb5 100644 --- a/Tests/ComposeEngineTests/ComposeEngineTests.swift +++ b/Tests/ComposeEngineTests/ComposeEngineTests.swift @@ -456,4 +456,38 @@ final class ComposeEngineTests: XCTestCase { XCTAssertTrue(shared.external) XCTAssertEqual(shared.name, "my-shared-network") } + + func testRuntimeNetworkNameUsesInternalComposeName() throws { + let yaml = """ + name: netproj + networks: + backend: + ipam: + config: + - subnet: "172.28.0.0/16" + services: + app: + image: nginx + networks: + - backend + """ + let project = try ComposeParser().parse(yaml: yaml) + XCTAssertEqual(ComposeOrchestrator.runtimeNetworkName(project: project, network: "backend"), "netproj_backend") + } + + func testRuntimeNetworkNameUsesExplicitExternalName() throws { + let yaml = """ + networks: + shared: + external: true + name: my-shared-network + services: + app: + image: nginx + networks: + - shared + """ + let project = try ComposeParser().parse(yaml: yaml) + XCTAssertEqual(ComposeOrchestrator.runtimeNetworkName(project: project, network: "shared"), "my-shared-network") + } }