Skip to content
Open
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
59 changes: 59 additions & 0 deletions Sources/Container-Compose/Codable Structs/Deploy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,74 @@
// 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 {
/// Deployment mode (e.g., 'replicated', 'global')
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
}
}
114 changes: 114 additions & 0 deletions Tests/Container-Compose-StaticTests/DockerComposeParsingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
Expand Down
Loading