From 7413141b82551f4b709c0ac3521615b62ac283c3 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sun, 7 Jun 2026 17:56:34 -0400 Subject: [PATCH 1/2] Add pull subcommand to fetch service images from a compose file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls each resolved service's image, skipping build-only services (no image key) and images already present locally — mirroring the already-present match logic up uses (exact, registry-prefixed, or short reference). Passes the service's platform: through when set. Resolves the project through the shared ComposeProjectOptions (#138) and fails fast on unknown service names per the #139 contract. --- Sources/Container-Compose/Application.swift | 1 + .../Commands/ComposePull.swift | 92 +++++++++++++++++++ .../ComposePullTests.swift | 35 +++++++ 3 files changed, 128 insertions(+) create mode 100644 Sources/Container-Compose/Commands/ComposePull.swift create mode 100644 Tests/Container-Compose-StaticTests/ComposePullTests.swift diff --git a/Sources/Container-Compose/Application.swift b/Sources/Container-Compose/Application.swift index 1a7b639..6ee2d70 100644 --- a/Sources/Container-Compose/Application.swift +++ b/Sources/Container-Compose/Application.swift @@ -31,6 +31,7 @@ public struct Main: AsyncParsableCommand { ComposeUp.self, ComposeDown.self, ComposeBuild.self, + ComposePull.self, Version.self ]) diff --git a/Sources/Container-Compose/Commands/ComposePull.swift b/Sources/Container-Compose/Commands/ComposePull.swift new file mode 100644 index 0000000..15e02d0 --- /dev/null +++ b/Sources/Container-Compose/Commands/ComposePull.swift @@ -0,0 +1,92 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +// +// ComposePull.swift +// Container-Compose +// + +import ArgumentParser +import ContainerCommands +import ContainerAPIClient +import Foundation +import Yams + +public struct ComposePull: AsyncParsableCommand { + public init() {} + + public static let configuration: CommandConfiguration = .init( + commandName: "pull", + abstract: "Pull images for services defined in a compose file" + ) + + @Argument(help: "Services to pull (pulls all if omitted)") + var services: [String] = [] + + @OptionGroup + var project: ComposeProjectOptions + + @OptionGroup + var logging: Flags.Logging + + public mutating func run() async throws { + let resolved = try project.resolve(filteringBy: services) + + // Fail fast on a typo'd/unknown service name — same contract #139 + // established for `up` ("no such service: "). + let defined: [(serviceName: String, service: Service)] = resolved.compose.services.compactMap { name, service in + service.map { (name, $0) } + } + try Service.validateRequestedServices(services, against: defined) + + for target in resolved.services { + let service = target.service + guard let image = service.image else { + // Build-only services have no image to pull; compose builds these. + print("Skipping \(target.serviceName) (no image, built from a Dockerfile)") + continue + } + + print("Pulling \(target.serviceName) (\(image))...") + try await pullImage(image, platform: service.platform) + } + } + + private func pullImage(_ imageName: String, platform: String?) async throws { + // Skip the pull if the image is already present locally. Mirrors the + // match logic used by `up`: exact reference, registry-prefixed + // reference, or matching last path component (short references). + let imageList = try await ClientImage.list() + let exists = imageList.contains { ref in + let stored = ref.description.reference + return stored == imageName + || stored.hasSuffix("/\(imageName)") + || stored.components(separatedBy: "/").last == imageName + } + guard !exists else { + print(" Image \(imageName) already present, skipping.") + return + } + + var commands = [imageName] + if let platform { + commands.append(contentsOf: ["--platform", platform]) + } + + let imagePull = try Application.ImagePull.parse(commands + logging.passThroughCommands()) + try await imagePull.run() + } +} diff --git a/Tests/Container-Compose-StaticTests/ComposePullTests.swift b/Tests/Container-Compose-StaticTests/ComposePullTests.swift new file mode 100644 index 0000000..a85599b --- /dev/null +++ b/Tests/Container-Compose-StaticTests/ComposePullTests.swift @@ -0,0 +1,35 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Testing +@testable import ContainerComposeCore + +@Suite("Compose pull Tests") +struct ComposePullTests { + + @Test("pull accepts service arguments alongside the project option group") + func pullParsesArguments() throws { + let cmd = try ComposePull.parse(["web", "db"]) + #expect(cmd.services == ["web", "db"]) + } + + @Test("pull accepts the shared -f/--cwd project options") + func pullParsesProjectOptions() throws { + let cmd = try ComposePull.parse(["-f", "my-compose.yaml", "--cwd", "/tmp"]) + #expect(cmd.project.composeFileOptions.composeFilename == "my-compose.yaml") + #expect(cmd.project.cwd == "/tmp") + } +} From a42104e84581441e991ed6b4434c34cfb0f6d0c3 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 12 Sep 2026 18:00:43 -0400 Subject: [PATCH 2/2] fix(pull): always pull, resolve image variables, and pull only requested services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the already-present shortcut: refreshing a moving tag like :latest is the point of an explicit pull (up's implicit pull keeps the shortcut). - Resolve ${VAR}/${VAR:-default} placeholders in image references from the environment file before pulling — a literal ${...} is not a valid registry reference. - Pull only the requested services' images: the shared selection expands depends_on for up's sake, so explicit requests are narrowed back to the named services, with --include-deps to opt into the expanded set (matching docker compose pull). Extracts pullTargets/resolvedImage as pure functions with unit coverage for dependency narrowing, --include-deps, variable resolution and defaults, and build-only services. --- .../Commands/ComposePull.swift | 51 +++++++----- .../ComposePullTests.swift | 77 +++++++++++++++++++ 2 files changed, 109 insertions(+), 19 deletions(-) diff --git a/Sources/Container-Compose/Commands/ComposePull.swift b/Sources/Container-Compose/Commands/ComposePull.swift index 15e02d0..0bdc47b 100644 --- a/Sources/Container-Compose/Commands/ComposePull.swift +++ b/Sources/Container-Compose/Commands/ComposePull.swift @@ -36,6 +36,9 @@ public struct ComposePull: AsyncParsableCommand { @Argument(help: "Services to pull (pulls all if omitted)") var services: [String] = [] + @Flag(name: .long, help: "Also pull images for the services that requested services depend on") + var includeDeps: Bool = false + @OptionGroup var project: ComposeProjectOptions @@ -52,35 +55,45 @@ public struct ComposePull: AsyncParsableCommand { } try Service.validateRequestedServices(services, against: defined) - for target in resolved.services { - let service = target.service - guard let image = service.image else { + let environment = loadEnvFile(path: project.envFilePath) + + for target in Self.pullTargets(in: resolved, requested: services, includeDeps: includeDeps) { + guard let image = Self.resolvedImage(for: target.service, environment: environment) else { // Build-only services have no image to pull; compose builds these. print("Skipping \(target.serviceName) (no image, built from a Dockerfile)") continue } print("Pulling \(target.serviceName) (\(image))...") - try await pullImage(image, platform: service.platform) + try await pullImage(image, platform: target.service.platform) } } - private func pullImage(_ imageName: String, platform: String?) async throws { - // Skip the pull if the image is already present locally. Mirrors the - // match logic used by `up`: exact reference, registry-prefixed - // reference, or matching last path component (short references). - let imageList = try await ClientImage.list() - let exists = imageList.contains { ref in - let stored = ref.description.reference - return stored == imageName - || stored.hasSuffix("/\(imageName)") - || stored.components(separatedBy: "/").last == imageName - } - guard !exists else { - print(" Image \(imageName) already present, skipping.") - return - } + /// The services `pull` acts on. The shared selection expands `depends_on` + /// (which `up` needs to start dependencies), but `docker compose pull + /// ` pulls only the named services unless `--include-deps` is passed — + /// so explicit requests are narrowed back to the requested names. + static func pullTargets( + in project: ComposeProject, + requested: [String], + includeDeps: Bool + ) -> [ComposeProject.ServiceTarget] { + guard !requested.isEmpty, !includeDeps else { return project.services } + return project.services.filter { requested.contains($0.serviceName) } + } + + /// The image reference to pull for a service, with `${VAR}` / + /// `${VAR:-default}` placeholders resolved from the environment file — + /// a literal `${...}` is not a valid registry reference. `nil` for + /// build-only services (no `image:` key). + static func resolvedImage(for service: Service, environment: [String: String]) -> String? { + service.image.map { resolveVariable($0, with: environment) } + } + private func pullImage(_ imageName: String, platform: String?) async throws { + // Always pull, even when a matching image exists locally: refreshing a + // moving tag like `:latest` is the point of an explicit `pull`. + // (`up`'s implicit pull is the place for the already-present shortcut.) var commands = [imageName] if let platform { commands.append(contentsOf: ["--platform", platform]) diff --git a/Tests/Container-Compose-StaticTests/ComposePullTests.swift b/Tests/Container-Compose-StaticTests/ComposePullTests.swift index a85599b..4dc4aa3 100644 --- a/Tests/Container-Compose-StaticTests/ComposePullTests.swift +++ b/Tests/Container-Compose-StaticTests/ComposePullTests.swift @@ -15,15 +15,92 @@ //===----------------------------------------------------------------------===// import Testing +import Yams @testable import ContainerComposeCore @Suite("Compose pull Tests") struct ComposePullTests { + /// A resolved project where the shared selection has already expanded + /// `web`'s dependency on `db` (topological order: db before web). + private func expandedProject() throws -> ComposeProject { + let compose = try YAMLDecoder().decode( + DockerCompose.self, + from: """ + name: demo + services: + web: + image: nginx + depends_on: [db] + db: + image: postgres + """) + let targets = [ + ComposeProject.ServiceTarget( + serviceName: "db", service: Service(image: "postgres"), + candidateContainerNames: ["demo-db", "db.demo"]), + ComposeProject.ServiceTarget( + serviceName: "web", service: Service(image: "nginx"), + candidateContainerNames: ["demo-web", "web.demo"]), + ] + return ComposeProject(compose: compose, projectName: "demo", services: targets) + } + + // MARK: - Service selection + + @Test("Explicitly requested services do not pull their dependencies") + func requestedServicesExcludeDependencies() throws { + let targets = ComposePull.pullTargets(in: try expandedProject(), requested: ["web"], includeDeps: false) + #expect(targets.map(\.serviceName) == ["web"]) + } + + @Test("--include-deps pulls the expanded dependency set") + func includeDepsPullsDependencies() throws { + let targets = ComposePull.pullTargets(in: try expandedProject(), requested: ["web"], includeDeps: true) + #expect(targets.map(\.serviceName) == ["db", "web"]) + } + + @Test("No requested services pulls the whole selection") + func defaultPullsEverything() throws { + let targets = ComposePull.pullTargets(in: try expandedProject(), requested: [], includeDeps: false) + #expect(targets.map(\.serviceName) == ["db", "web"]) + } + + // MARK: - Image reference resolution + + @Test("Variables in image references resolve from the environment") + func imageVariableResolution() { + let service = Service(image: "${REGISTRY}/app:${TAG}") + let image = ComposePull.resolvedImage(for: service, environment: ["REGISTRY": "ghcr.io/acme", "TAG": "1.2"]) + #expect(image == "ghcr.io/acme/app:1.2") + } + + @Test("Variable defaults apply when the variable is unset") + func imageVariableDefault() { + let service = Service(image: "nginx:${CC_PULL_TEST_UNSET:-latest}") + #expect(ComposePull.resolvedImage(for: service, environment: [:]) == "nginx:latest") + } + + @Test("Plain references pass through and build-only services resolve to nil") + func imagePassthroughAndBuildOnly() throws { + #expect(ComposePull.resolvedImage(for: Service(image: "nginx:1.27"), environment: [:]) == "nginx:1.27") + let buildOnly = try YAMLDecoder().decode(Service.self, from: "build: .\n") + #expect(ComposePull.resolvedImage(for: buildOnly, environment: [:]) == nil) + } + + // MARK: - CLI parsing + @Test("pull accepts service arguments alongside the project option group") func pullParsesArguments() throws { let cmd = try ComposePull.parse(["web", "db"]) #expect(cmd.services == ["web", "db"]) + #expect(!cmd.includeDeps) + } + + @Test("pull accepts --include-deps") + func pullParsesIncludeDeps() throws { + let cmd = try ComposePull.parse(["--include-deps", "web"]) + #expect(cmd.includeDeps) } @Test("pull accepts the shared -f/--cwd project options")