Skip to content
Open
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
1 change: 1 addition & 0 deletions Sources/Container-Compose/Application.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public struct Main: AsyncParsableCommand {
ComposeUp.self,
ComposeDown.self,
ComposeBuild.self,
ComposePs.self,
Version.self
])

Expand Down
135 changes: 135 additions & 0 deletions Sources/Container-Compose/Commands/ComposePs.swift
Original file line number Diff line number Diff line change
@@ -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 <svc>` ("no such service: <svc>") — 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<Container>(
_ 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)
}
}
47 changes: 47 additions & 0 deletions Sources/Container-Compose/TableFormatter.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
131 changes: 131 additions & 0 deletions Tests/Container-Compose-StaticTests/ComposePsTests.swift
Original file line number Diff line number Diff line change
@@ -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"])
}
}
Loading