From 23b9862b5c1ce51d1237714dd275cf1242919fc3 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sun, 7 Jun 2026 17:57:49 -0400 Subject: [PATCH 1/2] Add ps subcommand to list project containers Lists the compose project's containers in an aligned NAME/IMAGE/STATUS/PORTS table, showing only running containers unless -a/--all is passed. Containers are matched primarily by the com.docker.compose.project/service labels stamped since #126 (reliable across naming modes), falling back to the candidate container names for containers created before the labels existed. Resolves the project through the shared ComposeProjectOptions (#138) and fails fast on unknown service names with the noSuchService contract #139 established for up. --- Sources/Container-Compose/Application.swift | 1 + .../Commands/ComposePs.swift | 101 ++++++++++++++++++ .../Container-Compose/TableFormatter.swift | 47 ++++++++ .../ComposePsTests.swift | 46 ++++++++ 4 files changed, 195 insertions(+) create mode 100644 Sources/Container-Compose/Commands/ComposePs.swift create mode 100644 Sources/Container-Compose/TableFormatter.swift create mode 100644 Tests/Container-Compose-StaticTests/ComposePsTests.swift diff --git a/Sources/Container-Compose/Application.swift b/Sources/Container-Compose/Application.swift index 1a7b639..355ee4b 100644 --- a/Sources/Container-Compose/Application.swift +++ b/Sources/Container-Compose/Application.swift @@ -31,6 +31,7 @@ public struct Main: AsyncParsableCommand { ComposeUp.self, ComposeDown.self, ComposeBuild.self, + ComposePs.self, Version.self ]) diff --git a/Sources/Container-Compose/Commands/ComposePs.swift b/Sources/Container-Compose/Commands/ComposePs.swift new file mode 100644 index 0000000..9fac376 --- /dev/null +++ b/Sources/Container-Compose/Commands/ComposePs.swift @@ -0,0 +1,101 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +// +// ComposePs.swift +// Container-Compose +// +// Implements the `ps` subcommand, which lists the state of a compose +// project's containers. +// + +import ArgumentParser +import ContainerCommands +import ContainerAPIClient +import Foundation +import Yams + +public struct ComposePs: AsyncParsableCommand { + public init() {} + + public static let configuration: CommandConfiguration = .init( + commandName: "ps", + abstract: "List containers for this compose project" + ) + + @Argument(help: "Specify the services to list") + var services: [String] = [] + + @Flag( + name: [.customShort("a"), .customLong("all")], + help: "Show all containers (default shows just running)") + var all: Bool = false + + @OptionGroup + var project: ComposeProjectOptions + + public func run() async throws { + let resolved = try project.resolve(filteringBy: services) + try Self.validateRequestedServices(services, in: resolved) + + let client = ContainerClient() + let snapshots = try await client.list() + + // A service's container is matched primarily by the compose labels + // `up` stamps (reliable regardless of naming mode), falling back to + // the candidate names for containers created before the labels + // existed. Dependency order from the resolved project is preserved; + // containers that don't exist yet simply don't appear. + let ordered = resolved.services.flatMap { target in + snapshots.filter { snapshot in + let labels = snapshot.configuration.labels + if labels["com.docker.compose.project"] == resolved.projectName { + return labels["com.docker.compose.service"] == target.serviceName + } + return target.candidateContainerNames.contains(snapshot.id) + } + } + let visible = all ? ordered : ordered.filter { $0.status == .running } + + let header = ["NAME", "IMAGE", "STATUS", "PORTS"] + let rows = visible.map { snapshot in + [ + snapshot.id, + snapshot.configuration.image.reference, + snapshot.status.rawValue, + // Render published ports like `docker compose ps`: + // `0.0.0.0:8080->80/tcp`. Avoids naming the `PublishPort` type + // (not re-exported into this module) by mapping inline. + snapshot.configuration.publishedPorts + .map { "\($0.hostAddress):\($0.hostPort)->\($0.containerPort)/\($0.proto.rawValue)" } + .joined(separator: ", "), + ] + } + print(TableFormatter.render(header: header, rows: rows)) + } +} + +extension ComposePs { + /// Fail fast on a typo'd/unknown service name, matching + /// `docker compose ps ` ("no such service: ") — same contract + /// #139 established for `up`. + static func validateRequestedServices(_ requested: [String], in project: ComposeProject) throws { + let defined: [(serviceName: String, service: Service)] = project.compose.services.compactMap { name, service in + service.map { (name, $0) } + } + try Service.validateRequestedServices(requested, against: defined) + } +} diff --git a/Sources/Container-Compose/TableFormatter.swift b/Sources/Container-Compose/TableFormatter.swift new file mode 100644 index 0000000..a9fbf8a --- /dev/null +++ b/Sources/Container-Compose/TableFormatter.swift @@ -0,0 +1,47 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +// +// TableFormatter.swift +// Container-Compose +// +// Shared presentation helper for subcommands that render aligned tables. +// + +/// Minimal left-aligned, space-padded table renderer. +enum TableFormatter { + static func render(header: [String], rows: [[String]]) -> String { + let allRows = [header] + rows + let columnCount = header.count + var widths = [Int](repeating: 0, count: columnCount) + for row in allRows { + for (index, cell) in row.enumerated() where index < columnCount { + widths[index] = max(widths[index], cell.count) + } + } + + func format(_ row: [String]) -> String { + row.enumerated() + .map { index, cell in + // Don't pad the trailing column — avoids dangling whitespace. + index == columnCount - 1 ? cell : cell.padding(toLength: widths[index] + 3, withPad: " ", startingAt: 0) + } + .joined() + } + + return allRows.map(format).joined(separator: "\n") + } +} diff --git a/Tests/Container-Compose-StaticTests/ComposePsTests.swift b/Tests/Container-Compose-StaticTests/ComposePsTests.swift new file mode 100644 index 0000000..edda703 --- /dev/null +++ b/Tests/Container-Compose-StaticTests/ComposePsTests.swift @@ -0,0 +1,46 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Testing +@testable import ContainerComposeCore + +@Suite("Compose ps Tests") +struct ComposePsTests { + + // MARK: - TableFormatter + + @Test("Table columns align and the trailing column is unpadded") + func tableFormatting() { + let out = TableFormatter.render( + header: ["NAME", "STATUS"], + rows: [["web", "running"], ["database", "stopped"]]) + let lines = out.split(separator: "\n").map(String.init) + #expect(lines == [ + "NAME STATUS", + "web running", + "database stopped", + ]) + } + + // MARK: - CLI parsing + + @Test("ps accepts -a and service arguments alongside the project option group") + func psParsesArguments() throws { + let cmd = try ComposePs.parse(["-a", "web"]) + #expect(cmd.all) + #expect(cmd.services == ["web"]) + } +} From 893a178acf0334e55396bc0fcee714c556233f67 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 12 Sep 2026 16:45:54 -0400 Subject: [PATCH 2/2] fix(ps): limit the name fallback to unlabeled containers A container labeled for a different project could be included when its name collided with one of our candidate names (e.g. project demo + service web-x produce the candidate demo-web-x, which is also the name project demo-web gives its service x). Labels are now authoritative whenever present; the candidate-name fallback applies only to containers without compose labels (created before #126 stamped them). Extracts the selection into ComposePs.select, generic over the container representation, with unit coverage for project filtering, service filtering, the unlabeled fallback, and stopped-container visibility with and without --all. --- .../Commands/ComposePs.swift | 64 ++++++++++---- .../ComposePsTests.swift | 85 +++++++++++++++++++ 2 files changed, 134 insertions(+), 15 deletions(-) diff --git a/Sources/Container-Compose/Commands/ComposePs.swift b/Sources/Container-Compose/Commands/ComposePs.swift index 9fac376..e1ee039 100644 --- a/Sources/Container-Compose/Commands/ComposePs.swift +++ b/Sources/Container-Compose/Commands/ComposePs.swift @@ -54,21 +54,13 @@ public struct ComposePs: AsyncParsableCommand { let client = ContainerClient() let snapshots = try await client.list() - // A service's container is matched primarily by the compose labels - // `up` stamps (reliable regardless of naming mode), falling back to - // the candidate names for containers created before the labels - // existed. Dependency order from the resolved project is preserved; - // containers that don't exist yet simply don't appear. - let ordered = resolved.services.flatMap { target in - snapshots.filter { snapshot in - let labels = snapshot.configuration.labels - if labels["com.docker.compose.project"] == resolved.projectName { - return labels["com.docker.compose.service"] == target.serviceName - } - return target.candidateContainerNames.contains(snapshot.id) - } - } - let visible = all ? ordered : ordered.filter { $0.status == .running } + let visible = Self.select( + snapshots, + for: resolved, + all: all, + id: { $0.id }, + labels: { $0.configuration.labels }, + isRunning: { $0.status == .running }) let header = ["NAME", "IMAGE", "STATUS", "PORTS"] let rows = visible.map { snapshot in @@ -98,4 +90,46 @@ extension ComposePs { } try Service.validateRequestedServices(requested, against: defined) } + + /// Whether a container belongs to `target` within `projectName`. + /// + /// Labeled containers (anything created since the compose labels were + /// introduced in #126) are matched solely by their labels — a container + /// labeled for a different project is never included, even when its name + /// collides with one of our candidate names (e.g. our project `demo` + + /// service `web-x` produce the candidate `demo-web-x`, which is also the + /// name project `demo-web` gives its service `x`). The candidate-name + /// fallback applies only to unlabeled containers from older runs. + static func matches( + containerID: String, + labels: [String: String], + projectName: String, + target: ComposeProject.ServiceTarget + ) -> Bool { + if let labeledProject = labels["com.docker.compose.project"] { + return labeledProject == projectName + && labels["com.docker.compose.service"] == target.serviceName + } + return target.candidateContainerNames.contains(containerID) + } + + /// Selects and orders the containers `ps` shows: each resolved service's + /// matches (per `matches`), in the project's dependency order, filtered to + /// running containers unless `all` is set. Generic over the container + /// representation so the selection is unit-testable without daemon types. + static func select( + _ containers: [Container], + for project: ComposeProject, + all: Bool, + id: (Container) -> String, + labels: (Container) -> [String: String], + isRunning: (Container) -> Bool + ) -> [Container] { + let ordered = project.services.flatMap { target in + containers.filter { + matches(containerID: id($0), labels: labels($0), projectName: project.projectName, target: target) + } + } + return all ? ordered : ordered.filter(isRunning) + } } diff --git a/Tests/Container-Compose-StaticTests/ComposePsTests.swift b/Tests/Container-Compose-StaticTests/ComposePsTests.swift index edda703..6a1f103 100644 --- a/Tests/Container-Compose-StaticTests/ComposePsTests.swift +++ b/Tests/Container-Compose-StaticTests/ComposePsTests.swift @@ -15,11 +15,96 @@ //===----------------------------------------------------------------------===// import Testing +import Yams @testable import ContainerComposeCore @Suite("Compose ps Tests") struct ComposePsTests { + // MARK: - Container selection + + /// Minimal stand-in for a daemon container snapshot. + private struct FakeContainer: Equatable { + let id: String + let labels: [String: String] + var running = true + } + + /// A resolved two-service project (`web` depends on nothing, order web, db) + /// named `demo`, with the standard candidate names. + private func demoProject() throws -> ComposeProject { + let compose = try YAMLDecoder().decode( + DockerCompose.self, + from: """ + name: demo + services: + web-x: + image: nginx + db: + image: postgres + """) + let targets = [ + ComposeProject.ServiceTarget( + serviceName: "web-x", service: Service(image: "nginx"), + candidateContainerNames: ["demo-web-x", "web-x.demo"]), + ComposeProject.ServiceTarget( + serviceName: "db", service: Service(image: "postgres"), + candidateContainerNames: ["demo-db", "db.demo"]), + ] + return ComposeProject(compose: compose, projectName: "demo", services: targets) + } + + private func selectIDs(_ containers: [FakeContainer], in project: ComposeProject, all: Bool = true) -> [String] { + ComposePs.select( + containers, for: project, all: all, + id: \.id, labels: \.labels, isRunning: \.running + ).map(\.id) + } + + @Test("A container labeled for another project is excluded even when its name collides with a candidate") + func foreignProjectLabelBeatsNameCollision() throws { + // `demo-web-x` is our candidate name for service `web-x` — but this + // container's labels say it belongs to project `demo-web`, service `x`. + let foreign = FakeContainer( + id: "demo-web-x", + labels: ["com.docker.compose.project": "demo-web", "com.docker.compose.service": "x"]) + #expect(try selectIDs([foreign], in: demoProject()) == []) + } + + @Test("Labeled containers filter by project and service labels, not by name") + func labelFiltering() throws { + let ours = FakeContainer( + id: "some-unrelated-name", + labels: ["com.docker.compose.project": "demo", "com.docker.compose.service": "web-x"]) + let wrongService = FakeContainer( + id: "demo-db", + labels: ["com.docker.compose.project": "demo", "com.docker.compose.service": "cache"]) + let otherProject = FakeContainer( + id: "elsewhere", + labels: ["com.docker.compose.project": "other", "com.docker.compose.service": "web-x"]) + #expect(try selectIDs([ours, wrongService, otherProject], in: demoProject()) == ["some-unrelated-name"]) + } + + @Test("Unlabeled containers fall back to candidate-name matching") + func unlabeledNameFallback() throws { + let legacyDashed = FakeContainer(id: "demo-web-x", labels: [:]) + let legacyDotted = FakeContainer(id: "db.demo", labels: [:]) + let unrelated = FakeContainer(id: "somethingelse", labels: [:]) + // Ordered by the project's service order (web-x before db). + #expect(try selectIDs([legacyDotted, unrelated, legacyDashed], in: demoProject()) == ["demo-web-x", "db.demo"]) + } + + @Test("Stopped containers are hidden by default and shown with --all") + func stoppedContainerVisibility() throws { + let running = FakeContainer( + id: "a", labels: ["com.docker.compose.project": "demo", "com.docker.compose.service": "web-x"]) + let stopped = FakeContainer( + id: "b", labels: ["com.docker.compose.project": "demo", "com.docker.compose.service": "db"], + running: false) + #expect(try selectIDs([running, stopped], in: demoProject(), all: false) == ["a"]) + #expect(try selectIDs([running, stopped], in: demoProject(), all: true) == ["a", "b"]) + } + // MARK: - TableFormatter @Test("Table columns align and the trailing column is unpadded")