diff --git a/Sources/Container-Compose/Codable Structs/Deploy.swift b/Sources/Container-Compose/Codable Structs/Deploy.swift index d4bbba6..8bed6a2 100644 --- a/Sources/Container-Compose/Codable Structs/Deploy.swift +++ b/Sources/Container-Compose/Codable Structs/Deploy.swift @@ -21,6 +21,8 @@ // Created by Morris Richman on 6/17/25. // +import Foundation + /// Represents the `deploy` configuration for a service (primarily for Swarm orchestration). public struct Deploy: Codable, Hashable { @@ -28,8 +30,65 @@ public struct Deploy: Codable, Hashable { public let mode: String? /// Number of replicated service tasks public let replicas: Int? + /// Raw non-numeric replicas expression, such as an env-var placeholder + public let replicasExpression: String? /// Resource constraints (limits, reservations) public let resources: DeployResources? /// Restart policy for tasks public let restart_policy: DeployRestartPolicy? + + enum CodingKeys: String, CodingKey { + case mode, replicas, resources, restart_policy + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + mode = try container.decodeIfPresent(String.self, forKey: .mode) + resources = try container.decodeIfPresent(DeployResources.self, forKey: .resources) + restart_policy = try container.decodeIfPresent(DeployRestartPolicy.self, forKey: .restart_policy) + + do { + replicas = try container.decodeIfPresent(Int.self, forKey: .replicas) + replicasExpression = nil + } catch { + let rawReplicas = try container.decode(String.self, forKey: .replicas) + let normalizedReplicas = Self.normalizedReplicasExpression(rawReplicas) + + if let parsedReplicas = Int(normalizedReplicas) { + replicas = parsedReplicas + replicasExpression = nil + } else { + replicas = nil + replicasExpression = normalizedReplicas + } + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(mode, forKey: .mode) + if let replicas { + try container.encode(replicas, forKey: .replicas) + } else { + try container.encodeIfPresent(replicasExpression, forKey: .replicas) + } + try container.encodeIfPresent(resources, forKey: .resources) + try container.encodeIfPresent(restart_policy, forKey: .restart_policy) + } + + private static func normalizedReplicasExpression(_ value: String) -> String { + var normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalizedValue.count >= 2 else { return normalizedValue } + + let firstCharacter = normalizedValue.first + let lastCharacter = normalizedValue.last + if (firstCharacter == "\"" && lastCharacter == "\"") || (firstCharacter == "'" && lastCharacter == "'") { + normalizedValue = String(normalizedValue.dropFirst().dropLast()) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + return normalizedValue + } } diff --git a/Tests/Container-Compose-StaticTests/DockerComposeParsingTests.swift b/Tests/Container-Compose-StaticTests/DockerComposeParsingTests.swift index 90c5ba8..88392d5 100644 --- a/Tests/Container-Compose-StaticTests/DockerComposeParsingTests.swift +++ b/Tests/Container-Compose-StaticTests/DockerComposeParsingTests.swift @@ -428,6 +428,120 @@ struct DockerComposeParsingTests { #expect(compose.services["app"]??.platform == "linux/amd64") } + @Test("Parse deploy replicas as integer") + func parseComposeWithDeployReplicasInteger() throws { + let yaml = """ + version: '3.8' + services: + app: + image: alpine:latest + deploy: + mode: replicated + replicas: 3 + """ + + let decoder = YAMLDecoder() + let compose = try decoder.decode(DockerCompose.self, from: yaml) + + #expect(compose.services["app"]??.deploy?.mode == "replicated") + #expect(compose.services["app"]??.deploy?.replicas == 3) + #expect(compose.services["app"]??.deploy?.replicasExpression == nil) + } + + @Test("Parse deploy replicas as quoted numeric scalar") + func parseComposeWithDeployReplicasQuotedNumeric() throws { + let yaml = """ + version: '3.8' + services: + app: + image: alpine:latest + deploy: + mode: replicated + replicas: "2" + """ + + let decoder = YAMLDecoder() + let compose = try decoder.decode(DockerCompose.self, from: yaml) + + #expect(compose.services["app"]??.deploy?.mode == "replicated") + #expect(compose.services["app"]??.deploy?.replicas == 2) + #expect(compose.services["app"]??.deploy?.replicasExpression == nil) + } + + @Test("Parse deploy replicas as env-var expression") + func parseComposeWithDeployReplicasEnvVarExpression() throws { + let yaml = """ + version: '3.8' + services: + app: + image: alpine:latest + deploy: + mode: replicated + replicas: ${CORES} + resources: + limits: + cpus: "0.5" + memory: "512M" + """ + + let decoder = YAMLDecoder() + let compose = try decoder.decode(DockerCompose.self, from: yaml) + + #expect(compose.services["app"]??.deploy?.mode == "replicated") + #expect(compose.services["app"]??.deploy?.replicas == nil) + #expect(compose.services["app"]??.deploy?.replicasExpression == "${CORES}") + #expect(compose.services["app"]??.deploy?.resources?.limits?.cpus == "0.5") + #expect(compose.services["app"]??.deploy?.resources?.limits?.memory == "512M") + } + + @Test("Reject deploy replicas mapping value") + func rejectComposeWithDeployReplicasMapping() throws { + let yaml = """ + version: '3.8' + services: + app: + image: alpine:latest + deploy: + mode: replicated + replicas: + count: 2 + """ + + let decoder = YAMLDecoder() + #expect(throws: Error.self) { + try decoder.decode(DockerCompose.self, from: yaml) + } + } + + @Test("Parse multi-service compose with deploy replicas env-var expression") + func parseMultiServiceComposeWithDeployReplicasEnvVarExpression() throws { + let yaml = """ + version: '3.8' + services: + web: + image: nginx:latest + depends_on: + - worker + worker: + image: alpine:latest + deploy: + mode: replicated + replicas: ${CORES} + db: + image: postgres:14 + """ + + let decoder = YAMLDecoder() + let compose = try decoder.decode(DockerCompose.self, from: yaml) + + #expect(compose.services.count == 3) + #expect(compose.services["web"]??.depends_on?.contains("worker") == true) + #expect(compose.services["worker"]??.deploy?.mode == "replicated") + #expect(compose.services["worker"]??.deploy?.replicas == nil) + #expect(compose.services["worker"]??.deploy?.replicasExpression == "${CORES}") + #expect(compose.services["db"]??.image == "postgres:14") + } + @Test("Parse deploy resources limits (cpus and memory)") func parseComposeWithDeployResources() throws { let yaml = """