new features

This commit is contained in:
Mark Gregory
2026-11-09 08:20:57 +00:00
parent ecfa74a35f
commit 965ae923c2
27 changed files with 1474 additions and 168 deletions
+97 -9
View File
@@ -33,6 +33,7 @@ public struct ComposeService: Identifiable {
public var image: String
public var buildContext: String?
public var dockerfile: String?
public var buildArguments: [String]
public var ports: [String]
public var volumes: [String]
public var environment: [String]
@@ -55,6 +56,10 @@ public struct CommandResult {
public enum Shell {
public static func executable(_ name: String) -> String? {
if name == "container", Bundle.main.bundleURL.pathExtension == "app",
let chosen = UserDefaults.standard.string(forKey: "containerBinary"), !chosen.isEmpty {
return FileManager.default.isExecutableFile(atPath: chosen) ? chosen : nil
}
let paths = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init) + ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
return paths.map { ($0 as NSString).appendingPathComponent(name) }.first { FileManager.default.isExecutableFile(atPath: $0) }
}
@@ -96,7 +101,7 @@ public struct ComposeProject {
let url = URL(fileURLWithPath: manager.currentDirectoryPath).appendingPathComponent(name)
if manager.fileExists(atPath: url.path) { return url }
}
throw ComposeError.message("No compose.yaml found. Use -f FILE or run accompose init.")
throw ComposeError.message("No compose.yaml found. Use -f FILE or run container-compose init.")
}
public init(file: URL, overrideName: String? = nil) throws {
self.file = file
@@ -111,9 +116,39 @@ public struct ComposeProject {
if parts.count == 2 && vars[parts[0]] == nil { vars[parts[0]] = parts[1].trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) }
}
let expanded = try Self.interpolate(source, vars: vars)
guard let root = try Yams.load(yaml: expanded) as? [String: Any], let rawServices = root["services"] as? [String: Any], !rawServices.isEmpty else { throw ComposeError.message("Compose file must contain a nonempty services mapping") }
guard !source.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw ComposeError.message("Compose file is empty: \(file.path). Save your YAML to this file or select another file with -f.")
}
let loaded: Any?
do { loaded = try Yams.load(yaml: expanded) }
catch { throw ComposeError.message("Invalid YAML in \(file.path): \(error)") }
guard let root = loaded as? [String: Any] else {
throw ComposeError.message("Compose file must have a mapping at its root: \(file.path)")
}
guard let servicesValue = root["services"] else {
throw ComposeError.message("Missing top-level services mapping in \(file.path). Top-level keys: \(root.keys.sorted().joined(separator: ", ")). Check the filename and indentation.")
}
guard let rawServices = servicesValue as? [String: Any], !rawServices.isEmpty else {
throw ComposeError.message("The services value must be a nonempty mapping in \(file.path). Check YAML indentation.")
}
let unknownRoot = Set(root.keys).subtracting(["name", "version", "services", "networks", "volumes"]).filter { !$0.hasPrefix("x-") }
guard unknownRoot.isEmpty else { throw ComposeError.message("Unsupported Compose fields: \(unknownRoot.sorted().joined(separator: ", "))") }
for kind in ["networks", "volumes"] {
if let value = root[kind] {
guard let entries = value as? [String: Any] else { throw ComposeError.message("\(kind) must be a mapping") }
for (name, options) in entries {
let mapping = options as? [String: Any]
let defaultDriver = kind == "networks" ? "bridge" : "local"
let simpleDefault = mapping.map { Set($0.keys).isSubset(of: ["driver"]) && ($0["driver"] == nil || $0["driver"] as? String == defaultDriver) } ?? false
guard options is NSNull || simpleDefault else {
throw ComposeError.message("Unsupported \(kind).\(name) options (external, custom driver, name and IPAM semantics are not implemented)")
}
}
}
}
let requestedName = overrideName ?? (root["name"] as? String) ?? file.deletingLastPathComponent().lastPathComponent
self.name = Self.safeName(requestedName)
guard !self.name.isEmpty else { throw ComposeError.message("Project name must contain an ASCII letter or digit") }
self.networks = (root["networks"] as? [String: Any]).map { Array($0.keys).sorted() } ?? []
self.volumes = (root["volumes"] as? [String: Any]).map { Array($0.keys).sorted() } ?? []
let resolvedProjectName = self.name
@@ -124,9 +159,50 @@ public struct ComposeProject {
if !ignored.isEmpty {
throw ComposeError.message("Service \(key) contains options not yet supported by Apple Container: \(ignored.sorted().joined(separator: ", ")). Refusing to silently ignore them.")
}
for field in ["ports", "volumes"] {
if let value = raw[field] {
guard let list = value as? [Any], list.allSatisfy({ $0 is String || (field == "ports" && $0 is Int) }) else {
throw ComposeError.message("Service \(key) \(field) supports short scalar syntax only; long syntax is not implemented")
}
}
}
if let networks = raw["networks"] as? [String: Any] {
guard networks.values.allSatisfy({ $0 is NSNull || ($0 as? [String: Any])?.isEmpty == true }) else {
throw ComposeError.message("Service \(key) network aliases and attachment options are not implemented")
}
}
if let dependencies = raw["depends_on"] as? [String: Any] {
for (_, value) in dependencies {
guard let options = value as? [String: Any], Set(options.keys).isSubset(of: ["condition"]), options["condition"] as? String == "service_started" else {
throw ComposeError.message("Service \(key) supports depends_on service_started only; health/completion gating is not implemented")
}
}
}
let build = raw["build"]
let buildMap = build as? [String: Any]
if let buildMap {
let unknown = Set(buildMap.keys).subtracting(["context", "dockerfile", "args"])
guard unknown.isEmpty else { throw ComposeError.message("Unsupported build options for \(key): \(unknown.sorted().joined(separator: ", "))") }
}
let context = (build as? String) ?? (buildMap?["context"] as? String)
var buildArguments: [String] = []
if let value = buildMap?["args"] {
if let mapping = value as? [String: Any] {
for key in mapping.keys.sorted() {
let value = mapping[key]!
if value is NSNull {
if let inherited = vars[key] { buildArguments.append("\(key)=\(inherited)") }
} else if value is String || value is NSNumber {
buildArguments.append("\(key)=\(value)")
} else { throw ComposeError.message("Build argument \(key) for \(key) must be a scalar") }
}
} else if let list = value as? [String] {
for item in list {
if item.contains("=") { buildArguments.append(item) }
else if let inherited = vars[item] { buildArguments.append("\(item)=\(inherited)") }
}
} else { throw ComposeError.message("Service \(key) build.args must be a mapping or string list") }
}
let image = (raw["image"] as? String) ?? "\(resolvedProjectName)-\(key):latest"
guard context != nil || raw["image"] is String else { throw ComposeError.message("Service \(key) requires image or build") }
let deps: [String]
@@ -142,7 +218,7 @@ public struct ComposeProject {
if let arr = raw["command"] as? [String] { command = arr }
else if let str = raw["command"] as? String { command = ["sh", "-c", str] }
else { command = [] }
return ComposeService(id: key, image: image, buildContext: context, dockerfile: buildMap?["dockerfile"] as? String, ports: Self.strings(raw["ports"]), volumes: Self.strings(raw["volumes"]), environment: env, dependencies: deps, networks: networks, command: command, workingDirectory: raw["working_dir"] as? String, restart: raw["restart"] as? String)
return ComposeService(id: key, image: image, buildContext: context, dockerfile: buildMap?["dockerfile"] as? String, buildArguments: buildArguments, ports: Self.strings(raw["ports"]), volumes: Self.strings(raw["volumes"]), environment: env, dependencies: deps, networks: networks, command: command, workingDirectory: raw["working_dir"] as? String, restart: raw["restart"] as? String)
}
let ids = Set(services.map(\.id))
for service in services { for dep in service.dependencies where !ids.contains(dep) { throw ComposeError.message("Unknown dependency \(dep) for service \(service.id)") } }
@@ -205,6 +281,7 @@ public struct ComposeEngine {
private func build(_ service: ComposeService) throws {
guard let context = service.buildContext else { return }
var args = ["build", "-t", service.image]
for argument in service.buildArguments { args += ["--build-arg", argument] }
if let dockerfile = service.dockerfile { args += ["-f", path((context as NSString).appendingPathComponent(dockerfile))] }
args.append(path(context))
print(try run(args))
@@ -212,7 +289,7 @@ public struct ComposeEngine {
public func execute(_ action: String, args: [String] = [], attached: Bool = false, verbosity: Int = 0) throws -> String {
switch action {
case "doctor":
var lines = ["Apple Container Compose diagnostics", "Project: \(project.file.path)"]
var lines = ["Container Compose diagnostics", "Project: \(project.file.path)"]
guard let binary = Shell.executable("container") else { return (lines + ["FAIL: Apple container CLI not found. Install from https://github.com/apple/container/releases"]).joined(separator: "\n") }
lines.append("CLI: \(binary)")
let version = try Shell.run("container", ["--version"]); lines.append(version.output.trimmingCharacters(in: .whitespacesAndNewlines))
@@ -243,7 +320,7 @@ public struct ComposeEngine {
}
let rebuild = args.contains("--build")
if verbosity > 0 {
fputs("accompose: project=\(project.name) mode=\(attached ? "foreground" : "detached") verbosity=\(verbosity)\n", stderr)
fputs("container-compose: project=\(project.name) mode=\(attached ? "foreground" : "detached") verbosity=\(verbosity)\n", stderr)
}
if verbosity > 1 { fputs("Compose file: \(project.file.path)\n", stderr) }
if verbosity > 2 { fputs("Startup order: \(try project.orderedServices().map(\.id).joined(separator: ", "))\n", stderr) }
@@ -323,7 +400,7 @@ public struct ComposeEngine {
let all = args.contains("--all") || args.contains("--project")
let selected = args.first { !$0.hasPrefix("-") }
let targets = all ? project.services : project.services.filter { $0.id == selected || project.containerName($0) == selected }
guard !targets.isEmpty else { throw ComposeError.message("Select a service/container or use --all; see accompose clean --help") }
guard !targets.isEmpty else { throw ComposeError.message("Select a service/container or use --all; see container-compose clean --help") }
var output = ""
for service in targets {
let name = project.containerName(service)
@@ -336,7 +413,7 @@ public struct ComposeEngine {
return output
case "prune":
// Only safe, explicit image cleanup; no blind deletion of user data.
guard args.contains("--images") else { return "No resources deleted. Use accompose prune --images to request unused image pruning; named volumes are never pruned automatically.\n" }
guard args.contains("--images") else { return "No resources deleted. Use container-compose prune --images to request unused image pruning; named volumes are never pruned automatically.\n" }
if args.contains("--dry-run") { return "Dry run: inspect unused images with container image list; no changes made.\n" }
let result = try Shell.run("container", ["image", "prune"])
guard result.status == 0 else { throw ComposeError.message("Image prune unsupported or failed: \(result.output)") }
@@ -344,12 +421,16 @@ public struct ComposeEngine {
case "ps": return try run(["list", "--all"])
case "logs":
let selected = args.filter { !$0.hasPrefix("-") }
let unknown = Set(selected).subtracting(project.services.map(\.id))
guard unknown.isEmpty else { throw ComposeError.message("Unknown services: \(unknown.sorted().joined(separator: ", "))") }
let services = selected.isEmpty ? project.services : project.services.filter { selected.contains($0.id) }
var output = ""
for service in services { output += "=== \(service.id) ===\n" + (try run(["logs", project.containerName(service)])) }
return output
case "start", "stop", "restart":
let selected = args.filter { !$0.hasPrefix("-") }
let unknown = Set(selected).subtracting(project.services.map(\.id))
guard unknown.isEmpty else { throw ComposeError.message("Unknown services: \(unknown.sorted().joined(separator: ", "))") }
let services = selected.isEmpty ? project.services : project.services.filter { selected.contains($0.id) }
var output = ""
for service in services {
@@ -359,8 +440,15 @@ public struct ComposeEngine {
}
return output
case "exec":
guard let service = args.first, let match = project.services.first(where: { $0.id == service }), args.count > 1 else { throw ComposeError.message("Usage: accompose exec SERVICE COMMAND [ARGS]") }
return try run(["exec", project.containerName(match)] + Array(args.dropFirst()))
guard let service = args.first, let match = project.services.first(where: { $0.id == service }), args.count > 1 else { throw ComposeError.message("Usage: container-compose exec SERVICE COMMAND [ARGS]") }
guard let binary = Shell.executable("container") else { throw ComposeError.message("Missing container executable") }
let process = Process(); process.executableURL = URL(fileURLWithPath: binary)
process.currentDirectoryURL = project.directory
process.arguments = ["exec"] + (isatty(STDIN_FILENO) != 0 ? ["-i", "-t"] : ["-i"]) + [project.containerName(match)] + Array(args.dropFirst())
process.standardInput = FileHandle.standardInput; process.standardOutput = FileHandle.standardOutput; process.standardError = FileHandle.standardError
try process.run(); process.waitUntilExit()
guard process.terminationStatus == 0 else { throw ComposeError.message("exec failed (\(process.terminationStatus))") }
return ""
default: throw ComposeError.message("Unsupported command: \(action)")
}
}