diff --git a/Sources/Container-Compose/Codable Structs/DockerCompose.swift b/Sources/Container-Compose/Codable Structs/DockerCompose.swift index dad3c6e..804d94d 100644 --- a/Sources/Container-Compose/Codable Structs/DockerCompose.swift +++ b/Sources/Container-Compose/Codable Structs/DockerCompose.swift @@ -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') @@ -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].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 @@ -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) } } diff --git a/Sources/Container-Compose/Commands/ComposeDown.swift b/Sources/Container-Compose/Commands/ComposeDown.swift index 9108900..38b92d4 100644 --- a/Sources/Container-Compose/Commands/ComposeDown.swift +++ b/Sources/Container-Compose/Commands/ComposeDown.swift @@ -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 { diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 4b6adc6..00ebd84 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -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) diff --git a/Sources/Container-Compose/Helper Functions.swift b/Sources/Container-Compose/Helper Functions.swift index 9e38521..a785d2f 100644 --- a/Sources/Container-Compose/Helper Functions.swift +++ b/Sources/Container-Compose/Helper Functions.swift @@ -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. @@ -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.. 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.