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
72 changes: 71 additions & 1 deletion Sources/Container-Compose/Codable Structs/Service.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,21 @@ public struct Service: Codable, Hashable {
/// machine's IP as seen from inside the container.
public let extra_hosts: [String]?

/// Profile names that gate this service (per the Compose spec `profiles` key).
/// A service with no profiles (nil/empty) is always eligible. A service with
/// profiles is only eligible when at least one of them is active — unless the
/// service is named explicitly on the command line, or is a dependency of an
/// eligible service, both of which bypass the profile gate per the Compose spec.
public let profiles: [String]?

/// Other services that depend on this service
public var dependedBy: [String] = []

// 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, labels, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty, platform,
mem_limit, extra_hosts
mem_limit, extra_hosts, profiles
}

/// Public memberwise initializer for testing
Expand Down Expand Up @@ -160,6 +167,7 @@ public struct Service: Codable, Hashable {
tty: Bool? = nil,
mem_limit: String? = nil,
extra_hosts: [String]? = nil,
profiles: [String]? = nil,
dependedBy: [String] = []
) {
self.image = image
Expand Down Expand Up @@ -191,6 +199,7 @@ public struct Service: Codable, Hashable {
self.tty = tty
self.mem_limit = mem_limit
self.extra_hosts = extra_hosts
self.profiles = profiles
self.dependedBy = dependedBy
}

Expand Down Expand Up @@ -332,6 +341,21 @@ public struct Service: Codable, Hashable {
} else {
extra_hosts = nil
}

// `profiles` is a plain list of strings per the Compose spec (no shorthand
// single-string form).
profiles = try container.decodeIfPresent([String].self, forKey: .profiles)
}

/// True when this service should be included by default given the currently
/// active profiles. Per the Compose spec: a service with no `profiles` is
/// always eligible; a service with `profiles` is eligible only when at least
/// one of them is active. This gate is bypassed entirely for services named
/// explicitly on the command line and for dependencies of an eligible service
/// (see `selectServices(from:requestedServices:activeProfiles:)`).
public func isProfileEligible(activeProfiles: Set<String>) -> Bool {
guard let profiles, !profiles.isEmpty else { return true }
return !Set(profiles).isDisjoint(with: activeProfiles)
}

/// Translates the list-form of `environment:` into the same `[String: String]`
Expand Down Expand Up @@ -397,4 +421,50 @@ public struct Service: Codable, Hashable {

return sorted
}

/// Selects the services `up`, `build`, and `down` should act on by default,
/// applying both explicit service-name filtering and Compose `profiles` gating.
///
/// Per the Compose spec, `profiles` gating is bypassed in two cases:
/// - a service named explicitly in `requestedServices`
/// - a service reached only as a `depends_on` dependency of an eligible
/// service (its own `profiles` are ignored)
/// When `requestedServices` is empty, the seed set is every service that
/// is profile-eligible for `activeProfiles` (unprofiled, or one of its
/// profiles is active); dependencies of that seed are then pulled in
/// regardless of their own profile.
static func selectServices(
from services: [(serviceName: String, service: Service)],
requestedServices: [String],
activeProfiles: Set<String> = []
) -> [(serviceName: String, service: Service)] {
let servicesByName = Dictionary(uniqueKeysWithValues: services.map { ($0.serviceName, $0.service) })

let seedNames: [String]
if !requestedServices.isEmpty {
seedNames = requestedServices
} else {
seedNames = services
.filter { $0.service.isProfileEligible(activeProfiles: activeProfiles) }
.map(\.serviceName)
}

var selected = Set<String>()

func include(_ serviceName: String) {
guard let service = servicesByName[serviceName], selected.insert(serviceName).inserted else {
return
}

for dependency in service.depends_on ?? [] {
include(dependency)
}
}

for serviceName in seedNames {
include(serviceName)
}

return services.filter { selected.contains($0.serviceName) }
}
}
20 changes: 15 additions & 5 deletions Sources/Container-Compose/Commands/ComposeBuild.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,23 @@ public struct ComposeBuild: AsyncParsableCommand, @unchecked Sendable {
projectName = deriveProjectName(cwd: cwd)
}

var servicesToBuild: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap { name, service in
guard let service, service.build != nil else { return nil }
// Route both the explicit-service-name and default cases through the same
// selection `up`/`down` use: an explicit name (or the default profile-eligible
// set) pulls in its `depends_on` graph regardless of that dependency's own
// build/profile status. Without this, a dependency only reachable via
// `depends_on` — whether profile-gated or just not named explicitly — would
// be started by `up` but never get built here.
let allServices: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap { name, service in
guard let service else { return nil }
return (name, service)
}

if !services.isEmpty {
servicesToBuild = servicesToBuild.filter { services.contains($0.serviceName) }
let selectedNames = Set(
Service.selectServices(from: allServices, requestedServices: services, activeProfiles: composeFileOptions.activeProfiles)
.map(\.serviceName)
)
let servicesToBuild: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap { name, service in
guard let service, service.build != nil, selectedNames.contains(name) else { return nil }
return (name, service)
}

if servicesToBuild.isEmpty {
Expand Down
17 changes: 16 additions & 1 deletion Sources/Container-Compose/Commands/ComposeDown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,26 @@ public struct ComposeDown: AsyncParsableCommand {
})
services = try Service.topoSortConfiguredServices(services)

// Filter for specified services
// Filter for specified services and active Compose profiles
if !self.services.isEmpty {
services = services.filter({ serviceName, service in
self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) })
})
} else {
// Match `up`'s default selection: profile-eligible services plus their
// dependencies (which bypass the profile gate). Anything excluded here
// may still be running from a previous `up --profile ...` that isn't
// repeated on this `down` — warn instead of silently skipping it.
let selected = Service.selectServices(from: services, requestedServices: [], activeProfiles: composeFileOptions.activeProfiles)
let selectedNames = Set(selected.map(\.serviceName))
let skipped = services.map(\.serviceName).filter { !selectedNames.contains($0) }
if !skipped.isEmpty {
print(
"Note: not stopping '\(skipped.sorted().joined(separator: "', '"))' — gated by an inactive Compose profile. "
+ "Pass --profile <name> (or name the service explicitly) to also stop them."
)
}
services = selected
}

try await stopOldStuff(services, remove: false)
Expand Down
22 changes: 21 additions & 1 deletion Sources/Container-Compose/Commands/ComposeFileOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,30 @@
//===----------------------------------------------------------------------===//

import ArgumentParser
import Foundation

public struct ComposeFileOptions: ParsableArguments, Sendable {
public init() {}

@Option(name: [.customShort("f"), .customLong("file")], help: "The path to your Docker Compose file")
public var composeFilename: String?

@Option(
name: .long,
help: "Specify a profile to enable. Can be repeated. Services without a 'profiles' key are always enabled; profiled services are enabled only when one of their profiles is active."
)
public var profile: [String] = []

/// Active profiles from repeated `--profile` flags merged with the
/// comma-separated `COMPOSE_PROFILES` environment variable (the Compose
/// spec's documented equivalent, e.g. `COMPOSE_PROFILES=debug,frontend`).
public var activeProfiles: Set<String> {
var result = Set(profile)
if let envProfiles = ProcessInfo.processInfo.environment["COMPOSE_PROFILES"] {
result.formUnion(
envProfiles.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
)
}
return result
}
}
36 changes: 6 additions & 30 deletions Sources/Container-Compose/Commands/ComposeUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,12 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
})
services = try Service.topoSortConfiguredServices(services)

// Filter for specified services
services = Self.servicesSelectedForUp(services, requestedServices: self.services)
// Filter for specified services and active Compose profiles
services = Service.selectServices(
from: services,
requestedServices: self.services,
activeProfiles: composeFileOptions.activeProfiles
)

// Stop Services. Pass every name a previous run might have used (legacy
// dashed, dotted DNS-mode, and explicit container_name) so the cleanup
Expand Down Expand Up @@ -359,34 +363,6 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
])) != nil
}

static func servicesSelectedForUp(
_ services: [(serviceName: String, service: Service)],
requestedServices: [String]
) -> [(serviceName: String, service: Service)] {
guard !requestedServices.isEmpty else {
return services
}

let servicesByName = Dictionary(uniqueKeysWithValues: services.map { ($0.serviceName, $0.service) })
var selected = Set<String>()

func include(_ serviceName: String) {
guard let service = servicesByName[serviceName], selected.insert(serviceName).inserted else {
return
}

for dependency in service.depends_on ?? [] {
include(dependency)
}
}

for serviceName in requestedServices {
include(serviceName)
}

return services.filter { selected.contains($0.serviceName) }
}

static func validateStoppedServiceExitCode(_ exitCode: Int32, serviceName: String) throws {
guard exitCode == 0 else {
throw ComposeError.containerRunFailed(serviceName, exitCode)
Expand Down
33 changes: 33 additions & 0 deletions Tests/Container-Compose-DynamicTests/ComposeBuildTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,39 @@ struct ComposeBuildTests {
#expect(try await !imageExists(named: "excluded:latest"))
}

@Test("Build with explicit service filter also builds its depends_on dependency")
func buildWithServiceFilterAlsoBuildsDependency() async throws {
let yaml = """
services:
app:
depends_on:
- worker
build:
context: .
dockerfile: Dockerfile
worker:
build:
context: .
dockerfile: Dockerfile
unrelated:
build:
context: .
dockerfile: Dockerfile
"""

let project = try writeBuildProject(yaml: yaml)

var composeBuild = try ComposeBuild.parse([
"--cwd", project.base.path(percentEncoded: false),
"app",
])
try await composeBuild.run()

#expect(try await imageExists(named: "app:latest"))
#expect(try await imageExists(named: "worker:latest"))
#expect(try await !imageExists(named: "unrelated:latest"))
}

@Test("Build passes build args to Dockerfile")
func buildPassesBuildArgsToDockerfile() async throws {
let dockerfile = """
Expand Down
20 changes: 15 additions & 5 deletions Tests/Container-Compose-DynamicTests/ComposeUpTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ struct ComposeUpTests {
#expect(wpEnv["WORDPRESS_DB_NAME"] == "wordpress")

// Check Volume
#expect(wordpressContainer.configuration.mounts.map(\.destination) == ["/var/www/"])
#expect(wordpressContainer.configuration.mounts.map(\.destination) == ["/var/www/html"])

// Assert correct db container information

Expand All @@ -85,7 +85,7 @@ struct ComposeUpTests {
#expect(dbEnv["MYSQL_PASSWORD"] == "wordpress")

// Check Volume
#expect(dbContainer.configuration.mounts.map(\.destination) == ["/var/lib/"])
#expect(dbContainer.configuration.mounts.map(\.destination) == ["/var/lib/mysql"])
print("")

try? await stopInstance(location: tempLocation.deletingLastPathComponent())
Expand Down Expand Up @@ -426,9 +426,19 @@ struct ComposeUpTests {

struct ContainerDependentTrait: TestScoping, TestTrait, SuiteTrait {
func provideScope(for test: Test, testCase: Test.Case?, performing function: () async throws -> Void) async throws {
// Start Server
try await Application.SystemStart.parse(["--enable-kernel-install"]).run(); #warning("This is crashing")

// `Application.SystemStart.run()` resolves the `container-apiserver` binary
// relative to `CommandLine.executablePath` — correct when invoked as the real
// `container` CLI, but when called in-process from this test binary that
// path resolves to the test runner itself, which has no `container-apiserver`
// sibling ("No such file or directory"). Shell out to the real installed
// `container` binary instead, the same way the rest of this codebase talks
// to `container`/`container-compose`.
let process = Process()
process.launchPath = "/usr/bin/env"
process.arguments = ["container", "system", "start", "--enable-kernel-install"]
try process.run()
process.waitUntilExit()

// Run Test
try await function()
}
Expand Down
Loading
Loading