From c95d59afc5064d8fc513bfa7235d597b1c3e87a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro?= Date: Wed, 21 Oct 2026 01:17:23 -0400 Subject: [PATCH] feat(compose): honour deploy.replicas The replica count is the --scale value if given, else a non-default scale:, else deploy.replicas, else 1. An explicit scale: wins over deploy.replicas. --- .../ComposeEngine/ComposeOrchestrator.swift | 9 +++++-- .../ComposeEngine/Models/ComposeModels.swift | 7 ++++- .../ComposeMultiFileTests.swift | 26 +++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/Sources/ComposeEngine/ComposeOrchestrator.swift b/Sources/ComposeEngine/ComposeOrchestrator.swift index 4f5931b..12f4a88 100644 --- a/Sources/ComposeEngine/ComposeOrchestrator.swift +++ b/Sources/ComposeEngine/ComposeOrchestrator.swift @@ -87,9 +87,14 @@ public struct ComposeOrchestrator: Sendable { return "\(sanitize(project))-\(sanitize(service))-\(replica)" } - /// How many replicas a service runs: the `--scale` value when given, else `scale:`, else 1. + /// How many replicas a service runs. The `--scale` value wins, then a + /// non-default `scale:`, then `deploy.replicas`, then 1. public static func replicaCount(for serviceName: String, in project: ComposeProject, scales: [String: Int]) -> Int { - max(1, scales[serviceName] ?? project.services[serviceName]?.scale ?? 1) + let service = project.services[serviceName] + if let scaled = scales[serviceName] { return max(1, scaled) } + if let scale = service?.scale, scale != 1 { return max(1, scale) } + if let replicas = service?.deploy?.replicas { return max(1, replicas) } + return 1 } /// The image reference for a service: its `image:` when present, otherwise diff --git a/Sources/ComposeEngine/Models/ComposeModels.swift b/Sources/ComposeEngine/Models/ComposeModels.swift index 45f2a37..5d8deaf 100644 --- a/Sources/ComposeEngine/Models/ComposeModels.swift +++ b/Sources/ComposeEngine/Models/ComposeModels.swift @@ -798,18 +798,23 @@ public struct HealthCheckConfig: Sendable, Codable, Equatable { /// The deploy section (only resource limits are honored). public struct DeployConfig: Sendable, Codable, Equatable { public var resources: DeployResources? + /// Number of containers to run for the service (used when `scale:` is not set). + public var replicas: Int? - public init(resources: DeployResources? = nil) { + public init(resources: DeployResources? = nil, replicas: Int? = nil) { self.resources = resources + self.replicas = replicas } enum CodingKeys: String, CodingKey { case resources + case replicas } public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) resources = try c.decodeIfPresent(DeployResources.self, forKey: .resources) + replicas = try c.decodeIfPresent(Int.self, forKey: .replicas) } } diff --git a/Tests/ComposeEngineTests/ComposeMultiFileTests.swift b/Tests/ComposeEngineTests/ComposeMultiFileTests.swift index 302315e..d33c766 100644 --- a/Tests/ComposeEngineTests/ComposeMultiFileTests.swift +++ b/Tests/ComposeEngineTests/ComposeMultiFileTests.swift @@ -94,3 +94,29 @@ final class ComposeReplicaTests: XCTestCase { XCTAssertEqual(ComposeOrchestrator.replicaCount(for: "db", in: project, scales: ["db": 0]), 1) } } + +final class ComposeDeployReplicasTests: XCTestCase { + func testDeployReplicasUsedWhenScaleIsDefault() throws { + let project = try ComposeParser().parse(yaml: """ + services: + web: + image: nginx + deploy: + replicas: 3 + """) + XCTAssertEqual(ComposeOrchestrator.replicaCount(for: "web", in: project, scales: [:]), 3) + XCTAssertEqual(ComposeOrchestrator.replicaCount(for: "web", in: project, scales: ["web": 2]), 2) + } + + func testExplicitScaleBeatsDeployReplicas() throws { + let project = try ComposeParser().parse(yaml: """ + services: + web: + image: nginx + scale: 4 + deploy: + replicas: 3 + """) + XCTAssertEqual(ComposeOrchestrator.replicaCount(for: "web", in: project, scales: [:]), 4) + } +}