From 7342420c2d6ec8b8fa8fdcf8e80efba64442f69f Mon Sep 17 00:00:00 2001 From: us Date: Sun, 9 Aug 2026 16:51:26 +0300 Subject: [PATCH 1/3] feat(compose): make networks real, add external:, down --rmi and honored flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose networks did nothing. NetworkManager persisted them to a JSON directory of mocker's own that the runtime never saw, and ContainerConfig.network was set and then never emitted, so every container landed on the runtime's global network no matter what the file declared — and `mocker run --network` was ignored too. - NetworkManager now goes through the real `container network` CLI, so what it reports and removes is what the runtime actually has; connect/disconnect say they are unsupported instead of writing a record that changed nothing - buildRunArguments emits --network, which is what attaches a container - services that name no network get a project-scoped `-default`, so unrelated projects no longer share one network - networks support `external:` and `name:`: up verifies an external network exists and fails naming it, never creates it, and down never removes it - a service's `networks:` mapping form is parsed; it previously read as no networks at all - a container records the network it was created on, so changing a service's network recreates it instead of leaving it on the old one - `down --rmi all|local` removes the services' images, `local` limited to the ones compose built; an invalid value is rejected rather than ignored - `rm --volumes` errors: nothing tracks anonymous volumes, so honoring it would report a cleanup that never happened - `--timeout` reaches `container stop -t` instead of being parsed and dropped - `network prune` no longer removes the runtime's built-in network, and counts only what it actually removed - down --rmi and compose stop warn on stderr rather than reporting success for something that failed BREAKING CHANGE: containers now really join the networks a compose file declares, so services that relied on every container sharing the runtime's global network can no longer reach services in other projects. Networks are no longer stored under ~/.mocker/networks; `network connect`/`disconnect` report that the runtime does not support them. MockerKit: NetworkInfo.created is optional, NetworkManager's methods are async and throwing, and ObservedContainer gained a network field. --- COMMANDS.md | 2 +- README.md | 12 +- Sources/Mocker/Commands/Compose.swift | 38 ++- Sources/Mocker/Commands/Network.swift | 16 +- Sources/MockerKit/Compose/ComposeFile.swift | 47 +++- .../Compose/ComposeOrchestrator.swift | 197 +++++++++++++- Sources/MockerKit/Config/MockerConfig.swift | 3 +- .../MockerKit/Container/ContainerEngine.swift | 16 +- Sources/MockerKit/Models/NetworkInfo.swift | 4 +- Sources/MockerKit/Models/NetworkInspect.swift | 4 +- .../MockerKit/Network/NetworkManager.swift | 207 ++++++++------- .../ComposeNetworkLifecycleTests.swift | 248 ++++++++++++++++++ .../ComposeOrchestratorTests.swift | 30 ++- .../MockerKitTests/ContainerEngineTests.swift | 19 ++ .../MockerKitTests/NetworkManagerTests.swift | 104 ++++++++ Tests/MockerTests/ComposeConfigTests.swift | 11 + .../MockerTests/NetworkInspectCLITests.swift | 23 +- docs/cli-reference.md | 8 +- docs/compose.md | 12 + 19 files changed, 852 insertions(+), 149 deletions(-) create mode 100644 Tests/MockerKitTests/ComposeNetworkLifecycleTests.swift create mode 100644 Tests/MockerKitTests/NetworkManagerTests.swift diff --git a/COMMANDS.md b/COMMANDS.md index e23f82c..a716abd 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -1167,7 +1167,7 @@ mocker compose rm [OPTIONS] [SERVICE...] |------|-------|-------------| | `--force` | | Don't ask to confirm removal | | `--stop` | `-s` | Stop the containers, if required, before removing | -| `--volumes` | `-v` | Remove any anonymous volumes attached to containers | +| `--volumes` | `-v` | Not supported: mocker does not track anonymous volumes, so this errors rather than reporting a cleanup it did not do | | `--dry-run` | | Execute command in dry run mode | ### `mocker compose kill` diff --git a/README.md b/README.md index fa9bcf4..98f5977 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ See the **[CHANGELOG](CHANGELOG.md)** for the full, always-current release histo ## Features - **Docker CLI compatible** — `run`, `ps`, `stop`, `rm`, `exec`, `logs`, `build`, `pull`, `push`, `images`, `tag`, `rmi`, `inspect`, `stats` -- **Network management** — `network create/ls/rm/inspect/connect/disconnect` +- **Network management** — `network create/ls/rm/inspect`, and compose `networks:` - **Volume management** — `volume create/ls/rm/inspect` - **Docker Compose v2** — `compose up/down/ps/logs/restart` with dependency ordering - **MenuBar GUI** — Native SwiftUI app *(coming soon)* @@ -226,11 +226,8 @@ mocker network create mynet # List networks mocker network ls -# Connect a container -mocker network connect mynet myapp - -# Disconnect -mocker network disconnect mynet myapp +# Attach a container at run time +mocker run --network mynet nginx # Inspect mocker network inspect mynet @@ -347,7 +344,7 @@ mocker/ | Concern | Approach | |---------|----------| | Thread safety | All engines/managers are `actor` types | -| Persistence | JSON files in `~/.mocker/{containers,images,networks,volumes}/` | +| Persistence | JSON files in `~/.mocker/{containers,images,volumes}/`; networks live in the container runtime itself | | CLI parsing | `swift-argument-parser` with `AsyncParsableCommand` | | YAML parsing | `Yams` library | | Compose naming | Docker v2 convention: `projectName-serviceName-1` (hyphen separator) | @@ -361,7 +358,6 @@ Mocker stores all state in `~/.mocker/`: ~/.mocker/ ├── containers/ # Container metadata (one JSON file per container) ├── images/ # Image metadata -├── networks/ # Network metadata └── volumes/ # Volume metadata + actual data directories └── pgdata/ └── _data/ diff --git a/Sources/Mocker/Commands/Compose.swift b/Sources/Mocker/Commands/Compose.swift index a61070e..a48d8a4 100644 --- a/Sources/Mocker/Commands/Compose.swift +++ b/Sources/Mocker/Commands/Compose.swift @@ -136,6 +136,7 @@ enum ComposeFormatter { case .containerRemoved(let name): ("Container \(name)", "Removed") case .networkRemoved(let name): ("Network \(name)", "Removed") case .volumeRemoved(let name): ("Volume \(name)", "Removed") + case .imageRemoved(let name): ("Image \(name)", "Removed") } } } @@ -315,12 +316,18 @@ struct ComposeDown: AsyncParsableCommand { func run() async throws { let (composeFile, project, projectDir) = try options.loadCompose() let config = MockerConfig() + let removeImages = try Self.parseRemoveImages(rmi) if options.dryRun { var targets = composeFile.services.keys.sorted().map { "\(project)-\($0)-1" } if volumes { targets += ComposeOrchestrator.volumesToRemove(composeFile: composeFile, projectName: project) } + if let removeImages { + targets += ComposeOrchestrator.imagesToRemove( + composeFile: composeFile, projectName: project, mode: removeImages + ) + } ComposeDryRun.report("down", targets: targets) return } @@ -339,10 +346,24 @@ struct ComposeDown: AsyncParsableCommand { volumeManager: volumeManager ) - let events = try await orchestrator.down(composeFile: composeFile, removeVolumes: volumes) + let events = try await orchestrator.down( + composeFile: composeFile, removeVolumes: volumes, + removeImages: removeImages, timeout: timeout + ) let totalResources = events.count ComposeFormatter.printEvents(events, total: totalResources) } + + /// Validate `--rmi`. An unrecognized value used to be accepted and ignored, which + /// looks identical to a teardown that removed the images. + static func parseRemoveImages(_ value: String?) throws -> ComposeImageRemoval? { + guard let value else { return nil } + guard let mode = ComposeImageRemoval(rawValue: value) else { + let valid = ComposeImageRemoval.allCases.map(\.rawValue).joined(separator: "|") + throw MockerError.operationFailed("invalid --rmi value: \(value) (expected \(valid))") + } + return mode + } } struct ComposePS: AsyncParsableCommand { @@ -1084,8 +1105,11 @@ struct ComposeStop: AsyncParsableCommand { : containers.filter { c in services.contains { ComposeOrchestrator.belongs(c, to: $0, projectName: project) } } for c in targets { - _ = try? await engine.stop(c.id) - print("Container \(c.name) Stopped") + if (try? await engine.stop(c.id, timeout: timeout)) != nil { + print("Container \(c.name) Stopped") + } else { + FileHandle.standardError.write(Data("WARNING: could not stop \(c.name)\n".utf8)) + } } } @@ -1179,6 +1203,14 @@ struct ComposeRm: AsyncParsableCommand { let config = MockerConfig() try composeFile.validateServiceNames(services) + // Upstream removes the anonymous volumes attached to the removed containers. + // mocker records no anonymous volumes anywhere, so honoring this flag would be a + // no-op that reads as a successful cleanup — say so instead. + if volumes { + throw MockerError.operationFailed( + "compose rm --volumes is not yet supported with Apple Containerization") + } + if options.dryRun { ComposeDryRun.report("rm", targets: ComposeStop.dryRunTargets(composeFile, project, services)) return diff --git a/Sources/Mocker/Commands/Network.swift b/Sources/Mocker/Commands/Network.swift index 906b8e7..bced543 100644 --- a/Sources/Mocker/Commands/Network.swift +++ b/Sources/Mocker/Commands/Network.swift @@ -107,7 +107,7 @@ struct NetworkList: AsyncParsableCommand { func run() async throws { let config = MockerConfig() let manager = try NetworkManager(config: config) - var networks = await manager.list() + var networks = try await manager.list() for f in filter { let parts = f.split(separator: "=", maxSplits: 1) @@ -253,12 +253,18 @@ struct NetworkPrune: AsyncParsableCommand { let config = MockerConfig() let manager = try NetworkManager(config: config) - let networks = await manager.list() + let networks = try await manager.list() var removed = 0 - for n in networks where n.name != "bridge" && n.name != "host" && n.name != "none" { - _ = try? await manager.remove(n.name) - removed += 1 + // `list()` now returns the runtime's real networks, which include its built-in + // one — pruning that would break every container that does not name a network. + for n in networks where !NetworkManager.isBuiltIn(n) + && n.name != "bridge" && n.name != "host" && n.name != "none" { + // Count what was actually removed: a network with containers attached stays, + // and reporting it as deleted sends people looking for a network that is still there. + if (try? await manager.remove(n.name)) != nil { + removed += 1 + } } print("Deleted \(removed) networks") } diff --git a/Sources/MockerKit/Compose/ComposeFile.swift b/Sources/MockerKit/Compose/ComposeFile.swift index 4f1bb21..09be117 100644 --- a/Sources/MockerKit/Compose/ComposeFile.swift +++ b/Sources/MockerKit/Compose/ComposeFile.swift @@ -342,9 +342,15 @@ public struct ComposeFile: Sendable { var networks: [String: ComposeNetwork] = [:] for (name, value) in dict { let netDict = value as? [String: Any] ?? [:] + // `external` is either a bool or, in the legacy long form, a mapping + // (`external: {name: shared}`) — both mean "not owned by this project". + let externalDict = netDict["external"] as? [String: Any] + let external = netDict["external"] as? Bool ?? (netDict["external"] != nil) networks[name] = ComposeNetwork( name: name, - driver: netDict["driver"] as? String ?? "bridge" + driver: netDict["driver"] as? String ?? "bridge", + external: external, + customName: netDict["name"] as? String ?? externalDict?["name"] as? String ) } return networks @@ -407,6 +413,17 @@ public struct ComposeFile: Sendable { return ComposeFile(services: filteredServices, networks: networks, volumes: volumes, name: self.name) } + /// Throw when a service joins a network the file never declares — the container + /// would otherwise be started against a network nothing creates. + public func validateNetworkReferences() throws { + for service in services.values.sorted(by: { $0.name < $1.name }) { + for network in service.networks where networks[network] == nil { + throw MockerError.composeParseError( + "service \(service.name) refers to undefined network \(network)") + } + } + } + /// Throw `no such service: ` for any requested name absent from the project, /// matching `docker compose`'s behaviour of erroring instead of silently doing nothing. /// Only literal, user-typed names are checked — transitive dependencies are resolved @@ -516,7 +533,16 @@ public struct ComposeService: Sendable { let environment = parseEnvironment(dict["environment"]) let ports = (dict["ports"] as? [Any])?.compactMap { "\($0)" } ?? [] let volumes = (dict["volumes"] as? [Any])?.compactMap { "\($0)" } ?? [] - let networks = (dict["networks"] as? [Any])?.compactMap { "\($0)" } ?? [] + // Both spellings are valid: a list of names, or a mapping whose keys are the + // names and whose values carry per-network options such as `aliases`. + let networks: [String] + if let list = dict["networks"] as? [Any] { + networks = list.compactMap { "\($0)" } + } else if let mapping = dict["networks"] as? [String: Any] { + networks = mapping.keys.sorted() + } else { + networks = [] + } let dependsOn = parseDependsOn(dict["depends_on"]) let command = parseCommand(dict["command"]) let labels = (dict["labels"] as? [String: String]) ?? [:] @@ -844,12 +870,27 @@ public enum ComposeImageSource: Sendable, Equatable { /// Network definition in a compose file. public struct ComposeNetwork: Sendable { + /// The key this network is declared under in the compose file. public var name: String public var driver: String + /// `external: true` — the network lives outside the project lifecycle: it is + /// neither created nor removed by the project, only joined. + public var external: Bool + /// Explicit `name:` override — used verbatim, without the project prefix. + public var customName: String? - public init(name: String, driver: String = "bridge") { + public init(name: String, driver: String = "bridge", external: Bool = false, customName: String? = nil) { self.name = name self.driver = driver + self.external = external + self.customName = customName + } + + /// The network's real name in the runtime: an explicit `name:` wins, an external + /// network keeps its declared key, and everything else is project-namespaced. + public func runtimeName(projectName: String) -> String { + if let customName { return customName } + return external ? name : "\(projectName)-\(name)" } } diff --git a/Sources/MockerKit/Compose/ComposeOrchestrator.swift b/Sources/MockerKit/Compose/ComposeOrchestrator.swift index 016a253..6c8b305 100644 --- a/Sources/MockerKit/Compose/ComposeOrchestrator.swift +++ b/Sources/MockerKit/Compose/ComposeOrchestrator.swift @@ -11,6 +11,15 @@ public enum ComposeEvent: Sendable { case containerRemoved(String) case networkRemoved(String) case volumeRemoved(String) + case imageRemoved(String) +} + +/// Which images `compose down --rmi` removes. +public enum ComposeImageRemoval: String, Sendable, CaseIterable { + /// Every image the project's services reference, pulled ones included. + case all + /// Only images compose built itself, i.e. services with no explicit `image:`. + case local } /// Orchestrates multi-container deployments from a compose file. @@ -55,11 +64,34 @@ public actor ComposeOrchestrator { ) async throws -> [ComposeEvent] { var events: [ComposeEvent] = [] + try composeFile.validateNetworkReferences() + + // An external network is declared, not owned: the project joins it and must not + // start at all if it is missing, rather than silently running unconnected. + if composeFile.networks.values.contains(where: \.external) { + // Listed once: a failure here is a backend problem and must surface as itself, + // not as a misleading "your external network is missing". + let existing = Set(try await networkManager.list().map(\.name)) + for network in composeFile.networks.values where network.external { + let name = network.runtimeName(projectName: projectName) + guard existing.contains(name) else { + throw MockerError.operationFailed( + "network \(name) declared as external, but could not be found") + } + } + } + // Create networks - for (name, net) in composeFile.networks.sorted(by: { $0.key < $1.key }) { - let fullName = "\(projectName)-\(name)" - if (try? await networkManager.create(name: fullName, driver: net.driver)) != nil { + for (fullName, driver) in Self.networksToCreate(composeFile: composeFile, projectName: projectName) { + if (try? await networkManager.create(name: fullName, driver: driver)) != nil { events.append(.networkCreated(fullName)) + continue + } + // Creation fails both when the network already exists and when the runtime + // rejects it (it has its own naming rules). Only the first is benign, and a + // service cannot join a network that does not exist. + guard (try? await networkManager.inspect(fullName)) != nil else { + throw MockerError.operationFailed("failed to create network \(fullName)") } } @@ -78,6 +110,7 @@ public actor ComposeOrchestrator { name: container.name, serviceName: container.labels["com.mocker.compose.service"] ?? "", configHash: container.labels["com.mocker.compose.config-hash"], + network: container.labels["com.mocker.compose.network"], state: container.state ) } ?? [] @@ -126,7 +159,10 @@ public actor ComposeOrchestrator { for serviceName in order where !skipSet.contains(serviceName) { guard let service = composeFile.services[serviceName] else { continue } - let info = try await startService(service, detach: detach, forceBuild: build, noBuild: noBuild) + let info = try await startService( + service, composeFile: composeFile, + detach: detach, forceBuild: build, noBuild: noBuild + ) let containerName = "\(projectName)-\(service.name)-1" startedContainers.append((serviceName: serviceName, info: info)) events.append(.containerStarted(containerName)) @@ -142,24 +178,42 @@ public actor ComposeOrchestrator { /// Stop and remove all services. /// - Parameter removeVolumes: also remove the project's named volumes (compose `down -v`). - public func down(composeFile: ComposeFile, removeVolumes: Bool = false) async throws -> [ComposeEvent] { + /// - Parameter removeImages: also remove the services' images (compose `down --rmi`). + /// - Parameter timeout: seconds to wait for each container to exit (compose `--timeout`). + public func down( + composeFile: ComposeFile, + removeVolumes: Bool = false, + removeImages: ComposeImageRemoval? = nil, + timeout: Int? = nil + ) async throws -> [ComposeEvent] { var events: [ComposeEvent] = [] let containers = try await ps() for container in containers { if container.state.isActive { - _ = try await engine.stop(container.id) + _ = try await engine.stop(container.id, timeout: timeout) events.append(.containerStopped(container.name)) } _ = try await engine.remove(container.id) events.append(.containerRemoved(container.name)) } - // Remove networks - for (name, _) in composeFile.networks.sorted(by: { $0.key < $1.key }) { - let fullName = "\(projectName)-\(name)" - if (try? await networkManager.remove(fullName)) != nil { - events.append(.networkRemoved(fullName)) + // Remove networks. The backend can still consider a just-removed container + // attached for a moment, so retry briefly instead of silently leaving the + // network behind — and say so if it still cannot be removed. + for fullName in Self.networksToRemove(composeFile: composeFile, projectName: projectName) { + var removed = false + for attempt in 0..<3 { + if (try? await networkManager.remove(fullName)) != nil { + events.append(.networkRemoved(fullName)) + removed = true + break + } + if attempt < 2 { try? await Task.sleep(for: .milliseconds(300)) } + } + if !removed { + FileHandle.standardError.write(Data( + "WARNING: could not remove network \(fullName); remove it manually once its containers are gone\n".utf8)) } } @@ -171,6 +225,19 @@ public actor ComposeOrchestrator { } } + if let removeImages { + // Best-effort, like the network and volume loops: an image that is missing or + // still used by something outside the project must not abort the teardown. + for tag in Self.imagesToRemove(composeFile: composeFile, projectName: projectName, mode: removeImages) { + if (try? await imageManager.remove(tag)) != nil { + events.append(.imageRemoved(tag)) + } else { + FileHandle.standardError.write(Data( + "WARNING: could not remove image \(tag)\n".utf8)) + } + } + } + return events } @@ -186,6 +253,78 @@ public actor ComposeOrchestrator { return container.labels["com.mocker.compose.service"] == service } + /// Images `down --rmi` removes, under the tag each service actually runs from. + /// `local` is limited to images compose built (no explicit `image:`), matching + /// upstream; `all` covers pulled images too. Pure, so the selection is testable + /// without a backend. + public nonisolated static func imagesToRemove( + composeFile: ComposeFile, + projectName: String, + mode: ComposeImageRemoval + ) -> [String] { + let services = composeFile.services + .sorted { $0.key < $1.key } + .map(\.value) + .filter { mode == .all || $0.image == nil } + // A tag can be shared by two services; remove it once. + var seen = Set() + return services + .map { $0.buildTag(projectName: projectName) } + .filter { seen.insert($0).inserted } + } + + /// Networks `up` creates: the project-owned ones, under their runtime names. + /// External networks are joined, never created. + public nonisolated static func networksToCreate( + composeFile: ComposeFile, + projectName: String + ) -> [(name: String, driver: String)] { + var networks = composeFile.networks + .sorted { $0.key < $1.key } + .filter { !$0.value.external } + .map { ($0.value.runtimeName(projectName: projectName), $0.value.driver) } + if implicitDefaultNetworkNeeded(composeFile: composeFile) { + networks.append((implicitDefaultNetwork(projectName: projectName), "bridge")) + } + return networks + } + + /// The project-scoped network Compose gives services that name none. Without it they + /// land on the runtime's global network, where unrelated projects can reach each other. + public nonisolated static func implicitDefaultNetwork(projectName: String) -> String { + "\(projectName)-default" + } + + /// The runtime network a service joins: the first it names, else the file's own + /// `default:` entry if it has one, else the project's implicit default. + public nonisolated static func networkForService( + _ service: ComposeService, + composeFile: ComposeFile, + projectName: String + ) -> String { + if let named = service.networks.first { + return composeFile.networks[named]?.runtimeName(projectName: projectName) + ?? "\(projectName)-\(named)" + } + return composeFile.networks["default"]?.runtimeName(projectName: projectName) + ?? implicitDefaultNetwork(projectName: projectName) + } + + /// Only needed when some service does not name a network of its own. + nonisolated static func implicitDefaultNetworkNeeded(composeFile: ComposeFile) -> Bool { + composeFile.services.values.contains { $0.networks.isEmpty } + && composeFile.networks["default"] == nil + } + + /// Networks `down` may remove — the mirror of `networksToCreate`, so the two can + /// never disagree about which networks the project owns. + public nonisolated static func networksToRemove( + composeFile: ComposeFile, + projectName: String + ) -> [String] { + networksToCreate(composeFile: composeFile, projectName: projectName).map(\.name) + } + /// Volumes `up` creates: the project-owned ones, under their runtime names. /// The mirror image of `volumesToRemove`, so the two can never disagree. public nonisolated static func volumesToCreate( @@ -257,13 +396,13 @@ public actor ComposeOrchestrator { // Recreate services var restarted: [(serviceName: String, info: ContainerInfo)] = [] if let service, let svc = composeFile.services[service] { - let info = try await startService(svc, detach: true) + let info = try await startService(svc, composeFile: composeFile, detach: true) restarted.append((serviceName: service, info: info)) events.append(.containerStarted("\(projectName)-\(service)-1")) } else { for serviceName in composeFile.serviceOrder() { guard let svc = composeFile.services[serviceName] else { continue } - let info = try await startService(svc, detach: true) + let info = try await startService(svc, composeFile: composeFile, detach: true) restarted.append((serviceName: serviceName, info: info)) events.append(.containerStarted("\(projectName)-\(serviceName)-1")) } @@ -320,12 +459,25 @@ public actor ComposeOrchestrator { private func startService( _ service: ComposeService, + composeFile: ComposeFile = ComposeFile(), detach: Bool, forceBuild: Bool = false, noBuild: Bool = false ) async throws -> ContainerInfo { let containerName = "\(projectName)-\(service.name)-1" + // Resolved once: it is both what the container joins and what is recorded on it, + // so a later change to the network is visible to the reconcile. + let resolvedNetwork = Self.networkForService( + service, composeFile: composeFile, projectName: projectName + ) + + if service.networks.count > 1 { + FileHandle.standardError.write(Data( + ("WARNING: service \(service.name) lists \(service.networks.count) networks; " + + "the runtime attaches one, joining \(service.networks[0])\n").utf8)) + } + // Decide whether to build or pull. Per the Compose spec, a service with // both `image:` and `build:` is built and tagged with `image:` — not pulled. switch service.resolveImageSource(projectName: projectName, noBuild: noBuild) { @@ -373,13 +525,18 @@ public actor ComposeOrchestrator { environment: service.environment, ports: ports, volumes: volumes, - network: service.networks.first.map { "\(projectName)-\($0)" }, + // Resolved through the network's own declaration so an external network is + // joined under its real name instead of a project-prefixed one that + // does not exist. Only the first is used: attaching a container to a second + // network fails inside the guest on this runtime. + network: resolvedNetwork, detach: detach, labels: service.labels.merging( [ "com.mocker.compose.project": projectName, "com.mocker.compose.service": service.name, "com.mocker.compose.config-hash": ComposeService.hash(of: service), + "com.mocker.compose.network": resolvedNetwork, ] ) { _, new in new }, workingDir: service.workingDir, @@ -455,8 +612,15 @@ extension ComposeOrchestrator { kind = obs.state == .running ? .keep : .start case (let .some(obs), false, false): let expectedHash = ComposeService.hash(of: service) + let expectedNetwork = networkForService( + service, composeFile: composeFile, projectName: projectName + ) if obs.configHash != expectedHash { kind = .removeAndRecreate + } else if obs.network != expectedNetwork { + // Also covers containers from a version that never recorded a network + // and never joined one: they need recreating to land on the project's. + kind = .removeAndRecreate } else if let digest = obs.imageDigest, digest != service.image { kind = .removeAndRecreate } else { @@ -474,6 +638,9 @@ public struct ObservedContainer: Sendable, Equatable { public let name: String public let serviceName: String public let configHash: String? + /// The network the container was created on, from its label — a change here is not + /// visible in the service hash but still requires a new container. + public let network: String? public let imageDigest: String? public let state: ContainerState @@ -481,12 +648,14 @@ public struct ObservedContainer: Sendable, Equatable { name: String, serviceName: String, configHash: String? = nil, + network: String? = nil, imageDigest: String? = nil, state: ContainerState = .running ) { self.name = name self.serviceName = serviceName self.configHash = configHash + self.network = network self.imageDigest = imageDigest self.state = state } diff --git a/Sources/MockerKit/Config/MockerConfig.swift b/Sources/MockerKit/Config/MockerConfig.swift index 4d70817..3cc100a 100644 --- a/Sources/MockerKit/Config/MockerConfig.swift +++ b/Sources/MockerKit/Config/MockerConfig.swift @@ -71,7 +71,6 @@ public struct MockerConfig: Codable, Sendable { public var volumesPath: String { "\(dataRoot)/volumes" } /// Path for network metadata. - public var networksPath: String { "\(dataRoot)/networks" } /// Discover the Linux kernel binary installed by Apple's container CLI. public static var kernelPath: URL? { @@ -110,7 +109,7 @@ public struct MockerConfig: Codable, Sendable { /// Ensure all required directories exist. public func ensureDirectories() throws { let fm = FileManager.default - let dirs = [dataRoot, containersPath, volumesPath, networksPath, ociStorePath.path, logsPath, proxiesPath] + let dirs = [dataRoot, containersPath, volumesPath, ociStorePath.path, logsPath, proxiesPath] for dir in dirs { if !fm.fileExists(atPath: dir) { try fm.createDirectory(atPath: dir, withIntermediateDirectories: true) diff --git a/Sources/MockerKit/Container/ContainerEngine.swift b/Sources/MockerKit/Container/ContainerEngine.swift index 299cbc4..c6e5751 100644 --- a/Sources/MockerKit/Container/ContainerEngine.swift +++ b/Sources/MockerKit/Container/ContainerEngine.swift @@ -165,6 +165,12 @@ public actor ContainerEngine { args.append("--virtualization") } + // `network` was carried on the config but never emitted, so a compose service's + // `networks:` had no effect on the container at all. + if let network = containerConfig.network, !network.isEmpty { + args += ["--network", network] + } + if let kernel = containerConfig.kernel, !kernel.isEmpty { args += ["--kernel", kernel] } @@ -250,13 +256,19 @@ public actor ContainerEngine { // MARK: - Stop - public func stop(_ identifier: String) async throws -> ContainerInfo { + /// - Parameter timeout: seconds to wait for the container to exit before it is + /// killed. `compose stop`/`down` pass their `--timeout` here; it used to be parsed + /// and dropped, leaving every teardown on the runtime's own default. + public func stop(_ identifier: String, timeout: Int? = nil) async throws -> ContainerInfo { let container = try await resolve(identifier) guard container.state == .running else { throw MockerError.containerNotRunning(identifier) } - let (_, exitCode) = try await runCLI(["stop", container.name]) + var arguments = ["stop"] + if let timeout { arguments += ["-t", String(timeout)] } + arguments.append(container.name) + let (_, exitCode) = try await runCLI(arguments) guard exitCode == 0 else { throw MockerError.operationFailed("failed to stop container \(container.name)") } diff --git a/Sources/MockerKit/Models/NetworkInfo.swift b/Sources/MockerKit/Models/NetworkInfo.swift index 60ed7d7..a3bbed8 100644 --- a/Sources/MockerKit/Models/NetworkInfo.swift +++ b/Sources/MockerKit/Models/NetworkInfo.swift @@ -8,7 +8,7 @@ public struct NetworkInfo: Codable, Sendable, Identifiable { public var subnet: String? public var gateway: String? public var containers: [String] - public var created: Date + public var created: Date? public var labels: [String: String] public init( @@ -18,7 +18,7 @@ public struct NetworkInfo: Codable, Sendable, Identifiable { subnet: String? = nil, gateway: String? = nil, containers: [String] = [], - created: Date = Date(), + created: Date? = nil, labels: [String: String] = [:] ) { self.id = id diff --git a/Sources/MockerKit/Models/NetworkInspect.swift b/Sources/MockerKit/Models/NetworkInspect.swift index d7316f0..c26b6fa 100644 --- a/Sources/MockerKit/Models/NetworkInspect.swift +++ b/Sources/MockerKit/Models/NetworkInspect.swift @@ -120,7 +120,9 @@ public func mapToNetworkInspect(_ info: NetworkInfo) -> NetworkInspect { } return NetworkInspect( Name: info.name, Id: info.id, - Created: rfc3339String(info.created), + // The Docker inspect model types this as a string; an unknown timestamp is + // reported as empty rather than as a fabricated "now". + Created: info.created.map(rfc3339String) ?? "", Scope: "local", Driver: info.driver, EnableIPv4: true, EnableIPv6: false, IPAM: NetworkIPAM(Driver: "default", Options: nil, Config: ipamConfig), diff --git a/Sources/MockerKit/Network/NetworkManager.swift b/Sources/MockerKit/Network/NetworkManager.swift index bea25e2..f8c1d20 100644 --- a/Sources/MockerKit/Network/NetworkManager.swift +++ b/Sources/MockerKit/Network/NetworkManager.swift @@ -1,132 +1,155 @@ import Foundation -/// Manages container networks. +/// Manages container networks through Apple's `container` CLI. +/// +/// Networks used to live in a JSON file of mocker's own, disconnected from the runtime: +/// nothing it recorded existed as far as the backend was concerned, so a compose +/// project's `networks:` block had no effect on any container. Every operation now goes +/// to the real network store, which is what containers are actually attached to. public actor NetworkManager { - private let config: MockerConfig - private var networks: [String: NetworkInfo] = [:] - private let storagePath: String - - public init(config: MockerConfig = MockerConfig()) throws { - self.config = config - self.storagePath = config.networksPath - let fm = FileManager.default - if !fm.fileExists(atPath: storagePath) { - try fm.createDirectory(atPath: storagePath, withIntermediateDirectories: true) - } - - // Load persisted networks synchronously during init - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - if let files = try? fm.contentsOfDirectory(atPath: storagePath).filter({ $0.hasSuffix(".json") }) { - for file in files { - if let data = try? Data(contentsOf: URL(fileURLWithPath: "\(storagePath)/\(file)")), - let info = try? decoder.decode(NetworkInfo.self, from: data) { - networks[info.name] = info - } - } - } + private let runner: ProcessRunning + private let cli: String + + public init( + config: MockerConfig = MockerConfig(), + runner: ProcessRunning = RealProcessRunner(), + cli: String = CLIResolver.resolve() + ) throws { + _ = config + self.runner = runner + self.cli = cli } /// Create a new network. - public func create(name: String, driver: String = "bridge", subnet: String? = nil, gateway: String? = nil) throws -> NetworkInfo { - guard networks[name] == nil else { - throw MockerError.operationFailed("Network \(name) already exists") + /// - Parameters: + /// - driver: accepted for Docker surface parity; the backend has a single mode. + /// - gateway: likewise accepted and not forwarded — the backend derives it from the subnet. + /// - Throws: the backend's own message, so a rejected name or an unavailable runtime + /// is distinguishable from "it already exists". + public func create( + name: String, + driver: String = "bridge", + subnet: String? = nil, + gateway: String? = nil + ) async throws -> NetworkInfo { + // The runtime has a single mode and derives the gateway from the subnet. Saying + // so beats reporting success for a network that does not match what was asked. + if driver != "bridge" { + FileHandle.standardError.write(Data( + "WARNING: network driver \(driver) is not configurable; creating \(name) with the runtime's default\n".utf8)) + } + if let gateway, !gateway.isEmpty { + FileHandle.standardError.write(Data( + "WARNING: --gateway is not configurable; the runtime derives it from the subnet\n".utf8)) } - let info = NetworkInfo( - id: generateID(), - name: name, - driver: driver, - subnet: subnet, - gateway: gateway, - created: Date() - ) - networks[name] = info - try saveToDisk(info) - return info + var arguments = ["network", "create"] + if let subnet, !subnet.isEmpty { arguments += ["--subnet", subnet] } + arguments.append(name) + + let (output, status) = try await runner.run(executable: cli, arguments: arguments) + guard status == 0 else { + throw MockerError.operationFailed(Self.errorMessage(from: output, fallback: "failed to create network \(name)")) + } + // Read the created network back so callers see the subnet the backend assigned. + return try await inspect(name) } /// List all networks. - public func list() -> [NetworkInfo] { - Array(networks.values).sorted { $0.created > $1.created } + public func list() async throws -> [NetworkInfo] { + let (output, status) = try await runner.run(executable: cli, arguments: ["network", "ls", "--format", "json"]) + guard status == 0 else { + throw MockerError.operationFailed(Self.errorMessage(from: output, fallback: "failed to list networks")) + } + return Self.parseNetworks(output) } /// Remove a network. - public func remove(_ name: String) throws -> NetworkInfo { - guard let network = networks[name] else { - throw MockerError.networkNotFound(name) + public func remove(_ name: String) async throws -> NetworkInfo { + // Captured first so the caller can report what went, and so a missing network is + // reported as such rather than as a generic CLI failure. + let network = try await inspect(name) + + let (output, status) = try await runner.run(executable: cli, arguments: ["network", "delete", name]) + guard status == 0 else { + throw MockerError.operationFailed(Self.errorMessage(from: output, fallback: "failed to remove network \(name)")) } - guard network.containers.isEmpty else { - throw MockerError.operationFailed("Network \(name) has active containers") - } - networks.removeValue(forKey: name) - try deleteFromDisk(network.id) return network } /// Inspect a network. - public func inspect(_ name: String) throws -> NetworkInfo { - guard let network = networks[name] else { + public func inspect(_ name: String) async throws -> NetworkInfo { + guard let match = try await list().first(where: { $0.name == name }) else { throw MockerError.networkNotFound(name) } - return network + return match } /// Connect a container to a network. - public func connect(container: String, network: String) throws { - guard var net = networks[network] else { - throw MockerError.networkNotFound(network) - } - net.containers.append(container) - networks[network] = net - try saveToDisk(net) + public func connect(container: String, network: String) async throws { + throw MockerError.operationFailed( + "network connect is not yet supported with Apple Containerization") } /// Disconnect a container from a network. - public func disconnect(container: String, network: String) throws { - guard var net = networks[network] else { - throw MockerError.networkNotFound(network) - } - net.containers.removeAll { $0 == container } - networks[network] = net - try saveToDisk(net) + public func disconnect(container: String, network: String) async throws { + throw MockerError.operationFailed( + "network disconnect is not yet supported with Apple Containerization") } - // MARK: - Persistence + // MARK: - Parsing - private func loadFromDisk() throws { - let fm = FileManager.default - guard fm.fileExists(atPath: storagePath) else { return } - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - let files = try fm.contentsOfDirectory(atPath: storagePath) - .filter { $0.hasSuffix(".json") } + /// Decode `container network ls --format json`. Unparseable entries are skipped + /// rather than failing the whole listing. + static func parseNetworks(_ json: String) -> [NetworkInfo] { + guard let data = json.data(using: .utf8), + let entries = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + return [] + } - for file in files { - let data = try Data(contentsOf: URL(fileURLWithPath: "\(storagePath)/\(file)")) - let info = try decoder.decode(NetworkInfo.self, from: data) - networks[info.name] = info + return entries.compactMap { entry in + // The backend renamed this object between releases; accept both spellings so + // an upgrade does not quietly blank out creation dates and labels. + let config = (entry["config"] as? [String: Any]) + ?? (entry["configuration"] as? [String: Any]) + ?? [:] + guard let name = (config["id"] as? String) ?? (entry["id"] as? String) else { return nil } + let status = entry["status"] as? [String: Any] ?? [:] + + return NetworkInfo( + id: name, + name: name, + driver: config["mode"] as? String ?? "nat", + subnet: status["ipv4Subnet"] as? String, + gateway: status["ipv4Gateway"] as? String, + created: Self.parseCreationDate(config["creationDate"]), + labels: config["labels"] as? [String: String] ?? [:] + ) } + .sorted { $0.name < $1.name } } - private func saveToDisk(_ info: NetworkInfo) throws { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - encoder.dateEncodingStrategy = .iso8601 - let data = try encoder.encode(info) - try data.write(to: URL(fileURLWithPath: "\(storagePath)/\(info.id).json")) + /// The backend has encoded this as both a reference-date interval and an ISO-8601 + /// string across releases; a wrong guess makes `network inspect` report "now" every time. + static func parseCreationDate(_ value: Any?) -> Date? { + if let seconds = value as? Double { return Date(timeIntervalSinceReferenceDate: seconds) } + guard let text = value as? String else { return nil } + return RelativeDate.parse(text) } - private func deleteFromDisk(_ id: String) throws { - let filePath = "\(storagePath)/\(id).json" - if FileManager.default.fileExists(atPath: filePath) { - try FileManager.default.removeItem(atPath: filePath) - } + /// Networks the runtime owns and that must never be pruned, identified the way the + /// runtime itself marks them rather than by guessing at names. + public static func isBuiltIn(_ network: NetworkInfo) -> Bool { + network.labels["com.apple.container.resource.role"] == "builtin" || network.name == "default" } - private func generateID() -> String { - let bytes = (0..<16).map { _ in UInt8.random(in: 0...255) } - return bytes.map { String(format: "%02x", $0) }.joined() + /// The backend prints its diagnostics on stderr, which the runner folds into the + /// output; pass the last non-empty line through rather than a generic message. + private static func errorMessage(from output: String, fallback: String) -> String { + let lines = output + .components(separatedBy: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + return lines.last.map { $0.replacingOccurrences(of: "Error: ", with: "") } ?? fallback } } diff --git a/Tests/MockerKitTests/ComposeNetworkLifecycleTests.swift b/Tests/MockerKitTests/ComposeNetworkLifecycleTests.swift new file mode 100644 index 0000000..d0b9877 --- /dev/null +++ b/Tests/MockerKitTests/ComposeNetworkLifecycleTests.swift @@ -0,0 +1,248 @@ +import Testing +import Foundation +@testable import MockerKit + +@Suite("Compose network lifecycle") +struct ComposeNetworkLifecycleTests { + private func parse(_ yaml: String) throws -> ComposeFile { + try ComposeFile.parse(yaml) + } + + @Test("external: true is parsed, and defaults to false") + func parsesExternal() throws { + let compose = try parse(""" + networks: + shared: + external: true + owned: + """) + + #expect(compose.networks["shared"]?.external == true) + #expect(compose.networks["owned"]?.external == false) + } + + @Test("Legacy external mapping form is treated as external") + func parsesLegacyExternalMapping() throws { + let compose = try parse(""" + networks: + shared: + external: + name: already-there + """) + + #expect(compose.networks["shared"]?.external == true) + #expect(compose.networks["shared"]?.runtimeName(projectName: "proj") == "already-there") + } + + @Test("An explicit name: is used verbatim, without the project prefix") + func explicitNameWins() throws { + let compose = try parse(""" + networks: + backend: + name: shared-backend + """) + + #expect(compose.networks["backend"]?.runtimeName(projectName: "proj") == "shared-backend") + } + + @Test("Project-owned networks are namespaced") + func ownedNetworkIsNamespaced() throws { + let compose = try parse("networks:\n backend:\n") + + #expect(compose.networks["backend"]?.runtimeName(projectName: "proj") == "proj-backend") + } + + @Test("up creates owned networks and never external ones") + func networksToCreateSkipsExternal() throws { + let compose = try parse(""" + networks: + backend: + frontend: + driver: bridge + shared: + external: true + named: + name: custom-net + """) + + let created = ComposeOrchestrator.networksToCreate(composeFile: compose, projectName: "proj") + + #expect(created.map(\.name) == ["proj-backend", "proj-frontend", "custom-net"]) + #expect(created.allSatisfy { $0.driver == "bridge" }) + } + + @Test("down removes exactly what up created") + func networksToRemoveMirrorsCreate() throws { + let compose = try parse(""" + networks: + backend: + shared: + external: true + """) + + let created = ComposeOrchestrator.networksToCreate(composeFile: compose, projectName: "proj").map(\.name) + let removed = ComposeOrchestrator.networksToRemove(composeFile: compose, projectName: "proj") + + #expect(created == removed) + #expect(!removed.contains("shared")) + } + + @Test("A project that declares no networks still gets its own default network") + func implicitDefaultNetwork() throws { + let compose = try parse("services:\n web:\n image: nginx\n") + + // Without it the container lands on the runtime's global network, where unrelated + // projects can reach each other. + #expect(ComposeOrchestrator.networksToCreate(composeFile: compose, projectName: "proj") + .map(\.name) == ["proj-default"]) + #expect(ComposeOrchestrator.networksToRemove(composeFile: compose, projectName: "proj") + == ["proj-default"]) + } + + @Test("No implicit default when every service names a network") + func noImplicitDefaultWhenAllServicesNameOne() throws { + let compose = try parse(""" + services: + web: + image: nginx + networks: [backend] + networks: + backend: + """) + + #expect(ComposeOrchestrator.networksToCreate(composeFile: compose, projectName: "proj") + .map(\.name) == ["proj-backend"]) + } + + @Test("A file's own default: network is used instead of an implicit one") + func explicitDefaultNetworkWins() throws { + let compose = try parse(""" + services: + web: + image: nginx + networks: + default: + name: shared-default + """) + + #expect(ComposeOrchestrator.networksToCreate(composeFile: compose, projectName: "proj") + .map(\.name) == ["shared-default"]) + // The service must join that same network. Resolving it separately from the + // creation list is how a container ends up asking for a network nobody made. + #expect(ComposeOrchestrator.networkForService( + compose.services["web"]!, composeFile: compose, projectName: "proj") == "shared-default") + } + + @Test("A service joins the network the project actually creates", arguments: [ + ("services:\n web:\n image: nginx\n", "proj-default"), + ("services:\n web:\n image: nginx\n networks: [backend]\nnetworks:\n backend:\n", "proj-backend"), + ("services:\n web:\n image: nginx\n networks: [backend]\nnetworks:\n backend:\n external: true\n", "backend"), + ("services:\n web:\n image: nginx\nnetworks:\n default:\n external: true\n name: shared-net\n", "shared-net"), + ]) + func serviceNetworkMatchesCreatedNetwork(yaml: String, expected: String) throws { + let compose = try parse(yaml) + + #expect(ComposeOrchestrator.networkForService( + compose.services["web"]!, composeFile: compose, projectName: "proj") == expected) + } + + @Test("A service's networks: accepts both the list and the mapping form") + func serviceNetworksBothForms() throws { + let listForm = try parse(""" + services: + web: + image: nginx + networks: [backend, frontend] + """) + let mappingForm = try parse(""" + services: + web: + image: nginx + networks: + backend: + aliases: [db] + frontend: + """) + + #expect(listForm.services["web"]?.networks == ["backend", "frontend"]) + // The mapping form used to parse as no networks at all, silently leaving the + // container on the runtime's default network. + #expect(mappingForm.services["web"]?.networks == ["backend", "frontend"]) + } + + @Test("A service joining an undeclared network is an error") + func undefinedNetworkReferenceThrows() throws { + let compose = try parse(""" + services: + web: + image: nginx + networks: [ghost] + """) + + // Otherwise `up` asks the runtime for a network nothing ever creates. + #expect(throws: MockerError.self) { + try compose.validateNetworkReferences() + } + } + + @Test("Declared networks pass validation") + func declaredNetworkReferencesPass() throws { + let compose = try parse(""" + services: + web: + image: nginx + networks: [backend] + networks: + backend: + """) + + #expect(throws: Never.self) { try compose.validateNetworkReferences() } + } +} + +@Suite("Compose down --rmi") +struct ComposeImageRemovalTests { + private let compose = try! ComposeFile.parse(""" + services: + built: + build: ./built + pulled: + image: nginx:1.25 + tagged: + image: registry.example.com/app:v1 + build: ./app + """) + + @Test("local removes only what compose built") + func localOnlyBuiltImages() { + let images = ComposeOrchestrator.imagesToRemove( + composeFile: compose, projectName: "proj", mode: .local + ) + + #expect(images == ["proj-built:latest"]) + } + + @Test("all removes every image the services reference") + func allIncludesPulledImages() { + let images = ComposeOrchestrator.imagesToRemove( + composeFile: compose, projectName: "proj", mode: .all + ) + + #expect(images == ["proj-built:latest", "nginx:1.25", "registry.example.com/app:v1"]) + } + + @Test("A tag shared by two services is removed once") + func deduplicatesSharedTags() throws { + let shared = try ComposeFile.parse(""" + services: + a: + image: shared:1 + b: + image: shared:1 + """) + + #expect(ComposeOrchestrator.imagesToRemove( + composeFile: shared, projectName: "proj", mode: .all + ) == ["shared:1"]) + } +} diff --git a/Tests/MockerKitTests/ComposeOrchestratorTests.swift b/Tests/MockerKitTests/ComposeOrchestratorTests.swift index 2e2a611..d5ff77f 100644 --- a/Tests/MockerKitTests/ComposeOrchestratorTests.swift +++ b/Tests/MockerKitTests/ComposeOrchestratorTests.swift @@ -242,13 +242,41 @@ struct ComposeOrchestratorTests { """) } + /// Defaults to the project's implicit network, which is where a container created by + /// the current code actually lands — pass an explicit value to model an upgrade from a + /// version that recorded no network at all. private func observed( name: String, serviceName: String, configHash: String?, + network: String? = "proj-default", state: ContainerState = .running ) -> ObservedContainer { - ObservedContainer(name: name, serviceName: serviceName, configHash: configHash, state: state) + ObservedContainer( + name: name, serviceName: serviceName, configHash: configHash, + network: network, state: state + ) + } + + @Test("A container with no recorded network is recreated onto the project's network") + func reconcileRecreatesUnnetworkedContainer() throws { + let file = try singleServiceFile() + let svc = file.services["app"]! + + let actions = ComposeOrchestrator.reconcileDecision( + observedContainers: [ + observed(name: "proj-app-1", serviceName: "app", + configHash: ComposeService.hash(of: svc), network: nil) + ], + composeFile: file, + projectName: "proj", + forceRecreate: false, + noRecreate: false + ) + + // Containers from a version that never passed --network sit on the runtime's + // global network; leaving them there would silently skip the isolation fix. + #expect(actions == [ReconcileAction(serviceName: "app", kind: .removeAndRecreate)]) } @Test("reconcileDecision: observed container matches hash + defaults → .keep") diff --git a/Tests/MockerKitTests/ContainerEngineTests.swift b/Tests/MockerKitTests/ContainerEngineTests.swift index 7ffa00a..83373a2 100644 --- a/Tests/MockerKitTests/ContainerEngineTests.swift +++ b/Tests/MockerKitTests/ContainerEngineTests.swift @@ -234,6 +234,24 @@ struct ContainerEngineTests { #expect(args.contains("/path/to/kernel")) } + @Test("A configured network is passed to the runtime") + func testRunArgumentsNetwork() { + let config = ContainerConfig(image: "alpine:3.20", network: "shared") + + let args = ContainerEngine.buildRunArguments(name: "joined", config: config) + + // The field was carried on the config but never emitted, so a compose service's + // `networks:` had no effect on the container at all. + #expect(args.firstIndex(of: "--network").map { args[$0 + 1] } == "shared") + } + + @Test("No network flag is emitted when none is configured") + func testRunArgumentsNoNetwork() { + let config = ContainerConfig(image: "alpine:3.20") + + #expect(!ContainerEngine.buildRunArguments(name: "solo", config: config).contains("--network")) + } + @Test("Run arguments publish TCP ports natively with -p") func testRunArgumentsPublishTCPPort() { let config = ContainerConfig( @@ -413,4 +431,5 @@ private final class RootedFileManager: FileManager, @unchecked Sendable { override func isExecutableFile(atPath path: String) -> Bool { super.isExecutableFile(atPath: root + path) } + } diff --git a/Tests/MockerKitTests/NetworkManagerTests.swift b/Tests/MockerKitTests/NetworkManagerTests.swift new file mode 100644 index 0000000..ec33b2c --- /dev/null +++ b/Tests/MockerKitTests/NetworkManagerTests.swift @@ -0,0 +1,104 @@ +import Testing +import Foundation +@testable import MockerKit + +/// Networks used to be kept in a JSON file of mocker's own that the runtime knew nothing +/// about, so a compose project's `networks:` had no effect on any container. These tests +/// pin that every operation now goes to the real `container network` store. +@Suite("NetworkManager") +struct NetworkManagerTests { + /// Shape of a real `container network ls --format json` response. + private let listJSON = """ + [{"status":{"ipv4Gateway":"192.168.65.1","ipv4Subnet":"192.168.65.0/24"}, + "id":"shared","state":"running", + "config":{"mode":"nat","labels":{"team":"infra"},"creationDate":807968813.820583,"id":"shared"}}, + {"status":{"ipv4Subnet":"192.168.64.0/24","ipv4Gateway":"192.168.64.1"}, + "id":"default","state":"running", + "config":{"mode":"nat","id":"default","creationDate":807968261.978083,"labels":{}}}] + """ + + @Test("Listing maps the backend's JSON onto NetworkInfo") + func parsesListing() { + let networks = NetworkManager.parseNetworks(listJSON) + + #expect(networks.map(\.name) == ["default", "shared"]) + let shared = networks.first { $0.name == "shared" } + #expect(shared?.subnet == "192.168.65.0/24") + #expect(shared?.gateway == "192.168.65.1") + #expect(shared?.driver == "nat") + #expect(shared?.labels["team"] == "infra") + } + + @Test("Malformed listing output yields no networks instead of throwing") + func parsesGarbage() { + #expect(NetworkManager.parseNetworks("not json").isEmpty) + #expect(NetworkManager.parseNetworks("").isEmpty) + } + + @Test("list shells out to the real network store") + func listUsesBackend() async throws { + let runner = MockProcessRunner(responses: [(listJSON, 0)]) + let manager = try NetworkManager(runner: runner, cli: "/usr/bin/container") + + let networks = try await manager.list() + + #expect(networks.count == 2) + let calls = await runner.calls + #expect(calls.first?.arguments == ["network", "ls", "--format", "json"]) + } + + @Test("create passes the name and an explicit subnet through") + func createForwardsArguments() async throws { + let runner = MockProcessRunner(responses: [("", 0), (listJSON, 0)]) + let manager = try NetworkManager(runner: runner, cli: "/usr/bin/container") + + _ = try? await manager.create(name: "shared", subnet: "192.168.65.0/24") + + let calls = await runner.calls + #expect(calls.first?.arguments == ["network", "create", "--subnet", "192.168.65.0/24", "shared"]) + } + + @Test("A failed create reports the backend's own message") + func createSurfacesBackendError() async throws { + let runner = MockProcessRunner(responses: [("Error: network shared already exists", 1)]) + let manager = try NetworkManager(runner: runner, cli: "/usr/bin/container") + + await #expect(throws: MockerError.self) { + _ = try await manager.create(name: "shared") + } + } + + @Test("remove deletes through the backend and returns what it removed") + func removeUsesBackend() async throws { + let runner = MockProcessRunner(responses: [(listJSON, 0), ("", 0)]) + let manager = try NetworkManager(runner: runner, cli: "/usr/bin/container") + + let removed = try await manager.remove("shared") + + #expect(removed.name == "shared") + let calls = await runner.calls + #expect(calls.last?.arguments == ["network", "delete", "shared"]) + } + + @Test("Inspecting an unknown network reports it as missing") + func inspectUnknown() async throws { + let runner = MockProcessRunner(responses: [(listJSON, 0)]) + let manager = try NetworkManager(runner: runner, cli: "/usr/bin/container") + + await #expect(throws: MockerError.self) { + _ = try await manager.inspect("nope") + } + } + + @Test("connect and disconnect report that the runtime has no such operation") + func connectUnsupported() async throws { + let manager = try NetworkManager(runner: MockProcessRunner(), cli: "/usr/bin/container") + + await #expect(throws: MockerError.self) { + try await manager.connect(container: "c", network: "n") + } + await #expect(throws: MockerError.self) { + try await manager.disconnect(container: "c", network: "n") + } + } +} diff --git a/Tests/MockerTests/ComposeConfigTests.swift b/Tests/MockerTests/ComposeConfigTests.swift index a2c58a1..dab7702 100644 --- a/Tests/MockerTests/ComposeConfigTests.swift +++ b/Tests/MockerTests/ComposeConfigTests.swift @@ -222,4 +222,15 @@ struct ComposeConfigTests { #expect(reparsed.services["app"]?.environment["NOTE"] == "has # hash") #expect(reparsed.services["app"]?.environment["URL"] == "http://example.com:8080") } + + @Test("--rmi accepts only all|local and rejects anything else") + func rmiValidation() throws { + #expect(try ComposeDown.parseRemoveImages(nil) == nil) + #expect(try ComposeDown.parseRemoveImages("all") == .all) + #expect(try ComposeDown.parseRemoveImages("local") == .local) + // Previously any value was accepted and then ignored, which looked like success. + #expect(throws: MockerError.self) { + _ = try ComposeDown.parseRemoveImages("everything") + } + } } diff --git a/Tests/MockerTests/NetworkInspectCLITests.swift b/Tests/MockerTests/NetworkInspectCLITests.swift index 7d98723..926e2a7 100644 --- a/Tests/MockerTests/NetworkInspectCLITests.swift +++ b/Tests/MockerTests/NetworkInspectCLITests.swift @@ -7,14 +7,17 @@ import MockerKit @Suite("NetworkInspect CLI Tests") struct NetworkInspectCLITests { + /// One `container network ls --format json` row, so these exercise the CLI mapping + /// without touching the machine's real networks. + private static let listJSON = """ + [{"status":{"ipv4Gateway":"10.0.0.1","ipv4Subnet":"10.0.0.0/8"}, + "id":"testnet","state":"running", + "config":{"mode":"nat","labels":{},"creationDate":807968813.8,"id":"testnet"}}] + """ + @Test("network inspect unknown target throws networkNotFound") func network_inspect_unknown_exits_one() async throws { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent("mocker-test-\(UUID().uuidString)").path - defer { try? FileManager.default.removeItem(atPath: tempDir) } - let config = MockerConfig(dataRoot: tempDir) - try config.ensureDirectories() - let manager = try NetworkManager(config: config) + let manager = try NetworkManager(runner: MockProcessRunner(responses: [("[]", 0)])) await #expect(throws: MockerError.self) { _ = try await inspectNetworks(targets: ["ghostnet"], manager: manager) } @@ -22,13 +25,7 @@ struct NetworkInspectCLITests { @Test("network inspect single target emits JSON array with PascalCase keys") func network_inspect_single_emits_array() async throws { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent("mocker-test-\(UUID().uuidString)").path - defer { try? FileManager.default.removeItem(atPath: tempDir) } - let config = MockerConfig(dataRoot: tempDir) - try config.ensureDirectories() - let manager = try NetworkManager(config: config) - _ = try await manager.create(name: "testnet", driver: "bridge", subnet: "10.0.0.0/8", gateway: "10.0.0.1") + let manager = try NetworkManager(runner: MockProcessRunner(responses: [(Self.listJSON, 0)])) let results = try await inspectNetworks(targets: ["testnet"], manager: manager) #expect(results.count == 1) #expect(results[0].Name == "testnet") diff --git a/docs/cli-reference.md b/docs/cli-reference.md index c088e04..bf0620c 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -543,23 +543,27 @@ Services start in dependency order (`depends_on`). Networks and volumes are crea Stop and remove containers and networks. ```bash -mocker compose [OPTIONS] down [--volumes] +mocker compose [OPTIONS] down [--volumes] [--rmi all|local] ``` **Flags:** ``` -v, --volumes Also remove the project's named volumes + --rmi Also remove the services' images: `local` for the images + compose built, `all` for pulled images too ``` **Examples:** ```bash mocker compose down mocker compose down --volumes +mocker compose down --rmi local mocker compose -f staging.yml down ``` `--volumes` removes the volumes declared in the file's top-level `volumes:` section. -Volumes marked `external: true` are never removed. +Volumes and networks marked `external: true` are never removed — an external network is +joined under its own name and left in place. --- diff --git a/docs/compose.md b/docs/compose.md index 1a3adc2..2afd924 100644 --- a/docs/compose.md +++ b/docs/compose.md @@ -27,6 +27,8 @@ services: networks: : driver: bridge + external: true # declared elsewhere: joined, never created or removed + name: # explicit name, used verbatim without the project prefix volumes: : @@ -123,6 +125,10 @@ directory of the first `-f` file). Shell environment takes priority over `.env`. ## Networking +Networks declared in the file are created in the container runtime and services are +attached to them. A service joins one network — the runtime attaches a container to a +single network, so a service listing several joins the first and says so. + Services on the same network can reach each other by service name: ```yaml @@ -240,6 +246,12 @@ mocker compose down -v `-v` removes the volumes declared in the top-level `volumes:` section. Volumes marked `external: true` are never removed. +```bash +# also remove the services' images +mocker compose down --rmi local # only images compose built +mocker compose down --rmi all # pulled images too +``` + --- ## Example: Web + API + Database From cc03355a50368d63ef7b6461f0a113af826131e8 Mon Sep 17 00:00:00 2001 From: us Date: Sun, 9 Aug 2026 17:05:07 +0300 Subject: [PATCH 2/3] fix(runtime): stop shelling out from deadlocking multi-call commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RealProcessRunner waited for a child with `waitUntilExit()`, which can wedge a thread Swift concurrency still needs. A command that shells out once got away with it; now that networks go through the CLI, `compose down` makes several calls in a row and hung indefinitely — 150s and counting, against 2s once exit is observed through terminationHandler instead. Pipes are drained before the process starts so a full buffer cannot block the child either. Also skip network removal for a project that is already down: it retried three times and then told the user to remove a network that was not there. --- .../Compose/ComposeOrchestrator.swift | 5 +++ Sources/MockerKit/Config/MockerConfig.swift | 2 - Sources/MockerKit/ProcessRunner.swift | 42 +++++++------------ 3 files changed, 21 insertions(+), 28 deletions(-) diff --git a/Sources/MockerKit/Compose/ComposeOrchestrator.swift b/Sources/MockerKit/Compose/ComposeOrchestrator.swift index 6c8b305..4a4aa8c 100644 --- a/Sources/MockerKit/Compose/ComposeOrchestrator.swift +++ b/Sources/MockerKit/Compose/ComposeOrchestrator.swift @@ -201,7 +201,12 @@ public actor ComposeOrchestrator { // Remove networks. The backend can still consider a just-removed container // attached for a moment, so retry briefly instead of silently leaving the // network behind — and say so if it still cannot be removed. + let existingNetworks = Set(((try? await networkManager.list()) ?? []).map(\.name)) for fullName in Self.networksToRemove(composeFile: composeFile, projectName: projectName) { + // Nothing to do for a project that is already down — retrying that would burn + // a second and end in a warning telling the user to remove what is not there. + guard existingNetworks.contains(fullName) else { continue } + var removed = false for attempt in 0..<3 { if (try? await networkManager.remove(fullName)) != nil { diff --git a/Sources/MockerKit/Config/MockerConfig.swift b/Sources/MockerKit/Config/MockerConfig.swift index 3cc100a..0fa8887 100644 --- a/Sources/MockerKit/Config/MockerConfig.swift +++ b/Sources/MockerKit/Config/MockerConfig.swift @@ -70,8 +70,6 @@ public struct MockerConfig: Codable, Sendable { /// Path for volume storage. public var volumesPath: String { "\(dataRoot)/volumes" } - /// Path for network metadata. - /// Discover the Linux kernel binary installed by Apple's container CLI. public static var kernelPath: URL? { let home = FileManager.default.homeDirectoryForCurrentUser.path diff --git a/Sources/MockerKit/ProcessRunner.swift b/Sources/MockerKit/ProcessRunner.swift index e50afbe..a7597ca 100644 --- a/Sources/MockerKit/ProcessRunner.swift +++ b/Sources/MockerKit/ProcessRunner.swift @@ -21,35 +21,25 @@ public struct RealProcessRunner: ProcessRunning { process.standardOutput = stdoutPipe process.standardError = stderrPipe - try process.run() - - // Read stdout and stderr concurrently to prevent pipe-buffer deadlock. - // If one pipe fills while the other isn't being drained, the child blocks. - return await withCheckedContinuation { continuation in - var outData = Data() - var errData = Data() - let group = DispatchGroup() - - group.enter() - DispatchQueue.global().async { - outData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - group.leave() - } + // Drain both pipes concurrently: if one fills while the other is not being read, + // the child blocks on write and never exits. + let outHandle = stdoutPipe.fileHandleForReading + let errHandle = stderrPipe.fileHandleForReading + let outTask = Task.detached { outHandle.readDataToEndOfFile() } + let errTask = Task.detached { errHandle.readDataToEndOfFile() } - group.enter() - DispatchQueue.global().async { - errData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - group.leave() - } + try process.run() - group.notify(queue: .global()) { - process.waitUntilExit() - let out = String(data: outData, encoding: .utf8) ?? "" - let err = String(data: errData, encoding: .utf8) ?? "" - let combined = out.isEmpty ? err : out - continuation.resume(returning: (combined, process.terminationStatus)) - } + // Exit is observed through `terminationHandler` rather than `waitUntilExit()`: + // the blocking call can wedge a thread that Swift concurrency needed, which + // deadlocked commands that shell out several times (compose down). + let status: Int32 = await withCheckedContinuation { continuation in + process.terminationHandler = { continuation.resume(returning: $0.terminationStatus) } } + + let out = String(data: await outTask.value, encoding: .utf8) ?? "" + let err = String(data: await errTask.value, encoding: .utf8) ?? "" + return (out.isEmpty ? err : out, status) } } From 297a2bfb51bd57d98828a1ea88458d638cec96fd Mon Sep 17 00:00:00 2001 From: us Date: Sun, 9 Aug 2026 17:07:44 +0300 Subject: [PATCH 3/3] fix(compose): keep rm working with -v, and keep the runtime's network error `rm --volumes` refused to run at all, so a flag that merely has nothing to do also stopped the containers from being removed. It now warns and removes them, matching how the codebase treats a flag the runtime cannot honor. Network creation failures no longer lose the runtime's own message: a rejected name or an unavailable backend was being reported as a generic "failed to create network". --- COMMANDS.md | 2 +- Sources/Mocker/Commands/Compose.swift | 10 ++++++---- .../Compose/ComposeOrchestrator.swift | 18 ++++++++++-------- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/COMMANDS.md b/COMMANDS.md index a716abd..979f674 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -1167,7 +1167,7 @@ mocker compose rm [OPTIONS] [SERVICE...] |------|-------|-------------| | `--force` | | Don't ask to confirm removal | | `--stop` | `-s` | Stop the containers, if required, before removing | -| `--volumes` | `-v` | Not supported: mocker does not track anonymous volumes, so this errors rather than reporting a cleanup it did not do | +| `--volumes` | `-v` | No effect: mocker does not create anonymous volumes, so there is nothing to remove. Warns rather than reporting a cleanup it did not do | | `--dry-run` | | Execute command in dry run mode | ### `mocker compose kill` diff --git a/Sources/Mocker/Commands/Compose.swift b/Sources/Mocker/Commands/Compose.swift index a48d8a4..a86be39 100644 --- a/Sources/Mocker/Commands/Compose.swift +++ b/Sources/Mocker/Commands/Compose.swift @@ -1204,11 +1204,13 @@ struct ComposeRm: AsyncParsableCommand { try composeFile.validateServiceNames(services) // Upstream removes the anonymous volumes attached to the removed containers. - // mocker records no anonymous volumes anywhere, so honoring this flag would be a - // no-op that reads as a successful cleanup — say so instead. + // mocker never creates one — an anonymous mount is dropped before it reaches the + // runtime — so there is nothing to remove. Say so, but still remove the containers + // the user asked for rather than failing the whole command over the flag. if volumes { - throw MockerError.operationFailed( - "compose rm --volumes is not yet supported with Apple Containerization") + FileHandle.standardError.write(Data( + ("WARNING: --volumes has no effect — mocker does not create anonymous volumes, " + + "so there is nothing to remove\n").utf8)) } if options.dryRun { diff --git a/Sources/MockerKit/Compose/ComposeOrchestrator.swift b/Sources/MockerKit/Compose/ComposeOrchestrator.swift index 4a4aa8c..5929bd3 100644 --- a/Sources/MockerKit/Compose/ComposeOrchestrator.swift +++ b/Sources/MockerKit/Compose/ComposeOrchestrator.swift @@ -83,15 +83,17 @@ public actor ComposeOrchestrator { // Create networks for (fullName, driver) in Self.networksToCreate(composeFile: composeFile, projectName: projectName) { - if (try? await networkManager.create(name: fullName, driver: driver)) != nil { + do { + _ = try await networkManager.create(name: fullName, driver: driver) events.append(.networkCreated(fullName)) - continue - } - // Creation fails both when the network already exists and when the runtime - // rejects it (it has its own naming rules). Only the first is benign, and a - // service cannot join a network that does not exist. - guard (try? await networkManager.inspect(fullName)) != nil else { - throw MockerError.operationFailed("failed to create network \(fullName)") + } catch { + // Creation fails both when the network already exists and when the runtime + // rejects it (it has its own naming rules). Only the first is benign, and a + // service cannot join a network that does not exist — so keep the runtime's + // own diagnostic rather than replacing it with a generic one. + guard (try? await networkManager.inspect(fullName)) != nil else { + throw error + } } }