Skip to content

Commit 8a9d2fd

Browse files
committed
Added many advanced compose commands, including --pull and --wait
Package and config updates Fixed compilation issues
1 parent 89bbd50 commit 8a9d2fd

23 files changed

+1317
-164
lines changed

Makefile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ plugins: plugin-compose
6060
.PHONY: plugin-compose
6161
plugin-compose:
6262
@echo Building container-compose plugin...
63-
@cd Plugins/container-compose && $(SWIFT) build -c $(BUILD_CONFIGURATION)
63+
@cd Plugins/container-compose && $(SWIFT) build -c $(BUILD_CONFIGURATION) --product compose
6464

6565
.PHONY: container
6666
# Install binaries under project directory
@@ -104,8 +104,8 @@ $(STAGING_DIR):
104104
@install config/container-network-vmnet-config.json "$(join $(STAGING_DIR), libexec/container/plugins/container-network-vmnet/config.json)"
105105
@install "$(BUILD_BIN_DIR)/container-core-images" "$(join $(STAGING_DIR), libexec/container/plugins/container-core-images/bin/container-core-images)"
106106
@install config/container-core-images-config.json "$(join $(STAGING_DIR), libexec/container/plugins/container-core-images/config.json)"
107-
@if [ -f "Plugins/container-compose/.build/$(BUILD_CONFIGURATION)/container-compose" ]; then \
108-
install "Plugins/container-compose/.build/$(BUILD_CONFIGURATION)/container-compose" "$(join $(STAGING_DIR), libexec/container/plugins/compose/bin/compose)"; \
107+
@if [ -f "Plugins/container-compose/.build/$(BUILD_CONFIGURATION)/compose" ]; then \
108+
install "Plugins/container-compose/.build/$(BUILD_CONFIGURATION)/compose" "$(join $(STAGING_DIR), libexec/container/plugins/compose/bin/compose)"; \
109109
install "Plugins/container-compose/config.json" "$(join $(STAGING_DIR), libexec/container/plugins/compose/config.json)"; \
110110
fi
111111

Plugins/container-compose/Package.resolved

Lines changed: 3 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Plugins/container-compose/Package.swift

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@ let package = Package(
2121
name: "container-compose",
2222
platforms: [.macOS("15")],
2323
products: [
24-
.executable(name: "compose", targets: ["ComposePlugin"])
24+
.executable(name: "compose", targets: ["ComposePlugin"]),
25+
.executable(name: "compose-debug", targets: ["ComposeDebug"])
2526
],
2627
dependencies: [
2728
.package(name: "container", path: "../.."), // Main container package
28-
.package(url: "https://github.com/apple/containerization.git", exact: "0.5.0"),
29+
.package(url: "https://github.com/apple/containerization.git", exact: "0.6.2"),
2930
.package(url: "https://github.com/jpsim/Yams.git", from: "5.0.0"),
3031
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
3132
.package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"),
@@ -58,6 +59,14 @@ let package = Package(
5859
],
5960
path: "Sources/Core"
6061
),
62+
.executableTarget(
63+
name: "ComposeDebug",
64+
dependencies: [
65+
"ComposeCore",
66+
.product(name: "Logging", package: "swift-log"),
67+
],
68+
path: "Sources/Debug"
69+
),
6170
.testTarget(
6271
name: "ComposeTests",
6372
dependencies: [

Plugins/container-compose/README.md

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,18 @@ This plugin provides docker-compose functionality for Apple Container, allowing
77
- **Service Orchestration**: Define and run multi-container applications
88
- **Build Support**: Automatically build Docker images from Dockerfiles
99
- **Dependency Management**: Handle service dependencies and startup order
10-
- **Volume Management**: Support for named volumes and bind mounts
10+
- **Volume Management**: Bind mounts, named volumes, and anonymous volumes (bare `/path`)
1111
- **Network Configuration**: Automatic network setup and service discovery
1212
- **Health Checks**: Built-in health check support
1313
- **Environment Variables**: Flexible environment variable handling
14+
- **Compose Parity Additions**:
15+
- Build target (`build.target`) forwarded to container build `--target`
16+
- Port range mapping (e.g. `4510-4559:4510-4559[/proto]` expands to discrete rules)
17+
- Long-form volumes: `type` bind/volume/tmpfs, `~` expansion, relative → absolute normalization, supports `ro|rw|z|Z|cached|delegated`
18+
- Entrypoint/Cmd precedence: image Entrypoint/Cmd respected; service `entrypoint`/`command` override; `entrypoint: ''` clears image entrypoint
19+
- `tty` and `stdin_open` respected (`tty` → interactive terminal; `stdin_open` → keep STDIN open)
20+
- Image pulling policy on `compose up --pull` with `always|missing|never`
21+
- Health gating with `depends_on` conditions and `--wait/--wait-timeout`
1422

1523
## Building
1624

@@ -46,6 +54,74 @@ container compose ps
4654
# etc.
4755
```
4856

57+
### New flags (parity with Docker Compose)
58+
59+
- `--pull <policy>`: `always|missing|never` — controls image pull behavior during `up`.
60+
- `--wait`: block until services reach running/healthy state.
61+
- `--wait-timeout <seconds>`: maximum wait time for `--wait`.
62+
63+
## Volume and Mount Semantics
64+
65+
The plugin aligns closely with Docker Compose while mapping to Apple Container’s runtime primitives. There are three user‑facing mount types you can declare in compose; internally they map to two host mechanisms:
66+
67+
- Host directory share (virtiofs)
68+
- Managed block volume (ext4)
69+
70+
### 1) Bind Mounts (host directories)
71+
72+
- Compose syntax:
73+
- Short: `./host_path:/container/path[:ro]`, `~/dir:/container/path`, `/abs/host:/container/path`
74+
- Long: `type: bind`, `source: ./dir`, `target: /container/path`, `read_only: true`
75+
- Normalization:
76+
- `~` expands to your home directory.
77+
- Relative paths resolve to absolute paths using the working directory.
78+
- Runtime mapping:
79+
- Mapped as a virtiofs share from the host path to the container path.
80+
- Read‑only honored via `:ro` or `read_only: true`.
81+
- Notes:
82+
- Options like `:cached`, `:delegated`, SELinux flags `:z`/`:Z` are accepted in YAML but currently do not alter behavior; the mount is still a virtiofs host share.
83+
84+
### 2) Named Volumes
85+
86+
- Compose syntax:
87+
- Short: `myvol:/container/path[:ro]`
88+
- Long: `type: volume`, `source: myvol`, `target: /container/path`
89+
- Define in top‑level `volumes:` (optional if not `external: true`).
90+
- Runtime mapping:
91+
- The orchestrator ensures the volume exists (creates if missing and not external), then mounts it using Apple Container’s managed block volume (ext4) and its host mountpoint.
92+
- Labels set on created volumes: `com.apple.compose.project`, `com.apple.compose.service`, `com.apple.compose.target`, `com.apple.compose.anonymous=false`.
93+
- Cleanup:
94+
- `container compose down --volumes` removes non‑external volumes declared in the project.
95+
96+
### 3) Anonymous Volumes (bare container paths)
97+
98+
- Compose syntax:
99+
- Short: `- /container/path`
100+
- Long (equivalent semantics): `type: volume`, `target: /container/path` with no `source`.
101+
- Runtime mapping:
102+
- Treated as a named volume with a deterministic generated name: `<project>_<service>_anon_<hash>`.
103+
- Created if missing and mounted as a managed block volume (ext4) using the volume’s host mountpoint.
104+
- Labeled with `com.apple.compose.anonymous=true` for lifecycle management.
105+
- Cleanup:
106+
- `container compose down --volumes` also removes these anonymous volumes (matched via labels).
107+
108+
### 4) Tmpfs (container‑only memory mount)
109+
110+
- Compose long form only: `type: tmpfs`, `target: /container/tmp`, `read_only: true|false`.
111+
- Runtime mapping:
112+
- An in‑memory tmpfs mount at the container path.
113+
114+
### Behavior Summary
115+
116+
- Bind mount → virtiofs host share (best for live dev against host files).
117+
- Named/anonymous volume → managed block volume (best for persisted container data independent of your working tree).
118+
- Tmpfs → in‑memory ephemeral mount.
119+
120+
### Port Publishing (for completeness)
121+
122+
- Compose `ports:` entries like `"127.0.0.1:3000:3000"`, `"3000:3000"` are supported.
123+
- The runtime binds the host address/port and forwards to the container IP/port using a TCP/UDP forwarder.
124+
49125
## Build Support
50126

51127
The plugin now supports building Docker images directly from your compose file:
@@ -81,6 +157,7 @@ When you run `container compose up`, the plugin will:
81157
- `context`: Build context directory (default: ".")
82158
- `dockerfile`: Path to Dockerfile (default: "Dockerfile")
83159
- `args`: Build arguments as key-value pairs
160+
- `target`: Build stage to use as final image (forwarded to `container build --target`).
84161

85162
### Build Caching
86163

@@ -115,6 +192,8 @@ Services with unchanged build configurations reuse cached images using a stable
115192
- Prints service image tags and DNS names.
116193
- `--remove-orphans`: removes containers from the same project that are no longer defined (prefers labels; falls back to name prefix).
117194
- `--rm`: automatically removes containers when they exit.
195+
- `--pull`: image pulling policy (`always|missing|never`).
196+
- `--wait`, `--wait-timeout`: wait for running/healthy states (healthy if `healthcheck` exists; running otherwise).
118197
- `compose down`:
119198
- Stops and removes containers for the project, prints a summary of removed containers and volumes.
120199
- `--remove-orphans`: also removes any extra containers matching the project.
@@ -136,10 +215,33 @@ Services with unchanged build configurations reuse cached images using a stable
136215
- `.env` loading is applied consistently across commands: `up`, `down`, `ps`, `start`, `logs`, `exec`, `validate`.
137216
- Security: the loader warns if `.env` is group/other readable; consider `chmod 600 .env`.
138217

218+
### Environment semantics and validation
219+
220+
- Accepts both dictionary and list forms under `environment:`.
221+
- List items must be `KEY=VALUE`; unsupported forms are rejected.
222+
- Variable names must match `^[A-Za-z_][A-Za-z0-9_]*$`.
223+
- Unsafe interpolation in values is blocked during `${...}` and `$VAR` expansion.
224+
225+
Examples:
226+
227+
```yaml
228+
services:
229+
app:
230+
environment:
231+
- "APP_ENV=prod"
232+
- "_DEBUG=true" # ok
233+
# entrypoint override examples
234+
entrypoint: "bash -lc"
235+
command: ["./start.sh"]
236+
worker:
237+
entrypoint: '' # clears image entrypoint
238+
```
239+
139240
## Compatibility and Limitations
140241

141242
- YAML anchors and merge keys are disabled by default for hardening. You can enable them with `--allow-anchors` on compose commands.
142-
- Health gating and container recreation flags (`--force-recreate`, `--no-recreate`) are not fully implemented yet.
243+
- Health gating: `depends_on` supports `service_started`, `service_healthy`, and best‑effort `service_completed_successfully`.
244+
- Recreation flags: `--force-recreate` and `--no-recreate` respected; config hash drives default reuse behavior.
143245
- `ps`, `logs`, and `exec` implementations are limited and may not reflect full runtime state.
144246

145247
## Documentation

Plugins/container-compose/Sources/CLI/ComposeCommand.swift

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,23 +59,33 @@ struct ComposeOptions: ParsableArguments {
5959
}
6060

6161
// Default behavior: detect base compose file and include matching override
62+
// Preferred order (first match wins):
63+
// 1) container-compose.yaml / container-compose.yml
64+
// 2) compose.yaml / compose.yml (Docker Compose v2 default)
65+
// 3) docker-compose.yaml / docker-compose.yml (legacy)
6266
let candidates = [
63-
"docker-compose.yml",
64-
"docker-compose.yaml",
65-
"compose.yml",
67+
"container-compose.yaml",
68+
"container-compose.yml",
6669
"compose.yaml",
70+
"compose.yml",
71+
"docker-compose.yaml",
72+
"docker-compose.yml",
6773
]
68-
74+
6975
for base in candidates {
7076
let baseURL = URL(fileURLWithPath: currentPath).appendingPathComponent(base)
7177
if FileManager.default.fileExists(atPath: baseURL.path) {
7278
var urls = [baseURL]
7379
// Include override for the chosen base
7480
let overrideCandidates: [String]
75-
if base.hasPrefix("docker-compose") {
81+
if base.hasPrefix("container-compose") {
82+
overrideCandidates = ["container-compose.override.yaml", "container-compose.override.yml"]
83+
} else if base.hasPrefix("compose") {
84+
overrideCandidates = ["compose.override.yaml", "compose.override.yml"]
85+
} else if base.hasPrefix("docker-compose") {
7686
overrideCandidates = ["docker-compose.override.yml", "docker-compose.override.yaml"]
7787
} else {
78-
overrideCandidates = ["compose.override.yml", "compose.override.yaml"]
88+
overrideCandidates = []
7989
}
8090
for o in overrideCandidates {
8191
let oURL = URL(fileURLWithPath: currentPath).appendingPathComponent(o)

Plugins/container-compose/Sources/CLI/ComposeUp.swift

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ import ContainerClient
1919
import ComposeCore
2020
import ContainerizationError
2121
import Foundation
22+
import Dispatch
23+
#if os(macOS)
24+
import Darwin
25+
#else
26+
import Glibc
27+
#endif
2228

2329

2430
struct ComposeUp: AsyncParsableCommand {
@@ -51,6 +57,15 @@ struct ComposeUp: AsyncParsableCommand {
5157
@Flag(name: .long, help: "Automatically remove containers when they exit")
5258
var rm: Bool = false
5359

60+
@Option(name: .long, help: "Pull policy: always|missing|never")
61+
var pull: String = "missing"
62+
63+
@Flag(name: .long, help: "Wait for services to be running/healthy")
64+
var wait: Bool = false
65+
66+
@Option(name: .long, help: "Wait timeout in seconds")
67+
var waitTimeout: Int?
68+
5469
@Argument(help: "Services to start")
5570
var services: [String] = []
5671

@@ -95,7 +110,16 @@ struct ComposeUp: AsyncParsableCommand {
95110
noDeps: noDeps,
96111
removeOrphans: removeOrphans,
97112
removeOnExit: rm,
98-
progressHandler: progress.handler
113+
progressHandler: progress.handler,
114+
pullPolicy: {
115+
switch pull.lowercased() {
116+
case "always": return .always
117+
case "never": return .never
118+
default: return .missing
119+
}
120+
}(),
121+
wait: wait,
122+
waitTimeoutSeconds: waitTimeout
99123
)
100124

101125
progress.finish()
@@ -122,9 +146,42 @@ struct ComposeUp: AsyncParsableCommand {
122146
if detach {
123147
print("Started project '\(project.name)' in detached mode")
124148
} else {
149+
// Install signal handlers so Ctrl-C stops services gracefully
150+
func installSignal(_ signo: Int32) {
151+
signal(signo, SIG_IGN)
152+
// Create and retain the signal source on the main queue to satisfy concurrency rules
153+
DispatchQueue.main.async {
154+
let src = DispatchSource.makeSignalSource(signal: signo, queue: .main)
155+
src.setEventHandler {
156+
// Use a MainActor-isolated flag so the compiler is happy about concurrency
157+
Task { @MainActor in
158+
// Second signal forces exit
159+
if SignalState.shared.seenFirstSignal {
160+
Darwin.exit(130)
161+
}
162+
SignalState.shared.seenFirstSignal = true
163+
do {
164+
print("\nStopping project '\(project.name)' (Ctrl-C again to force)...")
165+
let orchestratorForStop = Orchestrator(log: log)
166+
_ = try await orchestratorForStop.down(project: project, removeVolumes: false, removeOrphans: false, progressHandler: nil)
167+
Darwin.exit(0)
168+
} catch {
169+
// If graceful stop fails, exit with error code
170+
FileHandle.standardError.write(Data("compose: failed to stop services: \(error)\n".utf8))
171+
Darwin.exit(1)
172+
}
173+
}
174+
}
175+
src.resume()
176+
SignalRetainer.retain(src)
177+
}
178+
}
179+
installSignal(SIGINT)
180+
installSignal(SIGTERM)
181+
125182
// Stream logs for selected services (or all if none selected), similar to docker-compose up
126-
let orchestrator = Orchestrator(log: log)
127-
let logStream = try await orchestrator.logs(
183+
let orchestratorForLogs = Orchestrator(log: log)
184+
let logStream = try await orchestratorForLogs.logs(
128185
project: project,
129186
services: services,
130187
follow: true,
@@ -143,3 +200,21 @@ struct ComposeUp: AsyncParsableCommand {
143200
}
144201
}
145202
}
203+
204+
// A tiny atomic flag helper for one-time behavior across signal handlers
205+
@MainActor
206+
fileprivate final class SignalState {
207+
static let shared = SignalState()
208+
var seenFirstSignal = false
209+
}
210+
211+
// Keep strong references to DispatchSourceSignal so handlers fire reliably
212+
@MainActor
213+
fileprivate final class SignalRetainer {
214+
private static let shared = SignalRetainer()
215+
private var sources: [DispatchSourceSignal] = []
216+
static func install() { /* ensure type is loaded */ }
217+
static func retain(_ src: DispatchSourceSignal) {
218+
shared.sources.append(src)
219+
}
220+
}

0 commit comments

Comments
 (0)