From 713dab814c1fdd33032b7b85a6e5b3fe6c455254 Mon Sep 17 00:00:00 2001 From: Denis Panfilov Date: Sun, 21 Jun 2026 16:00:32 +0200 Subject: [PATCH] Add logs --tail, kill, exec --workdir/--user/--env, up --wait All in the container runtime layer + CLI (no ComposeKit changes): - logs: -n/--tail N (container logs -n). - kill: new command, -s/--signal (default KILL), reverse dependency order. - exec: forward --workdir/--user/--env (container exec already accepts them). - up: --wait blocks until healthchecked services report healthy (reuses HealthChecker). Also fixes a pre-existing bug: logs' --follow claimed -f, which collides with the global --file -f and made ArgumentParser reject every 'logs' invocation; --follow is now long-only. Tests via the recording-shim harness for kill/logs/exec argv; 32 tests pass. --- README.md | 6 ++- .../ContainerComposeKit/Orchestrator.swift | 48 +++++++++++++++++-- Sources/container-compose/Commands.swift | 43 +++++++++++++++-- .../container-compose/ContainerCompose.swift | 2 +- .../ContainerComposeKitTests.swift | 32 +++++++++++++ 5 files changed, 121 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index bda36d0..994d7ad 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/Sources/ContainerComposeKit/Orchestrator.swift b/Sources/ContainerComposeKit/Orchestrator.swift index 2993266..b2f0cce 100644 --- a/Sources/ContainerComposeKit/Orchestrator.swift +++ b/Sources/ContainerComposeKit/Orchestrator.swift @@ -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() @@ -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) 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 @@ -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") @@ -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) } @@ -313,11 +331,15 @@ 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] @@ -325,6 +347,9 @@ public struct Orchestrator: Sendable { 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) @@ -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 { diff --git a/Sources/container-compose/Commands.swift b/Sources/container-compose/Commands.swift index 46b00ce..0e136ad 100644 --- a/Sources/container-compose/Commands.swift +++ b/Sources/container-compose/Commands.swift @@ -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) } } @@ -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) } } @@ -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 @@ -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) } } } @@ -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.") diff --git a/Sources/container-compose/ContainerCompose.swift b/Sources/container-compose/ContainerCompose.swift index 7a08ab9..a641878 100644 --- a/Sources/container-compose/ContainerCompose.swift +++ b/Sources/container-compose/ContainerCompose.swift @@ -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 diff --git a/Tests/ContainerComposeKitTests/ContainerComposeKitTests.swift b/Tests/ContainerComposeKitTests/ContainerComposeKitTests.swift index 6edf6d6..52bd428 100644 --- a/Tests/ContainerComposeKitTests/ContainerComposeKitTests.swift +++ b/Tests/ContainerComposeKitTests/ContainerComposeKitTests.swift @@ -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")