Skip to content

Commit f58ac04

Browse files
authored
Add logs --tail, kill, exec --workdir/--user/--env, up --wait (#11)
1 parent edc6664 commit f58ac04

5 files changed

Lines changed: 121 additions & 10 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,15 @@ Then, from a directory containing a `compose.yaml`:
5858

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

‎Sources/ContainerComposeKit/Orchestrator.swift‎

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ public struct Orchestrator: Sendable {
5252
/// dependencies); empty means every profile-enabled service.
5353
/// - Throws: `ComposeError` for unknown services, dependency cycles, or a
5454
/// dependency that fails to become healthy or complete successfully.
55-
public func up(build: Bool, only services: [String]) throws {
55+
public func up(build: Bool, only services: [String], wait: Bool = false) throws {
5656
try validate(services)
5757
let selected = project.enabledServices(explicit: services)
5858
try ensureNetworks()
@@ -92,6 +92,23 @@ public struct Orchestrator: Sendable {
9292
translator.runArgs(service: name, svc, image: image, files: resolved))
9393
}
9494
}
95+
96+
if wait { try waitUntilHealthy(order: order, oneShot: oneShot) }
97+
}
98+
99+
/// Block until every started service that defines a healthcheck reports
100+
/// healthy. Services without a healthcheck are considered ready once created;
101+
/// one-shot (`service_completed_successfully`) services already ran to exit.
102+
private func waitUntilHealthy(order: [String], oneShot: Set<String>) throws {
103+
for name in order where !oneShot.contains(name) {
104+
guard let svc = project.file.services[name], let hc = svc.healthcheck,
105+
hc.disable != true, let test = hc.test,
106+
HealthChecker.execArguments(for: test) != nil
107+
else { continue }
108+
let cname = translator.containerName(service: name, declared: svc.container_name)
109+
info("Waiting for '\(name)' to be healthy ...")
110+
try HealthChecker(runner: runner).waitHealthy(container: cname, health: hc)
111+
}
95112
}
96113

97114
/// Names of services that any selected service depends on with the
@@ -288,7 +305,7 @@ public struct Orchestrator: Sendable {
288305
/// one service this tails them sequentially; pass a single service to
289306
/// stream live.
290307
/// - services: limit to these services; empty means all.
291-
public func logs(follow: Bool, only services: [String]) throws {
308+
public func logs(follow: Bool, tail: Int? = nil, only services: [String]) throws {
292309
let selected = try select(services).sorted()
293310
if follow && selected.count > 1 {
294311
warn("--follow with multiple services tails them sequentially; pass one service to stream live")
@@ -298,6 +315,7 @@ public struct Orchestrator: Sendable {
298315
let cname = translator.containerName(service: name, declared: svc.container_name)
299316
var args = ["logs"]
300317
if follow { args.append("--follow") }
318+
if let tail { args += ["-n", String(tail)] }
301319
args.append(cname)
302320
_ = try? runner.run(args)
303321
}
@@ -313,18 +331,25 @@ public struct Orchestrator: Sendable {
313331
/// - command: the command and arguments to run.
314332
/// - interactive: keep stdin open (`-i`).
315333
/// - tty: allocate a TTY (`-t`).
334+
/// - workdir: working directory inside the container (`--workdir`).
335+
/// - user: user to run as, `name|uid[:gid]` (`--user`).
336+
/// - env: extra `KEY=VALUE` environment entries (`--env`, repeatable).
316337
/// - Returns: the command's exit status.
317338
/// - Throws: `ComposeError.unknownService` if the service is undeclared.
318339
@discardableResult
319340
public func exec(
320-
service: String, command: [String], interactive: Bool = false, tty: Bool = false
341+
service: String, command: [String], interactive: Bool = false, tty: Bool = false,
342+
workdir: String? = nil, user: String? = nil, env: [String] = []
321343
) throws -> Int32 {
322344
try validate([service])
323345
let svc = project.file.services[service]
324346
let cname = translator.containerName(service: service, declared: svc?.container_name)
325347
var args = ["exec"]
326348
if interactive { args.append("--interactive") }
327349
if tty { args.append("--tty") }
350+
if let workdir { args += ["--workdir", workdir] }
351+
if let user { args += ["--user", user] }
352+
for entry in env { args += ["--env", entry] }
328353
args.append(cname)
329354
args += command
330355
return try runner.run(args)
@@ -379,6 +404,23 @@ public struct Orchestrator: Sendable {
379404
try start(only: services)
380405
}
381406

407+
/// Send a signal (default KILL) to the selected services' containers, in
408+
/// reverse dependency order. Best-effort, like ``stop(only:)``.
409+
///
410+
/// - Parameter signal: the signal to send (e.g. `SIGTERM`); `nil` uses the
411+
/// `container` default (KILL).
412+
public func kill(only services: [String], signal: String? = nil) throws {
413+
for name in try ordered(services, reversed: true) {
414+
guard let svc = project.file.services[name] else { continue }
415+
let cname = translator.containerName(service: name, declared: svc.container_name)
416+
info("Killing \(cname) ...")
417+
var args = ["kill"]
418+
if let signal { args += ["--signal", signal] }
419+
args.append(cname)
420+
runner.runSilently(args)
421+
}
422+
}
423+
382424
// MARK: - shared helpers
383425

384426
private func select(_ services: [String]) throws -> Set<String> {

‎Sources/container-compose/Commands.swift‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@ struct Up: AsyncParsableCommand {
1616
@Flag(name: .long, help: "Build images before starting (even if an image is present).")
1717
var build = false
1818

19+
@Flag(name: .long, help: "Wait until services with a healthcheck report healthy.")
20+
var wait = false
21+
1922
@Argument(help: "Limit to these services (default: all).")
2023
var services: [String] = []
2124

2225
func run() async throws {
2326
let orchestrator = try options.makeOrchestrator()
24-
try orchestrator.up(build: build, only: services)
27+
try orchestrator.up(build: build, only: services, wait: wait)
2528
}
2629
}
2730

@@ -61,15 +64,19 @@ struct Logs: AsyncParsableCommand {
6164

6265
@OptionGroup var options: GlobalOptions
6366

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

71+
@Option(name: [.customShort("n"), .long], help: "Show only the last N lines.")
72+
var tail: Int?
73+
6774
@Argument(help: "Limit to these services (default: all).")
6875
var services: [String] = []
6976

7077
func run() async throws {
7178
let orchestrator = try options.makeOrchestrator()
72-
try orchestrator.logs(follow: follow, only: services)
79+
try orchestrator.logs(follow: follow, tail: tail, only: services)
7380
}
7481
}
7582

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

95+
@Option(name: [.customShort("w"), .long], help: "Working directory inside the container.")
96+
var workdir: String?
97+
98+
@Option(name: [.customShort("u"), .long], help: "User to run as (name|uid[:gid]).")
99+
var user: String?
100+
101+
@Option(name: [.customShort("e"), .long], help: "Set an env var KEY=VALUE (repeatable).")
102+
var env: [String] = []
103+
88104
@Argument(help: "Service whose container to exec into.")
89105
var service: String
90106

@@ -94,7 +110,8 @@ struct Exec: AsyncParsableCommand {
94110
func run() async throws {
95111
let orchestrator = try options.makeOrchestrator()
96112
let status = try orchestrator.exec(
97-
service: service, command: command, interactive: interactive, tty: tty)
113+
service: service, command: command, interactive: interactive, tty: tty,
114+
workdir: workdir, user: user, env: env)
98115
if status != 0 { throw ExitCode(status) }
99116
}
100117
}
@@ -159,6 +176,24 @@ struct Restart: AsyncParsableCommand {
159176
}
160177
}
161178

179+
struct Kill: AsyncParsableCommand {
180+
static let configuration = CommandConfiguration(
181+
abstract: "Force-stop service containers by sending a signal (default KILL).")
182+
183+
@OptionGroup var options: GlobalOptions
184+
185+
@Option(name: [.customShort("s"), .long], help: "Signal to send (e.g. SIGTERM; default KILL).")
186+
var signal: String?
187+
188+
@Argument(help: "Limit to these services (default: all).")
189+
var services: [String] = []
190+
191+
func run() async throws {
192+
let orchestrator = try options.makeOrchestrator()
193+
try orchestrator.kill(only: services, signal: signal)
194+
}
195+
}
196+
162197
struct Config: AsyncParsableCommand {
163198
static let configuration = CommandConfiguration(
164199
abstract: "Validate and show the resolved project plan.")

‎Sources/container-compose/ContainerCompose.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ struct ContainerCompose: AsyncParsableCommand {
1818
version: containerComposeVersion,
1919
subcommands: [
2020
Up.self, Down.self, Ps.self, Logs.self, Config.self,
21-
Exec.self, Pull.self, Stop.self, Start.self, Restart.self,
21+
Exec.self, Pull.self, Stop.self, Start.self, Restart.self, Kill.self,
2222
Update.self,
2323
],
2424
defaultSubcommand: Up.self

‎Tests/ContainerComposeKitTests/ContainerComposeKitTests.swift‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,38 @@ struct LifecycleTests {
337337
#expect(lines.contains("network delete proj-default"))
338338
#expect(!lines.contains { $0.hasPrefix("volume delete") })
339339
}
340+
341+
@Test("kill sends the signal in reverse order")
342+
func kill() throws {
343+
let lines = try capture("kill") { try $0.kill(only: [], signal: "SIGTERM") }
344+
let app = lines.firstIndex(of: "kill --signal SIGTERM proj-app")
345+
let db = lines.firstIndex(of: "kill --signal SIGTERM proj-db")
346+
#expect(app != nil && db != nil && app! < db!)
347+
}
348+
349+
@Test("kill with no signal omits --signal (container default KILL)")
350+
func killDefault() throws {
351+
let lines = try capture("killd") { try $0.kill(only: ["db"]) }
352+
#expect(lines.contains("kill proj-db"))
353+
}
354+
355+
@Test("logs --tail emits -n")
356+
func logsTail() throws {
357+
let lines = try capture("logs") { try $0.logs(follow: false, tail: 25, only: ["db"]) }
358+
#expect(lines.contains("logs -n 25 proj-db"))
359+
}
360+
361+
@Test("exec forwards --workdir/--user/--env")
362+
func execFlags() throws {
363+
let lines = try capture("exec") {
364+
_ = try $0.exec(
365+
service: "db", command: ["sh"], interactive: true, tty: true,
366+
workdir: "/app", user: "1000", env: ["A=b"])
367+
}
368+
#expect(
369+
lines.contains(
370+
"exec --interactive --tty --workdir /app --user 1000 --env A=b proj-db sh"))
371+
}
340372
}
341373

342374
@Suite("Port publishing")

0 commit comments

Comments
 (0)