diff --git a/COMMANDS.md b/COMMANDS.md index e23f82c..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` | Remove any anonymous volumes attached to containers | +| `--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/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..a86be39 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,16 @@ struct ComposeRm: AsyncParsableCommand { let config = MockerConfig() try composeFile.validateServiceNames(services) + // Upstream removes the anonymous volumes attached to the removed containers. + // 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 { + 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 { 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..5929bd3 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,36 @@ 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) { + do { + _ = try await networkManager.create(name: fullName, driver: driver) events.append(.networkCreated(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 + } } } @@ -78,6 +112,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 +161,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 +180,47 @@ 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. + 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 { + 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 +232,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 +260,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 +403,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 +466,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 +532,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 +619,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 +645,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 +655,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..0fa8887 100644 --- a/Sources/MockerKit/Config/MockerConfig.swift +++ b/Sources/MockerKit/Config/MockerConfig.swift @@ -70,9 +70,6 @@ public struct MockerConfig: Codable, Sendable { /// Path for volume storage. 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? { let home = FileManager.default.homeDirectoryForCurrentUser.path @@ -110,7 +107,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/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) } } 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