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..e1ee039 --- /dev/null +++ b/Sources/Container-Compose/Commands/ComposePs.swift @@ -0,0 +1,135 @@ +//===----------------------------------------------------------------------===// +// 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() + + 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 + [ + 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) + } + + /// 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/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..6a1f103 --- /dev/null +++ b/Tests/Container-Compose-StaticTests/ComposePsTests.swift @@ -0,0 +1,131 @@ +//===----------------------------------------------------------------------===// +// 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 +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") + 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"]) + } +}