Skip to content
179 changes: 106 additions & 73 deletions Sources/containertool/containertool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,112 +28,141 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti
abstract: "Build and publish a container image"
)

@Option(help: "Default registry for references which do not specify a registry")
private var defaultRegistry: String?

@Option(help: "Repository path")
private var repository: String?

@Argument(help: "Executable to package")
private var executable: String

@Option(help: "Resource bundle directory")
private var resources: [String] = []
/// Options controlling the locations of the source and destination images
struct RepositoryOptions: ParsableArguments {
@Option(help: "The default container registry to use when the image reference doesn't specify one")
var defaultRegistry: String?

@Option(
help: ArgumentHelp(
"[DEPRECATED] Default username, used if there are no matching entries in .netrc. Use --default-username instead.",
visibility: .private
)
)
private var username: String?
@Option(help: "The name and optional tag for the generated container image")
var repository: String?

@Option(help: "Default username, used if there are no matching entries in .netrc")
private var defaultUsername: String?
@Option(help: "The tag for the generated container image")
var tag: String?

@Option(
help: ArgumentHelp(
"[DEPRECATED] Default password, used if there are no matching entries in .netrc. Use --default-password instead.",
visibility: .private
)
)
private var password: String?
@Option(help: "The base container image name and optional tag")
var from: String?
}

@Option(help: "Default password, used if there are no matching entries in .netrc")
private var defaultPassword: String?
@OptionGroup(title: "Source and destination repository options")
var repositoryOptions: RepositoryOptions

@Flag(name: .shortAndLong, help: "Verbose output")
private var verbose: Bool = false
/// Options controlling how the destination image is built
struct ImageBuildOptions: ParsableArguments {
@Option(help: "Directory of resources to include in the image")
var resources: [String] = []
}

@OptionGroup(title: "Image build options")
var imageBuildOptions: ImageBuildOptions

// Options controlling the destination image's runtime configuration
struct ImageConfigurationOptions: ParsableArguments {
@Option(help: "CPU architecture")
var architecture: String?

@Option(help: "Operating system")
var os: String?
}

@OptionGroup(title: "Image configuration options")
var imageConfigurationOptions: ImageConfigurationOptions

@Option(help: "Connect to the container registry using plaintext HTTP")
private var allowInsecureHttp: AllowHTTP?
/// Options controlling how containertool authenticates to registries
struct AuthenticationOptions: ParsableArguments {
@Option(
help: ArgumentHelp(
"[DEPRECATED] Default username, used if there are no matching entries in .netrc. Use --default-username instead.",
visibility: .private
)
)
var username: String?

@Option(help: "CPU architecture")
private var architecture: String?
@Option(help: "Default username, used if there are no matching entries in .netrc")
var defaultUsername: String?

@Option(
help: ArgumentHelp(
"[DEPRECATED] Default password, used if there are no matching entries in .netrc. Use --default-password instead.",
visibility: .private
)
)
var password: String?

@Option(help: "Base image reference")
private var from: String?
@Option(help: "The default password to use if the tool can't find a matching entry in .netrc")
var defaultPassword: String?

@Option(help: "Operating system")
private var os: String?
@Flag(inversion: .prefixedEnableDisable, exclusivity: .exclusive, help: "Load credentials from a netrc file")
var netrc: Bool = true

@Option(help: "Tag for this manifest")
private var tag: String?
@Option(help: "Specify the netrc file path")
var netrcFile: String?

@Flag(inversion: .prefixedEnableDisable, exclusivity: .exclusive, help: "Load credentials from a netrc file")
private var netrc: Bool = true
@Option(help: "Connect to the registry using plaintext HTTP")
var allowInsecureHttp: AllowHTTP?

@Option(help: "Specify the netrc file path")
private var netrcFile: String?
mutating func validate() throws {
// The `--username` and `--password` options present v1.0 were deprecated and replaced by more descriptive
// `--default-username` and `--default-password`. The old names are still accepted, but specifying both the old
// and the new names at the same time is ambiguous and causes an error.
if username != nil {
guard defaultUsername == nil else {
throw ValidationError(
"--default-username and --username cannot be specified together. --username is deprecated, please use --default-username instead."
)
}

mutating func validate() throws {
if username != nil {
guard defaultUsername == nil else {
throw ValidationError(
"--default-username and --username cannot be specified together. Please use --default-username only."
)
log("Deprecation warning: --username is deprecated, please use --default-username instead.")
defaultUsername = username
}

log("Deprecation warning: --username is deprecated, please use --default-username instead.")
defaultUsername = username
}
if password != nil {
guard defaultPassword == nil else {
throw ValidationError(
"--default-password and --password cannot be specified together. --password is deprecated, please use --default-password instead."
)
}

if password != nil {
guard defaultPassword == nil else {
throw ValidationError(
"--default-password and --password cannot be specified together. Please use --default-password only."
)
log("Deprecation warning: --password is deprecated, please use --default-password instead.")
defaultPassword = password
}

log("Deprecation warning: --password is deprecated, please use --default-password instead.")
defaultPassword = password
}
}

@OptionGroup(title: "Authentication options")
var authenticationOptions: AuthenticationOptions

// General options

@Flag(name: .shortAndLong, help: "Verbose output")
private var verbose: Bool = false

func run() async throws {
// MARK: Apply defaults for unspecified configuration flags

let env = ProcessInfo.processInfo.environment

let defaultRegistry = defaultRegistry ?? env["CONTAINERTOOL_DEFAULT_REGISTRY"] ?? "docker.io"
guard let repository = repository ?? env["CONTAINERTOOL_REPOSITORY"] else {
let defaultRegistry = repositoryOptions.defaultRegistry ?? env["CONTAINERTOOL_DEFAULT_REGISTRY"] ?? "docker.io"
guard let repository = repositoryOptions.repository ?? env["CONTAINERTOOL_REPOSITORY"] else {
throw ValidationError(
"Please specify the destination repository using --repository or CONTAINERTOOL_REPOSITORY"
)
}

let username = defaultUsername ?? env["CONTAINERTOOL_DEFAULT_USERNAME"]
let password = defaultPassword ?? env["CONTAINERTOOL_DEFAULT_PASSWORD"]
let from = from ?? env["CONTAINERTOOL_BASE_IMAGE"] ?? "swift:slim"
let os = os ?? env["CONTAINERTOOL_OS"] ?? "linux"
let username = authenticationOptions.defaultUsername ?? env["CONTAINERTOOL_DEFAULT_USERNAME"]
let password = authenticationOptions.defaultPassword ?? env["CONTAINERTOOL_DEFAULT_PASSWORD"]
let from = repositoryOptions.from ?? env["CONTAINERTOOL_BASE_IMAGE"] ?? "swift:slim"
let os = imageConfigurationOptions.os ?? env["CONTAINERTOOL_OS"] ?? "linux"

// Try to detect the architecture of the application executable so a suitable base image can be selected.
// This reduces the risk of accidentally creating an image which stacks an aarch64 executable on top of an x86_64 base image.
let executableURL = URL(fileURLWithPath: executable)
let elfheader = try ELF.read(at: executableURL)

let architecture =
architecture
imageConfigurationOptions.architecture
?? env["CONTAINERTOOL_ARCHITECTURE"]
?? elfheader?.ISA.containerArchitecture
?? "amd64"
Expand All @@ -142,10 +171,12 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti
// MARK: Load netrc

let authProvider: AuthorizationProvider?
if !netrc {
if !authenticationOptions.netrc {
authProvider = nil
} else if let netrcFile {
guard FileManager.default.fileExists(atPath: netrcFile) else { throw "\(netrcFile) not found" }
} else if let netrcFile = authenticationOptions.netrcFile {
guard FileManager.default.fileExists(atPath: netrcFile) else {
throw "\(netrcFile) not found"
}
let customNetrc = URL(fileURLWithPath: netrcFile)
authProvider = try NetrcAuthorizationProvider(customNetrc)
} else {
Expand All @@ -166,15 +197,17 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti
} else {
source = try await RegistryClient(
registry: baseImage.registry,
insecure: allowInsecureHttp == .source || allowInsecureHttp == .both,
insecure: authenticationOptions.allowInsecureHttp == .source
|| authenticationOptions.allowInsecureHttp == .both,
auth: .init(username: username, password: password, auth: authProvider)
)
if verbose { log("Connected to source registry: \(baseImage.registry)") }
}

let destination = try await RegistryClient(
registry: destinationImage.registry,
insecure: allowInsecureHttp == .destination || allowInsecureHttp == .both,
insecure: authenticationOptions.allowInsecureHttp == .destination
|| authenticationOptions.allowInsecureHttp == .both,
auth: .init(username: username, password: password, auth: authProvider)
)

Expand All @@ -189,8 +222,8 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti
source: source,
architecture: architecture,
os: os,
resources: resources,
tag: tag,
resources: imageBuildOptions.resources,
tag: repositoryOptions.tag,
verbose: verbose,
executableURL: executableURL
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Wrap a binary in a container image and publish it.

### Usage

`swift package build-container-image [<options>] --repository <repository>`
`swift package build-container-image [<options>]`

### Options

Expand All @@ -17,22 +17,47 @@ Wrap a binary in a container image and publish it.

If `Package.swift` defines only one product, it will be selected by default.

### Source and destination repository options

- term `--default-registry <default-registry>`:
The default registry hostname. (default: `docker.io`)

If the repository path does not contain a registry hostname, the default registry will be prepended to it.

- term `--repository <repository>`:
The repository path.
Destination image repository.

If the path does not begin with a registry hostname, the default registry will be prepended to the path.
If the repository path does not begin with a registry hostname, the default registry will be prepended to the path.
The destination repository must be specified, either by setting the `--repository` option or the `CONTAINERTOOL_REPOSITORY` environment variable.

- term `--tag <tag>`:
The tag to apply to the destination image.

The `latest` tag is automatically updated to refer to the published image.

- term `--from <from>`:
Base image reference. (default: `swift:slim`)

### Image build options

- term `--resources <resources>`:
Add the file or directory at `resources` to the image.
Directories are added recursively.

If the `product` being packaged has a [resource bundle](https://developer.apple.com/documentation/xcode/bundling-resources-with-a-swift-package) it will be added to the image automatically.

### Image configuration options

- term `--architecture <architecture>`:
CPU architecture required to run the image.

If the base image is `scratch`, the final image will have no base layer and will consist only of the application layer and resource bundle layer, if the product has a resource bundle.

- term `--os <os>`:
Operating system required to run the image. (default: `linux`)

### Authentication options

- term `--default-username <username>`:
Default username to use when logging into the registry.

Expand All @@ -45,34 +70,20 @@ Wrap a binary in a container image and publish it.
This password is used if there is no matching `.netrc` entry for the registry, there is no `.netrc` file, or the `--disable-netrc` option is set.
The same password is used for the source and destination registries.

- term `-v, --verbose`:
Verbose output.

- term `--allow-insecure-http <allow-insecure-http>`:
Connect to the container registry using plaintext HTTP. (values: `source`, `destination`, `both`)

- term `--architecture <architecture>`:
CPU architecture to record in the image.

- term `--from <from>`:
Base image reference. (default: `swift:slim`)

If the base image is `scratch`, the final image will have no base layer and will consist only of the application layer and resource bundle layer, if the product has a resource bundle.

- term `--os <os>`:
Operating system to record in the image. (default: `linux`)

- term `--tag <tag>`:
Tag for this manifest.

The `latest` tag is automatically updated to refer to the published image.

- term `--enable-netrc/--disable-netrc`:
Load credentials from a netrc file (default: `--enable-netrc`)

- term `--netrc-file <netrc-file>`:
The path to the `.netrc` file.

- term `--allow-insecure-http <allow-insecure-http>`:
Connect to the container registry using plaintext HTTP. (values: `source`, `destination`, `both`)

### Options

- term `-v, --verbose`:
Verbose output.

- term `-h, --help`:
Show help information.

Expand All @@ -83,14 +94,15 @@ Wrap a binary in a container image and publish it.
(default: `docker.io`)

- term `CONTAINERTOOL_REPOSITORY`:
The repository path.
The destination image repository.

If the path does not begin with a registry hostname, the default registry will be prepended to the path.
The destination repository must be specified, either by setting the `--repository` option or the `CONTAINERTOOL_REPOSITORY` environment variable.

- term `CONTAINERTOOL_BASE_IMAGE`:
Base image on which to layer the application.
(default: `swift:slim`)

- term `CONTAINERTOOL_OS`:
Operating system to encode in the container image.
Operating system.
(default: `Linux`)