Skip to content
Merged
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
11 changes: 10 additions & 1 deletion Sources/Container-Compose/Codable Structs/Service.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ public struct Service: Codable, Hashable {
/// Explicit name for the container instance
public let container_name: String?

/// User-defined labels applied to the container (e.g. `{ "foo": "bar" }`).
/// Passed through as `--label key=value`; the `com.docker.compose.project` and
/// `com.docker.compose.service` labels are additionally stamped by `ComposeUp`
/// and take precedence over any user value for those keys.
public let labels: [String: String]?

/// List of networks the service will connect to
public let networks: [String]?

Expand Down Expand Up @@ -104,7 +110,7 @@ public struct Service: Codable, Hashable {
// Defines custom coding keys to map YAML keys to Swift properties
enum CodingKeys: String, CodingKey {
case image, build, deploy, restart, healthcheck, volumes, environment, env_file, ports, command, depends_on, user,
container_name, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty, platform
container_name, labels, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty, platform
}

/// Public memberwise initializer for testing
Expand All @@ -122,6 +128,7 @@ public struct Service: Codable, Hashable {
depends_on: [String]? = nil,
user: String? = nil,
container_name: String? = nil,
labels: [String: String]? = nil,
networks: [String]? = nil,
hostname: String? = nil,
entrypoint: [String]? = nil,
Expand All @@ -148,6 +155,7 @@ public struct Service: Codable, Hashable {
self.depends_on = depends_on
self.user = user
self.container_name = container_name
self.labels = labels
self.networks = networks
self.hostname = hostname
self.entrypoint = entrypoint
Expand Down Expand Up @@ -218,6 +226,7 @@ public struct Service: Codable, Hashable {
user = try container.decodeIfPresent(String.self, forKey: .user)

container_name = try container.decodeIfPresent(String.self, forKey: .container_name)
labels = try container.decodeIfPresent([String: String].self, forKey: .labels)
networks = try container.decodeIfPresent([String].self, forKey: .networks)
hostname = try container.decodeIfPresent(String.self, forKey: .hostname)

Expand Down
13 changes: 13 additions & 0 deletions Sources/Container-Compose/Commands/ComposeUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,19 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
runCommandArgs.append("--name")
runCommandArgs.append(containerName)

// Apply any user-defined labels, then stamp Docker-Compose-compatible project/service
// labels so external tools (GUIs, dashboards) can group a stack's containers reliably
// by label instead of guessing from the `<project>-<service>` name prefix (which
// mis-groups unrelated containers that merely share a prefix). The compose labels are
// set last so they take precedence over a user value for the same key; keys are sorted
// for a deterministic `container run` argv.
var labels = service.labels ?? [:]
labels["com.docker.compose.project"] = projectName
labels["com.docker.compose.service"] = serviceName
for key in labels.keys.sorted() {
runCommandArgs.append(contentsOf: ["--label", "\(key)=\(labels[key] ?? "")"])
}

// REMOVED: Restart policy is not supported by `container run`
// if let restart = service.restart {
// runCommandArgs.append("--restart")
Expand Down
37 changes: 37 additions & 0 deletions Tests/Container-Compose-DynamicTests/ComposeUpTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,43 @@ struct ComposeUpTests {
try? await stopInstance(location: upProject.base)
}

@Test("up stamps compose project/service labels and passes user labels through")
func testUpStampsComposeLabels() async throws {
let explicitName = "container-compose-test-\(makeContainerName())"
let yaml = DockerComposeYamlFiles.dockerComposeYaml10(containerName: explicitName)
let project = try DockerComposeYamlFiles.copyYamlToTemporaryLocation(yaml: yaml)

var composeUp = try ComposeUp.parse([
"-d", "--cwd", project.base.path(percentEncoded: false),
])
try await composeUp.run()

let container = try await ContainerClient().get(id: explicitName)
let labels = container.configuration.labels

// Compose labels are stamped with the resolved project/service name.
#expect(
labels["com.docker.compose.project"] == project.name,
"Expected com.docker.compose.project='\(project.name)', got '\(labels["com.docker.compose.project"] ?? "nil")'"
)
#expect(
labels["com.docker.compose.service"] == "web",
"Expected com.docker.compose.service='web', got '\(labels["com.docker.compose.service"] ?? "nil")'"
)
// User-defined labels pass through to the container.
#expect(
labels["app"] == "container-compose-tests",
"Expected user label app='container-compose-tests', got '\(labels["app"] ?? "nil")'"
)
// The stamped compose label wins over a user value for the same key.
#expect(
labels["com.docker.compose.service"] != "should-be-overridden",
"com.docker.compose.service should be overridden by the stamped value"
)

try? await stopInstance(location: project.base)
}

enum Errors: Error {
case containerNotFound
}
Expand Down
16 changes: 16 additions & 0 deletions Tests/TestHelpers/DockerComposeYamlFiles.swift
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,22 @@ public struct DockerComposeYamlFiles {
"""
}

/// A single service with an explicit `container_name` plus user-defined `labels`,
/// one of which (`com.docker.compose.service`) collides with a stamped label so the
/// precedence behaviour can be exercised.
public static func dockerComposeYaml10(containerName: String) -> String {
return """
version: '3.8'
services:
web:
image: nginx:alpine
container_name: \(containerName)
labels:
app: container-compose-tests
com.docker.compose.service: should-be-overridden
"""
}

/// Represents a temporary Docker Compose project copied to a temporary location for testing.
public struct TemporaryProject {
/// The URL of the temporary docker-compose.yaml file.
Expand Down
Loading