From e5c1a3d7848c44d94fdebaf4f54a689600d83a24 Mon Sep 17 00:00:00 2001 From: us Date: Sun, 9 Aug 2026 02:20:46 +0300 Subject: [PATCH] feat(compose): support include:, honor project name, volumes and dry-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - include: resolved recursively — short and long form, per-entry project_directory and env_file, cycle guard and depth cap, parent definitions winning; an included file's relative bind mounts and build context anchor to its own directory and its name: stays local (#69) - unknown service names error with "no such service" and exit 1 instead of reporting success on an empty selection (#70) - project name resolves as -p, COMPOSE_PROJECT_NAME (environment, then .env), top-level name:, then the directory, and config prints what it resolved (#71) - down --volumes removes the project's named volumes and leaves external ones alone; up and down now agree on the volume set they own (#72) - compose build tags -:latest, the same tag the runtime starts the service from, instead of shadowing the base image (#73) - --dry-run is declared once and honored by every mutating subcommand, returning before any manager, engine or directory is touched (#74) - images reads real SIZE and CREATED from each image's manifest and config, rendering N/A rather than a confident zero when unavailable (#75) - image references resolve literally before normalizing, so rmi removes the image that was named and can remove locally built ones (#76) Found while reviewing the above: service selection matched by substring, so stop web also stopped webhook, and project scoping by name prefix let one project reach another's containers; both are now label-based. Volume names that would escape the volumes directory are rejected, and compose config renders build:, environment and quoted scalars as valid YAML. BREAKING CHANGE: projects that set COMPOSE_PROJECT_NAME or a top-level name: resolve to that name now, and names are normalized to [a-z0-9_-], so existing containers, volumes and networks created under the previous name are no longer matched. compose build tags -:latest instead of the bare service name. MockerKit: ImageInfo.size and ImageInfo.created are optional, and ComposeEvent has a new volumeRemoved case. --- COMMANDS.md | 14 +- Sources/Mocker/Commands/Build.swift | 7 +- Sources/Mocker/Commands/Compose.swift | 408 +++++++++++------- Sources/Mocker/Commands/History.swift | 42 -- Sources/Mocker/Commands/System.swift | 4 +- Sources/Mocker/ComposeArgNormalizer.swift | 13 +- Sources/MockerKit/API/DockerAPIMappers.swift | 8 +- Sources/MockerKit/API/DockerAPIServer.swift | 3 +- Sources/MockerKit/Compose/ComposeFile.swift | 269 +++++++++++- .../Compose/ComposeOrchestrator.swift | 92 +++- Sources/MockerKit/Image/ImageManager.swift | 198 +++++++-- Sources/MockerKit/Image/ImageStore.swift | 3 +- Sources/MockerKit/Models/ImageInfo.swift | 19 +- Sources/MockerKit/Models/ImageInspect.swift | 8 +- Sources/MockerKit/Models/RelativeDate.swift | 43 ++ Sources/MockerKit/Volume/VolumeManager.swift | 17 + .../MockerKitTests/ComposeIncludeTests.swift | 260 +++++++++++ .../ComposeProjectNameTests.swift | 146 +++++++ .../ComposeVolumeLifecycleTests.swift | 111 +++++ .../MockerKitTests/DockerAPIServerTests.swift | 11 + .../MockerKitTests/ImageResolutionTests.swift | 69 +++ .../ComposeArgNormalizerTests.swift | 12 + Tests/MockerTests/ComposeConfigTests.swift | 163 ++++++- Tests/MockerTests/ComposeDryRunTests.swift | 71 +++ Tests/MockerTests/RelativeDateTests.swift | 1 + docs/cli-reference.md | 21 +- docs/compose.md | 43 +- 27 files changed, 1767 insertions(+), 289 deletions(-) create mode 100644 Sources/MockerKit/Models/RelativeDate.swift create mode 100644 Tests/MockerKitTests/ComposeIncludeTests.swift create mode 100644 Tests/MockerKitTests/ComposeProjectNameTests.swift create mode 100644 Tests/MockerKitTests/ComposeVolumeLifecycleTests.swift create mode 100644 Tests/MockerKitTests/ImageResolutionTests.swift create mode 100644 Tests/MockerTests/ComposeDryRunTests.swift diff --git a/COMMANDS.md b/COMMANDS.md index 4786d3f..e23f82c 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -492,7 +492,8 @@ mocker build [OPTIONS] PATH ### `mocker images` -List images. +List images. `SIZE` and `CREATED` are read from each image's manifest and config, and +render as `N/A` when that metadata is not present in the local content store. ``` mocker images [OPTIONS] @@ -892,12 +893,18 @@ mocker system prune [OPTIONS] ## Compose -All compose subcommands support these shared flags: +All compose subcommands support these shared flags, before or after the subcommand: | Flag | Short | Description | |------|-------|-------------| | `--file` | `-f` | Compose file path | | `--project-name` | `-p` | Project name | +| `--project-directory` | | Working directory for relative paths and `.env` | +| `--dry-run` | | Print the actions that would be taken and change nothing | + +The project name is resolved as `-p` → `COMPOSE_PROJECT_NAME` in the environment → +`COMPOSE_PROJECT_NAME` in `.env` → top-level `name:` in the compose file → the project +directory's name. ### `mocker compose up` @@ -998,7 +1005,8 @@ mocker compose logs [OPTIONS] [SERVICE] ### `mocker compose build` -Build or rebuild services. +Build or rebuild services. Each image is tagged with the service's `image:` when set, +otherwise `-:latest`. ``` mocker compose build [OPTIONS] [SERVICE...] diff --git a/Sources/Mocker/Commands/Build.swift b/Sources/Mocker/Commands/Build.swift index 1074bd5..da9c15d 100644 --- a/Sources/Mocker/Commands/Build.swift +++ b/Sources/Mocker/Commands/Build.swift @@ -133,10 +133,13 @@ struct Build: AsyncParsableCommand { labels: label, quiet: quiet, progress: progress, output: output, builder: builder ) + // The build can succeed while the image is not readable from our store; report + // the tag rather than an empty ID in that case. + let identifier = image.id.isEmpty ? tag : image.shortID if quiet { - print(image.shortID) + print(identifier) } else { - print("Successfully built \(image.shortID)") + print("Successfully built \(identifier)") print("Successfully tagged \(tag)") } } diff --git a/Sources/Mocker/Commands/Compose.swift b/Sources/Mocker/Commands/Compose.swift index 3215930..a61070e 100644 --- a/Sources/Mocker/Commands/Compose.swift +++ b/Sources/Mocker/Commands/Compose.swift @@ -59,6 +59,11 @@ struct ComposeOptions: ParsableArguments { @Option(name: .customLong("project-directory"), help: "Specify an alternate working directory") var projectDirectory: String? + /// Declared once here rather than per subcommand: every compose subcommand accepts + /// `--dry-run`, and each mutating one returns early instead of touching the runtime. + @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") + var dryRun = false + func loadCompose() throws -> (ComposeFile, projectName: String, projectDir: URL) { let cwd = FileManager.default.currentDirectoryPath let projectDir = ComposeFile.resolveProjectDirectory( @@ -86,12 +91,29 @@ struct ComposeOptions: ParsableArguments { } let composeFile = ComposeFile.merge(loaded) - let project = projectName ?? ComposeFile.normalizeProjectName(projectDir.lastPathComponent) + let project = ComposeFile.resolveProjectName( + explicit: projectName, + composeFileName: composeFile.name, + projectDir: projectDir + ) return (composeFile, project, projectDir) } } +// MARK: - Dry Run + +enum ComposeDryRun { + /// Report what a mutating subcommand would have done and change nothing, matching + /// `docker compose --dry-run`'s contract that no state is touched. + static func report(_ action: String, targets: [String]) { + print("DRY-RUN MODE - no changes will be made") + for target in targets { + print("DRY-RUN MODE - \(action): \(target)") + } + } +} + // MARK: - Compose Event Formatting enum ComposeFormatter { @@ -113,6 +135,7 @@ enum ComposeFormatter { case .containerStopped(let name): ("Container \(name)", "Stopped") case .containerRemoved(let name): ("Container \(name)", "Removed") case .networkRemoved(let name): ("Network \(name)", "Removed") + case .volumeRemoved(let name): ("Volume \(name)", "Removed") } } } @@ -148,9 +171,6 @@ struct ComposeUp: AsyncParsableCommand { @Flag(name: .long, help: "Build images before starting containers") var build = false - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Option(name: .customLong("exit-code-from"), help: "Return the exit code of the selected service container") var exitCodeFrom: String? @@ -229,13 +249,21 @@ struct ComposeUp: AsyncParsableCommand { func run() async throws { var (composeFile, project, projectDir) = try options.loadCompose() let config = MockerConfig() - try config.ensureDirectories() // Filter to requested services only if !services.isEmpty { + try composeFile.validateServiceNames(services) composeFile = composeFile.filtering(services: services) } + // Guard before `ensureDirectories`: a dry run must not write anything at all. + if options.dryRun { + ComposeDryRun.report("up", targets: composeFile.serviceOrder().map { "\(project)-\($0)-1" }) + return + } + + try config.ensureDirectories() + let engine = try ContainerEngine(config: config) let imageManager = try ImageManager(config: config) let networkManager = try NetworkManager(config: config) @@ -250,7 +278,6 @@ struct ComposeUp: AsyncParsableCommand { volumeManager: volumeManager ) - let totalResources = composeFile.networks.count + composeFile.volumes.count + composeFile.services.count let events = try await orchestrator.up( composeFile: composeFile, detach: detach, @@ -259,7 +286,9 @@ struct ComposeUp: AsyncParsableCommand { forceRecreate: forceRecreate, noRecreate: noRecreate ) - ComposeFormatter.printEvents(events, total: totalResources) + // Total is the work actually performed, matching `down`: counting declared + // resources instead reported `1/2` whenever a volume already existed. + ComposeFormatter.printEvents(events, total: events.count) } } @@ -280,9 +309,6 @@ struct ComposeDown: AsyncParsableCommand { @Option(name: .shortAndLong, help: "Timeout in seconds for stopping containers") var timeout: Int = 10 - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Option(name: .long, help: "Remove images used by services (all|local)") var rmi: String? @@ -290,6 +316,15 @@ struct ComposeDown: AsyncParsableCommand { let (composeFile, project, projectDir) = try options.loadCompose() let config = MockerConfig() + if options.dryRun { + var targets = composeFile.services.keys.sorted().map { "\(project)-\($0)-1" } + if volumes { + targets += ComposeOrchestrator.volumesToRemove(composeFile: composeFile, projectName: project) + } + ComposeDryRun.report("down", targets: targets) + return + } + let engine = try ContainerEngine(config: config) let imageManager = try ImageManager(config: config) let networkManager = try NetworkManager(config: config) @@ -304,7 +339,7 @@ struct ComposeDown: AsyncParsableCommand { volumeManager: volumeManager ) - let events = try await orchestrator.down(composeFile: composeFile) + let events = try await orchestrator.down(composeFile: composeFile, removeVolumes: volumes) let totalResources = events.count ComposeFormatter.printEvents(events, total: totalResources) } @@ -321,9 +356,6 @@ struct ComposePS: AsyncParsableCommand { @Flag(name: .shortAndLong, help: "Show all stopped containers") var all = false - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Option(name: .long, parsing: .singleValue, help: "Filter services by a property") var filter: [String] = [] @@ -349,8 +381,9 @@ struct ComposePS: AsyncParsableCommand { var services: [String] = [] func run() async throws { - let (_, project, projectDir) = try options.loadCompose() + let (composeFile, project, projectDir) = try options.loadCompose() let config = MockerConfig() + try composeFile.validateServiceNames(services) let engine = try ContainerEngine(config: config) let imageManager = try ImageManager(config: config) @@ -370,8 +403,7 @@ struct ComposePS: AsyncParsableCommand { if !services.isEmpty { containers = containers.filter { c in - let svc = c.labels["com.mocker.compose.service"] ?? "" - return services.contains(svc) + services.contains { ComposeOrchestrator.belongs(c, to: $0, projectName: project) } } } @@ -419,9 +451,6 @@ struct ComposeLogs: AsyncParsableCommand { @Flag(name: .long, help: "Follow log output") var follow = false - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Option(name: .long, help: "Show logs for a specific container index") var index: Int? @@ -444,8 +473,9 @@ struct ComposeLogs: AsyncParsableCommand { var until: String? func run() async throws { - let (_, project, projectDir) = try options.loadCompose() + let (composeFile, project, projectDir) = try options.loadCompose() let config = MockerConfig() + if let service { try composeFile.validateServiceNames([service]) } let engine = try ContainerEngine(config: config) let imageManager = try ImageManager(config: config) let networkManager = try NetworkManager(config: config) @@ -463,7 +493,7 @@ struct ComposeLogs: AsyncParsableCommand { let containers = try await orchestrator.ps() let targets: [ContainerInfo] if let service { - targets = containers.filter { $0.name.contains(service) } + targets = containers.filter { ComposeOrchestrator.belongs($0, to: service, projectName: project) } } else { targets = containers } @@ -488,9 +518,6 @@ struct ComposeKill: AsyncParsableCommand { @Argument(help: "Service name (kills all if omitted)") var service: String? - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Flag(name: .customLong("remove-orphans"), help: "Remove containers for services not defined in the Compose file") var removeOrphans = false @@ -498,8 +525,16 @@ struct ComposeKill: AsyncParsableCommand { var signal: String? func run() async throws { - let (_, project, projectDir) = try options.loadCompose() + let (composeFile, project, projectDir) = try options.loadCompose() let config = MockerConfig() + if let service { try composeFile.validateServiceNames([service]) } + + if options.dryRun { + let names = service.map { [$0] } ?? composeFile.services.keys.sorted() + ComposeDryRun.report("kill", targets: names.map { "\(project)-\($0)-1" }) + return + } + let engine = try ContainerEngine(config: config) let imageManager = try ImageManager(config: config) let networkManager = try NetworkManager(config: config) @@ -515,7 +550,7 @@ struct ComposeKill: AsyncParsableCommand { ) let containers = try await orchestrator.ps() - let targets = service.map { s in containers.filter { $0.name.contains(s) } } ?? containers + let targets = service.map { s in containers.filter { ComposeOrchestrator.belongs($0, to: s, projectName: project) } } ?? containers for c in targets { try? await engine.stop(c.id) print(c.name) @@ -534,9 +569,6 @@ struct ComposeRestart: AsyncParsableCommand { @Argument(help: "Service name (restarts all if omitted)") var service: String? - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Flag(name: .customLong("no-deps"), help: "Don't restart dependent services") var noDeps = false @@ -546,6 +578,13 @@ struct ComposeRestart: AsyncParsableCommand { func run() async throws { let (composeFile, project, projectDir) = try options.loadCompose() let config = MockerConfig() + if let service { try composeFile.validateServiceNames([service]) } + + if options.dryRun { + let names = service.map { [$0] } ?? composeFile.serviceOrder() + ComposeDryRun.report("restart", targets: names.map { "\(project)-\($0)-1" }) + return + } let engine = try ContainerEngine(config: config) let imageManager = try ImageManager(config: config) @@ -594,9 +633,6 @@ struct ComposeBuildCommand: AsyncParsableCommand { @Flag(name: .long, help: "Check build configuration and exit") var check = false - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Option(name: .shortAndLong, help: "Set memory limit for the build container") var memory: String? @@ -622,17 +658,20 @@ struct ComposeBuildCommand: AsyncParsableCommand { var services: [String] = [] func run() async throws { - let (composeFile, _, projectDir) = try options.loadCompose() + let (composeFile, project, projectDir) = try options.loadCompose() let config = MockerConfig() - let manager = try ImageManager(config: config) + try composeFile.validateServiceNames(services) - let servicesToBuild = services.isEmpty - ? composeFile.services.filter { $0.value.build != nil } - : composeFile.services.filter { services.contains($0.key) && $0.value.build != nil } + let plan = Self.buildPlan(composeFile: composeFile, project: project, services: services) - for (name, service) in servicesToBuild { + if options.dryRun { + ComposeDryRun.report("build", targets: plan.map(\.tag)) + return + } + + let manager = try ImageManager(config: config) + for (name, service, tag) in plan { guard let buildConfig = service.build else { continue } - let tag = service.image ?? "\(name):latest" if !quiet { print("Building \(name)...") } let absContext = ImageManager.resolveContextPath(context: buildConfig.context, cwd: projectDir.path) let dockerfilePath = ImageManager.composeDockerfilePath( @@ -649,6 +688,23 @@ struct ComposeBuildCommand: AsyncParsableCommand { if !quiet { print("Successfully built \(name)") } } } + + /// Services with a `build:` section and the tag each one is built under. + /// + /// The tag must be `ComposeService.buildTag` — the same value the runtime looks up + /// when starting the service. Computing it here as a bare `:latest` made + /// builds self-referential: a Dockerfile saying `FROM caddy:latest` then resolved to + /// mocker's own previous build instead of the official base image. + static func buildPlan( + composeFile: ComposeFile, + project: String, + services: [String] + ) -> [(name: String, service: ComposeService, tag: String)] { + composeFile.services + .filter { $0.value.build != nil && (services.isEmpty || services.contains($0.key)) } + .sorted { $0.key < $1.key } + .map { ($0.key, $0.value, $0.value.buildTag(projectName: project)) } + } } // MARK: - Compose Pull @@ -667,9 +723,6 @@ struct ComposePull: AsyncParsableCommand { @Flag(name: .customLong("ignore-pull-failures"), help: "Pull what it can and ignores images with pull failures") var ignorePullFailures = false - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Flag(name: .customLong("ignore-buildable"), help: "Ignore images that can be built") var ignoreBuildable = false @@ -685,12 +738,19 @@ struct ComposePull: AsyncParsableCommand { func run() async throws { let (composeFile, _, _) = try options.loadCompose() let config = MockerConfig() - let manager = try ImageManager(config: config) + try composeFile.validateServiceNames(services) let servicesToPull = services.isEmpty ? composeFile.services : composeFile.services.filter { services.contains($0.key) } + if options.dryRun { + ComposeDryRun.report("pull", targets: servicesToPull.values.compactMap(\.image).sorted()) + return + } + + let manager = try ImageManager(config: config) + for (name, service) in servicesToPull { guard let image = service.image else { continue } do { @@ -721,9 +781,6 @@ struct ComposePush: AsyncParsableCommand { @Flag(name: .customLong("ignore-push-failures"), help: "Push what it can and ignores images with push failures") var ignorePushFailures = false - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Flag(name: .customLong("include-deps"), help: "Also push images of services declared as dependencies") var includeDeps = false @@ -736,12 +793,19 @@ struct ComposePush: AsyncParsableCommand { func run() async throws { let (composeFile, _, _) = try options.loadCompose() let config = MockerConfig() - let manager = try ImageManager(config: config) + try composeFile.validateServiceNames(services) let servicesToPush = services.isEmpty ? composeFile.services : composeFile.services.filter { services.contains($0.key) } + if options.dryRun { + ComposeDryRun.report("push", targets: servicesToPush.values.compactMap(\.image).sorted()) + return + } + + let manager = try ImageManager(config: config) + for (name, service) in servicesToPush { guard let image = service.image else { continue } do { @@ -797,9 +861,6 @@ struct ComposeExec: AsyncParsableCommand { @Option(name: .long, help: "Index of the container if service is scaled") var index: Int = 1 - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Flag(name: [.customShort("T"), .customLong("no-tty")], help: "Disable pseudo-TTY allocation") var noTty = false @@ -813,11 +874,17 @@ struct ComposeExec: AsyncParsableCommand { } func run() async throws { - let (_, project, _) = try options.loadCompose() + let (composeFile, project, _) = try options.loadCompose() let config = MockerConfig() - let engine = try ContainerEngine(config: config) + try composeFile.validateServiceNames([service]) let containerName = "\(project)-\(service)-\(index)" + if options.dryRun { + ComposeDryRun.report("exec", targets: [containerName]) + return + } + + let engine = try ContainerEngine(config: config) try await engine.exec(containerName, command: command, interactive: interactive, tty: tty) } } @@ -875,9 +942,6 @@ struct ComposeRun: AsyncParsableCommand { @Option(name: .customLong("cap-drop"), parsing: .singleValue, help: "Drop Linux capabilities") var capDrop: [String] = [] - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Option(name: .customLong("env-from-file"), parsing: .singleValue, help: "Set environment variables from file") var envFromFile: [String] = [] @@ -929,7 +993,6 @@ struct ComposeRun: AsyncParsableCommand { func run() async throws { let (composeFile, _, _) = try options.loadCompose() let config = MockerConfig() - let engine = try ContainerEngine(config: config) guard let svc = composeFile.services[service] else { throw MockerError.operationFailed("no such service: \(service)") @@ -939,6 +1002,13 @@ struct ComposeRun: AsyncParsableCommand { throw MockerError.operationFailed("service \(service) has no image") } + if options.dryRun { + ComposeDryRun.report("run", targets: [image]) + return + } + + let engine = try ContainerEngine(config: config) + var environment: [String: String] = [:] for item in env { let parts = item.split(separator: "=", maxSplits: 1) @@ -981,15 +1051,19 @@ struct ComposeStop: AsyncParsableCommand { @Option(name: .shortAndLong, help: "Specify a shutdown timeout in seconds") var timeout: Int = 10 - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Argument(help: "Services to stop (stops all if omitted)") var services: [String] = [] func run() async throws { - let (_, project, projectDir) = try options.loadCompose() + let (composeFile, project, projectDir) = try options.loadCompose() let config = MockerConfig() + try composeFile.validateServiceNames(services) + + if options.dryRun { + ComposeDryRun.report("stop", targets: Self.dryRunTargets(composeFile, project, services)) + return + } + let engine = try ContainerEngine(config: config) let imageManager = try ImageManager(config: config) let networkManager = try NetworkManager(config: config) @@ -1007,13 +1081,20 @@ struct ComposeStop: AsyncParsableCommand { let containers = try await orchestrator.ps() let targets = services.isEmpty ? containers - : containers.filter { c in services.contains(where: { c.name.contains($0) }) } + : containers.filter { c in services.contains { ComposeOrchestrator.belongs(c, to: $0, projectName: project) } } for c in targets { _ = try? await engine.stop(c.id) print("Container \(c.name) Stopped") } } + + /// Container names a service-scoped subcommand would act on. Shared by the + /// `--dry-run` branches of stop/start/rm, which select the same way. + static func dryRunTargets(_ composeFile: ComposeFile, _ project: String, _ services: [String]) -> [String] { + let names = services.isEmpty ? composeFile.services.keys.sorted() : services.sorted() + return names.map { "\(project)-\($0)-1" } + } } // MARK: - Compose Start @@ -1026,9 +1107,6 @@ struct ComposeStart: AsyncParsableCommand { @OptionGroup var options: ComposeOptions - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Flag(name: .long, help: "Wait for services to be running|healthy") var wait = false @@ -1039,8 +1117,15 @@ struct ComposeStart: AsyncParsableCommand { var services: [String] = [] func run() async throws { - let (_, project, projectDir) = try options.loadCompose() + let (composeFile, project, projectDir) = try options.loadCompose() let config = MockerConfig() + try composeFile.validateServiceNames(services) + + if options.dryRun { + ComposeDryRun.report("start", targets: ComposeStop.dryRunTargets(composeFile, project, services)) + return + } + let engine = try ContainerEngine(config: config) let imageManager = try ImageManager(config: config) let networkManager = try NetworkManager(config: config) @@ -1058,7 +1143,7 @@ struct ComposeStart: AsyncParsableCommand { let containers = try await orchestrator.ps() let targets = services.isEmpty ? containers - : containers.filter { c in services.contains(where: { c.name.contains($0) }) } + : containers.filter { c in services.contains { ComposeOrchestrator.belongs(c, to: $0, projectName: project) } } for c in targets { _ = try? await engine.start(c.id) @@ -1086,15 +1171,19 @@ struct ComposeRm: AsyncParsableCommand { @Flag(name: [.customShort("v"), .long], help: "Remove any anonymous volumes attached to containers") var volumes = false - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Argument(help: "Services to remove (removes all if omitted)") var services: [String] = [] func run() async throws { - let (_, project, projectDir) = try options.loadCompose() + let (composeFile, project, projectDir) = try options.loadCompose() let config = MockerConfig() + try composeFile.validateServiceNames(services) + + if options.dryRun { + ComposeDryRun.report("rm", targets: ComposeStop.dryRunTargets(composeFile, project, services)) + return + } + let engine = try ContainerEngine(config: config) let imageManager = try ImageManager(config: config) let networkManager = try NetworkManager(config: config) @@ -1112,7 +1201,7 @@ struct ComposeRm: AsyncParsableCommand { let containers = try await orchestrator.ps() let targets = services.isEmpty ? containers - : containers.filter { c in services.contains(where: { c.name.contains($0) }) } + : containers.filter { c in services.contains { ComposeOrchestrator.belongs(c, to: $0, projectName: project) } } for c in targets { if stopBeforeRemove { _ = try? await engine.stop(c.id) } @@ -1141,9 +1230,6 @@ struct ComposeConfig: AsyncParsableCommand { @Flag(name: .shortAndLong, help: "Only validate the configuration, don't print anything") var quiet = false - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Flag(name: .long, help: "Print the environment variables") var environment = false @@ -1193,7 +1279,7 @@ struct ComposeConfig: AsyncParsableCommand { var variables = false func run() async throws { - let (composeFile, _, _) = try options.loadCompose() + let (composeFile, project, _) = try options.loadCompose() if quiet { return } @@ -1211,7 +1297,7 @@ struct ComposeConfig: AsyncParsableCommand { return } - print(Self.renderConfig(composeFile: composeFile, projectName: options.projectName)) + print(Self.renderConfig(composeFile: composeFile, projectName: project)) } /// Render the resolved Compose file as YAML-like output, mirroring what `up` @@ -1222,21 +1308,54 @@ struct ComposeConfig: AsyncParsableCommand { /// are wired into the container's `-m`/`-c` flags by `ComposeOrchestrator. /// startService` — this must surface them too, or `config` misleadingly implies /// they're dropped (see #62). - static func renderConfig(composeFile: ComposeFile, projectName: String?) -> String { + /// Quote a whole scalar when leaving it bare would change how YAML reads it — a `#` + /// starts a comment, a `:` splits a mapping, and surrounding spaces are stripped. + /// Must be applied to the entire scalar: quoting only the tail of `KEY=value` still + /// leaves the `#` outside the quotes and the rest of the line commented out. + static func yamlScalar(_ value: String) -> String { + let needsQuoting = value.contains("#") || value.contains(":") + || value != value.trimmingCharacters(in: .whitespaces) + || value.isEmpty + guard needsQuoting else { return value } + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + } + + static func renderConfig(composeFile: ComposeFile, projectName: String) -> String { var lines: [String] = [] - lines.append("name: \(projectName ?? "default")") + // Non-optional on purpose: this printed a literal `default` for years because the + // resolved name was available at the call site and simply not passed in. + lines.append("name: \(projectName)") lines.append("services:") for (name, svc) in composeFile.services.sorted(by: { $0.key < $1.key }) { lines.append(" \(name):") if let image = svc.image { lines.append(" image: \(image)") } - if let build = svc.build { lines.append(" build: \(build)") } + if let build = svc.build { + // Rendered as the Compose long form; interpolating the struct printed + // Swift's own description (`ComposeBuild(context: "...", ...)`). + lines.append(" build:") + lines.append(" context: \(build.context)") + if let dockerfile = build.dockerfile { lines.append(" dockerfile: \(dockerfile)") } + if let target = build.target { lines.append(" target: \(target)") } + if !build.args.isEmpty { + lines.append(" args:") + build.args.sorted { $0.key < $1.key }.forEach { + lines.append(" \($0.key): \(yamlScalar($0.value))") + } + } + } if !svc.ports.isEmpty { lines.append(" ports:") svc.ports.forEach { lines.append(" - \($0)") } } if !svc.environment.isEmpty { lines.append(" environment:") - svc.environment.forEach { lines.append(" - \($0)") } + // Interpolating the pair printed Swift's tuple syntax, `(key: "FOO", value: "bar")`. + svc.environment.sorted { $0.key < $1.key }.forEach { + lines.append(" - \(yamlScalar("\($0.key)=\($0.value)"))") + } } if svc.memLimit != nil || svc.cpus != nil || svc.memReservation != nil || svc.cpusReservation != nil { lines.append(" deploy:") @@ -1270,9 +1389,6 @@ struct ComposeCreate: AsyncParsableCommand { @Flag(name: .long, help: "Build images before starting containers") var build = false - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Flag(name: .customLong("force-recreate"), help: "Recreate containers even if configuration hasn't changed") var forceRecreate = false @@ -1310,12 +1426,20 @@ struct ComposeCreate: AsyncParsableCommand { // create is essentially up without starting — for now delegate to up var (composeFile, project, projectDir) = try options.loadCompose() let config = MockerConfig() - try config.ensureDirectories() if !services.isEmpty { + try composeFile.validateServiceNames(services) composeFile = composeFile.filtering(services: services) } + // Guard before `ensureDirectories`: a dry run must not write anything at all. + if options.dryRun { + ComposeDryRun.report("create", targets: composeFile.serviceOrder().map { "\(project)-\($0)-1" }) + return + } + + try config.ensureDirectories() + let engine = try ContainerEngine(config: config) let imageManager = try ImageManager(config: config) let networkManager = try NetworkManager(config: config) @@ -1352,9 +1476,6 @@ struct ComposeImages: AsyncParsableCommand { @OptionGroup var options: ComposeOptions - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Option(name: .long, help: "Format the output (table|json)") var format: String? @@ -1400,15 +1521,13 @@ struct ComposeTop: AsyncParsableCommand { @OptionGroup var options: ComposeOptions - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Argument(help: "Services to show (shows all if omitted)") var services: [String] = [] func run() async throws { - let (_, project, projectDir) = try options.loadCompose() + let (composeFile, project, projectDir) = try options.loadCompose() let config = MockerConfig() + try composeFile.validateServiceNames(services) let engine = try ContainerEngine(config: config) let imageManager = try ImageManager(config: config) let networkManager = try NetworkManager(config: config) @@ -1426,7 +1545,7 @@ struct ComposeTop: AsyncParsableCommand { let containers = try await orchestrator.ps() let targets = services.isEmpty ? containers - : containers.filter { c in services.contains(where: { c.name.contains($0) }) } + : containers.filter { c in services.contains { ComposeOrchestrator.belongs(c, to: $0, projectName: project) } } for c in targets { print("\(c.name)") @@ -1459,15 +1578,13 @@ struct ComposePort: AsyncParsableCommand { @Option(name: .long, help: "Index of the container if service is scaled") var index: Int = 1 - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Option(name: .long, help: "Protocol (tcp or udp)") var `protocol`: String? func run() async throws { - let (_, project, _) = try options.loadCompose() + let (composeFile, project, _) = try options.loadCompose() let config = MockerConfig() + try composeFile.validateServiceNames([service]) let engine = try ContainerEngine(config: config) let containerName = "\(project)-\(service)-\(index)" @@ -1488,15 +1605,19 @@ struct ComposePause: AsyncParsableCommand { @OptionGroup var options: ComposeOptions - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Argument(help: "Services to pause (pauses all if omitted)") var services: [String] = [] func run() async throws { - let (_, project, projectDir) = try options.loadCompose() + let (composeFile, project, projectDir) = try options.loadCompose() let config = MockerConfig() + try composeFile.validateServiceNames(services) + + if options.dryRun { + ComposeDryRun.report("pause", targets: ComposeStop.dryRunTargets(composeFile, project, services)) + return + } + let engine = try ContainerEngine(config: config) let imageManager = try ImageManager(config: config) let networkManager = try NetworkManager(config: config) @@ -1514,7 +1635,7 @@ struct ComposePause: AsyncParsableCommand { let containers = try await orchestrator.ps() let targets = services.isEmpty ? containers - : containers.filter { c in services.contains(where: { c.name.contains($0) }) } + : containers.filter { c in services.contains { ComposeOrchestrator.belongs(c, to: $0, projectName: project) } } for c in targets { try await engine.pause(c.id) @@ -1531,15 +1652,19 @@ struct ComposeUnpause: AsyncParsableCommand { @OptionGroup var options: ComposeOptions - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Argument(help: "Services to unpause (unpauses all if omitted)") var services: [String] = [] func run() async throws { - let (_, project, projectDir) = try options.loadCompose() + let (composeFile, project, projectDir) = try options.loadCompose() let config = MockerConfig() + try composeFile.validateServiceNames(services) + + if options.dryRun { + ComposeDryRun.report("unpause", targets: ComposeStop.dryRunTargets(composeFile, project, services)) + return + } + let engine = try ContainerEngine(config: config) let imageManager = try ImageManager(config: config) let networkManager = try NetworkManager(config: config) @@ -1557,7 +1682,7 @@ struct ComposeUnpause: AsyncParsableCommand { let containers = try await orchestrator.ps() let targets = services.isEmpty ? containers - : containers.filter { c in services.contains(where: { c.name.contains($0) }) } + : containers.filter { c in services.contains { ComposeOrchestrator.belongs(c, to: $0, projectName: project) } } for c in targets { try await engine.unpause(c.id) @@ -1583,12 +1708,14 @@ struct ComposeLs: AsyncParsableCommand { @Flag(name: .shortAndLong, help: "Only display project names") var quiet = false - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Option(name: .long, parsing: .singleValue, help: "Filter output based on conditions provided") var filter: [String] = [] + /// `ls` has no ComposeOptions group, so it declares the shared flag itself. + /// Listing changes nothing, so there is nothing for a dry run to skip. + @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") + var dryRun = false + func run() async throws { let config = MockerConfig() let engine = try ContainerEngine(config: config) @@ -1641,15 +1768,18 @@ struct ComposeCp: AsyncParsableCommand { @Flag(name: .long, help: "Archive mode (copy all uid/gid information)") var archive = false - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Flag(name: [.customShort("L"), .customLong("follow-link")], help: "Always follow symbol link in source path") var followLink = false func run() async throws { let (_, project, _) = try options.loadCompose() let config = MockerConfig() + + if options.dryRun { + ComposeDryRun.report("cp", targets: ["\(source) -> \(destination)"]) + return + } + let engine = try ContainerEngine(config: config) // Parse service:path format @@ -1680,9 +1810,6 @@ struct ComposeEvents: AsyncParsableCommand { @OptionGroup var options: ComposeOptions - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Flag(name: .long, help: "Output events as a stream of json objects") var json = false @@ -1713,9 +1840,6 @@ struct ComposeAttach: AsyncParsableCommand { @Option(name: .customLong("detach-keys"), help: "Override the key sequence for detaching from a container") var detachKeys: String? - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Option(name: .long, help: "Index of the container if service has multiple replicas") var index: Int? @@ -1729,11 +1853,18 @@ struct ComposeAttach: AsyncParsableCommand { var service: String func run() async throws { - let (_, project, _) = try options.loadCompose() + let (composeFile, project, _) = try options.loadCompose() let config = MockerConfig() - let engine = try ContainerEngine(config: config) + try composeFile.validateServiceNames([service]) let idx = index ?? 1 let containerName = "\(project)-\(service)-\(idx)" + + if options.dryRun { + ComposeDryRun.report("attach", targets: [containerName]) + return + } + + let engine = try ContainerEngine(config: config) try await engine.exec(containerName, command: []) } } @@ -1754,9 +1885,6 @@ struct ComposeCommit: AsyncParsableCommand { @Option(name: [.customShort("c"), .long], parsing: .singleValue, help: "Apply Dockerfile instruction to the created image") var change: [String] = [] - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Option(name: .long, help: "Index of the container if service has multiple replicas") var index: Int? @@ -1787,9 +1915,6 @@ struct ComposeExport: AsyncParsableCommand { @OptionGroup var options: ComposeOptions - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Option(name: .long, help: "Index of the container if service has multiple replicas") var index: Int? @@ -1814,9 +1939,6 @@ struct ComposeScale: AsyncParsableCommand { @OptionGroup var options: ComposeOptions - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Flag(name: .customLong("no-deps"), help: "Don't start linked services") var noDeps = false @@ -1841,9 +1963,6 @@ struct ComposeStats: AsyncParsableCommand { @Flag(name: .shortAndLong, help: "Show all containers (default shows just running)") var all = false - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Option(name: .long, help: "Format output using a custom template") var format: String? @@ -1869,15 +1988,17 @@ struct ComposeVersion: AsyncParsableCommand { abstract: "Show the Docker Compose version information" ) - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Option(name: .shortAndLong, help: "Format the output (pretty|json)") var format: String? @Flag(name: .long, help: "Shows only Compose's version number") var short = false + /// `version` has no ComposeOptions group, so it declares the shared flag itself. + /// Printing a version changes nothing, so a dry run has nothing to skip. + @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") + var dryRun = false + func run() async throws { print("Mocker Compose version v\(Version.currentVersion)") } @@ -1893,9 +2014,6 @@ struct ComposeVolumes: AsyncParsableCommand { @OptionGroup var options: ComposeOptions - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Option(name: .long, help: "Format output using a custom template") var format: String? @@ -1923,9 +2041,6 @@ struct ComposeWait: AsyncParsableCommand { @Flag(name: .customLong("down-project"), help: "Drops project when the first container stops") var downProject = false - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Argument(help: "Service names") var services: [String] = [] @@ -1944,9 +2059,6 @@ struct ComposeWatch: AsyncParsableCommand { @OptionGroup var options: ComposeOptions - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Flag(name: .customLong("no-up"), help: "Do not build & start services before watching") var noUp = false @@ -1974,9 +2086,6 @@ struct ComposeBridge: AsyncParsableCommand { @OptionGroup var options: ComposeOptions - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - func run() async throws { throw MockerError.operationFailed("compose bridge is not yet supported with Apple Containerization") } @@ -1995,9 +2104,6 @@ struct ComposePublish: AsyncParsableCommand { @Flag(name: .long, help: "Published compose application (includes env)") var app = false - @Flag(name: .customLong("dry-run"), help: "Execute command in dry run mode") - var dryRun = false - @Option(name: .customLong("oci-version"), help: "OCI image/artifact specification version") var ociVersion: String? diff --git a/Sources/Mocker/Commands/History.swift b/Sources/Mocker/Commands/History.swift index 154d8e2..1a0be55 100644 --- a/Sources/Mocker/Commands/History.swift +++ b/Sources/Mocker/Commands/History.swift @@ -51,45 +51,3 @@ struct History: AsyncParsableCommand { TableFormatter.print(headers: headers, rows: rows) } } - -/// Parses OCI `created` timestamps and renders them as human-relative dates. -/// -/// `ISO8601DateFormatter` only understands millisecond fractions, so OCI configs -/// carrying nanosecond precision (e.g. `2024-11-19T17:01:02.000000000Z`) fail to -/// parse and previously fell back to the raw string. This helper tolerates variable -/// fractional precision, including 9-digit nanoseconds. -enum RelativeDate { - /// Parses an RFC3339 / RFC3339Nano timestamp into a `Date`, or `nil` if unparseable. - static func parse(_ string: String) -> Date? { - let fractional = ISO8601DateFormatter() - fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = fractional.date(from: string) { return date } - - let plain = ISO8601DateFormatter() - plain.formatOptions = [.withInternetDateTime] - if let date = plain.date(from: string) { return date } - - // Last resort: strip an over-precise fractional component and retry. - if let stripped = stripFractionalSeconds(string) { - return plain.date(from: stripped) - } - return nil - } - - /// Renders the timestamp as an abbreviated relative date, falling back to the - /// raw string only when it cannot be parsed. - static func humanRelative(_ string: String, relativeTo now: Date = Date()) -> String { - guard let date = parse(string) else { return string } - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - return formatter.localizedString(for: date, relativeTo: now) - } - - /// Removes the fractional-seconds component, preserving the timezone designator. - private static func stripFractionalSeconds(_ string: String) -> String? { - guard let dot = string.firstIndex(of: ".") else { return nil } - let afterDot = string[string.index(after: dot)...] - let timezone = afterDot.firstIndex { !$0.isNumber }.map { String(afterDot[$0...]) } ?? "" - return String(string[string.startIndex.. = ["--dry-run"] + static func reorder(_ args: [String]) -> [String] { guard args.first == "compose" else { return args } @@ -44,6 +47,12 @@ enum ComposeArgNormalizer { continue } + if boolFlags.contains(token) { + relocated.append(token) + index += 1 + continue + } + if valueFlags.contains(token) { relocated.append(token) // Consume the following value token unless it is itself a flag diff --git a/Sources/MockerKit/API/DockerAPIMappers.swift b/Sources/MockerKit/API/DockerAPIMappers.swift index 5d4d982..88afcce 100644 --- a/Sources/MockerKit/API/DockerAPIMappers.swift +++ b/Sources/MockerKit/API/DockerAPIMappers.swift @@ -46,9 +46,11 @@ public func mapToImageListItem(_ i: ImageInfo) -> [String: Any] { "ParentId": "", "RepoTags": ["\(repo):\(tag)"], "RepoDigests": [Any](), - "Created": Int(i.created.timeIntervalSince1970), - "Size": Int(i.size), - "VirtualSize": Int(i.size), + // The Engine API types these as concrete integers, so an unknown value is + // reported as 0 here even though the CLI renders it as `N/A`. + "Created": Int(i.created?.timeIntervalSince1970 ?? 0), + "Size": Int(i.size ?? 0), + "VirtualSize": Int(i.size ?? 0), "SharedSize": -1, "Containers": -1, "Labels": i.labels, diff --git a/Sources/MockerKit/API/DockerAPIServer.swift b/Sources/MockerKit/API/DockerAPIServer.swift index 5356db0..8a695db 100644 --- a/Sources/MockerKit/API/DockerAPIServer.swift +++ b/Sources/MockerKit/API/DockerAPIServer.swift @@ -147,7 +147,8 @@ final class HTTPHandler: ChannelInboundHandler, @unchecked Sendable { private func infoJSON() async -> [String: Any] { let containers = (try? await engine.list(all: true)) ?? [] let running = containers.filter { $0.state == .running }.count - let imageCount = ((try? await images.list()) ?? []).count + // A count only — no need to read every image's manifest and config. + let imageCount = ((try? await images.list(enrich: false)) ?? []).count return [ "ID": "MOCKER", "Containers": containers.count, diff --git a/Sources/MockerKit/Compose/ComposeFile.swift b/Sources/MockerKit/Compose/ComposeFile.swift index 2a11126..4f1bb21 100644 --- a/Sources/MockerKit/Compose/ComposeFile.swift +++ b/Sources/MockerKit/Compose/ComposeFile.swift @@ -7,15 +7,19 @@ public struct ComposeFile: Sendable { public var services: [String: ComposeService] public var networks: [String: ComposeNetwork] public var volumes: [String: ComposeVolume] + /// Top-level `name:` key — one of the project-name sources (see `resolveProjectName`). + public var name: String? public init( services: [String: ComposeService] = [:], networks: [String: ComposeNetwork] = [:], - volumes: [String: ComposeVolume] = [:] + volumes: [String: ComposeVolume] = [:], + name: String? = nil ) { self.services = services self.networks = networks self.volumes = volumes + self.name = name } /// Default compose file names searched in order, matching Docker Compose V2 behaviour. @@ -33,9 +37,19 @@ public struct ComposeFile: Sendable { return cwdURL } - /// Normalize a directory basename to a compose project name (lowercase + spaces to dashes). + /// Normalize a string to a valid compose project name: lowercase, only + /// `[a-z0-9_-]`, and starting with an alphanumeric — the character set Docker + /// Compose enforces. Anything else becomes a dash. public static func normalizeProjectName(_ s: String) -> String { - s.lowercased().replacingOccurrences(of: " ", with: "-") + var chars = s.lowercased().map { ch -> Character in + ch.isASCII && (ch.isLetter || ch.isNumber || ch == "_" || ch == "-") ? ch : "-" + } + while let first = chars.first, !(first.isLetter || first.isNumber) { + chars.removeFirst() + } + // A name of only invalid characters leaves nothing to work with; keep the + // previous placeholder rather than emitting resource names starting with `-`. + return chars.isEmpty ? "default" : String(chars) } /// Return the path of the first default compose file found in `directory`. @@ -50,6 +64,7 @@ public struct ComposeFile: Sendable { } /// Parse a compose file at 'path'. '.env' auto-discovery uses 'projectDir/.env'. + /// Top-level `include:` entries are resolved relative to the file's own directory. public static func load(from path: String, projectDir: URL) throws -> ComposeFile { let url = URL(fileURLWithPath: path) guard FileManager.default.fileExists(atPath: path) else { @@ -57,26 +72,193 @@ public struct ComposeFile: Sendable { } let content = try String(contentsOf: url, encoding: .utf8) - return try parseAndSubstitute(content: content, projectDir: projectDir) + return try parseFile( + content: content, + fileDir: url.standardizedFileURL.deletingLastPathComponent(), + envFiles: [projectDir.appendingPathComponent(".env").path], + visited: [url.resolvingSymlinksInPath().path], + depth: 0 + ) } /// Parse a compose file from an in-memory string (stdin `-f -`). /// '.env' auto-discovery uses 'projectDir/.env'. Throws if content is empty. + /// `include:` paths resolve relative to `projectDir`, which is all the location + /// context a piped file has. public static func load(content: String, projectDir: URL) throws -> ComposeFile { if content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { throw MockerError.composeParseError("compose file content is empty") } - return try parseAndSubstitute(content: content, projectDir: projectDir) + return try parseFile( + content: content, + fileDir: projectDir, + envFiles: [projectDir.appendingPathComponent(".env").path], + visited: [], + depth: 0 + ) } - private static func parseAndSubstitute(content: String, projectDir: URL) throws -> ComposeFile { - let envFile = projectDir.appendingPathComponent(".env").path - let dotEnv = loadDotEnv(from: envFile) + /// Maximum `include:` nesting depth — a cheap backstop next to the cycle guard. + private static let maxIncludeDepth = 10 + + /// Parse one compose file and recursively resolve its `include:` entries. + /// + /// - `fileDir`: directory of the file being parsed; `include` paths resolve against it. + /// - `envFiles`: env files driving `${VAR}` interpolation for *this* file only — + /// an include's env never leaks into its parent or siblings. + /// - `visited`: canonical paths on the current include chain, for cycle detection. + private static func parseFile( + content: String, + fileDir: URL, + envFiles: [String], + visited: Set, + depth: Int + ) throws -> ComposeFile { + guard depth <= maxIncludeDepth else { + throw MockerError.composeParseError("include: nesting deeper than \(maxIncludeDepth) levels") + } + + var dotEnv: [String: String] = [:] + for file in envFiles { + dotEnv.merge(loadDotEnv(from: file)) { _, new in new } + } // Substitute ${VAR:-default} and $VAR patterns before YAML parsing let substituted = substituteVariables(in: content, dotEnv: dotEnv) + guard let dict = try Yams.load(yaml: substituted) as? [String: Any] else { + throw MockerError.composeParseError("Invalid YAML structure") + } + let own = try parse(dict) + + guard let includes = try parseIncludes(dict["include"]), !includes.isEmpty else { + return own + } + + var included: [ComposeFile] = [] + for entry in includes { + for rawPath in entry.paths { + let url = URL(fileURLWithPath: rawPath, relativeTo: fileDir).standardizedFileURL + let canonical = url.resolvingSymlinksInPath().path + guard !visited.contains(canonical) else { + throw MockerError.composeParseError("include: cycle detected at \(url.path)") + } + guard let body = try? String(contentsOf: url, encoding: .utf8) else { + throw MockerError.composeFileNotFound(url.path) + } + + // Per the spec, an entry's project_directory defaults to the included + // file's own directory, and its env_file to `.env` beneath that. + let entryDir = entry.projectDirectory + .map { URL(fileURLWithPath: $0, relativeTo: fileDir).standardizedFileURL } + ?? url.deletingLastPathComponent() + let entryEnvFiles = entry.envFiles.isEmpty + ? [entryDir.appendingPathComponent(".env").path] + : entry.envFiles.map { URL(fileURLWithPath: $0, relativeTo: entryDir).path } + + var model = try parseFile( + content: body, + fileDir: url.deletingLastPathComponent(), + envFiles: entryEnvFiles, + visited: visited.union([canonical]), + depth: depth + 1 + ) + // The orchestrator only knows the top-level project directory, so an + // included service's relative paths are anchored to the include's own + // directory here instead. + model.anchorRelativePaths(to: entryDir) + // An include contributes resources, not identity: its `name:` must not + // become the including project's name. + model.name = nil + included.append(model) + } + } + + // Parent last: its own inline definitions override anything it includes. + return merge(included + [own]) + } + + /// One `include:` entry, in either the short (`- path/to/file.yml`) or long + /// (`- {path, project_directory, env_file}`) form. + private struct IncludeEntry { + var paths: [String] + var projectDirectory: String? + var envFiles: [String] + } - return try parse(substituted) + /// Decode the top-level `include:` list. Returns nil when the key is absent. + /// A malformed entry is an error: silently dropping it is the very failure this + /// element was added to fix. + private static func parseIncludes(_ value: Any?) throws -> [IncludeEntry]? { + guard let value else { return nil } + guard let list = value as? [Any] else { + throw MockerError.composeParseError("include: must be a list of entries") + } + return try list.map { item in + if let path = item as? String { + return IncludeEntry(paths: [path], projectDirectory: nil, envFiles: []) + } + guard let dict = item as? [String: Any] else { + throw MockerError.composeParseError("include: entry must be a path or a mapping") + } + let paths: [String] + if let path = dict["path"] as? String { + paths = [path] + } else if let list = dict["path"] as? [Any] { + paths = list.map { "\($0)" } + } else { + throw MockerError.composeParseError("include: entry is missing a `path`") + } + guard !paths.isEmpty else { + throw MockerError.composeParseError("include: entry has an empty `path`") + } + let envFiles: [String] + if let file = dict["env_file"] as? String { + envFiles = [file] + } else if let list = dict["env_file"] as? [Any] { + // Entries are either a path or the long form `{path, required}`. + envFiles = try list.map { item in + if let path = item as? String { return path } + guard let path = (item as? [String: Any])?["path"] as? String else { + throw MockerError.composeParseError("include: env_file entry is missing a `path`") + } + return path + } + } else { + envFiles = [] + } + return IncludeEntry( + paths: paths, + projectDirectory: dict["project_directory"] as? String, + envFiles: envFiles + ) + } + } + + /// Rewrite every service's relative bind-mount sources and build context to + /// absolute paths under `dir`. Named volumes, anonymous volumes and + /// already-absolute paths are left untouched. + mutating func anchorRelativePaths(to dir: URL) { + for (name, service) in services { + services[name]?.volumes = service.volumes.map { Self.anchorVolumeSpec($0, to: dir) } + // `build.dockerfile` needs no anchoring of its own: it is resolved against + // the build context, which is absolute once this runs. + guard var build = service.build, !build.context.hasPrefix("/") else { continue } + build.context = dir.appendingPathComponent(build.context).standardized.path + services[name]?.build = build + } + } + + /// Absolutize the source of a `source:target[:mode]` spec when it is a relative + /// bind mount. Mirrors the branch conditions in `ComposeOrchestrator.resolveVolumeMounts`. + static func anchorVolumeSpec(_ spec: String, to dir: URL) -> String { + let parts = spec.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2 else { return spec } // anonymous volume: "/data" + let source = String(parts[0]) + guard !source.isEmpty, !source.hasPrefix("/"), !source.hasPrefix("~"), + source.hasPrefix(".") || source.contains("/") else { + return spec // absolute, home-relative, or a named volume + } + return dir.appendingPathComponent(source).standardized.path + ":" + String(parts[1]) } /// Load key=value pairs from a .env file. @@ -127,17 +309,24 @@ public struct ComposeFile: Sendable { return result } - /// Parse a docker-compose.yml string. + /// Parse a docker-compose.yml string. `include:` is not resolved here — it needs + /// the file's location, so it is handled by `load(from:projectDir:)`. public static func parse(_ yaml: String) throws -> ComposeFile { guard let dict = try Yams.load(yaml: yaml) as? [String: Any] else { throw MockerError.composeParseError("Invalid YAML structure") } + return try parse(dict) + } + private static func parse(_ dict: [String: Any]) throws -> ComposeFile { let services = try parseServices(dict["services"] as? [String: Any] ?? [:]) let networks = parseNetworks(dict["networks"] as? [String: Any] ?? [:]) let volumes = parseVolumes(dict["volumes"] as? [String: Any] ?? [:]) - return ComposeFile(services: services, networks: networks, volumes: volumes) + return ComposeFile( + services: services, networks: networks, volumes: volumes, + name: dict["name"] as? String + ) } private static func parseServices(_ dict: [String: Any]) throws -> [String: ComposeService] { @@ -165,9 +354,15 @@ public struct ComposeFile: Sendable { var volumes: [String: ComposeVolume] = [:] for (name, value) in dict { let volDict = value as? [String: Any] ?? [:] + // `external` is either a bool or, in the legacy long form, a mapping + // (`external: {name: shared}`) — both mean "not owned by this project". + let externalDict = volDict["external"] as? [String: Any] + let external = volDict["external"] as? Bool ?? (volDict["external"] != nil) volumes[name] = ComposeVolume( name: name, - driver: volDict["driver"] as? String ?? "local" + driver: volDict["driver"] as? String ?? "local", + external: external, + customName: volDict["name"] as? String ?? externalDict?["name"] as? String ) } return volumes @@ -209,7 +404,17 @@ public struct ComposeFile: Sendable { for name in requested { include(name) } let filteredServices = services.filter { included.contains($0.key) } - return ComposeFile(services: filteredServices, networks: networks, volumes: volumes) + return ComposeFile(services: filteredServices, networks: networks, volumes: volumes, name: self.name) + } + + /// Throw `no such service: ` for any requested name absent from the project, + /// matching `docker compose`'s behaviour of erroring instead of silently doing nothing. + /// Only literal, user-typed names are checked — transitive dependencies are resolved + /// afterwards by `filtering(services:)`. + public func validateServiceNames(_ requested: [String]) throws { + for name in requested where services[name] == nil { + throw MockerError.operationFailed("no such service: \(name)") + } } /// Merge multiple compose files in order, matching `docker compose -f a -f b` @@ -229,9 +434,30 @@ public struct ComposeFile: Sendable { } result.networks.merge(overlay.networks) { _, new in new } result.volumes.merge(overlay.volumes) { _, new in new } + result.name = overlay.name ?? result.name } return result } + + /// Resolve the effective Compose project name, matching upstream precedence: + /// `-p` flag → `COMPOSE_PROJECT_NAME` in the environment → `COMPOSE_PROJECT_NAME` + /// in `projectDir/.env` → top-level `name:` in the compose file → directory basename. + public static func resolveProjectName( + explicit: String?, + composeFileName: String? = nil, + projectDir: URL, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> String { + let candidates = [ + explicit, + environment["COMPOSE_PROJECT_NAME"], + loadDotEnv(from: projectDir.appendingPathComponent(".env").path)["COMPOSE_PROJECT_NAME"], + composeFileName, + projectDir.lastPathComponent, + ] + let resolved = candidates.compactMap { $0 }.first { !$0.isEmpty } ?? projectDir.lastPathComponent + return normalizeProjectName(resolved) + } } /// A service definition in a compose file. @@ -629,11 +855,26 @@ public struct ComposeNetwork: Sendable { /// Volume definition in a compose file. public struct ComposeVolume: Sendable { + /// The key this volume is declared under in the compose file. public var name: String public var driver: String + /// `external: true` — the volume lives outside the project lifecycle: it is + /// neither created by `up` nor removed by `down --volumes`. + public var external: Bool + /// Explicit `name:` override — used verbatim, without the project prefix. + public var customName: String? - public init(name: String, driver: String = "local") { + public init(name: String, driver: String = "local", external: Bool = false, customName: String? = nil) { self.name = name self.driver = driver + self.external = external + self.customName = customName + } + + /// The volume's real name in the runtime: an explicit `name:` wins, an external + /// volume keeps its declared key, and everything else is project-namespaced. + public func runtimeName(projectName: String) -> String { + if let customName { return customName } + return external ? name : "\(projectName)-\(name)" } } diff --git a/Sources/MockerKit/Compose/ComposeOrchestrator.swift b/Sources/MockerKit/Compose/ComposeOrchestrator.swift index 3f37783..016a253 100644 --- a/Sources/MockerKit/Compose/ComposeOrchestrator.swift +++ b/Sources/MockerKit/Compose/ComposeOrchestrator.swift @@ -10,6 +10,7 @@ public enum ComposeEvent: Sendable { case containerStopped(String) case containerRemoved(String) case networkRemoved(String) + case volumeRemoved(String) } /// Orchestrates multi-container deployments from a compose file. @@ -62,17 +63,16 @@ public actor ComposeOrchestrator { } } - // Create volumes - for (name, vol) in composeFile.volumes.sorted(by: { $0.key < $1.key }) { - let fullName = "\(projectName)-\(name)" - if (try? await volumeManager.create(name: fullName, driver: vol.driver)) != nil { + // Create volumes. External volumes are declared, not owned — the project + // must use them as they are and never create (or later remove) them. + for (fullName, driver) in Self.volumesToCreate(composeFile: composeFile, projectName: projectName) { + if (try? await volumeManager.create(name: fullName, driver: driver)) != nil { events.append(.volumeCreated(fullName)) } } - let prefix = "\(projectName)-" let observed = (try? await engine.list(all: true))? - .filter { $0.name.hasPrefix(prefix) } + .filter { Self.belongs($0, toProject: projectName) } .map { container -> ObservedContainer in ObservedContainer( name: container.name, @@ -141,12 +141,12 @@ public actor ComposeOrchestrator { } /// Stop and remove all services. - public func down(composeFile: ComposeFile) async throws -> [ComposeEvent] { + /// - Parameter removeVolumes: also remove the project's named volumes (compose `down -v`). + public func down(composeFile: ComposeFile, removeVolumes: Bool = false) async throws -> [ComposeEvent] { var events: [ComposeEvent] = [] - let containers = try await engine.list(all: true) - let prefix = "\(projectName)-" + let containers = try await ps() - for container in containers where container.name.hasPrefix(prefix) { + for container in containers { if container.state.isActive { _ = try await engine.stop(container.id) events.append(.containerStopped(container.name)) @@ -163,14 +163,71 @@ public actor ComposeOrchestrator { } } + if removeVolumes { + for fullName in Self.volumesToRemove(composeFile: composeFile, projectName: projectName) { + if (try? await volumeManager.remove(fullName)) != nil { + events.append(.volumeRemoved(fullName)) + } + } + } + return events } + /// Whether a container belongs to `service` in this project. The label written at + /// creation time is authoritative; containers predating it fall back to the + /// `--` naming, which is still an exact match. + public nonisolated static func belongs( + _ container: ContainerInfo, + to service: String, + projectName: String + ) -> Bool { + guard belongs(container, toProject: projectName) else { return false } + return container.labels["com.mocker.compose.service"] == service + } + + /// Volumes `up` creates: the project-owned ones, under their runtime names. + /// The mirror image of `volumesToRemove`, so the two can never disagree. + public nonisolated static func volumesToCreate( + composeFile: ComposeFile, + projectName: String + ) -> [(name: String, driver: String)] { + composeFile.volumes + .sorted { $0.key < $1.key } + .filter { !$0.value.external } + .map { ($0.value.runtimeName(projectName: projectName), $0.value.driver) } + } + + /// Project-owned volumes that `down --volumes` may remove: every non-`external:` + /// volume in the file's top-level `volumes:` section, under the name it actually + /// has at runtime. That is the project-prefixed name unless the file gives an + /// explicit `name:`, which Compose uses verbatim — an unprefixed volume shared + /// with another project is therefore in range, exactly as with `docker compose`. + /// Pure so the removal set can be unit-tested without a container backend. + public nonisolated static func volumesToRemove( + composeFile: ComposeFile, + projectName: String + ) -> [String] { + composeFile.volumes + .sorted { $0.key < $1.key } + .filter { !$0.value.external } + .map { $0.value.runtimeName(projectName: projectName) } + } + /// List services and their status. public func ps() async throws -> [ContainerInfo] { let containers = try await engine.list(all: true) - let prefix = "\(projectName)-" - return containers.filter { $0.name.hasPrefix(prefix) } + return containers.filter { Self.belongs($0, toProject: projectName) } + } + + /// Whether a container belongs to this project, by the label written when compose + /// created it (since the first release, so every project container carries it). + /// + /// Deliberately no name-prefix fallback: `app-prod-web-1` is indistinguishable from + /// project `app`'s service `prod-web` by name alone, and `down`/`restart` remove what + /// they select — a guess there deletes another project's containers. + public nonisolated static func belongs(_ container: ContainerInfo, toProject projectName: String) -> Bool { + container.labels["com.mocker.compose.project"] == projectName } /// Restart a specific service or all services. @@ -180,8 +237,9 @@ public actor ComposeOrchestrator { let targets: [ContainerInfo] if let service { - let fullName = "\(projectName)-\(service)" - targets = containers.filter { $0.name.hasPrefix(fullName) } + // Exact service match: a `hasPrefix` here also restarted `web2`/`webhook` + // when asked for `web`, and restart stops and REMOVES what it selects. + targets = containers.filter { Self.belongs($0, to: service, projectName: projectName) } } else { targets = containers } @@ -277,7 +335,8 @@ public actor ComposeOrchestrator { // Skip the rebuild only when `--build` wasn't requested and the image exists. var shouldBuild = forceBuild if !shouldBuild { - let existingImages = try await imageManager.list() + // Only repository and tag are compared here, so skip the per-image metadata reads. + let existingImages = try await imageManager.list(enrich: false) shouldBuild = !existingImages.contains { ComposeService.imageMatches($0, tag: tag) } } if shouldBuild { @@ -299,7 +358,8 @@ public actor ComposeOrchestrator { break } - let imageName = service.image ?? "\(projectName)-\(service.name):latest" + // Same helper the build path tags with, so run and build never disagree. + let imageName = service.buildTag(projectName: projectName) // Parse port mappings let ports = try service.ports.map { try PortMapping.parse($0) } diff --git a/Sources/MockerKit/Image/ImageManager.swift b/Sources/MockerKit/Image/ImageManager.swift index 0903939..102f5d6 100644 --- a/Sources/MockerKit/Image/ImageManager.swift +++ b/Sources/MockerKit/Image/ImageManager.swift @@ -24,29 +24,35 @@ public actor ImageManager { // the existing entry does not cover. if parsedPlatform == nil, let existing = try? await imageStore.get(reference: normalized) { - return (Self.toImageInfo(existing), true) + return (await Self.toImageInfo(existing), true) } let image = try await imageStore.pull( reference: normalized, platform: parsedPlatform, auth: RegistryAuth.resolve(for: normalized) ) - return (Self.toImageInfo(image), false) + return (await Self.toImageInfo(image), false) } // MARK: - List /// List all local images — merges Apple CLI store with our OCI store. - public func list() async throws -> [ImageInfo] { + /// - Parameter enrich: resolve each image's real SIZE/CREATED. Costs a manifest and + /// config read per image, so callers that only compare repository and tag pass false. + public func list(enrich: Bool = true) async throws -> [ImageInfo] { // Primary: Apple CLI store (includes pulled and built images) - let cliImages = try await listFromCLI() + let cliImages = try await listFromCLI(enrich: enrich) if !cliImages.isEmpty { return cliImages } // Fallback: our OCI store let images = try await imageStore.list() - return images.map(Self.toImageInfo) + var infos: [ImageInfo] = [] + for image in images { + infos.append(enrich ? await Self.toImageInfo(image) : Self.basicImageInfo(image)) + } + return infos } - private func listFromCLI() async throws -> [ImageInfo] { + private func listFromCLI(enrich: Bool) async throws -> [ImageInfo] { let process = Process() process.executableURL = URL(fileURLWithPath: Self.containerCLI) process.arguments = ["images", "ls"] @@ -62,11 +68,40 @@ public actor ImageManager { } } - return parseCLIImageList(output) + let parsed = parseCLIImageList(output) + return enrich ? await self.enrich(parsed) : parsed.map(\.info) + } + + /// Fill in SIZE and CREATED for a CLI-derived listing. + /// + /// `container images ls` prints only NAME/TAG/DIGEST, so the values come from each + /// image's manifest and config in the shared content store. An image missing from + /// the store keeps its unknown (nil) values rather than a fabricated zero. + private func enrich(_ images: [(info: ImageInfo, reference: String)]) async -> [ImageInfo] { + var result: [ImageInfo] = [] + result.reserveCapacity(images.count) + for (info, reference) in images { + let listedDigest = info.id.replacingOccurrences(of: ".", with: "") + guard let image = try? await resolve(reference), + image.digest.hasPrefix(listedDigest) || listedDigest.hasPrefix(image.digest) else { + // No match, or a different image happens to answer to that reference — + // leave the values unknown rather than attaching another image's metadata. + result.append(info) + continue + } + var enriched = info + let metadata = await Self.sizeAndCreated(of: image) + enriched.size = metadata.size + enriched.created = metadata.created + result.append(enriched) + } + return result } - private func parseCLIImageList(_ output: String) -> [ImageInfo] { - var results: [ImageInfo] = [] + /// Rows of the CLI listing, each with the reference exactly as the CLI printed it — + /// a locally built image is stored under that bare reference, not the display form. + private func parseCLIImageList(_ output: String) -> [(info: ImageInfo, reference: String)] { + var results: [(info: ImageInfo, reference: String)] = [] let lines = output.components(separatedBy: "\n").dropFirst() // skip header for line in lines { let cols = line.split(separator: " ", omittingEmptySubsequences: true).map(String.init) @@ -75,7 +110,7 @@ public actor ImageManager { let tag = cols[1] let digest = "sha256:" + cols[2] let repo = name.contains(".") || name.contains("/") ? name : "docker.io/library/\(name)" - results.append(ImageInfo(id: digest, repository: repo, tag: tag, size: 0, created: Date())) + results.append((ImageInfo(id: digest, repository: repo, tag: tag), "\(name):\(tag)")) } return results } @@ -84,12 +119,10 @@ public actor ImageManager { /// Remove an image by reference. public func remove(_ reference: String) async throws -> ImageInfo { - let normalized = try Self.normalize(reference) - guard let image = try? await imageStore.get(reference: normalized) else { - throw MockerError.imageNotFound(reference) - } - let info = Self.toImageInfo(image) - try await imageStore.delete(reference: normalized) + let image = try await resolve(reference) + let info = await Self.toImageInfo(image) + // Delete the reference that actually matched, never a re-derived one. + try await imageStore.delete(reference: image.reference) return info } @@ -97,19 +130,17 @@ public actor ImageManager { /// Tag an image with a new reference. public func tag(_ source: String, _ target: String) async throws { - let src = try Self.normalize(source) + let image = try await resolve(source) let dst = try Self.normalize(target) - _ = try await imageStore.tag(existing: src, new: dst) + _ = try await imageStore.tag(existing: image.reference, new: dst) } // MARK: - Inspect /// Inspect an image reference, returning a Docker-compatible ImageInspect. public func inspect(_ reference: String, platform: String? = nil) async throws -> ImageInspect { - let normalized = try Self.normalize(reference) - guard let image = try? await imageStore.get(reference: normalized) else { - throw MockerError.imageNotFound(reference) - } + let image = try await resolve(reference) + let normalized = image.reference let resolvedPlatform: ContainerizationOCI.Platform if let platformString = platform { resolvedPlatform = try ContainerizationOCI.Platform(from: platformString) @@ -307,16 +338,16 @@ public actor ImageManager { throw MockerError.buildError("Build failed with exit code \(exitCode)") } - // Fetch real image info from the store after build - let normalized = try Self.normalize(tag) - if let image = try? await imageStore.get(reference: normalized) { - return Self.toImageInfo(image) + // Fetch real image info from the store after build. `container build -t ` + // stores the literal tag, so this must resolve verbatim-first too. + if let image = try? await resolve(tag) { + return await Self.toImageInfo(image) } - // Fallback if store lookup fails (image was built but not indexed) + // The build succeeded but the image is not indexed in the store we can read. + // Report what is known instead of inventing a digest, size and timestamp. let ref = try ImageReference.parse(tag) - let digest = "sha256:" + (0..<32).map { _ in String(format: "%02x", UInt8.random(in: 0...255)) }.joined() - return ImageInfo(id: digest, repository: ref.fullRepository, tag: ref.tag, size: 0, created: Date()) + return ImageInfo(id: "", repository: ref.fullRepository, tag: ref.tag) } // MARK: - Push @@ -324,9 +355,16 @@ public actor ImageManager { /// Push an image to a registry. /// - Parameter platform: optional `linux/amd64`-style filter; nil pushes the full manifest list. public func push(_ reference: String, platform: String? = nil) async throws { + // The image is found the way the user spelled it (a local build is stored under + // its bare tag), but the push target must be the registry-qualified reference — + // it is also the key RegistryAuth resolves credentials with. Tag the local image + // under that name first, otherwise the store has nothing to push. + let image = try await resolve(reference) let normalized = try Self.normalize(reference) - guard (try? await imageStore.get(reference: normalized)) != nil else { - throw MockerError.imageNotFound(reference) + if image.reference != normalized { + // Must not be swallowed: pushing after a failed tag would upload whatever + // image that reference already points at. + _ = try await imageStore.tag(existing: image.reference, new: normalized) } let parsedPlatform = try platform.map { try ContainerizationOCI.Platform(from: $0) } try await imageStore.push( @@ -338,7 +376,10 @@ public actor ImageManager { /// Save images to an OCI tar archive. public func save(references: [String], to outputPath: String) async throws { - let normalizedRefs = try references.map { try Self.normalize($0) } + var normalizedRefs: [String] = [] + for reference in references { + normalizedRefs.append(try await resolve(reference).reference) + } let outputURL = URL(fileURLWithPath: outputPath) try await imageStore.save(references: normalizedRefs, out: outputURL) } @@ -347,11 +388,85 @@ public actor ImageManager { public func load(from inputPath: String) async throws -> [ImageInfo] { let inputURL = URL(fileURLWithPath: inputPath) let images = try await imageStore.load(from: inputURL) - return images.map(Self.toImageInfo) + var infos: [ImageInfo] = [] + for image in images { + infos.append(await Self.toImageInfo(image)) + } + return infos } // MARK: - Helpers + /// Look an image up the way the user spelled it, falling back to the normalized + /// `docker.io/library/...` form. + /// + /// Store keys are whatever string created them: `container build -t caddy:latest` + /// stores a bare reference while a pull stores a fully-qualified one. Normalizing + /// first therefore matched a *different* image than the one named — `rmi caddy:latest` + /// deleted the pulled base image instead of the local build. + private func resolve(_ reference: String) async throws -> Containerization.Image { + for candidate in Self.resolutionCandidates(reference) { + if let hit = try? await imageStore.get(reference: candidate) { + return hit + } + } + throw MockerError.imageNotFound(reference) + } + + /// Store keys to try, in order: exactly what the user typed, then the same with an + /// implied `:latest`, then the normalized registry-qualified form. Pure, so the + /// ordering that keeps `rmi` from deleting the wrong image is directly testable. + static func resolutionCandidates(_ reference: String) -> [String] { + var candidates = [reference] + if !hasTag(reference) { + candidates.append("\(reference):latest") + } + for normalized in [try? normalize(reference), try? normalize(hasTag(reference) ? reference : "\(reference):latest")] { + if let normalized, !candidates.contains(normalized) { + candidates.append(normalized) + } + } + return candidates + } + + /// Whether a reference carries an explicit tag (a colon after the last slash, + /// so a registry port like `localhost:5000/app` doesn't count as one). + static func hasTag(_ reference: String) -> Bool { + let lastComponent = reference.split(separator: "/").last.map(String.init) ?? reference + return lastComponent.contains(":") + } + + /// Size and creation date of an image, read from its manifest and config. + /// Returns nils when the metadata cannot be resolved, so callers surface the gap + /// instead of reporting a confident zero. + static func sizeAndCreated(of image: Containerization.Image) async -> (size: UInt64?, created: Date?) { + guard let manifest = await manifestForListing(of: image) else { return (nil, nil) } + let total = manifestSize(manifest) + var created: Date? + if let content = try? await image.getContent(digest: manifest.config.digest), + let config: ContainerizationOCI.Image = try? content.decode(), + let stamp = config.created { + created = RelativeDate.parse(stamp) + } + return (UInt64(max(0, total)), created) + } + + /// The manifest a listing should measure: the current platform's when present, + /// otherwise the image's sole manifest (single-arch images). + private static func manifestForListing( + of image: Containerization.Image + ) async -> ContainerizationOCI.Manifest? { + if let matched = try? await image.manifest(for: ContainerizationOCI.Platform.current) { + return matched + } + guard let index = try? await image.index(), + let sole = soleManifestDescriptor(from: index.manifests), + let content = try? await image.getContent(digest: sole.digest) else { + return nil + } + return try? content.decode() + } + private static func normalize(_ reference: String) throws -> String { // ContainerizationOCI.Reference.parse requires a fully-qualified reference with domain. // Docker-style short references ("alpine", "nginx:1.25", "user/image:tag") need a domain. @@ -374,18 +489,29 @@ public actor ImageManager { return ref.description } - private static func toImageInfo(_ image: Containerization.Image) -> ImageInfo { + /// Repository/tag/digest only — no manifest or config reads. + private static func basicImageInfo(_ image: Containerization.Image) -> ImageInfo { + let ref = try? ImageReference.parse(image.reference) + return ImageInfo( + id: image.digest, + repository: ref?.fullRepository ?? image.reference, + tag: ref?.tag ?? "latest" + ) + } + + private static func toImageInfo(_ image: Containerization.Image) async -> ImageInfo { // Parse repo and tag from the reference string let ref = try? ImageReference.parse(image.reference) let repository = ref?.fullRepository ?? image.reference let tag = ref?.tag ?? "latest" + let metadata = await sizeAndCreated(of: image) return ImageInfo( id: image.digest, repository: repository, tag: tag, - size: 0, // Size requires reading all layer blobs — expensive - created: Date() // Created requires reading image config — async + size: metadata.size, + created: metadata.created ) } } diff --git a/Sources/MockerKit/Image/ImageStore.swift b/Sources/MockerKit/Image/ImageStore.swift index 81b7c59..bdb3902 100644 --- a/Sources/MockerKit/Image/ImageStore.swift +++ b/Sources/MockerKit/Image/ImageStore.swift @@ -45,7 +45,8 @@ actor ImageStore { let data = try Data(contentsOf: URL(fileURLWithPath: filePath)) return try decoder.decode(ImageInfo.self, from: data) } - .sorted { $0.created > $1.created } + // Newest first; entries with an unknown timestamp sort last. + .sorted { ($0.created ?? .distantPast) > ($1.created ?? .distantPast) } } func findByReference(_ reference: String) throws -> ImageInfo? { diff --git a/Sources/MockerKit/Models/ImageInfo.swift b/Sources/MockerKit/Models/ImageInfo.swift index 682d18f..a7b918c 100644 --- a/Sources/MockerKit/Models/ImageInfo.swift +++ b/Sources/MockerKit/Models/ImageInfo.swift @@ -5,16 +5,19 @@ public struct ImageInfo: Codable, Sendable, Identifiable { public var id: String public var repository: String public var tag: String - public var size: UInt64 - public var created: Date + /// Total image size in bytes, or nil when it could not be determined — + /// rendering an unknown size as `0` reads as a measurement, which it is not. + public var size: UInt64? + /// Image creation timestamp, or nil when unavailable (see `size`). + public var created: Date? public var labels: [String: String] public init( id: String, repository: String, tag: String = "latest", - size: UInt64 = 0, - created: Date = Date(), + size: UInt64? = nil, + created: Date? = nil, labels: [String: String] = [:] ) { self.id = id @@ -35,13 +38,15 @@ public struct ImageInfo: Codable, Sendable, Identifiable { "\(repository):\(tag)" } - /// Human-readable size string. + /// Human-readable size string, or `N/A` when the size is unknown. public var sizeString: String { - ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file) + guard let size else { return "N/A" } + return ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file) } - /// Formatted creation time relative to now. + /// Formatted creation time relative to now, or `N/A` when unknown. public var createdAgo: String { + guard let created else { return "N/A" } let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .abbreviated return formatter.localizedString(for: created, relativeTo: Date()) diff --git a/Sources/MockerKit/Models/ImageInspect.swift b/Sources/MockerKit/Models/ImageInspect.swift index 44ab067..dc3a49d 100644 --- a/Sources/MockerKit/Models/ImageInspect.swift +++ b/Sources/MockerKit/Models/ImageInspect.swift @@ -151,6 +151,12 @@ public struct ImageInspectRootFS: Codable, Sendable { } // MARK: - Pure Mapping Function +/// Total size of an image: every layer plus its config blob. Shared by `image inspect` +/// and the `images` listing so the two can never report different sizes. +func manifestSize(_ manifest: ContainerizationOCI.Manifest) -> Int64 { + manifest.layers.reduce(0) { $0 + $1.size } + manifest.config.size +} + /// Maps OCI manifest + config to a Docker-compatible `ImageInspect`. Pure, no I/O. /// @@ -178,7 +184,7 @@ public func mapToImageInspect( // Id is the config blob digest, not the index digest — Docker parity for single-platform inspect. let id = manifest.config.digest - let size = manifest.layers.reduce(0) { $0 + $1.size } + manifest.config.size + let size = manifestSize(manifest) let repoTags = overrideRepoTags ?? extractRepoTags(from: reference) let repo = extractRepo(from: reference) diff --git a/Sources/MockerKit/Models/RelativeDate.swift b/Sources/MockerKit/Models/RelativeDate.swift new file mode 100644 index 0000000..ffc9b68 --- /dev/null +++ b/Sources/MockerKit/Models/RelativeDate.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Parses OCI `created` timestamps and renders them as human-relative dates. +/// +/// `ISO8601DateFormatter` only understands millisecond fractions, so OCI configs +/// carrying nanosecond precision (e.g. `2024-11-19T17:01:02.000000000Z`) fail to +/// parse and previously fell back to the raw string. This helper tolerates variable +/// fractional precision, including 9-digit nanoseconds. +public enum RelativeDate { + /// Parses an RFC3339 / RFC3339Nano timestamp into a `Date`, or `nil` if unparseable. + public static func parse(_ string: String) -> Date? { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: string) { return date } + + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + if let date = plain.date(from: string) { return date } + + // Last resort: strip an over-precise fractional component and retry. + if let stripped = stripFractionalSeconds(string) { + return plain.date(from: stripped) + } + return nil + } + + /// Renders the timestamp as an abbreviated relative date, falling back to the + /// raw string only when it cannot be parsed. + public static func humanRelative(_ string: String, relativeTo now: Date = Date()) -> String { + guard let date = parse(string) else { return string } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: now) + } + + /// Removes the fractional-seconds component, preserving the timezone designator. + private static func stripFractionalSeconds(_ string: String) -> String? { + guard let dot = string.firstIndex(of: ".") else { return nil } + let afterDot = string[string.index(after: dot)...] + let timezone = afterDot.firstIndex { !$0.isNumber }.map { String(afterDot[$0...]) } ?? "" + return String(string[string.startIndex.. VolumeInfo { + try Self.validateName(name) guard volumes[name] == nil else { throw MockerError.operationFailed("Volume \(name) already exists") } @@ -69,6 +85,7 @@ public actor VolumeManager { /// Remove a volume. public func remove(_ name: String) throws -> VolumeInfo { + try Self.validateName(name) guard let vol = volumes[name] else { throw MockerError.volumeNotFound(name) } diff --git a/Tests/MockerKitTests/ComposeIncludeTests.swift b/Tests/MockerKitTests/ComposeIncludeTests.swift new file mode 100644 index 0000000..4142cfa --- /dev/null +++ b/Tests/MockerKitTests/ComposeIncludeTests.swift @@ -0,0 +1,260 @@ +import Testing +import Foundation +@testable import MockerKit + +@Suite("Compose include:") +struct ComposeIncludeTests { + private func load(_ files: [ComposeTestHelpers.FileSpec], entry: String = "compose.yaml") throws -> (ComposeFile, URL) { + let root = try ComposeTestHelpers.makeProjectDir(files) + let file = try ComposeFile.load(from: root.appendingPathComponent(entry).path, projectDir: root) + return (file, root) + } + + @Test("Short-form include pulls in the included services") + func shortForm() throws { + let (compose, _) = try load([ + .file("compose.yaml", """ + include: + - svc/compose.yaml + """), + .file("svc/compose.yaml", """ + services: + hello: + image: alpine:3.20 + """), + ]) + + #expect(compose.services["hello"]?.image == "alpine:3.20") + } + + @Test("Long-form include accepts a path list") + func longFormPathList() throws { + let (compose, _) = try load([ + .file("compose.yaml", """ + include: + - path: + - a/compose.yaml + - b/compose.yaml + """), + .file("a/compose.yaml", "services:\n a:\n image: alpine:3.20\n"), + .file("b/compose.yaml", "services:\n b:\n image: busybox:1.37\n"), + ]) + + #expect(compose.services.keys.sorted() == ["a", "b"]) + } + + @Test("Parent's own definition overrides an included one") + func parentWins() throws { + let (compose, _) = try load([ + .file("compose.yaml", """ + include: + - svc/compose.yaml + services: + hello: + image: parent:1.0 + """), + .file("svc/compose.yaml", "services:\n hello:\n image: included:1.0\n"), + ]) + + #expect(compose.services["hello"]?.image == "parent:1.0") + } + + @Test("Included file's relative bind mount anchors to its own directory") + func relativeBindMountAnchoring() throws { + let (compose, root) = try load([ + .file("compose.yaml", "include:\n - svc/compose.yaml\n"), + .file("svc/compose.yaml", """ + services: + hello: + image: alpine:3.20 + volumes: + - ./data:/data + """), + ]) + + let expected = root.appendingPathComponent("svc/data").standardized.path + #expect(compose.services["hello"]?.volumes == ["\(expected):/data"]) + } + + @Test("project_directory overrides where relative bind mounts anchor") + func projectDirectoryOverride() throws { + let (compose, root) = try load([ + .file("compose.yaml", """ + include: + - path: svc/compose.yaml + project_directory: . + """), + .file("svc/compose.yaml", """ + services: + hello: + image: alpine:3.20 + volumes: + - ./data:/data + """), + ]) + + let expected = root.appendingPathComponent("data").standardized.path + #expect(compose.services["hello"]?.volumes == ["\(expected):/data"]) + } + + @Test("Named and absolute volume specs are left alone") + func nonRelativeVolumesUntouched() throws { + let (compose, _) = try load([ + .file("compose.yaml", "include:\n - svc/compose.yaml\n"), + .file("svc/compose.yaml", """ + services: + hello: + image: alpine:3.20 + volumes: + - data:/var/lib/data + - /etc/hosts:/etc/hosts + """), + ]) + + #expect(compose.services["hello"]?.volumes == ["data:/var/lib/data", "/etc/hosts:/etc/hosts"]) + } + + @Test("Include uses its own env_file for interpolation") + func perIncludeEnvFile() throws { + let (compose, _) = try load([ + .file("compose.yaml", "include:\n - path: svc/compose.yaml\n env_file: custom.env\n"), + .file("svc/custom.env", "TAG=3.21\n"), + .file("svc/compose.yaml", "services:\n hello:\n image: alpine:${TAG}\n"), + ]) + + #expect(compose.services["hello"]?.image == "alpine:3.21") + } + + @Test("Include defaults to .env beside the included file") + func perIncludeDefaultEnv() throws { + let (compose, _) = try load([ + .file("compose.yaml", "include:\n - svc/compose.yaml\n"), + .file("svc/.env", "TAG=3.19\n"), + .file("svc/compose.yaml", "services:\n hello:\n image: alpine:${TAG}\n"), + ]) + + #expect(compose.services["hello"]?.image == "alpine:3.19") + } + + @Test("Nested includes are resolved recursively") + func nestedIncludes() throws { + let (compose, _) = try load([ + .file("compose.yaml", "include:\n - a/compose.yaml\n"), + .file("a/compose.yaml", "include:\n - ../b/compose.yaml\n"), + .file("b/compose.yaml", "services:\n deep:\n image: alpine:3.20\n"), + ]) + + #expect(compose.services["deep"] != nil) + } + + @Test("Include cycles are rejected instead of recursing forever") + func cycleDetected() throws { + let root = try ComposeTestHelpers.makeProjectDir([ + .file("compose.yaml", "include:\n - other.yaml\n"), + .file("other.yaml", "include:\n - compose.yaml\n"), + ]) + + #expect(throws: MockerError.self) { + _ = try ComposeFile.load(from: root.appendingPathComponent("compose.yaml").path, projectDir: root) + } + } + + @Test("A missing included file is an error, not a silent drop") + func missingIncludeErrors() throws { + let root = try ComposeTestHelpers.makeProjectDir([ + .file("compose.yaml", "include:\n - nope.yaml\n"), + ]) + + #expect(throws: MockerError.self) { + _ = try ComposeFile.load(from: root.appendingPathComponent("compose.yaml").path, projectDir: root) + } + } + + @Test("Included networks and volumes are merged too") + func networksAndVolumesMerged() throws { + let (compose, _) = try load([ + .file("compose.yaml", "include:\n - svc/compose.yaml\n"), + .file("svc/compose.yaml", """ + services: + hello: + image: alpine:3.20 + networks: + backend: + volumes: + data: + """), + ]) + + #expect(compose.networks["backend"] != nil) + #expect(compose.volumes["data"] != nil) + } + + @Test("A malformed include: is an error, not a silent drop", arguments: [ + "include: svc/compose.yaml\n", + "include:\n - project_directory: ./svc\n", + "include:\n - 42\n", + ]) + func malformedIncludeThrows(body: String) throws { + let root = try ComposeTestHelpers.makeProjectDir([.file("compose.yaml", body)]) + + #expect(throws: MockerError.self) { + _ = try ComposeFile.load(from: root.appendingPathComponent("compose.yaml").path, projectDir: root) + } + } + + @Test("An included service's relative build context anchors to the include's directory") + func buildContextAnchoring() throws { + let (compose, root) = try load([ + .file("compose.yaml", "include:\n - svc/compose.yaml\n"), + .file("svc/compose.yaml", """ + services: + api: + build: . + """), + ]) + + let expected = root.appendingPathComponent("svc").standardized.path + #expect(compose.services["api"]?.build?.context == expected) + } + + @Test("project_directory also moves the build context") + func buildContextHonorsProjectDirectory() throws { + let (compose, root) = try load([ + .file("compose.yaml", """ + include: + - path: svc/compose.yaml + project_directory: . + """), + .file("svc/compose.yaml", """ + services: + api: + build: + context: ./image + dockerfile: Dockerfile + """), + ]) + + let expected = root.appendingPathComponent("image").standardized.path + #expect(compose.services["api"]?.build?.context == expected) + } + + @Test("An included file's name: does not become the project name") + func includedNameDoesNotLeak() throws { + let (compose, _) = try load([ + .file("compose.yaml", "include:\n - svc/compose.yaml\n"), + .file("svc/compose.yaml", "name: vendor\nservices:\n hello:\n image: alpine:3.20\n"), + ]) + + #expect(compose.name == nil) + } + + @Test("The parent's own name: still wins") + func parentNameKept() throws { + let (compose, _) = try load([ + .file("compose.yaml", "name: mine\ninclude:\n - svc/compose.yaml\n"), + .file("svc/compose.yaml", "name: vendor\nservices:\n hello:\n image: alpine:3.20\n"), + ]) + + #expect(compose.name == "mine") + } +} diff --git a/Tests/MockerKitTests/ComposeProjectNameTests.swift b/Tests/MockerKitTests/ComposeProjectNameTests.swift new file mode 100644 index 0000000..bb8beb9 --- /dev/null +++ b/Tests/MockerKitTests/ComposeProjectNameTests.swift @@ -0,0 +1,146 @@ +import Testing +import Foundation +@testable import MockerKit + +@Suite("Compose project name + service validation") +struct ComposeProjectNameTests { + @Test("Top-level name: is parsed") + func parsesTopLevelName() throws { + let compose = try ComposeFile.parse(""" + name: myproj + services: + web: + image: nginx:latest + """) + + #expect(compose.name == "myproj") + } + + @Test("Explicit -p wins over every other source") + func explicitWins() throws { + let root = try ComposeTestHelpers.makeProjectDir([ + .file(".env", "COMPOSE_PROJECT_NAME=fromdotenv\n"), + ]) + + let resolved = ComposeFile.resolveProjectName( + explicit: "flag", + composeFileName: "fromfile", + projectDir: root, + environment: ["COMPOSE_PROJECT_NAME": "fromenv"] + ) + + #expect(resolved == "flag") + } + + @Test("Environment beats .env, compose name and directory") + func environmentBeatsDotEnv() throws { + let root = try ComposeTestHelpers.makeProjectDir([ + .file(".env", "COMPOSE_PROJECT_NAME=fromdotenv\n"), + ]) + + let resolved = ComposeFile.resolveProjectName( + explicit: nil, + composeFileName: "fromfile", + projectDir: root, + environment: ["COMPOSE_PROJECT_NAME": "fromenv"] + ) + + #expect(resolved == "fromenv") + } + + @Test(".env beats the compose file's name: and the directory") + func dotEnvBeatsComposeName() throws { + let root = try ComposeTestHelpers.makeProjectDir([ + .file(".env", "COMPOSE_PROJECT_NAME=fromdotenv\n"), + ]) + + let resolved = ComposeFile.resolveProjectName( + explicit: nil, + composeFileName: "fromfile", + projectDir: root, + environment: [:] + ) + + #expect(resolved == "fromdotenv") + } + + @Test("Compose file name: beats the directory basename") + func composeNameBeatsDirectory() throws { + let root = try ComposeTestHelpers.makeProjectDir() + + let resolved = ComposeFile.resolveProjectName( + explicit: nil, + composeFileName: "fromfile", + projectDir: root, + environment: [:] + ) + + #expect(resolved == "fromfile") + } + + @Test("Directory basename is the last resort") + func directoryFallback() throws { + let root = try ComposeTestHelpers.makeProjectDir() + .appendingPathComponent("My Project") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + + let resolved = ComposeFile.resolveProjectName( + explicit: nil, + composeFileName: nil, + projectDir: root, + environment: [:] + ) + + #expect(resolved == "my-project") + } + + @Test("Resolved names are normalized to Compose's character set", arguments: [ + ("Feature.FOO", "feature-foo"), + ("-leading", "leading"), + ("UPPER_case-1", "upper_case-1"), + ]) + func normalization(input: String, expected: String) throws { + #expect(ComposeFile.normalizeProjectName(input) == expected) + } + + @Test("A name with nothing usable falls back to a placeholder") + func normalizationEmpty() throws { + #expect(ComposeFile.normalizeProjectName("...") == "default") + } + + @Test("Unknown service names are rejected") + func validateUnknownService() throws { + let compose = try ComposeFile.parse("services:\n web:\n image: nginx\n") + + #expect(throws: MockerError.self) { + try compose.validateServiceNames(["ghost"]) + } + #expect(throws: Never.self) { + try compose.validateServiceNames(["web"]) + } + } + + @Test("Filtering keeps the project name") + func filteringKeepsName() throws { + let compose = try ComposeFile.parse(""" + name: keepme + services: + web: + image: nginx + db: + image: postgres + """) + + #expect(compose.filtering(services: ["web"]).name == "keepme") + } + + @Test("Later -f file's name: wins on merge") + func mergeNamePrecedence() throws { + let base = try ComposeFile.parse("name: first\nservices:\n web:\n image: nginx\n") + let overlay = try ComposeFile.parse("name: second\nservices: {}\n") + let noName = try ComposeFile.parse("services: {}\n") + + #expect(ComposeFile.merge([base, overlay]).name == "second") + #expect(ComposeFile.merge([base, noName]).name == "first") + } +} diff --git a/Tests/MockerKitTests/ComposeVolumeLifecycleTests.swift b/Tests/MockerKitTests/ComposeVolumeLifecycleTests.swift new file mode 100644 index 0000000..c4b6da3 --- /dev/null +++ b/Tests/MockerKitTests/ComposeVolumeLifecycleTests.swift @@ -0,0 +1,111 @@ +import Testing +import Foundation +@testable import MockerKit + +@Suite("Compose volume lifecycle") +struct ComposeVolumeLifecycleTests { + private func parse(_ yaml: String) throws -> ComposeFile { + try ComposeFile.parse(yaml) + } + + @Test("external: true is parsed, and defaults to false") + func parsesExternal() throws { + let compose = try parse(""" + volumes: + shared: + external: true + owned: + """) + + #expect(compose.volumes["shared"]?.external == true) + #expect(compose.volumes["owned"]?.external == false) + } + + @Test("Legacy external mapping form is treated as external") + func parsesLegacyExternalMapping() throws { + let compose = try parse(""" + volumes: + shared: + external: + name: already-there + """) + + #expect(compose.volumes["shared"]?.external == true) + #expect(compose.volumes["shared"]?.runtimeName(projectName: "proj") == "already-there") + } + + @Test("An explicit name: is used verbatim, without the project prefix") + func explicitNameWins() throws { + let compose = try parse(""" + volumes: + data: + name: shared-data + """) + + #expect(compose.volumes["data"]?.runtimeName(projectName: "proj") == "shared-data") + } + + @Test("Project-owned volumes are namespaced") + func ownedVolumeIsNamespaced() throws { + let compose = try parse("volumes:\n data:\n") + + #expect(compose.volumes["data"]?.runtimeName(projectName: "proj") == "proj-data") + } + + @Test("down --volumes removes owned volumes only") + func volumesToRemoveSkipsExternal() throws { + let compose = try parse(""" + volumes: + data: + cache: + shared: + external: true + named: + name: custom-name + """) + + let removals = ComposeOrchestrator.volumesToRemove(composeFile: compose, projectName: "proj") + + #expect(removals == ["proj-cache", "proj-data", "custom-name"]) + } + + @Test("A project with no volumes removes nothing") + func volumesToRemoveEmpty() throws { + let compose = try parse("services:\n web:\n image: nginx\n") + + #expect(ComposeOrchestrator.volumesToRemove(composeFile: compose, projectName: "proj").isEmpty) + } + + @Test("up creates owned volumes and never external ones") + func volumesToCreateSkipsExternal() throws { + let compose = try parse(""" + volumes: + data: + shared: + external: true + named: + name: custom-name + """) + + let created = ComposeOrchestrator.volumesToCreate(composeFile: compose, projectName: "proj") + + #expect(created.map(\.name) == ["proj-data", "custom-name"]) + #expect(created.allSatisfy { $0.driver == "local" }) + } + + @Test("Volume names that would escape the volumes directory are rejected", arguments: [ + "../etc", "a/b", "..", ".", "", + ]) + func rejectsEscapingVolumeNames(name: String) { + #expect(throws: MockerError.self) { + try VolumeManager.validateName(name) + } + } + + @Test("Ordinary volume names are accepted", arguments: ["proj-data", "custom_name", "-legacy"]) + func acceptsOrdinaryVolumeNames(name: String) { + #expect(throws: Never.self) { + try VolumeManager.validateName(name) + } + } +} diff --git a/Tests/MockerKitTests/DockerAPIServerTests.swift b/Tests/MockerKitTests/DockerAPIServerTests.swift index d21a58b..d56dcfb 100644 --- a/Tests/MockerKitTests/DockerAPIServerTests.swift +++ b/Tests/MockerKitTests/DockerAPIServerTests.swift @@ -136,4 +136,15 @@ struct DockerAPIServerTests { #expect(lines.first == "1") #expect(lines.last == "200000") } + + @Test("Unknown image size and creation date map to 0 over the Engine API") + func unknownImageMetadataMapsToZero() { + let i = ImageInfo(id: "sha256:abc", repository: "alpine", tag: "3.20") + + let mapped = mapToImageListItem(i) + + // The CLI prints N/A; the Engine API types these as integers, so 0 is deliberate. + #expect(mapped["Size"] as? Int == 0) + #expect(mapped["Created"] as? Int == 0) + } } diff --git a/Tests/MockerKitTests/ImageResolutionTests.swift b/Tests/MockerKitTests/ImageResolutionTests.swift new file mode 100644 index 0000000..f05d39e --- /dev/null +++ b/Tests/MockerKitTests/ImageResolutionTests.swift @@ -0,0 +1,69 @@ +import Testing +import Foundation +@testable import MockerKit + +@Suite("Image reference resolution order") +struct ImageResolutionTests { + @Test("The literal reference is tried before the normalized one") + func literalFirst() { + let candidates = ImageManager.resolutionCandidates("caddy:latest") + + #expect(candidates.first == "caddy:latest") + #expect(candidates.contains("docker.io/library/caddy:latest")) + } + + @Test("A tag-less reference implies :latest before normalizing") + func taglessImpliesLatest() { + let candidates = ImageManager.resolutionCandidates("caddy") + + #expect(candidates.prefix(2) == ["caddy", "caddy:latest"]) + #expect(candidates.contains("docker.io/library/caddy:latest")) + } + + @Test("An already-qualified reference resolves to itself first") + func qualifiedReference() { + let candidates = ImageManager.resolutionCandidates("docker.io/library/busybox:1.37") + + #expect(candidates.first == "docker.io/library/busybox:1.37") + } + + @Test("No candidate is repeated") + func candidatesAreUnique() { + let candidates = ImageManager.resolutionCandidates("docker.io/library/alpine:3.20") + + #expect(Set(candidates).count == candidates.count) + } + + @Test("A registry port is not mistaken for a tag") + func registryPortIsNotATag() { + #expect(ImageManager.hasTag("localhost:5000/app") == false) + #expect(ImageManager.hasTag("localhost:5000/app:v1") == true) + #expect(ImageManager.hasTag("alpine") == false) + #expect(ImageManager.hasTag("alpine:3.20") == true) + // A digest reference already pins the image; no `:latest` may be appended. + #expect(ImageManager.hasTag("nginx@sha256:abc") == true) + } + + @Test("Unknown size and creation date render as N/A, never as a measurement") + func unknownMetadataRendersAsNA() { + let unknown = ImageInfo(id: "sha256:abc", repository: "alpine", tag: "3.20") + + #expect(unknown.sizeString == "N/A") + #expect(unknown.createdAgo == "N/A") + } + + @Test("Known size and creation date are rendered normally") + func knownMetadataRenders() { + let known = ImageInfo( + id: "sha256:abc", + repository: "alpine", + tag: "3.20", + size: 22_731_608, + created: Date(timeIntervalSinceNow: -3600) + ) + + #expect(known.sizeString != "N/A") + #expect(known.sizeString.contains("MB")) + #expect(known.createdAgo != "N/A") + } +} diff --git a/Tests/MockerTests/ComposeArgNormalizerTests.swift b/Tests/MockerTests/ComposeArgNormalizerTests.swift index fd1afec..886f380 100644 --- a/Tests/MockerTests/ComposeArgNormalizerTests.swift +++ b/Tests/MockerTests/ComposeArgNormalizerTests.swift @@ -53,4 +53,16 @@ struct ComposeArgNormalizerTests { let out = ComposeArgNormalizer.reorder(["compose", "-f", "-p"]) #expect(out == ["compose", "-f", "-p"]) } + + @Test("Relocates a global --dry-run placed before the subcommand") + func relocateDryRun() { + let out = ComposeArgNormalizer.reorder(["compose", "--dry-run", "up", "-d"]) + #expect(out == ["compose", "up", "--dry-run", "-d"]) + } + + @Test("Relocates --dry-run alongside value flags") + func relocateDryRunWithFile() { + let out = ComposeArgNormalizer.reorder(["compose", "-f", "a.yaml", "--dry-run", "build"]) + #expect(out == ["compose", "build", "-f", "a.yaml", "--dry-run"]) + } } diff --git a/Tests/MockerTests/ComposeConfigTests.swift b/Tests/MockerTests/ComposeConfigTests.swift index da8a86a..a2c58a1 100644 --- a/Tests/MockerTests/ComposeConfigTests.swift +++ b/Tests/MockerTests/ComposeConfigTests.swift @@ -21,7 +21,7 @@ struct ComposeConfigTests { mem_limit: 2g """) - let output = ComposeConfig.renderConfig(composeFile: composeFile, projectName: nil) + let output = ComposeConfig.renderConfig(composeFile: composeFile, projectName: "proj") #expect(output.contains("deploy:")) #expect(output.contains("limits:")) @@ -44,7 +44,7 @@ struct ComposeConfigTests { memory: 256M """) - let output = ComposeConfig.renderConfig(composeFile: composeFile, projectName: nil) + let output = ComposeConfig.renderConfig(composeFile: composeFile, projectName: "proj") #expect(output.contains("cpus: \"0.50\"")) #expect(output.contains("memory: 512M")) @@ -61,8 +61,165 @@ struct ComposeConfigTests { image: nginx:latest """) - let output = ComposeConfig.renderConfig(composeFile: composeFile, projectName: nil) + let output = ComposeConfig.renderConfig(composeFile: composeFile, projectName: "proj") #expect(!output.contains("deploy:")) } + + @Test("compose config prints the resolved project name, not the literal 'default'") + func composeConfigPrintsResolvedName() throws { + let composeFile = try ComposeFile.parse(""" + services: + app: + image: nginx:latest + """) + + let output = ComposeConfig.renderConfig(composeFile: composeFile, projectName: "myproj") + + #expect(output.hasPrefix("name: myproj")) + #expect(!output.contains("name: default")) + } + + @Test("Service selection matches the exact service, not a name substring") + func serviceSelectionIsExact() { + let web = ContainerInfo( + id: "1", name: "proj-web-1", image: "nginx", state: .running, status: "running", + created: Date(), labels: ["com.mocker.compose.project": "proj", + "com.mocker.compose.service": "web"] + ) + let webhook = ContainerInfo( + id: "2", name: "proj-webhook-1", image: "nginx", state: .running, status: "running", + created: Date(), labels: ["com.mocker.compose.project": "proj", + "com.mocker.compose.service": "webhook"] + ) + + #expect(ComposeOrchestrator.belongs(web, to: "web", projectName: "proj")) + #expect(!ComposeOrchestrator.belongs(webhook, to: "web", projectName: "proj")) + } + + @Test("An unlabeled container is never claimed by a project") + func unlabeledContainersAreOutOfScope() { + // Naming alone cannot tell project `app` + service `prod-web` apart from project + // `app-prod` + service `web`, and down/restart remove what they select. + let unlabeled = ContainerInfo(id: "3", name: "proj-web-1", image: "nginx", state: .running, status: "running", created: Date()) + + #expect(!ComposeOrchestrator.belongs(unlabeled, toProject: "proj")) + #expect(!ComposeOrchestrator.belongs(unlabeled, to: "web", projectName: "proj")) + } + + @Test("compose build tags images -, never the bare service name") + func buildPlanUsesProjectPrefixedTag() throws { + let compose = try ComposeFile.parse(""" + services: + caddy: + build: + context: ./caddy + api: + image: registry.example.com/api:v1 + build: + context: ./api + nobuild: + image: nginx:latest + """) + + let plan = ComposeBuildCommand.buildPlan(composeFile: compose, project: "laradock", services: []) + + #expect(plan.map(\.name) == ["api", "caddy"]) + // An explicit image: is the tag; otherwise the project-prefixed default. + #expect(plan.map(\.tag) == ["registry.example.com/api:v1", "laradock-caddy:latest"]) + } + + @Test("compose build honors the requested service subset") + func buildPlanFiltersServices() throws { + let compose = try ComposeFile.parse(""" + services: + web: + build: + context: ./web + worker: + build: + context: ./worker + """) + + let plan = ComposeBuildCommand.buildPlan(composeFile: compose, project: "proj", services: ["worker"]) + + #expect(plan.map(\.tag) == ["proj-worker:latest"]) + } + + @Test("A sibling project's containers are never in scope") + func projectScopingIsExact() { + let sibling = ContainerInfo( + id: "5", name: "app-prod-web-1", image: "nginx", state: .running, status: "running", + created: Date(), labels: ["com.mocker.compose.project": "app-prod", + "com.mocker.compose.service": "web"] + ) + let own = ContainerInfo( + id: "6", name: "app-web-1", image: "nginx", state: .running, status: "running", + created: Date(), labels: ["com.mocker.compose.project": "app", + "com.mocker.compose.service": "web"] + ) + + #expect(!ComposeOrchestrator.belongs(sibling, toProject: "app")) + #expect(ComposeOrchestrator.belongs(own, toProject: "app")) + #expect(!ComposeOrchestrator.belongs(sibling, to: "web", projectName: "app")) + #expect(ComposeOrchestrator.belongs(own, to: "web", projectName: "app")) + } + + @Test("compose config renders build: as Compose long form, not a Swift description") + func composeConfigRendersBuildSection() throws { + let compose = try ComposeFile.parse(""" + services: + api: + build: + context: ./api + dockerfile: Dockerfile.dev + args: + VERSION: "1.2" + """) + + let output = ComposeConfig.renderConfig(composeFile: compose, projectName: "proj") + + #expect(output.contains(" build:")) + #expect(output.contains(" context: ./api")) + #expect(output.contains(" dockerfile: Dockerfile.dev")) + #expect(output.contains(" VERSION: 1.2")) + #expect(!output.contains("ComposeBuild(")) + } + + @Test("compose config renders environment as KEY=value, not a Swift tuple") + func composeConfigRendersEnvironment() throws { + let compose = try ComposeFile.parse(""" + services: + app: + image: nginx:latest + environment: + - FOO=bar + - "NOTE=has # hash" + """) + + let output = ComposeConfig.renderConfig(composeFile: compose, projectName: "proj") + + #expect(output.contains(" - FOO=bar")) + #expect(!output.contains("(key:")) + // A `#` would start a YAML comment if left bare. + #expect(output.contains(" - \"NOTE=has # hash\"")) + } + + @Test("config output stays valid YAML for values that would break it") + func composeConfigOutputRoundTrips() throws { + let compose = try ComposeFile.parse(""" + services: + app: + image: nginx:latest + environment: + - "NOTE=has # hash" + - "URL=http://example.com:8080" + """) + + let output = ComposeConfig.renderConfig(composeFile: compose, projectName: "proj") + let reparsed = try ComposeFile.parse(output) + + #expect(reparsed.services["app"]?.environment["NOTE"] == "has # hash") + #expect(reparsed.services["app"]?.environment["URL"] == "http://example.com:8080") + } } diff --git a/Tests/MockerTests/ComposeDryRunTests.swift b/Tests/MockerTests/ComposeDryRunTests.swift new file mode 100644 index 0000000..965efc4 --- /dev/null +++ b/Tests/MockerTests/ComposeDryRunTests.swift @@ -0,0 +1,71 @@ +import Testing +import Foundation +import ArgumentParser +import MockerKit +@testable import Mocker + +/// `--dry-run` was declared on every compose subcommand and read by none, so +/// `compose build --dry-run` really built and tagged an image (#74). These tests lock in +/// that the flag still parses everywhere it used to, that a dry run returns before +/// reaching the runtime, and which targets it reports. +@Suite("Compose dry-run") +struct ComposeDryRunTests { + private func composeFile(_ body: String = "services:\n web:\n image: nginx:latest\n") throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let file = root.appendingPathComponent("compose.yaml") + try body.write(to: file, atomically: true, encoding: .utf8) + return file + } + + @Test("Mutating subcommands accept --dry-run") + func mutatingSubcommandsParseFlag() throws { + let file = try composeFile().path + + #expect(try ComposeUp.parse(["-f", file, "--dry-run"]).options.dryRun) + #expect(try ComposeDown.parse(["-f", file, "--dry-run"]).options.dryRun) + #expect(try ComposeBuildCommand.parse(["-f", file, "--dry-run"]).options.dryRun) + #expect(try ComposePull.parse(["-f", file, "--dry-run"]).options.dryRun) + #expect(try ComposeStop.parse(["-f", file, "--dry-run"]).options.dryRun) + } + + @Test("Subcommands without shared options still accept --dry-run") + func standaloneSubcommandsParseFlag() throws { + #expect(try ComposeLs.parse(["--dry-run"]).dryRun) + #expect(try ComposeVersion.parse(["--dry-run"]).dryRun) + } + + @Test("Read-only subcommands accept --dry-run") + func readOnlySubcommandsParseFlag() throws { + let file = try composeFile().path + + #expect(try ComposePS.parse(["-f", file, "--dry-run"]).options.dryRun) + #expect(try ComposeConfig.parse(["-f", file, "--dry-run"]).options.dryRun) + } + + @Test("A dry-run up returns before touching the runtime") + func dryRunUpTouchesNothing() async throws { + let file = try composeFile() + + var command = try ComposeUp.parse(["-f", file.path, "--dry-run", "-d"]) + command.options.projectName = "dryproj" + + // Reaching the runtime would mean constructing the engine and shelling out to + // `container`; returning cleanly here is what proves the guard comes first. + try await command.run() + } + + @Test("Dry-run targets name the project's containers") + func dryRunTargets() throws { + let compose = try ComposeFile.parse(""" + services: + web: + image: nginx + db: + image: postgres + """) + + #expect(ComposeStop.dryRunTargets(compose, "proj", []) == ["proj-db-1", "proj-web-1"]) + #expect(ComposeStop.dryRunTargets(compose, "proj", ["web"]) == ["proj-web-1"]) + } +} diff --git a/Tests/MockerTests/RelativeDateTests.swift b/Tests/MockerTests/RelativeDateTests.swift index 856e734..908dbcc 100644 --- a/Tests/MockerTests/RelativeDateTests.swift +++ b/Tests/MockerTests/RelativeDateTests.swift @@ -1,6 +1,7 @@ import Testing import Foundation @testable import Mocker +import MockerKit @Suite("RelativeDate Tests") struct RelativeDateTests { diff --git a/docs/cli-reference.md b/docs/cli-reference.md index c19d7dd..c088e04 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -503,10 +503,16 @@ mocker volume inspect VOLUME All compose subcommands share these options: ``` --f, --file Compose file path (default: docker-compose.yml) --p, --project-name Project name (default: directory name) +-f, --file Compose file path (default: docker-compose.yml) +-p, --project-name Project name (see resolution order below) + --project-directory Working directory for relative paths and `.env` + --dry-run Print what would happen and change nothing ``` +The project name is resolved as `-p` → `COMPOSE_PROJECT_NAME` in the environment → +`COMPOSE_PROJECT_NAME` in `.env` → top-level `name:` in the compose file → the project +directory's name. + ### `mocker compose up` Create and start containers defined in a Compose file. @@ -537,15 +543,24 @@ Services start in dependency order (`depends_on`). Networks and volumes are crea Stop and remove containers and networks. ```bash -mocker compose [OPTIONS] down +mocker compose [OPTIONS] down [--volumes] +``` + +**Flags:** +``` +-v, --volumes Also remove the project's named volumes ``` **Examples:** ```bash mocker compose down +mocker compose down --volumes mocker compose -f staging.yml down ``` +`--volumes` removes the volumes declared in the file's top-level `volumes:` section. +Volumes marked `external: true` are never removed. + --- ### `mocker compose ps` diff --git a/docs/compose.md b/docs/compose.md index 690e7b0..1a3adc2 100644 --- a/docs/compose.md +++ b/docs/compose.md @@ -14,6 +14,10 @@ Mocker reads standard `docker-compose.yml` / `docker-compose.yaml` files. ```yaml version: "3.8" # optional +name: # optional, sets the project name + +include: # optional, pulls in other compose files + - services: : @@ -27,8 +31,27 @@ networks: volumes: : driver: local + external: true # declared elsewhere: never created or removed by mocker + name: # explicit name, used verbatim without the project prefix +``` + +### include + +Split a project across files with the Compose `include` element, in short or long form: + +```yaml +include: + - services/database.yml + - path: services/api.yml + project_directory: . + env_file: .env.api ``` +Relative paths inside an included file resolve against that entry's +`project_directory`, which defaults to the directory of the included file. Each +included file interpolates variables from its own `env_file` (default: `.env` +beside it). The including file's own definitions win over anything it includes. + --- ## Services @@ -93,7 +116,8 @@ environment: - API_KEY=${MY_API_KEY} ``` -Mocker loads `.env` from the same directory as the compose file. Shell environment takes priority over `.env`. +Mocker loads `.env` from the project directory (`--project-directory`, otherwise the +directory of the first `-f` file). Shell environment takes priority over `.env`. --- @@ -142,7 +166,16 @@ Container and resource names follow Docker Compose v2 convention: -- ``` -The project name defaults to the directory containing the compose file. Override with `-p`: +The project name is resolved in this order, first match wins: + +1. `-p` / `--project-name` +2. `COMPOSE_PROJECT_NAME` in the environment +3. `COMPOSE_PROJECT_NAME` in the project directory's `.env` +4. top-level `name:` in the compose file +5. the project directory's name + +The resolved name is lowercased, and any character outside `[a-z0-9_-]` becomes a dash. +`mocker compose config` prints the name it resolved. ```bash mocker compose -p staging up -d @@ -199,8 +232,14 @@ mocker compose kill api ```bash mocker compose down + +# also remove the project's named volumes +mocker compose down -v ``` +`-v` removes the volumes declared in the top-level `volumes:` section. Volumes marked +`external: true` are never removed. + --- ## Example: Web + API + Database