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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion PACKAGING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
42 changes: 38 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]… <service> <cmd…>` | Run a command in a running service container |
Expand Down Expand Up @@ -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 |
Expand All @@ -152,13 +153,46 @@ dependency declares no `healthcheck`, we warn and start without gating.
Resources are project-scoped: containers are named `<project>-<service>`, networks/volumes
`<project>-<name>`, 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/<project>/hosts/`
(or `$XDG_STATE_HOME`). It lists every project container on a shared network under its
service name, container name, and `networks.<net>.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.
- **`entrypoint` as a list** — first token becomes `--entrypoint`; the rest are appended to the command.
- **`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
Expand Down
116 changes: 116 additions & 0 deletions Sources/ContainerComposeKit/ContainerInventory.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
5 changes: 4 additions & 1 deletion Sources/ContainerComposeKit/ContainerTranslator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down Expand Up @@ -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 ?? []
Expand Down
Loading
Loading