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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,15 @@ Then, from a directory containing a `compose.yaml`:

```sh
container compose up # create networks/volumes, start services in order
container compose up --wait # ...and block until healthchecked services are healthy
container compose ps
container compose logs web -f
container compose exec -it web sh # run a command in a running service container
container compose logs web --follow # -n/--tail N limits to the last N lines
container compose exec -it web sh # also -w/--workdir, -u/--user, -e/--env KEY=VALUE
container compose pull # pre-fetch images for all services
container compose stop # stop containers without removing them
container compose start # start existing containers without recreating
container compose restart # stop then start (no native --restart in container)
container compose kill -s SIGTERM # send a signal (default KILL) to containers
container compose down -v # stop+remove containers, networks, and named volumes
```

Expand Down
48 changes: 45 additions & 3 deletions Sources/ContainerComposeKit/Orchestrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public struct Orchestrator: Sendable {
/// dependencies); empty means every profile-enabled service.
/// - 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]) throws {
public func up(build: Bool, only services: [String], wait: Bool = false) throws {
try validate(services)
let selected = project.enabledServices(explicit: services)
try ensureNetworks()
Expand Down Expand Up @@ -92,6 +92,23 @@ public struct Orchestrator: Sendable {
translator.runArgs(service: name, svc, image: image, files: resolved))
}
}

if wait { try waitUntilHealthy(order: order, oneShot: oneShot) }
}

/// Block until every started service that defines a healthcheck reports
/// healthy. Services without a healthcheck are considered ready once created;
/// one-shot (`service_completed_successfully`) services already ran to exit.
private func waitUntilHealthy(order: [String], oneShot: Set<String>) throws {
for name in order where !oneShot.contains(name) {
guard let svc = project.file.services[name], let hc = svc.healthcheck,
hc.disable != true, let test = hc.test,
HealthChecker.execArguments(for: test) != nil
else { continue }
let cname = translator.containerName(service: name, declared: svc.container_name)
info("Waiting for '\(name)' to be healthy ...")
try HealthChecker(runner: runner).waitHealthy(container: cname, health: hc)
}
}

/// Names of services that any selected service depends on with the
Expand Down Expand Up @@ -288,7 +305,7 @@ public struct Orchestrator: Sendable {
/// one service this tails them sequentially; pass a single service to
/// stream live.
/// - services: limit to these services; empty means all.
public func logs(follow: Bool, only services: [String]) throws {
public func logs(follow: Bool, tail: Int? = nil, only services: [String]) throws {
let selected = try select(services).sorted()
if follow && selected.count > 1 {
warn("--follow with multiple services tails them sequentially; pass one service to stream live")
Expand All @@ -298,6 +315,7 @@ public struct Orchestrator: Sendable {
let cname = translator.containerName(service: name, declared: svc.container_name)
var args = ["logs"]
if follow { args.append("--follow") }
if let tail { args += ["-n", String(tail)] }
args.append(cname)
_ = try? runner.run(args)
}
Expand All @@ -313,18 +331,25 @@ public struct Orchestrator: Sendable {
/// - command: the command and arguments to run.
/// - interactive: keep stdin open (`-i`).
/// - tty: allocate a TTY (`-t`).
/// - workdir: working directory inside the container (`--workdir`).
/// - user: user to run as, `name|uid[:gid]` (`--user`).
/// - env: extra `KEY=VALUE` environment entries (`--env`, repeatable).
/// - Returns: the command's exit status.
/// - Throws: `ComposeError.unknownService` if the service is undeclared.
@discardableResult
public func exec(
service: String, command: [String], interactive: Bool = false, tty: Bool = false
service: String, command: [String], interactive: Bool = false, tty: Bool = false,
workdir: String? = nil, user: String? = nil, env: [String] = []
) throws -> Int32 {
try validate([service])
let svc = project.file.services[service]
let cname = translator.containerName(service: service, declared: svc?.container_name)
var args = ["exec"]
if interactive { args.append("--interactive") }
if tty { args.append("--tty") }
if let workdir { args += ["--workdir", workdir] }
if let user { args += ["--user", user] }
for entry in env { args += ["--env", entry] }
args.append(cname)
args += command
return try runner.run(args)
Expand Down Expand Up @@ -379,6 +404,23 @@ public struct Orchestrator: Sendable {
try start(only: services)
}

/// Send a signal (default KILL) to the selected services' containers, in
/// reverse dependency order. Best-effort, like ``stop(only:)``.
///
/// - Parameter signal: the signal to send (e.g. `SIGTERM`); `nil` uses the
/// `container` default (KILL).
public func kill(only services: [String], signal: String? = nil) throws {
for name in try ordered(services, reversed: true) {
guard let svc = project.file.services[name] else { continue }
let cname = translator.containerName(service: name, declared: svc.container_name)
info("Killing \(cname) ...")
var args = ["kill"]
if let signal { args += ["--signal", signal] }
args.append(cname)
runner.runSilently(args)
}
}

// MARK: - shared helpers

private func select(_ services: [String]) throws -> Set<String> {
Expand Down
43 changes: 39 additions & 4 deletions Sources/container-compose/Commands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@ struct Up: AsyncParsableCommand {
@Flag(name: .long, help: "Build images before starting (even if an image is present).")
var build = false

@Flag(name: .long, help: "Wait until services with a healthcheck report healthy.")
var wait = false

@Argument(help: "Limit to these services (default: all).")
var services: [String] = []

func run() async throws {
let orchestrator = try options.makeOrchestrator()
try orchestrator.up(build: build, only: services)
try orchestrator.up(build: build, only: services, wait: wait)
}
}

Expand Down Expand Up @@ -61,15 +64,19 @@ struct Logs: AsyncParsableCommand {

@OptionGroup var options: GlobalOptions

@Flag(name: [.short, .long], help: "Follow log output.")
// Long-only: `-f` is the global `--file` short, so `--follow` can't claim it.
@Flag(name: .long, help: "Follow log output.")
var follow = false

@Option(name: [.customShort("n"), .long], help: "Show only the last N lines.")
var tail: Int?

@Argument(help: "Limit to these services (default: all).")
var services: [String] = []

func run() async throws {
let orchestrator = try options.makeOrchestrator()
try orchestrator.logs(follow: follow, only: services)
try orchestrator.logs(follow: follow, tail: tail, only: services)
}
}

Expand All @@ -85,6 +92,15 @@ struct Exec: AsyncParsableCommand {
@Flag(name: [.customShort("t"), .long], help: "Allocate a pseudo-TTY.")
var tty = false

@Option(name: [.customShort("w"), .long], help: "Working directory inside the container.")
var workdir: String?

@Option(name: [.customShort("u"), .long], help: "User to run as (name|uid[:gid]).")
var user: String?

@Option(name: [.customShort("e"), .long], help: "Set an env var KEY=VALUE (repeatable).")
var env: [String] = []

@Argument(help: "Service whose container to exec into.")
var service: String

Expand All @@ -94,7 +110,8 @@ struct Exec: AsyncParsableCommand {
func run() async throws {
let orchestrator = try options.makeOrchestrator()
let status = try orchestrator.exec(
service: service, command: command, interactive: interactive, tty: tty)
service: service, command: command, interactive: interactive, tty: tty,
workdir: workdir, user: user, env: env)
if status != 0 { throw ExitCode(status) }
}
}
Expand Down Expand Up @@ -159,6 +176,24 @@ struct Restart: AsyncParsableCommand {
}
}

struct Kill: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Force-stop service containers by sending a signal (default KILL).")

@OptionGroup var options: GlobalOptions

@Option(name: [.customShort("s"), .long], help: "Signal to send (e.g. SIGTERM; default KILL).")
var signal: String?

@Argument(help: "Limit to these services (default: all).")
var services: [String] = []

func run() async throws {
let orchestrator = try options.makeOrchestrator()
try orchestrator.kill(only: services, signal: signal)
}
}

struct Config: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Validate and show the resolved project plan.")
Expand Down
2 changes: 1 addition & 1 deletion Sources/container-compose/ContainerCompose.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ struct ContainerCompose: AsyncParsableCommand {
version: containerComposeVersion,
subcommands: [
Up.self, Down.self, Ps.self, Logs.self, Config.self,
Exec.self, Pull.self, Stop.self, Start.self, Restart.self,
Exec.self, Pull.self, Stop.self, Start.self, Restart.self, Kill.self,
Update.self,
],
defaultSubcommand: Up.self
Expand Down
32 changes: 32 additions & 0 deletions Tests/ContainerComposeKitTests/ContainerComposeKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,38 @@ struct LifecycleTests {
#expect(lines.contains("network delete proj-default"))
#expect(!lines.contains { $0.hasPrefix("volume delete") })
}

@Test("kill sends the signal in reverse order")
func kill() throws {
let lines = try capture("kill") { try $0.kill(only: [], signal: "SIGTERM") }
let app = lines.firstIndex(of: "kill --signal SIGTERM proj-app")
let db = lines.firstIndex(of: "kill --signal SIGTERM proj-db")
#expect(app != nil && db != nil && app! < db!)
}

@Test("kill with no signal omits --signal (container default KILL)")
func killDefault() throws {
let lines = try capture("killd") { try $0.kill(only: ["db"]) }
#expect(lines.contains("kill proj-db"))
}

@Test("logs --tail emits -n")
func logsTail() throws {
let lines = try capture("logs") { try $0.logs(follow: false, tail: 25, only: ["db"]) }
#expect(lines.contains("logs -n 25 proj-db"))
}

@Test("exec forwards --workdir/--user/--env")
func execFlags() throws {
let lines = try capture("exec") {
_ = try $0.exec(
service: "db", command: ["sh"], interactive: true, tty: true,
workdir: "/app", user: "1000", env: ["A=b"])
}
#expect(
lines.contains(
"exec --interactive --tty --workdir /app --user 1000 --env A=b proj-db sh"))
}
}

@Suite("Port publishing")
Expand Down