diff --git a/Sources/Container-Compose/Codable Structs/Service.swift b/Sources/Container-Compose/Codable Structs/Service.swift index 8e84cb09..21392d86 100644 --- a/Sources/Container-Compose/Codable Structs/Service.swift +++ b/Sources/Container-Compose/Codable Structs/Service.swift @@ -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]? @@ -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 @@ -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, @@ -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 @@ -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) diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 97a16f96..f3a3359b 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -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 `-` 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") diff --git a/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift b/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift index 2d5b9877..6de494b5 100644 --- a/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift +++ b/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift @@ -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 } diff --git a/Tests/TestHelpers/DockerComposeYamlFiles.swift b/Tests/TestHelpers/DockerComposeYamlFiles.swift index f5e28bc7..8ca96e05 100644 --- a/Tests/TestHelpers/DockerComposeYamlFiles.swift +++ b/Tests/TestHelpers/DockerComposeYamlFiles.swift @@ -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.