Skip to content
Draft
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
80 changes: 75 additions & 5 deletions Sources/Container-Compose/Codable Structs/DockerCompose.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
// Created by Morris Richman on 6/17/25.
//


/// Represents the top-level structure of a docker-compose.yml file.
public struct DockerCompose: Codable {
/// The Compose file format version (e.g., '3.8')
Expand All @@ -38,15 +37,17 @@ public struct DockerCompose: Codable {
public let configs: [String: Config?]?
/// Optional top-level secret definitions (primarily for Swarm)
public let secrets: [String: Secret?]?

/// Optional includes of other compose files
public let includes: [DockerInclude]?

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
version = try container.decodeIfPresent(String.self, forKey: .version)
name = try container.decodeIfPresent(String.self, forKey: .name)
services = try container.decode([String: Service?].self, forKey: .services)
if let volumes = try container.decodeIfPresent([String: Optional<Volume>].self, forKey: .volumes) {
let safeVolumes: [String : Volume] = volumes.mapValues { value in

if let volumes = try container.decodeIfPresent([String: Volume?].self, forKey: .volumes) {
let safeVolumes: [String: Volume] = volumes.mapValues { value in
value ?? Volume()
}
self.volumes = safeVolumes
Expand All @@ -56,5 +57,74 @@ public struct DockerCompose: Codable {
networks = try container.decodeIfPresent([String: Network?].self, forKey: .networks)
configs = try container.decodeIfPresent([String: Config?].self, forKey: .configs)
secrets = try container.decodeIfPresent([String: Secret?].self, forKey: .secrets)
includes = try container.decodeIfPresent([DockerInclude].self, forKey: .includes)
}

public init(
version: String? = nil,
name: String? = nil,
services: [String: Service?],
volumes: [String: Volume?]? = nil,
networks: [String: Network?]? = nil,
configs: [String: Config?]? = nil,
secrets: [String: Secret?]? = nil,
includes: [DockerInclude]? = nil
) {
self.version = version
self.name = name
self.services = services
self.volumes = volumes
self.networks = networks
self.configs = configs
self.secrets = secrets
self.includes = includes
}

/// Merges another DockerCompose into this one, with the other taking precedence in case of conflicts.
/// - Parameter with: The DockerCompose to merge into this one.
/// - Returns: A new DockerCompose instance representing the merged result.
public func merge(with: DockerCompose) -> DockerCompose {
// Merge services
var mergedServices = self.services
for (key, service) in with.services {
mergedServices[key] = service
}

// Merge volumes
var mergedVolumes = self.volumes ?? [:]
if let withVolumes = with.volumes {
for (key, volume) in withVolumes {
mergedVolumes[key] = volume
}
}

// Merge networks
var mergedNetworks = self.networks ?? [:]
if let withNetworks = with.networks {
for (key, network) in withNetworks {
mergedNetworks[key] = network
}
}

return DockerCompose(
version: with.version ?? self.version,
name: with.name ?? self.name,
services: mergedServices,
volumes: mergedVolumes.isEmpty ? nil : mergedVolumes,
networks: mergedNetworks.isEmpty ? nil : mergedNetworks,
configs: with.configs ?? self.configs,
secrets: with.secrets ?? self.secrets,
includes: with.includes ?? self.includes
)
}
}

public struct DockerInclude: Codable {
// The file to include
let file: String

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
file = try container.decode(String.self, forKey: .file)
}
}
11 changes: 1 addition & 10 deletions Sources/Container-Compose/Commands/ComposeDown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,7 @@ public struct ComposeDown: AsyncParsableCommand {
}

// Read docker-compose.yml content
guard let yamlData = fileManager.contents(atPath: composePath) else {
let path = URL(fileURLWithPath: composePath)
.deletingLastPathComponent()
.path
throw YamlError.composeFileNotFound(path)
}

// Decode the YAML file into the DockerCompose struct
let dockerComposeString = String(data: yamlData, encoding: .utf8)!
let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString)
let dockerCompose = try fileManager.loadComposeFile(composePath: composePath)

// Determine project name for container naming
if let name = dockerCompose.name {
Expand Down
11 changes: 1 addition & 10 deletions Sources/Container-Compose/Commands/ComposeUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
}

// Read compose.yml content
guard let yamlData = fileManager.contents(atPath: composePath) else {
let path = URL(fileURLWithPath: composePath)
.deletingLastPathComponent()
.path
throw YamlError.composeFileNotFound(path)
}

// Decode the YAML file into the DockerCompose struct
let dockerComposeString = String(data: yamlData, encoding: .utf8)!
let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString)
let dockerCompose = try fileManager.loadComposeFile(composePath: composePath)

// Load environment variables from .env file
environmentVariables = loadEnvFile(path: envFilePath)
Expand Down
67 changes: 54 additions & 13 deletions Sources/Container-Compose/Helper Functions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@
// Created by Morris Richman on 6/17/25.
//

import ContainerCommands
import Foundation
import Yams
import Rainbow
import ContainerCommands
import Yams

/// Loads environment variables from a .env file.
/// - Parameter path: The full path to the .env file.
Expand Down Expand Up @@ -63,28 +63,41 @@ public func loadEnvFile(path: String) -> [String: String] {
public func resolveVariable(_ value: String, with envVars: [String: String]) -> String {
var resolvedValue = value
// Regex to find ${VAR}, ${VAR:-default}, ${VAR:?error}
let regex = try! NSRegularExpression(pattern: #"\$\{([A-Za-z0-9_]+)(:?-(.*?))?(:\?(.*?))?\}"#, options: [])

let regex = try! NSRegularExpression(
pattern: #"\$\{([A-Za-z0-9_]+)(:?-(.*?))?(:\?(.*?))?\}"#, options: [])

// Combine process environment with loaded .env file variables, prioritizing process environment
let combinedEnv = ProcessInfo.processInfo.environment.merging(envVars) { (current, _) in current }

let combinedEnv = ProcessInfo.processInfo.environment.merging(envVars) { (current, _) in current
}

// Loop to resolve all occurrences of variables in the string
while let match = regex.firstMatch(in: resolvedValue, options: [], range: NSRange(resolvedValue.startIndex..<resolvedValue.endIndex, in: resolvedValue)) {
while let match = regex.firstMatch(
in: resolvedValue, options: [],
range: NSRange(resolvedValue.startIndex..<resolvedValue.endIndex, in: resolvedValue))
{
guard let varNameRange = Range(match.range(at: 1), in: resolvedValue) else { break }
let varName = String(resolvedValue[varNameRange])

if let envValue = combinedEnv[varName] {
// Variable found in environment, replace with its value
resolvedValue.replaceSubrange(Range(match.range(at: 0), in: resolvedValue)!, with: envValue)
resolvedValue.replaceSubrange(
Range(match.range(at: 0), in: resolvedValue)!, with: envValue)
} else if let defaultValueRange = Range(match.range(at: 3), in: resolvedValue) {
// Variable not found, but default value is provided, replace with default
let defaultValue = String(resolvedValue[defaultValueRange])
resolvedValue.replaceSubrange(Range(match.range(at: 0), in: resolvedValue)!, with: defaultValue)
} else if match.range(at: 5).location != NSNotFound, let errorMessageRange = Range(match.range(at: 5), in: resolvedValue) {
resolvedValue.replaceSubrange(
Range(match.range(at: 0), in: resolvedValue)!, with: defaultValue)
} else if match.range(at: 5).location != NSNotFound,
let errorMessageRange = Range(match.range(at: 5), in: resolvedValue)
{
// Variable not found, and error-on-missing syntax used, print error and exit
let errorMessage = String(resolvedValue[errorMessageRange])
fputs("Error: Missing required environment variable '\(varName)': \(errorMessage)\n", stderr)
Application.exit(withError: "Error: Missing required environment variable '\(varName)': \(errorMessage)\n")
fputs(
"Error: Missing required environment variable '\(varName)': \(errorMessage)\n",
stderr)
Application.exit(
withError:
"Error: Missing required environment variable '\(varName)': \(errorMessage)\n")
} else {
// Variable not found and no default/error specified, leave as is and break loop to avoid infinite loop
break
Expand All @@ -93,6 +106,34 @@ public func resolveVariable(_ value: String, with envVars: [String: String]) ->
return resolvedValue
}

extension FileManager {
public func loadComposeFile(composePath: String) throws -> DockerCompose {
// Read YAML
guard let yamlData = contents(atPath: composePath) else {
let path = URL(fileURLWithPath: composePath)
.deletingLastPathComponent()
.path
throw YamlError.composeFileNotFound(path)
}

// Decode the YAML file into the DockerCompose struct
let dockerComposeString = String(data: yamlData, encoding: .utf8)!
var dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString)

// Loop through includes and load additional files

if let includes = dockerCompose.includes {
for include in includes {
let included = try loadComposeFile(composePath: include.file)
// Merge into main dockerCompose
dockerCompose = dockerCompose.merge(with: included)
}
}

return dockerCompose
}
}

extension String: @retroactive Error {}

/// A structure representing the result of a command-line process execution.
Expand Down