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
31 changes: 29 additions & 2 deletions Sources/Mocker/Commands/Compose.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ enum ComposeFormatter {
case .volumeCreated(let name): ("Volume \(name)", "Created")
case .containerCreated(let name): ("Container \(name)", "Created")
case .containerStarted(let name): ("Container \(name)", "Started")
case .containerRunning(let name): ("Container \(name)", "Running")
case .containerStopped(let name): ("Container \(name)", "Stopped")
case .containerRemoved(let name): ("Container \(name)", "Removed")
case .networkRemoved(let name): ("Network \(name)", "Removed")
Expand Down Expand Up @@ -219,6 +220,12 @@ struct ComposeUp: AsyncParsableCommand {
@Argument(help: "Services to start (starts all if omitted)")
var services: [String] = []

mutating func validate() throws {
if forceRecreate && noRecreate {
throw ValidationError("--force-recreate and --no-recreate are mutually exclusive")
}
}

func run() async throws {
var (composeFile, project, projectDir) = try options.loadCompose()
let config = MockerConfig()
Expand All @@ -244,7 +251,14 @@ struct ComposeUp: AsyncParsableCommand {
)

let totalResources = composeFile.networks.count + composeFile.volumes.count + composeFile.services.count
let events = try await orchestrator.up(composeFile: composeFile, detach: detach, build: build, noBuild: noBuild)
let events = try await orchestrator.up(
composeFile: composeFile,
detach: detach,
build: build,
noBuild: noBuild,
forceRecreate: forceRecreate,
noRecreate: noRecreate
)
ComposeFormatter.printEvents(events, total: totalResources)
}
}
Expand Down Expand Up @@ -1253,6 +1267,12 @@ struct ComposeCreate: AsyncParsableCommand {
@Argument(help: "Services to create (creates all if omitted)")
var services: [String] = []

mutating func validate() throws {
if forceRecreate && noRecreate {
throw ValidationError("--force-recreate and --no-recreate are mutually exclusive")
}
}

func run() async throws {
// create is essentially up without starting — for now delegate to up
var (composeFile, project, projectDir) = try options.loadCompose()
Expand All @@ -1277,7 +1297,14 @@ struct ComposeCreate: AsyncParsableCommand {
volumeManager: volumeManager
)

let events = try await orchestrator.up(composeFile: composeFile, detach: true, build: build, noBuild: noBuild)
let events = try await orchestrator.up(
composeFile: composeFile,
detach: true,
build: build,
noBuild: noBuild,
forceRecreate: forceRecreate,
noRecreate: noRecreate
)
ComposeFormatter.printEvents(events, total: events.count)
}
}
Expand Down
53 changes: 53 additions & 0 deletions Sources/MockerKit/Compose/ComposeFile.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import Yams
import CryptoKit

/// Represents a parsed docker-compose.yml file.
public struct ComposeFile: Sendable {
Expand Down Expand Up @@ -263,6 +264,28 @@ public struct ComposeService: Sendable {
public var restartPolicyMaxAttempts: Int?
public var restartPolicyWindow: String?

private enum HashCodingKeys: String, CodingKey {
case name, image, command, environment, ports, volumes, networks
case restart, labels, hostname, workingDir
case memLimit, cpus, memReservation, cpusReservation, memSwapLimit
case shmSize, pidsLimit
case restartPolicyDelay, restartPolicyMaxAttempts, restartPolicyWindow
}

/// Compute a stable `sha256:<hex>` hash of the normalized service config.
///
/// `sortedKeys` is mandatory: without it, two services with identical content but
/// different source-field order would hash differently. Mirrors Docker's
/// `ServiceHash` algorithm at `pkg/compose/hash.go:27-43`.
public static func hash(of service: ComposeService) -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let data = (try? encoder.encode(service)) ?? Data()
let digest = SHA256.hash(data: data)
let hex = digest.map { String(format: "%02x", $0) }.joined()
return "sha256:" + hex
}

public static func parse(name: String, from dict: [String: Any]) throws -> ComposeService {
let environment = parseEnvironment(dict["environment"])
let ports = (dict["ports"] as? [Any])?.compactMap { "\($0)" } ?? []
Expand Down Expand Up @@ -526,6 +549,36 @@ struct RestartPolicyConfig: Sendable {
var window: String?
}

extension ComposeService: Encodable {
/// Emits only the hash-relevant fields per `HashCodingKeys`, excluding
/// Docker-normalized fields (`Build`, `DependsOn`, etc.). Used by
/// `hash(of:)`; not intended as a general-purpose service serializer.
public func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: HashCodingKeys.self)
try c.encode(name, forKey: .name)
try c.encodeIfPresent(image, forKey: .image)
try c.encode(command, forKey: .command)
try c.encode(environment, forKey: .environment)
try c.encode(ports, forKey: .ports)
try c.encode(volumes, forKey: .volumes)
try c.encode(networks, forKey: .networks)
try c.encodeIfPresent(restart, forKey: .restart)
try c.encode(labels, forKey: .labels)
try c.encodeIfPresent(hostname, forKey: .hostname)
try c.encodeIfPresent(workingDir, forKey: .workingDir)
try c.encodeIfPresent(memLimit, forKey: .memLimit)
try c.encodeIfPresent(cpus, forKey: .cpus)
try c.encodeIfPresent(memReservation, forKey: .memReservation)
try c.encodeIfPresent(cpusReservation, forKey: .cpusReservation)
try c.encodeIfPresent(memSwapLimit, forKey: .memSwapLimit)
try c.encodeIfPresent(shmSize, forKey: .shmSize)
try c.encodeIfPresent(pidsLimit, forKey: .pidsLimit)
try c.encodeIfPresent(restartPolicyDelay, forKey: .restartPolicyDelay)
try c.encodeIfPresent(restartPolicyMaxAttempts, forKey: .restartPolicyMaxAttempts)
try c.encodeIfPresent(restartPolicyWindow, forKey: .restartPolicyWindow)
}
}

/// Build configuration for a compose service.
public struct ComposeBuild: Sendable, Equatable {
public var context: String
Expand Down
147 changes: 144 additions & 3 deletions Sources/MockerKit/Compose/ComposeOrchestrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ public enum ComposeEvent: Sendable {
case volumeCreated(String)
case containerCreated(String)
case containerStarted(String)
case containerRunning(String)
case containerStopped(String)
case containerRemoved(String)
case networkRemoved(String)
Expand Down Expand Up @@ -40,11 +41,16 @@ public actor ComposeOrchestrator {
/// - Parameters:
/// - build: force-build images even if they already exist (compose `--build`).
/// - noBuild: never build, pull `image:` instead (compose `--no-build`).
/// - forceRecreate: recreate every project container regardless of hash
/// (compose `--force-recreate`).
/// - noRecreate: never recreate existing project containers (compose `--no-recreate`).
public func up(
composeFile: ComposeFile,
detach: Bool = false,
build: Bool = false,
noBuild: Bool = false
noBuild: Bool = false,
forceRecreate: Bool = false,
noRecreate: Bool = false
) async throws -> [ComposeEvent] {
var events: [ComposeEvent] = []

Expand All @@ -64,11 +70,61 @@ public actor ComposeOrchestrator {
}
}

let prefix = "\(projectName)-"
let observed = (try? await engine.list(all: true))?
.filter { $0.name.hasPrefix(prefix) }
.map { container -> ObservedContainer in
ObservedContainer(
name: container.name,
serviceName: container.labels["com.mocker.compose.service"] ?? "",
configHash: container.labels["com.mocker.compose.config-hash"],
state: container.state
)
} ?? []
let actions = Self.reconcileDecision(
observedContainers: observed,
composeFile: composeFile,
projectName: projectName,
forceRecreate: forceRecreate,
noRecreate: noRecreate
)
var skipSet: Set<String> = []
for action in actions {
switch action.kind {
case .keep:
// Already running and config-hash matches: true no-op, no engine calls.
skipSet.insert(action.serviceName)
events.append(.containerRunning("\(projectName)-\(action.serviceName)-1"))
case .start:
// Config-hash matches but the container isn't running (stopped, exited,
// or crashed) — start the existing container instead of recreating it.
// This mirrors Docker Compose: a no-op reconcile still starts stopped
// containers, it just doesn't recreate them.
skipSet.insert(action.serviceName)
if let existing = observed.first(where: { $0.serviceName == action.serviceName }),
let started = try? await engine.start(existing.name) {
events.append(.containerRunning(started.name))
}
case .removeAndRecreate:
if let existing = observed.first(where: { $0.serviceName == action.serviceName }) {
if let live = (try? await engine.list(all: true))?.first(where: { $0.name == existing.name }),
live.state.isActive {
_ = try? await engine.stop(live.id)
events.append(.containerStopped(live.name))
}
_ = try? await engine.remove(existing.name, force: true)
events.append(.containerRemoved(existing.name))
}
case .noOp:
break
}
}

// Start services in dependency order
let order = composeFile.serviceOrder()
var startedContainers: [(serviceName: String, info: ContainerInfo)] = []

for serviceName in order {
for serviceName in order where !skipSet.contains(serviceName) {
guard let service = composeFile.services[serviceName] else { continue }
let info = try await startService(service, detach: detach, forceBuild: build, noBuild: noBuild)
let containerName = "\(projectName)-\(service.name)-1"
Expand Down Expand Up @@ -260,7 +316,11 @@ public actor ComposeOrchestrator {
network: service.networks.first.map { "\(projectName)-\($0)" },
detach: detach,
labels: service.labels.merging(
["com.mocker.compose.project": projectName, "com.mocker.compose.service": service.name]
[
"com.mocker.compose.project": projectName,
"com.mocker.compose.service": service.name,
"com.mocker.compose.config-hash": ComposeService.hash(of: service),
]
) { _, new in new },
workingDir: service.workingDir,
hostname: service.hostname,
Expand Down Expand Up @@ -306,3 +366,84 @@ public actor ComposeOrchestrator {
return volumes
}
}
extension ComposeOrchestrator {
/// Pure helper that returns one `ReconcileAction` per service in the project.
/// Mirrors Docker's `mustRecreate` at `pkg/compose/reconcile.go:517-543`.
public nonisolated static func reconcileDecision(
observedContainers: [ObservedContainer],
composeFile: ComposeFile,
projectName: String,
forceRecreate: Bool,
noRecreate: Bool
) -> [ReconcileAction] {
composeFile.serviceOrder().compactMap { serviceName -> ReconcileAction? in
guard let service = composeFile.services[serviceName] else { return nil }
let observed = observedContainers.first { $0.serviceName == serviceName }
let kind: ReconcileAction.Kind
switch (observed, forceRecreate, noRecreate) {
case (.none, _, _):
kind = .noOp
case (_, true, _):
kind = .removeAndRecreate
case (let .some(obs), _, true):
// --no-recreate never replaces an existing container, but a stopped one
// still needs to be started — Docker's reconcile is not a pure skip.
kind = obs.state == .running ? .keep : .start
case (let .some(obs), false, false):
let expectedHash = ComposeService.hash(of: service)
if obs.configHash != expectedHash {
kind = .removeAndRecreate
} else if let digest = obs.imageDigest, digest != service.image {
kind = .removeAndRecreate
} else {
kind = obs.state == .running ? .keep : .start
}
}
return ReconcileAction(serviceName: serviceName, kind: kind)
}
}
}

/// Snapshot of a project container as observed by `engine.list(all: true)`, scoped to
/// the per-service decision inputs (`configHash`, `imageDigest`, `serviceName`).
public struct ObservedContainer: Sendable, Equatable {
public let name: String
public let serviceName: String
public let configHash: String?
public let imageDigest: String?
public let state: ContainerState

public init(
name: String,
serviceName: String,
configHash: String? = nil,
imageDigest: String? = nil,
state: ContainerState = .running
) {
self.name = name
self.serviceName = serviceName
self.configHash = configHash
self.imageDigest = imageDigest
self.state = state
}
}

/// Per-service action emitted by `ComposeOrchestrator.reconcileDecision`.
public struct ReconcileAction: Sendable, Equatable {
public enum Kind: Sendable, Equatable {
/// Container is already running and its config hash matches: true no-op.
case keep
/// Config hash matches but the container isn't running: start it in place.
case start
case removeAndRecreate
case noOp
}

public let serviceName: String
public let kind: Kind

public init(serviceName: String, kind: Kind) {
self.serviceName = serviceName
self.kind = kind
}
}
Loading
Loading