Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Sources/Container-Compose/Application.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public struct Main: AsyncParsableCommand {
ComposeUp.self,
ComposeDown.self,
ComposeBuild.self,
ComposePull.self,
Version.self
])

Expand Down
105 changes: 105 additions & 0 deletions Sources/Container-Compose/Commands/ComposePull.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
//===----------------------------------------------------------------------===//
// 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] = []

@Flag(name: .long, help: "Also pull images for the services that requested services depend on")
var includeDeps: Bool = false

@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: <svc>").
let defined: [(serviceName: String, service: Service)] = resolved.compose.services.compactMap { name, service in
service.map { (name, $0) }
}
try Service.validateRequestedServices(services, against: defined)

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: target.service.platform)
}
}

/// The services `pull` acts on. The shared selection expands `depends_on`
/// (which `up` needs to start dependencies), but `docker compose pull
/// <svc>` 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])
}

let imagePull = try Application.ImagePull.parse(commands + logging.passThroughCommands())
try await imagePull.run()
}
}
112 changes: 112 additions & 0 deletions Tests/Container-Compose-StaticTests/ComposePullTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//===----------------------------------------------------------------------===//
// 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
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")
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")
}
}
Loading