From 47b5c8b0ff3d361738499ec89373d230d4fb27ec Mon Sep 17 00:00:00 2001 From: Denis Panfilov Date: Thu, 15 Oct 2026 14:04:46 +0200 Subject: [PATCH 1/3] Add service DNS, host.docker.internal, optional deps, orphans, up -d Apple container 1.3.1 resolves no container names: peers are NXDOMAIN on every network, `--network name,alias=` is rejected, and the opt-in `[dns] domain` registers `.`, not service names. Nothing resolved `redis` from another service and there was no `host.docker.internal`. ProjectHosts writes one hosts file per service container under ~/.local/state/container-compose//hosts/ and the translator mounts it over /etc/hosts. It lists project containers on a shared network under service name, container name and aliases, plus host.docker.internal/gateway.docker.internal (the network gateway) and extra_hosts incl. host-gateway. up/start inspect each container after start and rewrite the files of peers sharing a network; down removes them. Rewrites are in place and padded to 4 KiB: the guest bind mount pins the inode and virtiofs caches the size for ~1 s, so a growing rewrite was read truncated. `up -d` is accepted. `up`/`down --remove-orphans` remove containers with the project label whose service label is gone from the file; `up` warns otherwise. depends_on.required comes from ComposeKit 0.0.5; `config` hides optional deps outside the plan. Localhost-bound Mac listeners stay unreachable through the gateway. With `sudo container system dns create host.docker.internal --localhost ` registered, the hosts entry is skipped so container's DNS answers. Closes #16, #17, #18, #19, #20. Claude-Session: https://claude.ai/code/session_011sCioLoXQJi1yNF7q4gYmJ --- PACKAGING.md | 2 +- Package.resolved | 6 +- Package.swift | 2 +- README.md | 42 ++- .../ContainerInventory.swift | 116 ++++++++ .../ContainerTranslator.swift | 5 +- .../ContainerComposeKit/Orchestrator.swift | 220 ++++++++++++++- .../ContainerComposeKit/ProjectHosts.swift | 158 +++++++++++ Sources/container-compose/Commands.swift | 22 +- .../container-compose/ContainerCompose.swift | 9 +- .../ContainerComposeKitTests.swift | 14 +- .../NameResolutionTests.swift | 253 ++++++++++++++++++ .../OrchestratorConcurrencyTests.swift | 6 +- .../CommandFlagTests.swift | 25 ++ docs/index.html | 4 +- 15 files changed, 840 insertions(+), 44 deletions(-) create mode 100644 Sources/ContainerComposeKit/ContainerInventory.swift create mode 100644 Sources/ContainerComposeKit/ProjectHosts.swift create mode 100644 Tests/ContainerComposeKitTests/NameResolutionTests.swift create mode 100644 Tests/container-composeTests/CommandFlagTests.swift diff --git a/PACKAGING.md b/PACKAGING.md index 7704ff8..b5bb22d 100644 --- a/PACKAGING.md +++ b/PACKAGING.md @@ -19,7 +19,7 @@ target — it is no longer a `ComposeKitContainer` product of ComposeKit. `Package.swift` pins ComposeKit to an exact tag: ```swift -.package(url: "https://github.com/flaticols/ComposeKit.git", exact: "0.0.3"), +.package(url: "https://github.com/flaticols/ComposeKit.git", exact: "0.0.5"), ``` `0.0.3` is the first ComposeKit release after the runtime layer was extracted diff --git a/Package.resolved b/Package.resolved index c83dc75..190aeca 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "0839ee7480a769f38d32417692afe859f4283e7b573e253fb041109acbd9a861", + "originHash" : "1cce34dafa08fc1e95dedce73ca1f417b696e2caf5690c7ea95c62748b45ad1a", "pins" : [ { "identity" : "composekit", "kind" : "remoteSourceControl", "location" : "https://github.com/flaticols/ComposeKit.git", "state" : { - "revision" : "ded92ba664f622b714de14e86d283cebdf58ef8d", - "version" : "0.0.4" + "revision" : "053e7693035bbbc4abb797503e7cfc81c2474ea7", + "version" : "0.0.5" } }, { diff --git a/Package.swift b/Package.swift index 299b737..fa1bdaf 100644 --- a/Package.swift +++ b/Package.swift @@ -23,7 +23,7 @@ let package = Package( // `from:` would float up to <1.0.0. Bump this string when adopting a // newer ComposeKit. For local changes, use // `swift package edit ComposeKit --path ../ComposeKit`. See PACKAGING.md. - .package(url: "https://github.com/flaticols/ComposeKit.git", exact: "0.0.4"), + .package(url: "https://github.com/flaticols/ComposeKit.git", exact: "0.0.5"), ], targets: [ // The `container` runtime layer: maps the parsed model onto `container` diff --git a/README.md b/README.md index c255286..8292725 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,8 @@ Run from a directory containing a `compose.yaml`: | Command | What it does | |---|---| -| `up [--build] [--wait] [services…]` | Create networks/volumes and start services in dependency order; `--wait` blocks until healthchecked services are healthy | -| `down [-v/--volumes]` | Stop + remove containers and project networks (and named volumes with `-v`) | +| `up [-d] [--build] [--wait] [--remove-orphans] [services…]` | Create networks/volumes and start services in dependency order; `--wait` blocks until healthchecked services are healthy; `-d` is accepted (containers always run detached) | +| `down [-v/--volumes] [--remove-orphans]` | Stop + remove containers and project networks (named volumes with `-v`; containers of services no longer in the file with `--remove-orphans`) | | `ps [-a/--all]` | List the project's containers | | `logs [-f/--follow] [-n/--tail N] [services…]` | Show container logs | | `exec [-i] [-t] [-w DIR] [-u USER] [-e K=V]… ` | Run a command in a running service container | @@ -125,8 +125,9 @@ Supported and translated to `container run`/`build`/`network`/`volume`: | `environment`, `env_file` | `--env`, `--env-file` | | `ports` (short & long) | `--publish` | | `volumes` (named, bind, tmpfs; short & long) | `--volume` / `--tmpfs` | -| `networks` (+ top-level, ipam subnet, internal) | `network create` + `--network` | -| `depends_on` (incl. `condition: service_healthy` / `service_completed_successfully`) | start ordering + health/completion gating (see below) | +| `networks` (+ top-level, ipam subnet, internal, service `aliases`) | `network create` + `--network`; names via a generated `/etc/hosts` (see below) | +| `extra_hosts` (incl. `host-gateway`), `host.docker.internal` | entries in the generated `/etc/hosts` | +| `depends_on` (incl. `condition: service_healthy` / `service_completed_successfully`, `required: false`) | start ordering + health/completion gating (see below) | | `profiles` | service gating via `--profile` / `COMPOSE_PROFILES` | | `extends`, `include` | flattened into the model at load time | | `configs`, `secrets` (file-based) | provisioned and mounted into containers | @@ -152,6 +153,38 @@ dependency declares no `healthcheck`, we warn and start without gating. Resources are project-scoped: containers are named `-`, networks/volumes `-`, and tagged with `com.apple.container.compose.project/service` labels. +### Service names & reaching the Mac + +`container` has no service DNS: containers do not resolve each other by name, and `--network` +has no alias option. container-compose instead bind-mounts a generated hosts file over each +service container's `/etc/hosts`, kept under `~/.local/state/container-compose//hosts/` +(or `$XDG_STATE_HOME`). It lists every project container on a shared network under its +service name, container name, and `networks..aliases`, and is rewritten in place as +services start, so `redis` and `mongodb` resolve from other services. Containers get a new +address on every start, so `start`/`restart` refresh the files too. Resolution of a peer that +starts *after* you takes about a second to appear (virtiofs attribute caching). + +`host.docker.internal` and `gateway.docker.internal` resolve to the gateway of the service's +network, which is the Mac. Listeners bound to `0.0.0.0` are reachable; listeners bound to +`127.0.0.1` (the `kubectl port-forward` default) are **not** reachable through the gateway. +For those, either bind the listener to all interfaces (`kubectl port-forward --address 0.0.0.0 …`) +or register Apple's localhost redirect once: + +```sh +sudo container system dns create host.docker.internal --localhost 203.0.113.113 +``` + +When that domain is registered, container-compose leaves `host.docker.internal` to +`container`'s DNS instead of writing it into `/etc/hosts`. The redirect disables iCloud +Private Relay and its packet-filter rule does not survive a reboot (re-run the command). +`extra_hosts: ["name:host-gateway"]` maps `name` to the same gateway address. + +### Profiles & optional dependencies + +A required `depends_on` (the default) pulls the dependency in even when its profile is +inactive. With `depends_on: { name: { required: false } }` the dependency joins only when its +own profile is active, so `--profile ui` does not drag in a `local-db`-gated service. + ### Not yet / approximated (warns at runtime) - **`restart:` policy** — recorded as a label, **not enforced** (no `--restart` in `container`). The `restart` *command* (stop+start) works. @@ -159,6 +192,7 @@ Resources are project-scoped: containers are named `-`, networ - **`command` as a string** — run via `/bin/sh -c` (Compose shell form). - **healthcheck `timeout`** — not enforced per-attempt (`container exec` has no timeout flag). - **`privileged`** — ignored (no equivalent in the VM-isolation model). +- **`hostname`**, **`network_mode`** — no `container run` equivalent; ignored. - Multiple `-f` files / overrides, replicas/`scale` — not implemented. ## License diff --git a/Sources/ContainerComposeKit/ContainerInventory.swift b/Sources/ContainerComposeKit/ContainerInventory.swift new file mode 100644 index 0000000..06983c2 --- /dev/null +++ b/Sources/ContainerComposeKit/ContainerInventory.swift @@ -0,0 +1,116 @@ +import Foundation + +/// A container as reported by `container list --format json` or +/// `container inspect`, reduced to what the orchestrator needs: identity, the +/// Compose bookkeeping labels, lifecycle state, and per-network addressing. +/// +/// Only these keys are decoded; everything else in the CLI's JSON is ignored so +/// the schema can grow without breaking the decoder. +public struct ContainerRecord: Decodable, Sendable, Equatable { + /// One network attachment, as reported under `status.networks`. + public struct Attachment: Decodable, Sendable, Equatable { + /// The network name. + public var network: String + /// The IPv4 address as printed by the CLI, with its prefix length + /// (`192.168.64.2/24`). + public var ipv4Address: String? + /// The network's IPv4 gateway, which is also the Mac host. + public var ipv4Gateway: String? + + public init(network: String, ipv4Address: String? = nil, ipv4Gateway: String? = nil) { + self.network = network + self.ipv4Address = ipv4Address + self.ipv4Gateway = ipv4Gateway + } + + /// The IPv4 address without its prefix length. + public var ipv4: String? { + guard let ipv4Address else { return nil } + return String(ipv4Address.prefix { $0 != "/" }) + } + } + + /// The container ID, which `container` equates with its `--name`. + public var id: String + /// Labels set at `container run` time. + public var labels: [String: String] + /// Lifecycle state as printed by the CLI (`running`, `stopped`, ...). + public var state: String? + /// Network attachments; empty until the container has started. + public var networks: [Attachment] + + public init( + id: String, labels: [String: String] = [:], state: String? = nil, + networks: [Attachment] = [] + ) { + self.id = id + self.labels = labels + self.state = state + self.networks = networks + } + + private enum CodingKeys: String, CodingKey { case id, configuration, status } + private enum ConfigurationKeys: String, CodingKey { case labels } + private enum StatusKeys: String, CodingKey { case state, networks } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + let configuration = try c.nestedContainer(keyedBy: ConfigurationKeys.self, forKey: .configuration) + labels = try configuration.decodeIfPresent([String: String].self, forKey: .labels) ?? [:] + if let status = try? c.nestedContainer(keyedBy: StatusKeys.self, forKey: .status) { + state = try status.decodeIfPresent(String.self, forKey: .state) + networks = try status.decodeIfPresent([Attachment].self, forKey: .networks) ?? [] + } else { + state = nil + networks = [] + } + } + + /// The Compose project this container belongs to, if container-compose created it. + public var project: String? { labels[ContainerTranslator.projectLabel] } + /// The Compose service this container runs, if container-compose created it. + public var service: String? { labels[ContainerTranslator.serviceLabel] } + /// Whether the CLI reports the container as running. + public var isRunning: Bool { state == "running" } + + /// Decode the JSON array printed by `container list --format json` or + /// `container inspect`. Blank input (a dry run) yields an empty list. + public static func decodeList(_ json: String) throws -> [ContainerRecord] { + let trimmed = json.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + return try JSONDecoder().decode([ContainerRecord].self, from: Data(trimmed.utf8)) + } +} + +/// A network as reported by `container network inspect`, reduced to its name +/// and IPv4 gateway. +public struct NetworkRecord: Decodable, Sendable, Equatable { + /// The network name. + public var id: String + /// The IPv4 gateway, which is also the Mac host as seen from the network. + public var ipv4Gateway: String? + + public init(id: String, ipv4Gateway: String? = nil) { + self.id = id + self.ipv4Gateway = ipv4Gateway + } + + private enum CodingKeys: String, CodingKey { case id, status } + private enum StatusKeys: String, CodingKey { case ipv4Gateway } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + let status = try? c.nestedContainer(keyedBy: StatusKeys.self, forKey: .status) + ipv4Gateway = try status?.decodeIfPresent(String.self, forKey: .ipv4Gateway) + } + + /// Decode the JSON array printed by `container network inspect`. Blank + /// input yields an empty list. + public static func decodeList(_ json: String) throws -> [NetworkRecord] { + let trimmed = json.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + return try JSONDecoder().decode([NetworkRecord].self, from: Data(trimmed.utf8)) + } +} diff --git a/Sources/ContainerComposeKit/ContainerTranslator.swift b/Sources/ContainerComposeKit/ContainerTranslator.swift index 6487d25..35fb3f2 100644 --- a/Sources/ContainerComposeKit/ContainerTranslator.swift +++ b/Sources/ContainerComposeKit/ContainerTranslator.swift @@ -108,10 +108,12 @@ public struct ContainerTranslator: Sendable { /// - detach: run in the background (`--detach`). One-shot dependencies /// gated by `service_completed_successfully` are run attached (`false`). /// - files: resolved host paths for the service's configs/secrets. + /// - hostsFile: host path of the generated hosts file to mount over + /// `/etc/hosts` (see ``ProjectHosts``); `nil` keeps the runtime's own. /// - Returns: the argument vector following the `container` executable. public func runArgs( service name: String, _ svc: Service, image: String, - detach: Bool = true, files: ResolvedFileObjects = .none + detach: Bool = true, files: ResolvedFileObjects = .none, hostsFile: String? = nil ) -> [String] { var a = ["run"] if detach { a += ["--detach"] } @@ -142,6 +144,7 @@ public struct ContainerTranslator: Sendable { if let vols = svc.volumes { for v in vols { a += volumeArguments(v) } } + if let hostsFile { a += ["--volume", "\(hostsFile):/etc/hosts"] } // Networks (attach to project default when none declared). let nets = svc.networks?.names ?? [] diff --git a/Sources/ContainerComposeKit/Orchestrator.swift b/Sources/ContainerComposeKit/Orchestrator.swift index 1b99a9e..55781d0 100644 --- a/Sources/ContainerComposeKit/Orchestrator.swift +++ b/Sources/ContainerComposeKit/Orchestrator.swift @@ -15,10 +15,15 @@ public struct Orchestrator: Sendable { public let runner: ContainerRunner /// The translator mapping services to `container` argument vectors. public let translator: ContainerTranslator + /// The per-container hosts files mounted into services for name resolution. + public let hosts: ProjectHosts /// Create an orchestrator for a loaded `Project`, wiring a /// ``ContainerTranslator`` from the project's identity and variables. - public init(project: Project, runner: ContainerRunner) { + /// + /// - Parameter stateDirectory: root for per-project state such as hosts + /// files; defaults to ``ProjectHosts/defaultRoot(environment:)``. + public init(project: Project, runner: ContainerRunner, stateDirectory: URL? = nil) { self.project = project self.runner = runner self.translator = ContainerTranslator( @@ -26,6 +31,10 @@ public struct Orchestrator: Sendable { baseDirectory: project.baseDirectory, hostEnv: project.variables ) + let root = stateDirectory ?? ProjectHosts.defaultRoot() + self.hosts = ProjectHosts( + directory: root.appendingPathComponent(project.name, isDirectory: true) + .appendingPathComponent("hosts", isDirectory: true)) } private func warn(_ message: String) { @@ -50,14 +59,32 @@ public struct Orchestrator: Sendable { /// section, even when an `image:` already exists. /// - services: limit the operation to these services (and their /// dependencies); empty means every profile-enabled service. + /// - wait: block until every started service with a healthcheck is healthy. + /// - removeOrphans: remove project containers whose service is no longer + /// declared; otherwise they are only warned about. /// - Throws: `ComposeError` for unknown services, dependency cycles, or a /// dependency that fails to become healthy or complete successfully. - public func up(build: Bool, only services: [String], wait: Bool = false) async throws { + public func up( + build: Bool, only services: [String], wait: Bool = false, removeOrphans: Bool = false + ) async throws { try validate(services) let selected = project.enabledServices(explicit: services) try await ensureNetworks() try await ensureVolumes() + var book = try await addressBook(for: selected) + let orphans = orphans(in: Array(book.records.values)) + if !orphans.isEmpty { + if removeOrphans { + try await remove(orphans: orphans) + for orphan in orphans { book.records[orphan.id] = nil } + } else { + warn( + "found orphan containers (\(orphans.map(\.id).sorted().joined(separator: ", "))) " + + "for this project; remove them with `down --remove-orphans`") + } + } + // Services that a selected service depends on with // `condition: service_completed_successfully` — run these to completion. let oneShot = completedSuccessfullyTargets(selected: selected) @@ -79,6 +106,11 @@ public struct Orchestrator: Sendable { // Recreate semantics: remove any stale container with the same name. let cname = translator.containerName(service: name, declared: svc.container_name) try await runner.runSilently(["delete", "--force", cname]) + book.records[cname] = nil + + // The hosts file must exist before `run` mounts it; it lists the + // peers started so far (all dependencies, by topological order). + let hostsFile = try writeHosts(service: name, svc, book: book) // One-shot dependency: run attached and use the exit code as the gate // (`container` has no `wait`); a non-zero exit aborts `up`. Topological @@ -86,13 +118,16 @@ public struct Orchestrator: Sendable { if oneShot.contains(name) { info("Running \(cname) (one-shot) ...") let args = translator.runArgs( - service: name, svc, image: image, detach: false, files: resolved) + service: name, svc, image: image, detach: false, files: resolved, + hostsFile: hostsFile) let status = try await runner.run(args) if status != 0 { throw ComposeError.dependencyFailed(name, status) } } else { info("Creating \(cname) ...") try await runner.runChecked( - translator.runArgs(service: name, svc, image: image, files: resolved)) + translator.runArgs( + service: name, svc, image: image, files: resolved, hostsFile: hostsFile)) + try await noteStarted(cname, book: &book) } } @@ -318,9 +353,12 @@ public struct Orchestrator: Sendable { /// Stop and remove the project's containers and networks (in reverse /// dependency order), optionally removing named volumes too. /// - /// - Parameter removeVolumes: also delete the project's declared named - /// volumes (data loss); defaults to keeping them. - public func down(removeVolumes: Bool) async throws { + /// - Parameters: + /// - removeVolumes: also delete the project's declared named volumes + /// (data loss); defaults to keeping them. + /// - removeOrphans: also remove project containers whose service is no + /// longer declared in the Compose file. + public func down(removeVolumes: Bool, removeOrphans: Bool = false) async throws { // Stop & remove in reverse dependency order. let order = try Planner.startOrder(project.file.services).reversed() for name in order { @@ -330,6 +368,14 @@ public struct Orchestrator: Sendable { try await runner.runSilently(["stop", cname]) try await runner.runSilently(["delete", "--force", cname]) } + if removeOrphans { + try await remove(orphans: orphans(in: try await projectContainers())) + } + do { + try hosts.remove() + } catch { + warn("could not remove hosts files at \(hosts.directory.path): \(error)") + } // Remove project networks (default + declared). var networks = [translator.defaultNetwork] @@ -465,13 +511,17 @@ public struct Orchestrator: Sendable { } /// Start previously-created containers for the selected services (dependency - /// order) without recreating them. + /// order) without recreating them. Containers get new addresses on start, so + /// the project's hosts files are refreshed as each one comes up. public func start(only services: [String]) async throws { - for name in try ordered(services, reversed: false) { + let order = try ordered(services, reversed: false) + var book = try await addressBook(for: Set(order)) + for name in order { guard let svc = project.file.services[name] else { continue } let cname = translator.containerName(service: name, declared: svc.container_name) info("Starting \(cname) ...") try await runner.runChecked(["start", cname]) + try await noteStarted(cname, book: &book) } } @@ -521,6 +571,157 @@ public struct Orchestrator: Sendable { return reversed ? order.reversed() : order } + // MARK: - Name resolution & orphans + + /// What is known about the project's containers while services start: their + /// records (by container name), the gateway of each network, and whether the + /// generated hosts files should carry the `host.docker.internal` entries. + private struct AddressBook { + var records: [String: ContainerRecord] = [:] + var gateways: [String: String] = [:] + var hostEntries = true + + mutating func note(_ record: ContainerRecord) { + records[record.id] = record + for attachment in record.networks { + if let gateway = attachment.ipv4Gateway { gateways[attachment.network] = gateway } + } + } + } + + /// Runtime network names a service attaches to, primary first. + private func runtimeNetworks(_ svc: Service) -> [String] { + let declared = svc.networks?.names ?? [] + return declared.isEmpty ? [translator.defaultNetwork] : declared.map(translator.networkName) + } + + /// Project containers known to `container`, in any state. Parse failures are + /// warned and treated as "none", so a schema change degrades name resolution + /// and orphan detection rather than aborting the command. + private func projectContainers() async throws -> [ContainerRecord] { + let (status, out) = try await runner.capture(["list", "--all", "--format", "json"]) + guard status == 0 else { return [] } + do { + return try ContainerRecord.decodeList(out).filter { $0.project == project.name } + } catch { + warn("could not parse `container list` output (\(error)); " + + "service names and orphans may not be detected") + return [] + } + } + + /// Seed the address book from the containers already running, the gateways + /// of every network the selected services use, and the registered DNS domains. + private func addressBook(for selected: Set) async throws -> AddressBook { + var book = AddressBook() + for record in try await projectContainers() where record.isRunning { + book.note(record) + } + + var wanted: [String] = [] + for name in selected.sorted() { + guard let svc = project.file.services[name] else { continue } + for network in runtimeNetworks(svc) where book.gateways[network] == nil && !wanted.contains(network) { + wanted.append(network) + } + } + if !wanted.isEmpty { + let (status, out) = try await runner.capture(["network", "inspect"] + wanted) + if status == 0, let networks = try? NetworkRecord.decodeList(out) { + for network in networks { + if let gateway = network.ipv4Gateway { book.gateways[network.id] = gateway } + } + } + } + + // A user-registered `host.docker.internal` DNS domain (Apple's route to + // localhost-bound Mac services) must not be shadowed by /etc/hosts. + book.hostEntries = !(await registeredDNSDomains()).contains(ProjectHosts.hostNames[0]) + return book + } + + /// Domains registered with `container system dns create`; empty on any failure. + private func registeredDNSDomains() async -> Set { + guard let (status, out) = try? await runner.capture(["system", "dns", "list", "--format", "json"]), + status == 0, + let domains = try? JSONDecoder().decode([String].self, from: Data(out.utf8)) + else { return [] } + return Set(domains) + } + + /// Peers for hosts rendering: every recorded container that runs a declared + /// service, under its service name, container name, and network aliases. + private func peers(in book: AddressBook) -> [ProjectHosts.Peer] { + var peers: [ProjectHosts.Peer] = [] + for record in book.records.values { + guard let service = record.service, let svc = project.file.services[service] else { continue } + var aliases: [String: [String]] = [:] + for declared in svc.networks?.names ?? [] { + let names = svc.networks?.aliases(on: declared) ?? [] + if !names.isEmpty { aliases[translator.networkName(declared)] = names } + } + var addresses: [String: String] = [:] + for attachment in record.networks { + if let ipv4 = attachment.ipv4 { addresses[attachment.network] = ipv4 } + } + let names = record.id == service ? [service] : [service, record.id] + peers.append(.init(names: names, aliases: aliases, addresses: addresses)) + } + return peers + } + + /// Render and write the hosts file for a service from the current address + /// book, returning its host path for `--volume`. Nothing is written during a + /// dry run. + private func writeHosts(service name: String, _ svc: Service, book: AddressBook) throws -> String { + let cname = translator.containerName(service: name, declared: svc.container_name) + let networks = runtimeNetworks(svc) + let content = ProjectHosts.render( + networks: networks, peers: peers(in: book), + gateway: networks.first.flatMap { book.gateways[$0] }, + extraHosts: svc.extra_hosts?.entries ?? [], + hostEntries: book.hostEntries) + if !runner.dryRun { try hosts.write(content, for: cname) } + return hosts.path(for: cname) + } + + /// Record a just-started container's addresses and rewrite the hosts files of + /// every running project container that shares a network with it (including + /// its own, which now gains a self entry). + private func noteStarted(_ cname: String, book: inout AddressBook) async throws { + if runner.dryRun { return } + let (status, out) = try await runner.capture(["inspect", cname]) + guard status == 0, let record = try? ContainerRecord.decodeList(out).first else { + warn("could not inspect '\(cname)'; other services will not resolve it by name") + return + } + book.note(record) + + let touched = Set(record.networks.map(\.network)) + for peer in book.records.values where peer.isRunning { + guard let service = peer.service, let svc = project.file.services[service], + !touched.isDisjoint(with: runtimeNetworks(svc)) + else { continue } + _ = try writeHosts(service: service, svc, book: book) + } + } + + /// Project containers whose service label is missing or no longer declared. + private func orphans(in records: [ContainerRecord]) -> [ContainerRecord] { + records.filter { record in + guard let service = record.service else { return true } + return project.file.services[service] == nil + } + } + + private func remove(orphans: [ContainerRecord]) async throws { + for orphan in orphans.sorted(by: { $0.id < $1.id }) { + info("Removing orphan \(orphan.id) ...") + try await runner.runSilently(["stop", orphan.id]) + try await runner.runSilently(["delete", "--force", orphan.id]) + } + } + private func ensureNetworks() async throws { var toCreate: [(name: String, spec: NetworkSpec?)] = [(translator.defaultNetwork, nil)] for (declared, spec) in project.file.networks ?? [:] { @@ -563,7 +764,6 @@ public struct Orchestrator: Sendable { // but flagged so the gap is visible rather than silently dropped. var ignored: [String] = [] if svc.hostname != nil { ignored.append("hostname") } - if svc.extra_hosts != nil { ignored.append("extra_hosts") } if svc.network_mode != nil { ignored.append("network_mode") } if svc.devices != nil { ignored.append("devices") } if svc.sysctls != nil { ignored.append("sysctls") } diff --git a/Sources/ContainerComposeKit/ProjectHosts.swift b/Sources/ContainerComposeKit/ProjectHosts.swift new file mode 100644 index 0000000..0b5b876 --- /dev/null +++ b/Sources/ContainerComposeKit/ProjectHosts.swift @@ -0,0 +1,158 @@ +import Foundation + +/// Per-container `/etc/hosts` files that give a project Compose-style name +/// resolution on Apple `container`, which has no service DNS of its own. +/// +/// Each service container bind-mounts its own file over `/etc/hosts`. The file +/// lists every project container reachable on a shared network under its service +/// name, container name, and declared network aliases, plus `host.docker.internal` +/// (the network gateway, which is the Mac) and the service's `extra_hosts`. +/// +/// Files are rewritten in place as containers start. The guest bind mount pins +/// the host file's inode, so an atomic replace would detach the mount, whereas an +/// in-place rewrite becomes visible inside the container within about a second. +public struct ProjectHosts: Sendable { + /// Directory holding one file per container. + public let directory: URL + + /// Files are padded with trailing newlines to a multiple of this size. The + /// guest caches file attributes for about a second, and a rewrite that grows + /// the file is read truncated at the old size during that window; a same-size + /// rewrite is read whole (either the old or the new consistent contents). + public static let sizeQuantum = 4096 + + /// Names that resolve to the Mac from inside a container. + public static let hostNames = ["host.docker.internal", "gateway.docker.internal"] + /// The `extra_hosts` address keyword Docker maps to the host gateway. + public static let hostGatewayKeyword = "host-gateway" + + /// Create a manager for files under `directory`. + public init(directory: URL) { + self.directory = directory + } + + /// The default state root: `$XDG_STATE_HOME/container-compose`, or + /// `~/.local/state/container-compose`. Kept outside the temp directory so a + /// mounted file is never cleaned up underneath a long-running container, and + /// free of spaces so it is safe to pass to `--volume`. + public static func defaultRoot( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> URL { + let base: URL + if let xdg = environment["XDG_STATE_HOME"], !xdg.isEmpty { + base = URL(fileURLWithPath: xdg, isDirectory: true) + } else { + base = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("state", isDirectory: true) + } + return base.appendingPathComponent("container-compose", isDirectory: true) + } + + /// Host path of the file mounted into `container`. + public func path(for container: String) -> String { + directory.appendingPathComponent(container).path + } + + /// A project container's resolvable names and its address on each network. + public struct Peer: Sendable, Equatable { + /// Names valid on every network: the service name and the container name. + public var names: [String] + /// Compose `networks..aliases`, keyed by runtime network name. + public var aliases: [String: [String]] + /// IPv4 address keyed by runtime network name. + public var addresses: [String: String] + + public init(names: [String], aliases: [String: [String]] = [:], addresses: [String: String]) { + self.names = names + self.aliases = aliases + self.addresses = addresses + } + } + + /// Render the hosts file for a container attached to `networks`. + /// + /// - Parameters: + /// - networks: the container's runtime network names, primary first. + /// - peers: project containers whose addresses are known (including this + /// one, once it has started). Peers on no shared network are omitted. + /// - gateway: the gateway of the primary network; `nil` omits the host + /// entries and any `host-gateway` extra host. + /// - extraHosts: Compose `extra_hosts` entries (`host:ip` or `host=ip`). + /// - hostEntries: emit ``hostNames``. Pass `false` when the user has + /// registered `host.docker.internal` with `container system dns`, so the + /// file does not shadow that DNS answer. + public static func render( + networks: [String], peers: [Peer], gateway: String?, + extraHosts: [String] = [], hostEntries: Bool = true + ) -> String { + var lines = ["127.0.0.1 localhost", "::1 localhost ip6-localhost ip6-loopback"] + for peer in peers.sorted(by: { ($0.names.first ?? "") < ($1.names.first ?? "") }) { + for network in networks { + guard let address = peer.addresses[network] else { continue } + var seen = Set() + let names = (peer.names + (peer.aliases[network] ?? [])).filter { seen.insert($0).inserted } + lines.append("\(address) \(names.joined(separator: " "))") + } + } + if let gateway, hostEntries { + lines.append("\(gateway) \(hostNames.joined(separator: " "))") + } + for entry in extraHosts { + guard let (host, address) = parseExtraHost(entry) else { continue } + if address == hostGatewayKeyword { + if let gateway { lines.append("\(gateway) \(host)") } + } else { + lines.append("\(address) \(host)") + } + } + return lines.joined(separator: "\n") + "\n" + } + + /// Split an `extra_hosts` entry into host and address. Docker accepts + /// `host:ip` and `host=ip`; the split is on the first separator because IPv6 + /// addresses contain colons, and brackets around an IPv6 address are dropped. + static func parseExtraHost(_ entry: String) -> (host: String, address: String)? { + guard let separator = entry.firstIndex(where: { $0 == ":" || $0 == "=" }) else { return nil } + let host = entry[..= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + try handle.write(contentsOf: Self.padded(Data(content.utf8))) + try handle.close() + } + + /// `data` followed by newlines up to the next multiple of ``sizeQuantum``. + static func padded(_ data: Data) -> Data { + let remainder = data.count % sizeQuantum + guard remainder != 0 else { return data } + return data + Data(repeating: UInt8(ascii: "\n"), count: sizeQuantum - remainder) + } + + /// Delete the project's hosts files, and the project directory if that + /// leaves it empty. Containers that still mount a file keep its last + /// contents until they are recreated. + public func remove() throws { + let fm = FileManager.default + guard fm.fileExists(atPath: directory.path) else { return } + try fm.removeItem(at: directory) + let parent = directory.deletingLastPathComponent() + if (try? fm.contentsOfDirectory(atPath: parent.path))?.isEmpty == true { + try? fm.removeItem(at: parent) + } + } +} diff --git a/Sources/container-compose/Commands.swift b/Sources/container-compose/Commands.swift index b5ce3dd..7a41c49 100644 --- a/Sources/container-compose/Commands.swift +++ b/Sources/container-compose/Commands.swift @@ -1,7 +1,3 @@ -//===----------------------------------------------------------------------===// -// Subcommands: up, down, ps, logs, config. -//===----------------------------------------------------------------------===// - import ArgumentParser import ComposeKit import ContainerComposeKit @@ -19,12 +15,20 @@ struct Up: AsyncParsableCommand { @Flag(name: .long, help: "Wait until services with a healthcheck report healthy.") var wait = false + // Containers always run detached; accepted so `up -d` from Docker habits works. + @Flag(name: [.customShort("d"), .long], help: "Detached mode (always on; accepted for compatibility).") + var detach = false + + @Flag(name: .long, help: "Remove containers for services no longer declared in the Compose file.") + var removeOrphans = false + @Argument(help: "Limit to these services (default: all).") var services: [String] = [] func run() async throws { let orchestrator = try options.makeOrchestrator() - try await orchestrator.up(build: build, only: services, wait: wait) + try await orchestrator.up( + build: build, only: services, wait: wait, removeOrphans: removeOrphans) } } @@ -37,9 +41,12 @@ struct Down: AsyncParsableCommand { @Flag(name: [.customShort("v"), .long], help: "Also remove named volumes declared in the file.") var volumes = false + @Flag(name: .long, help: "Also remove containers for services no longer declared in the Compose file.") + var removeOrphans = false + func run() async throws { let orchestrator = try options.makeOrchestrator() - try await orchestrator.down(removeVolumes: volumes) + try await orchestrator.down(removeVolumes: volumes, removeOrphans: removeOrphans) } } @@ -216,7 +223,8 @@ struct Config: AsyncParsableCommand { print("services: \(services.keys.sorted().joined(separator: ", "))") print("start order:") for (i, name) in order.enumerated() { - let deps = project.file.services[name]?.depends_on?.names ?? [] + // Optional (`required: false`) dependencies outside the plan are not started. + let deps = (project.file.services[name]?.depends_on?.names ?? []).filter(enabled.contains) let suffix = deps.isEmpty ? "" : " (after: \(deps.sorted().joined(separator: ", ")))" print(" \(i + 1). \(name)\(suffix)") } diff --git a/Sources/container-compose/ContainerCompose.swift b/Sources/container-compose/ContainerCompose.swift index a641878..f677fa0 100644 --- a/Sources/container-compose/ContainerCompose.swift +++ b/Sources/container-compose/ContainerCompose.swift @@ -1,15 +1,10 @@ -//===----------------------------------------------------------------------===// -// container-compose — entry point. -// -// Invoked either standalone (`container-compose up`) or as a `container` CLI -// plugin (`container compose up`, which execs this binary). -//===----------------------------------------------------------------------===// - import ArgumentParser import ComposeKit import ContainerComposeKit import Foundation +/// Entry point. Invoked either standalone (`container-compose up`) or as a +/// `container` CLI plugin (`container compose up`, which execs this binary). @main struct ContainerCompose: AsyncParsableCommand { static let configuration = CommandConfiguration( diff --git a/Tests/ContainerComposeKitTests/ContainerComposeKitTests.swift b/Tests/ContainerComposeKitTests/ContainerComposeKitTests.swift index 015c0cb..a951b0a 100644 --- a/Tests/ContainerComposeKitTests/ContainerComposeKitTests.swift +++ b/Tests/ContainerComposeKitTests/ContainerComposeKitTests.swift @@ -4,6 +4,10 @@ import Testing import ComposeKit @testable import ContainerComposeKit +/// Keeps hosts files written by tests out of the real per-user state directory. +let testStateDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("container-compose-tests-state", isDirectory: true) + private func loadFixture() throws -> Project { let url = Bundle.module.url(forResource: "compose", withExtension: "yaml", subdirectory: "Fixtures")! let dir = url.deletingLastPathComponent() @@ -193,7 +197,7 @@ struct OneShotTests { forResource: "local-dev", withExtension: "yaml", subdirectory: "Fixtures/corpus")! let project = try Project.load( explicit: url.path, projectName: nil, cwd: url.deletingLastPathComponent()) - let orch = Orchestrator(project: project, runner: ContainerRunner(dryRun: true)) + let orch = Orchestrator(project: project, runner: ContainerRunner(dryRun: true), stateDirectory: testStateDirectory) try await orch.up(build: false, only: []) } @@ -223,7 +227,7 @@ struct OneShotTests { let project = Project( name: "t", file: try ComposeFile.parse(yaml: yaml), baseDirectory: URL(fileURLWithPath: "/tmp"), variables: [:]) - let orch = Orchestrator(project: project, runner: runner) + let orch = Orchestrator(project: project, runner: runner, stateDirectory: testStateDirectory) do { try await orch.up(build: false, only: []) @@ -283,7 +287,7 @@ struct LifecycleTests { let project = Project( name: "proj", file: try ComposeFile.parse(yaml: yaml), baseDirectory: URL(fileURLWithPath: "/tmp"), variables: [:]) - try await body(Orchestrator(project: project, runner: runner)) + try await body(Orchestrator(project: project, runner: runner, stateDirectory: testStateDirectory)) let text = (try? String(contentsOf: log, encoding: .utf8)) ?? "" return text.split(separator: "\n").map(String.init) } @@ -406,7 +410,7 @@ struct OrchestratorErrorTests { @Test("up on a service with neither image nor build throws serviceMissingImage") func missingImage() async throws { let orch = Orchestrator( - project: try project("services:\n x: {}\n"), runner: ContainerRunner(dryRun: true)) + project: try project("services:\n x: {}\n"), runner: ContainerRunner(dryRun: true), stateDirectory: testStateDirectory) await #expect(throws: ComposeError.self) { try await orch.up(build: false, only: []) } @@ -416,7 +420,7 @@ struct OrchestratorErrorTests { func unknownExec() async throws { let orch = Orchestrator( project: try project("services:\n a:\n image: x\n"), - runner: ContainerRunner(dryRun: true)) + runner: ContainerRunner(dryRun: true), stateDirectory: testStateDirectory) await #expect(throws: ComposeError.self) { _ = try await orch.exec(service: "nope", command: ["sh"]) } diff --git a/Tests/ContainerComposeKitTests/NameResolutionTests.swift b/Tests/ContainerComposeKitTests/NameResolutionTests.swift new file mode 100644 index 0000000..8f2de29 --- /dev/null +++ b/Tests/ContainerComposeKitTests/NameResolutionTests.swift @@ -0,0 +1,253 @@ +import Foundation +import Testing + +import ComposeKit +@testable import ContainerComposeKit + +@Suite("Container inventory decoding") +struct InventoryTests { + static let listJSON = """ + [{"configuration":{"id":"demo-db","labels":{"com.apple.container.compose.project":"demo", + "com.apple.container.compose.service":"db"},"mounts":[]},"id":"demo-db", + "status":{"networks":[{"hostname":"demo-db","ipv4Address":"192.168.64.2\\/24", + "ipv4Gateway":"192.168.64.1","network":"demo-default","variant":"reserved"}], + "state":"running"}}, + {"configuration":{"id":"buildkit","labels":{}},"id":"buildkit","status":{"state":"stopped","networks":[]}}] + """ + + @Test("list output yields ids, labels, state and stripped addresses") + func decodeList() throws { + let records = try ContainerRecord.decodeList(Self.listJSON) + #expect(records.map(\.id) == ["demo-db", "buildkit"]) + let db = records[0] + #expect(db.project == "demo") + #expect(db.service == "db") + #expect(db.isRunning) + #expect(db.networks.first?.ipv4 == "192.168.64.2") + #expect(db.networks.first?.ipv4Gateway == "192.168.64.1") + #expect(records[1].project == nil) + #expect(!records[1].isRunning) + } + + @Test("blank output (dry run) decodes to nothing; network inspect yields gateways") + func blankAndNetworks() throws { + #expect(try ContainerRecord.decodeList(" \n") == []) + let networks = try NetworkRecord.decodeList( + #"[{"id":"demo-default","configuration":{"mode":"nat"},"status":{"ipv4Gateway":"192.168.64.1","ipv4Subnet":"192.168.64.0/24"}}]"# + ) + #expect(networks == [NetworkRecord(id: "demo-default", ipv4Gateway: "192.168.64.1")]) + } +} + +@Suite("Hosts file rendering") +struct HostsRenderingTests { + private let db = ProjectHosts.Peer( + names: ["db", "demo-db"], aliases: ["demo-backend": ["postgres", "db"]], + addresses: ["demo-backend": "10.5.0.2"]) + private let web = ProjectHosts.Peer( + names: ["web", "demo-web"], + addresses: ["demo-backend": "10.5.0.3", "demo-frontend": "192.168.64.3"]) + private let cache = ProjectHosts.Peer(names: ["cache"], addresses: ["demo-frontend": "192.168.64.4"]) + + @Test("peers on shared networks, aliases deduped, gateway and extra_hosts") + func render() { + let text = ProjectHosts.render( + networks: ["demo-backend"], peers: [web, db, cache], gateway: "10.5.0.1", + extraHosts: ["api.local:1.2.3.4", "mac=host-gateway", "v6:[::1]"]) + let lines = text.split(separator: "\n").map(String.init) + #expect(lines[0] == "127.0.0.1 localhost") + #expect(lines[1] == "::1 localhost ip6-localhost ip6-loopback") + #expect(lines.contains("10.5.0.2 db demo-db postgres")) + #expect(lines.contains("10.5.0.3 web demo-web")) + #expect(!text.contains("cache")) // not on a shared network + #expect(lines.contains("10.5.0.1 host.docker.internal gateway.docker.internal")) + #expect(lines.contains("1.2.3.4 api.local")) + #expect(lines.contains("10.5.0.1 mac")) + #expect(lines.contains("::1 v6")) + #expect(text.hasSuffix("\n")) + } + + @Test("host entries are omitted when a DNS domain owns host.docker.internal or no gateway is known") + func hostEntriesOmitted() { + let viaDNS = ProjectHosts.render( + networks: ["n"], peers: [], gateway: "10.0.0.1", extraHosts: ["x:host-gateway"], + hostEntries: false) + #expect(!viaDNS.contains("host.docker.internal")) + #expect(viaDNS.contains("10.0.0.1 x")) + let noGateway = ProjectHosts.render(networks: ["n"], peers: [], gateway: nil, extraHosts: ["x:host-gateway"]) + #expect(!noGateway.contains("host.docker.internal")) + #expect(!noGateway.contains(" x")) + } + + @Test("extra_hosts parsing accepts colon and equals, rejects malformed entries") + func parseExtraHost() { + let colon = ProjectHosts.parseExtraHost("a:1.1.1.1") + #expect(colon?.host == "a" && colon?.address == "1.1.1.1") + let equals = ProjectHosts.parseExtraHost("a = 2.2.2.2") + #expect(equals?.host == "a" && equals?.address == "2.2.2.2") + let v6 = ProjectHosts.parseExtraHost("a:[fe80::1]") + #expect(v6?.host == "a" && v6?.address == "fe80::1") + #expect(ProjectHosts.parseExtraHost("nonsense") == nil) + #expect(ProjectHosts.parseExtraHost(":1.1.1.1") == nil) + } + + @Test("writes rewrite the file in place, keeping its inode") + func inPlaceWrite() throws { + let projectDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cc-hosts-\(UUID().uuidString)", isDirectory: true) + let dir = projectDir.appendingPathComponent("hosts", isDirectory: true) + defer { try? FileManager.default.removeItem(at: projectDir) } + let hosts = ProjectHosts(directory: dir) + try hosts.write("127.0.0.1 localhost\n", for: "demo-web") + let before = try FileManager.default.attributesOfItem(atPath: hosts.path(for: "demo-web")) + try hosts.write("127.0.0.1 localhost\n10.0.0.2 db\n", for: "demo-web") + let after = try FileManager.default.attributesOfItem(atPath: hosts.path(for: "demo-web")) + #expect(before[.systemFileNumber] as? UInt64 == after[.systemFileNumber] as? UInt64) + #expect(try String(contentsOfFile: hosts.path(for: "demo-web"), encoding: .utf8).contains("10.0.0.2 db")) + #expect(after[.size] as? Int == ProjectHosts.sizeQuantum) + #expect(ProjectHosts.padded(Data(repeating: 65, count: 4097)).count == 2 * ProjectHosts.sizeQuantum) + #expect(ProjectHosts.padded(Data(repeating: 65, count: 8192)).count == 8192) + try hosts.remove() + #expect(!FileManager.default.fileExists(atPath: projectDir.path)) + } + + @Test("the default state root avoids the temp dir and honors XDG_STATE_HOME") + func defaultRoot() { + #expect(ProjectHosts.defaultRoot(environment: ["XDG_STATE_HOME": "/x/state"]).path == "/x/state/container-compose") + #expect(ProjectHosts.defaultRoot(environment: [:]).path.hasSuffix("/.local/state/container-compose")) + } +} + +@Suite("Service name resolution & orphans") +struct NameResolutionOrchestrationTests { + /// A `container` shim that logs every call and answers `list`, `inspect`, + /// `network inspect` and `system dns list` with canned JSON for project `proj`. + private struct Harness { + let dir: URL + let log: URL + let state: URL + let runner: ContainerRunner + + init(tag: String) throws { + dir = FileManager.default.temporaryDirectory + .appendingPathComponent("cc-\(tag)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + log = dir.appendingPathComponent("commands.txt") + state = dir.appendingPathComponent("state", isDirectory: true) + let shim = dir.appendingPathComponent("container-shim") + let labels = #""labels":{"com.apple.container.compose.project":"proj","com.apple.container.compose.service":"%s"}"# + let record = """ + {"configuration":{\(labels)},"id":"%s","status":{"state":"running","networks":[{"network":"proj-default","ipv4Address":"%s/24","ipv4Gateway":"192.168.64.1"}]}} + """ + let script = """ + #!/bin/sh + printf '%s\\n' "$*" >> '\(log.path)' + case "$1" in + list) + printf '[\(record),\(record)]\\n' old proj-old 192.168.64.9 db proj-db 192.168.64.2 ;; + inspect) + case "$2" in + proj-db) printf '[\(record)]\\n' db proj-db 192.168.64.2 ;; + proj-app) printf '[\(record)]\\n' app proj-app 192.168.64.3 ;; + esac ;; + network) printf '[{"id":"proj-default","status":{"ipv4Gateway":"192.168.64.1"}}]\\n' ;; + system) printf '[]\\n' ;; + esac + exit 0 + """ + try script.write(to: shim, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: shim.path) + runner = ContainerRunner(executable: shim.path) + } + + func orchestrator(_ yaml: String) throws -> Orchestrator { + let project = Project( + name: "proj", file: try ComposeFile.parse(yaml: yaml), + baseDirectory: URL(fileURLWithPath: "/tmp"), variables: [:]) + return Orchestrator(project: project, runner: runner, stateDirectory: state) + } + + var commands: [String] { + ((try? String(contentsOf: log, encoding: .utf8)) ?? "").split(separator: "\n").map(String.init) + } + + func hostsFile(_ container: String) throws -> String { + try String(contentsOfFile: state.appendingPathComponent("proj/hosts/\(container)").path, encoding: .utf8) + } + + func tearDown() { try? FileManager.default.removeItem(at: dir) } + } + + private let stack = """ + services: + db: + image: postgres:16 + networks: + default: + aliases: [postgres] + app: + image: app + depends_on: [db] + extra_hosts: + - "mac:host-gateway" + - "legacy.local:10.1.1.1" + """ + + @Test("up mounts a hosts file per service listing peers, host gateway and extra_hosts") + func upWritesHosts() async throws { + let h = try Harness(tag: "hosts") + defer { h.tearDown() } + try await h.orchestrator(stack).up(build: false, only: []) + + let appHosts = try h.hostsFile("proj-app") + #expect(appHosts.contains("192.168.64.2 db proj-db postgres\n")) + #expect(appHosts.contains("192.168.64.3 app proj-app\n")) + #expect(appHosts.contains("192.168.64.1 host.docker.internal gateway.docker.internal\n")) + #expect(appHosts.contains("192.168.64.1 mac\n")) + #expect(appHosts.contains("10.1.1.1 legacy.local\n")) + + // db started first, but its file is refreshed once app is up. + #expect(try h.hostsFile("proj-db").contains("192.168.64.3 app proj-app\n")) + + let mount = "--volume \(h.state.path)/proj/hosts/proj-app:/etc/hosts" + #expect(h.commands.contains { $0.hasPrefix("run --detach --name proj-app") && $0.contains(mount) }) + #expect(h.commands.contains("inspect proj-db")) + #expect(h.commands.contains("inspect proj-app")) + } + + @Test("orphans are only warned about unless --remove-orphans is passed") + func orphans() async throws { + let keep = try Harness(tag: "orphan-keep") + defer { keep.tearDown() } + try await keep.orchestrator(stack).up(build: false, only: []) + #expect(!keep.commands.contains("delete --force proj-old")) + + let remove = try Harness(tag: "orphan-remove") + defer { remove.tearDown() } + try await remove.orchestrator(stack).up(build: false, only: [], removeOrphans: true) + #expect(remove.commands.contains("stop proj-old")) + #expect(remove.commands.contains("delete --force proj-old")) + } + + @Test("down --remove-orphans removes orphans and the hosts files") + func downOrphans() async throws { + let h = try Harness(tag: "down") + defer { h.tearDown() } + let orch = try h.orchestrator(stack) + try await orch.up(build: false, only: []) + #expect(FileManager.default.fileExists(atPath: h.state.appendingPathComponent("proj/hosts").path)) + try await orch.down(removeVolumes: false, removeOrphans: true) + #expect(h.commands.contains("delete --force proj-old")) + #expect(!FileManager.default.fileExists(atPath: h.state.appendingPathComponent("proj/hosts").path)) + } + + @Test("start refreshes hosts files with the new addresses") + func startRefreshes() async throws { + let h = try Harness(tag: "start") + defer { h.tearDown() } + try await h.orchestrator(stack).start(only: []) + #expect(h.commands.contains("start proj-db")) + #expect(h.commands.contains("inspect proj-app")) + #expect(try h.hostsFile("proj-db").contains("192.168.64.3 app proj-app\n")) + } +} diff --git a/Tests/ContainerComposeKitTests/OrchestratorConcurrencyTests.swift b/Tests/ContainerComposeKitTests/OrchestratorConcurrencyTests.swift index 45989af..be200d8 100644 --- a/Tests/ContainerComposeKitTests/OrchestratorConcurrencyTests.swift +++ b/Tests/ContainerComposeKitTests/OrchestratorConcurrencyTests.swift @@ -27,7 +27,7 @@ struct OrchestratorConcurrencyTests { retries: 1 """) let orchestrator = Orchestrator( - project: project, runner: ContainerRunner(executable: runner.shim.path)) + project: project, runner: ContainerRunner(executable: runner.shim.path), stateDirectory: testStateDirectory) try await orchestrator.up(build: false, only: [], wait: true) } @@ -61,7 +61,7 @@ struct OrchestratorConcurrencyTests { condition: service_healthy """) let orchestrator = Orchestrator( - project: project, runner: ContainerRunner(executable: runner.shim.path)) + project: project, runner: ContainerRunner(executable: runner.shim.path), stateDirectory: testStateDirectory) try await orchestrator.up(build: false, only: [], wait: false) } @@ -99,7 +99,7 @@ struct OrchestratorConcurrencyTests { condition: service_healthy """) let orchestrator = Orchestrator( - project: project, runner: ContainerRunner(executable: runner.shim.path)) + project: project, runner: ContainerRunner(executable: runner.shim.path), stateDirectory: testStateDirectory) try await orchestrator.up(build: false, only: [], wait: false) diff --git a/Tests/container-composeTests/CommandFlagTests.swift b/Tests/container-composeTests/CommandFlagTests.swift new file mode 100644 index 0000000..20f6eda --- /dev/null +++ b/Tests/container-composeTests/CommandFlagTests.swift @@ -0,0 +1,25 @@ +import ArgumentParser +import Testing + +@testable import container_compose + +@Suite("Docker-compatible flags") +struct CommandFlagTests { + @Test("up accepts -d/--detach and --remove-orphans") + func upFlags() throws { + let short = try Up.parse(["-d", "web"]) + #expect(short.detach) + #expect(short.services == ["web"]) + let long = try Up.parse(["--detach", "--remove-orphans"]) + #expect(long.detach) + #expect(long.removeOrphans) + #expect(!(try Up.parse([])).removeOrphans) + } + + @Test("down accepts --remove-orphans alongside -v") + func downFlags() throws { + let down = try Down.parse(["-v", "--remove-orphans"]) + #expect(down.volumes) + #expect(down.removeOrphans) + } +} diff --git a/docs/index.html b/docs/index.html index a017e18..4701ff1 100644 --- a/docs/index.html +++ b/docs/index.html @@ -165,8 +165,8 @@

Quick start

Commands

The familiar Compose surface, mapped to native container operations.

-
up
Create networks & volumes, start services in dependency order. --wait blocks until healthy.
-
down
Stop and remove containers, networks, and named volumes.
+
up
Create networks & volumes, start services in dependency order. --wait blocks until healthy; -d accepted; --remove-orphans drops stale containers.
+
down
Stop and remove containers, networks, and named volumes; --remove-orphans also removes containers of services no longer in the file.
ps
List the project’s containers.
logs
Show or follow container logs; --tail N limits the output.
exec
Run a command in a running service container (-it, --workdir/--user/--env).
From 8376799cc785de7542d4043da22107252f7be4a6 Mon Sep 17 00:00:00 2001 From: Denis Panfilov Date: Thu, 15 Oct 2026 14:07:25 +0200 Subject: [PATCH 2/3] Count stopped containers as orphans in up addressBook(for:) seeded only running containers, so `up` never warned about or removed a stopped orphan. down was unaffected. Stopped records carry no addresses, so hosts rendering is unchanged. Claude-Session: https://claude.ai/code/session_011sCioLoXQJi1yNF7q4gYmJ --- Sources/ContainerComposeKit/Orchestrator.swift | 8 +++++--- Tests/ContainerComposeKitTests/NameResolutionTests.swift | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Sources/ContainerComposeKit/Orchestrator.swift b/Sources/ContainerComposeKit/Orchestrator.swift index 55781d0..69a57ac 100644 --- a/Sources/ContainerComposeKit/Orchestrator.swift +++ b/Sources/ContainerComposeKit/Orchestrator.swift @@ -610,11 +610,13 @@ public struct Orchestrator: Sendable { } } - /// Seed the address book from the containers already running, the gateways - /// of every network the selected services use, and the registered DNS domains. + /// Seed the address book from the project's containers in any state (a + /// stopped one has no addresses but still counts for orphan detection), the + /// gateways of every network the selected services use, and the registered + /// DNS domains. private func addressBook(for selected: Set) async throws -> AddressBook { var book = AddressBook() - for record in try await projectContainers() where record.isRunning { + for record in try await projectContainers() { book.note(record) } diff --git a/Tests/ContainerComposeKitTests/NameResolutionTests.swift b/Tests/ContainerComposeKitTests/NameResolutionTests.swift index 8f2de29..d2ba5c1 100644 --- a/Tests/ContainerComposeKitTests/NameResolutionTests.swift +++ b/Tests/ContainerComposeKitTests/NameResolutionTests.swift @@ -144,7 +144,7 @@ struct NameResolutionOrchestrationTests { printf '%s\\n' "$*" >> '\(log.path)' case "$1" in list) - printf '[\(record),\(record)]\\n' old proj-old 192.168.64.9 db proj-db 192.168.64.2 ;; + printf '[\(record),\(record),{"configuration":{\(labels)},"id":"proj-stale","status":{"state":"stopped","networks":[]}}]\\n' old proj-old 192.168.64.9 db proj-db 192.168.64.2 stale ;; inspect) case "$2" in proj-db) printf '[\(record)]\\n' db proj-db 192.168.64.2 ;; @@ -221,12 +221,14 @@ struct NameResolutionOrchestrationTests { defer { keep.tearDown() } try await keep.orchestrator(stack).up(build: false, only: []) #expect(!keep.commands.contains("delete --force proj-old")) + #expect(!keep.commands.contains("delete --force proj-stale")) let remove = try Harness(tag: "orphan-remove") defer { remove.tearDown() } try await remove.orchestrator(stack).up(build: false, only: [], removeOrphans: true) #expect(remove.commands.contains("stop proj-old")) #expect(remove.commands.contains("delete --force proj-old")) + #expect(remove.commands.contains("delete --force proj-stale")) // stopped orphans count too } @Test("down --remove-orphans removes orphans and the hosts files") From 6172a31f1077df9d1043a8b1d54ec3326bec13d7 Mon Sep 17 00:00:00 2001 From: Denis Panfilov Date: Thu, 15 Oct 2026 14:13:17 +0200 Subject: [PATCH 3/3] Resolve ComposeKit 0.0.5 at its on-main commit The tag moved to the squash-merge commit 68bb824 after flaticols/ComposeKit#17 landed; same sources plus the nightly schema sync. --- Package.resolved | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.resolved b/Package.resolved index 190aeca..de55b32 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,7 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/flaticols/ComposeKit.git", "state" : { - "revision" : "053e7693035bbbc4abb797503e7cfc81c2474ea7", + "revision" : "68bb824c3a2cf52633a2ccd781c3553867542d44", "version" : "0.0.5" } },