From c52c1d1dd17d88dc00c56437c5922be58298228f Mon Sep 17 00:00:00 2001 From: Jahir Vidrio Date: Fri, 3 Jul 2026 23:02:40 -0600 Subject: [PATCH 1/2] fix(compose): reconcile up -d by service config hash --- Sources/Mocker/Commands/Compose.swift | 31 ++++- Sources/MockerKit/Compose/ComposeFile.swift | 53 +++++++ .../Compose/ComposeOrchestrator.swift | 130 ++++++++++++++++- Tests/MockerKitTests/ComposeFileTests.swift | 117 ++++++++++++++++ .../ComposeOrchestratorTests.swift | 131 ++++++++++++++++++ Tests/MockerTests/CLITests.swift | 52 +++++++ 6 files changed, 509 insertions(+), 5 deletions(-) diff --git a/Sources/Mocker/Commands/Compose.swift b/Sources/Mocker/Commands/Compose.swift index 6368d3a..f95a228 100644 --- a/Sources/Mocker/Commands/Compose.swift +++ b/Sources/Mocker/Commands/Compose.swift @@ -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") @@ -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() @@ -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) } } @@ -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() @@ -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) } } diff --git a/Sources/MockerKit/Compose/ComposeFile.swift b/Sources/MockerKit/Compose/ComposeFile.swift index 3e42daa..2a11126 100644 --- a/Sources/MockerKit/Compose/ComposeFile.swift +++ b/Sources/MockerKit/Compose/ComposeFile.swift @@ -1,5 +1,6 @@ import Foundation import Yams +import CryptoKit /// Represents a parsed docker-compose.yml file. public struct ComposeFile: Sendable { @@ -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:` 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)" } ?? [] @@ -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 diff --git a/Sources/MockerKit/Compose/ComposeOrchestrator.swift b/Sources/MockerKit/Compose/ComposeOrchestrator.swift index 698c3ce..278c9a0 100644 --- a/Sources/MockerKit/Compose/ComposeOrchestrator.swift +++ b/Sources/MockerKit/Compose/ComposeOrchestrator.swift @@ -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) @@ -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] = [] @@ -64,11 +70,49 @@ 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"] + ) + } ?? [] + let actions = Self.reconcileDecision( + observedContainers: observed, + composeFile: composeFile, + projectName: projectName, + forceRecreate: forceRecreate, + noRecreate: noRecreate + ) + var skipSet: Set = [] + for action in actions { + switch action.kind { + case .keep: + skipSet.insert(action.serviceName) + events.append(.containerRunning("\(projectName)-\(action.serviceName)-1")) + 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" @@ -260,7 +304,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, @@ -306,3 +354,79 @@ 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 (_, _, true): + kind = .keep + 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 = .keep + } + } + 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 { + case keep + case removeAndRecreate + case noOp + } + + public let serviceName: String + public let kind: Kind + + public init(serviceName: String, kind: Kind) { + self.serviceName = serviceName + self.kind = kind + } +} diff --git a/Tests/MockerKitTests/ComposeFileTests.swift b/Tests/MockerKitTests/ComposeFileTests.swift index 2f8f7f0..d0a5e6c 100644 --- a/Tests/MockerKitTests/ComposeFileTests.swift +++ b/Tests/MockerKitTests/ComposeFileTests.swift @@ -1,5 +1,6 @@ import Testing import Foundation +import CryptoKit @testable import MockerKit @Suite("ComposeFile Tests") @@ -949,4 +950,120 @@ struct ComposeFileTests { Issue.record("unexpected error type: \(error)") } } + + // MARK: - Config hash (issue #59) + + @Test("ComposeService.hash returns sha256:<64 hex chars> for a fixed spec") + func hashReturnsSha256Literal() throws { + let svc = try ComposeFile.parse(""" + services: + app: + image: nginx:alpine + environment: + FOO: bar + """).services["app"]! + + let hash = ComposeService.hash(of: svc) + + #expect(hash.hasPrefix("sha256:")) + #expect(hash.count == "sha256:".count + 64) + let hex = String(hash.dropFirst("sha256:".count)) + #expect(hex.allSatisfy { $0.isHexDigit && !$0.isUppercase }) + } + + @Test("ComposeService.hash is stable across equivalent service specs (different field order)") + func hashIsDeterministicAcrossFieldOrder() throws { + let first = try ComposeFile.parse(""" + services: + app: + image: nginx:alpine + environment: + FOO: bar + BAZ: qux + ports: + - "8080:80" + """).services["app"]! + let second = try ComposeFile.parse(""" + services: + app: + ports: + - "8080:80" + environment: + BAZ: qux + FOO: bar + image: nginx:alpine + """).services["app"]! + + #expect(ComposeService.hash(of: first) == ComposeService.hash(of: second)) + } + + @Test("ComposeService.hash ignores Docker-normalized fields (build, dependsOn)") + func hashIgnoresNormalizedFields() throws { + let withoutBuild = try ComposeFile.parse(""" + services: + app: + image: nginx:alpine + """).services["app"]! + let withBuild = try ComposeFile.parse(""" + services: + app: + image: nginx:alpine + build: + context: . + dockerfile: Dockerfile.dev + depends_on: + - db + """).services["app"]! + + #expect(ComposeService.hash(of: withoutBuild) == ComposeService.hash(of: withBuild)) + } + + @Test("ComposeService.encode emits only hash-relevant fields (no build/dependsOn)") + func encodeProducesOnlyHashRelevantFields() throws { + let svc = try ComposeFile.parse(""" + services: + app: + image: nginx:alpine + build: + context: . + depends_on: + - db + """).services["app"]! + + let data = try JSONEncoder().encode(svc) + let json = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + + #expect(json["build"] == nil) + #expect(json["depends_on"] == nil) + #expect(json["name"] as? String == "app") + #expect(json["image"] as? String == "nginx:alpine") + } + + @Test("ComposeService.hash(of:) equals sha256 of JSONEncoder.encode(service) with sortedKeys") + func hashEqualsSHA256OfEncodedJSON() throws { + let svc = try ComposeFile.parse(""" + services: + app: + image: nginx:alpine + environment: + FOO: bar + """).services["app"]! + + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let expectedHex = SHA256.hash(data: try encoder.encode(svc)) + .map { String(format: "%02x", $0) }.joined() + + #expect(ComposeService.hash(of: svc) == "sha256:" + expectedHex) + } + + @Test("ComposeService.hash is deterministic for the same input") + func hashDeterministicForSameInput() throws { + let svc = try ComposeFile.parse("services:\n app:\n image: nginx:alpine\n") + .services["app"]! + let hash = ComposeService.hash(of: svc) + #expect(hash.hasPrefix("sha256:")) + #expect(hash.count == "sha256:".count + 64) + #expect(ComposeService.hash(of: svc) == hash) + } } diff --git a/Tests/MockerKitTests/ComposeOrchestratorTests.swift b/Tests/MockerKitTests/ComposeOrchestratorTests.swift index 14d0a53..8f7271d 100644 --- a/Tests/MockerKitTests/ComposeOrchestratorTests.swift +++ b/Tests/MockerKitTests/ComposeOrchestratorTests.swift @@ -228,4 +228,135 @@ struct ComposeOrchestratorTests { #expect(mounts[0].source.contains("data")) #expect(mounts[0].destination == "/container/data") } + + // MARK: - reconcileDecision (issue #59) + + private func singleServiceFile() throws -> ComposeFile { + try ComposeFile.parse(""" + services: + app: + image: nginx:alpine + """) + } + + private func observed(name: String, serviceName: String, configHash: String?) -> ObservedContainer { + ObservedContainer(name: name, serviceName: serviceName, configHash: configHash) + } + + @Test("reconcileDecision: observed container matches hash + defaults → .keep") + func reconcileObservedMatchesDefaults() throws { + let file = try singleServiceFile() + let svc = file.services["app"]! + let hash = ComposeService.hash(of: svc) + let actions = ComposeOrchestrator.reconcileDecision( + observedContainers: [ + observed(name: "proj-app-1", serviceName: "app", configHash: hash) + ], + composeFile: file, + projectName: "proj", + forceRecreate: false, + noRecreate: false + ) + #expect(actions == [ReconcileAction(serviceName: "app", kind: .keep)]) + } + + @Test("reconcileDecision: observed container matches hash + --force-recreate → .removeAndRecreate") + func reconcileForceRecreateOverridesKeep() throws { + let file = try singleServiceFile() + let svc = file.services["app"]! + let hash = ComposeService.hash(of: svc) + let actions = ComposeOrchestrator.reconcileDecision( + observedContainers: [ + observed(name: "proj-app-1", serviceName: "app", configHash: hash) + ], + composeFile: file, + projectName: "proj", + forceRecreate: true, + noRecreate: false + ) + #expect(actions == [ReconcileAction(serviceName: "app", kind: .removeAndRecreate)]) + } + + @Test("reconcileDecision: observed container matches hash + --no-recreate → .keep") + func reconcileNoRecreateEvenWithDrift() throws { + let file = try singleServiceFile() + let actions = ComposeOrchestrator.reconcileDecision( + observedContainers: [ + observed(name: "proj-app-1", serviceName: "app", configHash: "stale-different-value") + ], + composeFile: file, + projectName: "proj", + forceRecreate: false, + noRecreate: true + ) + #expect(actions == [ReconcileAction(serviceName: "app", kind: .keep)]) + } + + @Test("reconcileDecision: observed container has diverged hash + defaults → .removeAndRecreate") + func reconcileHashDriftDefaults() throws { + let file = try singleServiceFile() + let actions = ComposeOrchestrator.reconcileDecision( + observedContainers: [ + observed(name: "proj-app-1", serviceName: "app", configHash: "sha256:different") + ], + composeFile: file, + projectName: "proj", + forceRecreate: false, + noRecreate: false + ) + #expect(actions == [ReconcileAction(serviceName: "app", kind: .removeAndRecreate)]) + } + + @Test("reconcileDecision: multiple services + --force-recreate → all .removeAndRecreate") + func reconcileMultipleServicesForceRecreate() throws { + let file = try ComposeFile.parse(""" + services: + web: + image: nginx:alpine + db: + image: redis:7 + """) + let actions = ComposeOrchestrator.reconcileDecision( + observedContainers: [ + observed(name: "proj-web-1", serviceName: "web", configHash: ComposeService.hash(of: file.services["web"]!)), + observed(name: "proj-db-1", serviceName: "db", configHash: ComposeService.hash(of: file.services["db"]!)), + ], + composeFile: file, + projectName: "proj", + forceRecreate: true, + noRecreate: false + ) + #expect(actions == [ + ReconcileAction(serviceName: "db", kind: .removeAndRecreate), + ReconcileAction(serviceName: "web", kind: .removeAndRecreate), + ]) + } + + @Test("reconcileDecision: empty observedContainers (post-prefix-filter) → all .noOp") + func reconcileIgnoresForeignProject() throws { + let file = try singleServiceFile() + let actions = ComposeOrchestrator.reconcileDecision( + observedContainers: [], + composeFile: file, + projectName: "proj", + forceRecreate: false, + noRecreate: false + ) + #expect(actions == [ReconcileAction(serviceName: "app", kind: .noOp)]) + } + + @Test("reconcileDecision: empty composeFile → empty actions list") + func reconcileEmptyFile() { + let empty = ComposeFile() + let actions = ComposeOrchestrator.reconcileDecision( + observedContainers: [ + observed(name: "proj-app-1", serviceName: "app", configHash: "sha256:x") + ], + composeFile: empty, + projectName: "proj", + forceRecreate: false, + noRecreate: false + ) + #expect(actions.isEmpty) + } } diff --git a/Tests/MockerTests/CLITests.swift b/Tests/MockerTests/CLITests.swift index c7c5153..1f4ae66 100644 --- a/Tests/MockerTests/CLITests.swift +++ b/Tests/MockerTests/CLITests.swift @@ -53,6 +53,58 @@ struct CLITests { #expect(command.options.projectDirectory == "/tmp/myproj") } + @Test("Compose up parses --force-recreate") + func composeUpForceRecreateFlag() throws { + let command = try ComposeUp.parse(["-d", "--force-recreate"]) + #expect(command.forceRecreate == true) + #expect(command.noRecreate == false) + } + + @Test("Compose up parses --no-recreate") + func composeUpNoRecreateFlag() throws { + let command = try ComposeUp.parse(["-d", "--no-recreate"]) + #expect(command.noRecreate == true) + #expect(command.forceRecreate == false) + } + + @Test("Compose up rejects --force-recreate + --no-recreate together") + func composeUpForceAndNoRecreateConflict() throws { + do { + _ = try ComposeUp.parse(["-d", "--force-recreate", "--no-recreate"]) + Issue.record("expected parse() to throw") + } catch { + let msg = String(describing: error) + #expect(msg.contains("--force-recreate")) + #expect(msg.contains("--no-recreate")) + } + } + + @Test("Compose create parses --force-recreate") + func composeCreateForceRecreateFlag() throws { + let command = try ComposeCreate.parse(["--force-recreate"]) + #expect(command.forceRecreate == true) + #expect(command.noRecreate == false) + } + + @Test("Compose create parses --no-recreate") + func composeCreateNoRecreateFlag() throws { + let command = try ComposeCreate.parse(["--no-recreate"]) + #expect(command.noRecreate == true) + #expect(command.forceRecreate == false) + } + + @Test("Compose create rejects --force-recreate + --no-recreate together") + func composeCreateForceAndNoRecreateConflict() throws { + do { + _ = try ComposeCreate.parse(["--force-recreate", "--no-recreate"]) + Issue.record("expected parse() to throw") + } catch { + let msg = String(describing: error) + #expect(msg.contains("--force-recreate")) + #expect(msg.contains("--no-recreate")) + } + } + @Test("loadCompose discovers the default file inside --project-directory when -f is omitted") func loadComposeUsesProjectDirectoryForDefaultDiscovery() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) From 1a43abfb965b08daa428a9b92ed9fd36ea94b874 Mon Sep 17 00:00:00 2001 From: us Date: Sat, 4 Jul 2026 13:50:11 +0300 Subject: [PATCH 2/2] fix(compose): start stopped containers on up -d reconcile Previously ObservedContainer.state always defaulted to .running because up() never populated it from the real container state, so reconcileDecision's .keep branch fired purely on config-hash equality. A .keep action skipped the start loop and unconditionally emitted .containerRunning, so a stopped-but-matching container was left stopped while mocker reported it as Running. - Populate ObservedContainer.state from the real container state in up(). - Split ReconcileAction.Kind with a new .start case: hash matches but the container isn't running, so start it via engine.start(_:) instead of recreating or skipping it. Applies under --no-recreate too, since that flag only blocks recreation, not starting. - Only emit .containerRunning after the container is confirmed running (already running for .keep, or successfully started for .start). - Add reconcileDecision tests covering the state dimension: stopped/ exited/created/dead + matching hash -> .start, running + matching hash -> .keep, --no-recreate + stopped -> .start, and stopped + diverged hash -> .removeAndRecreate (not .start). --- .../Compose/ComposeOrchestrator.swift | 25 ++++- .../ComposeOrchestratorTests.swift | 95 ++++++++++++++++++- 2 files changed, 114 insertions(+), 6 deletions(-) diff --git a/Sources/MockerKit/Compose/ComposeOrchestrator.swift b/Sources/MockerKit/Compose/ComposeOrchestrator.swift index 278c9a0..ca49123 100644 --- a/Sources/MockerKit/Compose/ComposeOrchestrator.swift +++ b/Sources/MockerKit/Compose/ComposeOrchestrator.swift @@ -77,7 +77,8 @@ public actor ComposeOrchestrator { ObservedContainer( name: container.name, serviceName: container.labels["com.mocker.compose.service"] ?? "", - configHash: container.labels["com.mocker.compose.config-hash"] + configHash: container.labels["com.mocker.compose.config-hash"], + state: container.state ) } ?? [] let actions = Self.reconcileDecision( @@ -91,8 +92,19 @@ public actor ComposeOrchestrator { 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 }), @@ -373,8 +385,10 @@ extension ComposeOrchestrator { kind = .noOp case (_, true, _): kind = .removeAndRecreate - case (_, _, true): - kind = .keep + 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 { @@ -382,7 +396,7 @@ extension ComposeOrchestrator { } else if let digest = obs.imageDigest, digest != service.image { kind = .removeAndRecreate } else { - kind = .keep + kind = obs.state == .running ? .keep : .start } } return ReconcileAction(serviceName: serviceName, kind: kind) @@ -417,7 +431,10 @@ public struct ObservedContainer: Sendable, Equatable { /// 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 } diff --git a/Tests/MockerKitTests/ComposeOrchestratorTests.swift b/Tests/MockerKitTests/ComposeOrchestratorTests.swift index 8f7271d..c49f33f 100644 --- a/Tests/MockerKitTests/ComposeOrchestratorTests.swift +++ b/Tests/MockerKitTests/ComposeOrchestratorTests.swift @@ -239,8 +239,13 @@ struct ComposeOrchestratorTests { """) } - private func observed(name: String, serviceName: String, configHash: String?) -> ObservedContainer { - ObservedContainer(name: name, serviceName: serviceName, configHash: configHash) + private func observed( + name: String, + serviceName: String, + configHash: String?, + state: ContainerState = .running + ) -> ObservedContainer { + ObservedContainer(name: name, serviceName: serviceName, configHash: configHash, state: state) } @Test("reconcileDecision: observed container matches hash + defaults → .keep") @@ -307,6 +312,92 @@ struct ComposeOrchestratorTests { #expect(actions == [ReconcileAction(serviceName: "app", kind: .removeAndRecreate)]) } + @Test("reconcileDecision: stopped container + matching hash + defaults → .start (not .keep)") + func reconcileStoppedContainerMatchingHashStarts() throws { + let file = try singleServiceFile() + let svc = file.services["app"]! + let hash = ComposeService.hash(of: svc) + let actions = ComposeOrchestrator.reconcileDecision( + observedContainers: [ + observed(name: "proj-app-1", serviceName: "app", configHash: hash, state: .stopped) + ], + composeFile: file, + projectName: "proj", + forceRecreate: false, + noRecreate: false + ) + #expect(actions == [ReconcileAction(serviceName: "app", kind: .start)]) + } + + @Test( + "reconcileDecision: non-running container + matching hash + defaults → .start", + arguments: [ContainerState.stopped, .exited, .created, .dead] + ) + func reconcileNonRunningStatesStart(state: ContainerState) throws { + let file = try singleServiceFile() + let svc = file.services["app"]! + let hash = ComposeService.hash(of: svc) + let actions = ComposeOrchestrator.reconcileDecision( + observedContainers: [ + observed(name: "proj-app-1", serviceName: "app", configHash: hash, state: state) + ], + composeFile: file, + projectName: "proj", + forceRecreate: false, + noRecreate: false + ) + #expect(actions == [ReconcileAction(serviceName: "app", kind: .start)]) + } + + @Test("reconcileDecision: running container + matching hash + defaults → .keep") + func reconcileRunningContainerMatchingHashKeeps() throws { + let file = try singleServiceFile() + let svc = file.services["app"]! + let hash = ComposeService.hash(of: svc) + let actions = ComposeOrchestrator.reconcileDecision( + observedContainers: [ + observed(name: "proj-app-1", serviceName: "app", configHash: hash, state: .running) + ], + composeFile: file, + projectName: "proj", + forceRecreate: false, + noRecreate: false + ) + #expect(actions == [ReconcileAction(serviceName: "app", kind: .keep)]) + } + + @Test("reconcileDecision: stopped container + matching hash + --no-recreate → .start") + func reconcileStoppedContainerNoRecreateStarts() throws { + let file = try singleServiceFile() + let svc = file.services["app"]! + let hash = ComposeService.hash(of: svc) + let actions = ComposeOrchestrator.reconcileDecision( + observedContainers: [ + observed(name: "proj-app-1", serviceName: "app", configHash: hash, state: .stopped) + ], + composeFile: file, + projectName: "proj", + forceRecreate: false, + noRecreate: true + ) + #expect(actions == [ReconcileAction(serviceName: "app", kind: .start)]) + } + + @Test("reconcileDecision: stopped container + diverged hash + defaults → .removeAndRecreate (not .start)") + func reconcileStoppedContainerDivergedHashRecreates() throws { + let file = try singleServiceFile() + let actions = ComposeOrchestrator.reconcileDecision( + observedContainers: [ + observed(name: "proj-app-1", serviceName: "app", configHash: "sha256:different", state: .stopped) + ], + composeFile: file, + projectName: "proj", + forceRecreate: false, + noRecreate: false + ) + #expect(actions == [ReconcileAction(serviceName: "app", kind: .removeAndRecreate)]) + } + @Test("reconcileDecision: multiple services + --force-recreate → all .removeAndRecreate") func reconcileMultipleServicesForceRecreate() throws { let file = try ComposeFile.parse("""