Update cctl to respect entrypoint and cmd (#906)

This expands `cctl run` to utilize entrypoint and cmd when present in
the OCI image, instead of always defaulting to `/bin/sh`. The user can
still provide explicit commands if necessary to override these values.
This commit is contained in:
Noah Thornton authored and GitHub committed 2026-09-03 16:05:47 -07:00
1 parent fc9e63846f
commit d7fc7c15a2
8 files changed
+555 -66

No files matched your search

-3
View File
@@ -211,9 +211,6 @@ let package = Package(
.product(name: "NIOSSL", package: "swift-nio-ssl"),
.product(name: "X509", package: "swift-certificates"),
.product(name: "NIOHTTP1", package: "swift-nio"),
.product(name: "NIOSSL", package: "swift-nio-ssl"),
.product(name: "X509", package: "swift-certificates"),
.product(name: "NIOHTTP1", package: "swift-nio"),
.product(name: "Crypto", package: "swift-crypto"),
]
),
+125
View File
@@ -0,0 +1,125 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization project authors.
//
// 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 Foundation
#if os(macOS)
extension IntegrationSuite {
/// Image used by the `cctl run` command-resolution tests. It declares no
/// ENTRYPOINT and `CMD ["/bin/sh"]`, which is what makes the default-command
/// case observable.
static let cctlRunImage = "docker.io/library/alpine:3.16"
/// With no trailing command, `cctl run` runs the image's ENTRYPOINT + CMD.
///
/// The alpine test image declares no ENTRYPOINT and `CMD ["/bin/sh"]`, so a
/// bare `cctl run` should land in a shell. Feeding a command on stdin proves
/// it really is a shell rather than something that merely exited cleanly.
func testCctlRunUsesImageDefaultCommand() async throws {
let marker = "DEFAULT_CMD_RAN"
let (output, exitCode) = try runCctl(
arguments: [
"run", "--kernel", self.kernel, "--id", "test-cctl-run-default-cmd",
"-i", Self.cctlRunImage,
],
input: "echo \(marker)\nexit\n"
)
guard exitCode == 0 else {
throw IntegrationError.assert(msg: "cctl run exited with \(exitCode): \(output)")
}
guard output.contains(marker) else {
throw IntegrationError.assert(msg: "expected the image's CMD shell to run, got: \(output)")
}
}
/// A trailing command replaces the image's CMD.
func testCctlRunExplicitCommandOverridesDefault() async throws {
let marker = "EXPLICIT_COMMAND_RAN"
let (output, exitCode) = try runCctl(arguments: [
"run", "--kernel", self.kernel, "--id", "test-cctl-run-explicit-command",
"-i", Self.cctlRunImage,
"/bin/echo", marker,
])
guard exitCode == 0 else {
throw IntegrationError.assert(msg: "cctl run exited with \(exitCode): \(output)")
}
guard output.contains(marker) else {
throw IntegrationError.assert(msg: "expected the explicit command to run, got: \(output)")
}
}
/// An image declaring neither ENTRYPOINT nor CMD has nothing to run, so a
/// bare `cctl run` must say so rather than falling back to a hardcoded
/// shell. `vminit:latest` is built by `cctl rootfs create`, which sets only
/// labels on the image config — no command of any kind.
///
/// This is the case that cannot pass under the old behaviour, where the
/// command defaulted to `/bin/sh` regardless of the image.
func testCctlRunWithoutEntrypointOrCmdFails() async throws {
let (output, exitCode) = try runCctl(arguments: [
"run", "--kernel", self.kernel, "--id", "test-cctl-run-no-command",
"-i", Self.initImage,
])
guard exitCode != 0 else {
throw IntegrationError.assert(msg: "expected a failure for an image with no entrypoint or cmd: \(output)")
}
guard output.contains("declares no entrypoint or cmd") else {
throw IntegrationError.assert(msg: "expected an entrypoint/cmd error, got: \(output)")
}
}
/// `--entrypoint` replaces the image's ENTRYPOINT while leaving its CMD in
/// place. The alpine test image has no ENTRYPOINT and `CMD ["/bin/sh"]`, so
/// overriding with `/bin/echo` and passing no command must print the CMD
/// rather than start a shell — which is what makes the appending observable.
func testCctlRunEntrypointOverrideKeepsImageCmd() async throws {
let (output, exitCode) = try runCctl(arguments: [
"run", "--kernel", self.kernel, "--id", "test-cctl-run-entrypoint",
"-i", Self.cctlRunImage,
"--entrypoint", "/bin/echo",
])
guard exitCode == 0 else {
throw IntegrationError.assert(msg: "cctl run exited with \(exitCode): \(output)")
}
guard output.contains("/bin/sh") else {
throw IntegrationError.assert(msg: "expected the image's cmd to be appended to the override, got: \(output)")
}
}
/// An override plus a trailing command: the override supplies the executable
/// and the command replaces the image's CMD.
func testCctlRunEntrypointOverrideWithCommand() async throws {
let marker = "ENTRYPOINT_OVERRIDE_RAN"
let (output, exitCode) = try runCctl(arguments: [
"run", "--kernel", self.kernel, "--id", "test-cctl-run-entrypoint-command",
"-i", Self.cctlRunImage,
"--entrypoint", "/bin/echo", marker,
])
guard exitCode == 0 else {
throw IntegrationError.assert(msg: "cctl run exited with \(exitCode): \(output)")
}
guard output.contains(marker) else {
throw IntegrationError.assert(msg: "expected the override to run with the trailing command, got: \(output)")
}
}
}
#endif
@@ -0,0 +1,89 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization project authors.
//
// 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 Foundation
import struct ContainerizationOS.Terminal
#if os(macOS)
extension IntegrationSuite {
/// Run `bin/cctl run` to completion and return its combined stdout+stderr.
///
/// Exercises the `cctl` CLI rather than the library directly, so it covers
/// argument parsing and the CLI's own wiring end to end. Shared with the
/// `--block` tests in PodVolumeTests. `bin/cctl` must be built and
/// codesigned with the virtualization entitlement.
///
/// - Parameter input: written to the container's stdin, for commands that
/// read from it. The pty stays open until the child exits either way.
func runCctl(arguments: [String], input: String? = nil) throws -> (output: String, exitCode: Int32) {
let cctl = Self.binPath(name: "cctl")
guard FileManager.default.isExecutableFile(atPath: cctl.path) else {
throw IntegrationError.assert(msg: "bin/cctl not built or not executable at \(cctl.path); run `make containerization`")
}
let (parent, child) = try Terminal.create()
let process = Process()
process.executableURL = cctl
process.arguments = arguments
// `cctl run` resolves relative paths (kernel, bin/) against the cwd.
process.currentDirectoryURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
process.standardInput = child.handle
process.standardOutput = child.handle
process.standardError = child.handle
try process.run()
// Drop our copy of the child fd. While any process here holds it open
// the parent side never reports EOF and the drain below never returns.
try child.close()
if let input {
// Written to the pty parent, which the child reads as stdin. Ignore
// a short write: the drain below reports whatever the child did.
_ = try? parent.handle.write(contentsOf: Data(input.utf8))
}
// Drain before waiting: the pty buffer is far smaller than a pipe's, so
// a chatty container blocks quickly.
var data = Data()
var buf = [UInt8](repeating: 0, count: 4096)
let parentFD = parent.handle.fileDescriptor
while true {
let n = read(parentFD, &buf, buf.count)
if n > 0 {
data.append(contentsOf: buf[0..<n])
continue
}
// n == 0 is EOF. n < 0 with EIO is how Darwin reports "last child
// fd closed" on a pty parent, which is EOF for us too. Retry EINTR.
if n < 0 && errno == EINTR {
continue
}
break
}
process.waitUntilExit()
try? parent.close()
// The pty applies ONLCR until cctl switches it to raw mode, so the
// output is a mix of "\r\n" and "\n". Normalize for callers' matching.
let output = (String(data: data, encoding: .utf8) ?? "")
.replacingOccurrences(of: "\r\n", with: "\n")
return (output, process.terminationStatus)
}
}
#endif
-57
View File
@@ -79,63 +79,6 @@ extension IntegrationSuite {
}
}
/// Run `bin/cctl run` to completion and return its combined stdout+stderr.
///
/// This exercises the `cctl` CLI rather than the
/// library directly, so it validates the `--block` option wiring end to end:
/// argument parsing, `Mount` construction, VZ attachment, and the guest
/// mount. `bin/cctl` is built and codesigned with the virtualization
/// entitlement.
private func runCctl(arguments: [String]) throws -> (output: String, exitCode: Int32) {
let cctl = Self.binPath(name: "cctl")
guard FileManager.default.isExecutableFile(atPath: cctl.path) else {
throw IntegrationError.assert(msg: "bin/cctl not built or not executable at \(cctl.path); run `make containerization`")
}
let (parent, child) = try Terminal.create()
let process = Process()
process.executableURL = cctl
process.arguments = arguments
// `cctl run` resolves relative paths (kernel, bin/) against the cwd.
process.currentDirectoryURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
process.standardInput = child.handle
process.standardOutput = child.handle
process.standardError = child.handle
try process.run()
// Drop our copy of the child fd. While any process here holds it open
// the parent side never reports EOF and the drain below never returns.
try child.close()
// Drain before waiting: the pty buffer is far smaller than a pipe's, so
// a chatty container blocks quickly.
var data = Data()
var buf = [UInt8](repeating: 0, count: 4096)
let parentFD = parent.handle.fileDescriptor
while true {
let n = read(parentFD, &buf, buf.count)
if n > 0 {
data.append(contentsOf: buf[0..<n])
continue
}
// n == 0 is EOF. n < 0 with EIO is how Darwin reports "last child
// fd closed" on a pty parent, which is EOF for us too. Retry EINTR.
if n < 0 && errno == EINTR {
continue
}
break
}
process.waitUntilExit()
try? parent.close()
// The pty applies ONLCR until cctl switches it to raw mode, so the
// output is a mix of "\r\n" and "\n". Normalize for callers' matching.
let output = (String(data: data, encoding: .utf8) ?? "")
.replacingOccurrences(of: "\r\n", with: "\n")
return (output, process.terminationStatus)
}
func testContainerNBDMount() async throws {
let id = "test-container-nbd-mount"
let bs = try await bootstrap(id)
+8 -1
View File
@@ -623,11 +623,18 @@ struct IntegrationSuite: AsyncParsableCommand {
Test("pod shared disk image volume", testPodSharedDiskImageVolume),
Test("pod shared tmpfs volume", testPodSharedTmpfsVolume),
// cctl --block CLI wiring (spawns bin/cctl; skips if unbuilt)
// cctl --block CLI wiring
Test("cctl block NBD mount", testCctlBlockNBDMount),
Test("cctl block NBD raw", testCctlBlockNBDRaw),
Test("cctl block NBD format and persist", testCctlBlockNBDFormatAndPersist),
Test("cctl block rejects invalid spec", testCctlBlockRejectsInvalidSpec),
// cctl run command entrypoint resolution
Test("cctl run uses image default command", testCctlRunUsesImageDefaultCommand),
Test("cctl run explicit command overrides default", testCctlRunExplicitCommandOverridesDefault),
Test("cctl run without entrypoint or cmd fails", testCctlRunWithoutEntrypointOrCmdFails),
Test("cctl run entrypoint override keeps image cmd", testCctlRunEntrypointOverrideKeepsImageCmd),
Test("cctl run entrypoint override with command", testCctlRunEntrypointOverrideWithCommand),
] + macOS26Tests()
let tests: [Test] = crossPlatformTests + macOSOnlyTests
#else
+62 -5
View File
@@ -99,8 +99,20 @@ extension Application {
@Option(name: .long, help: "Current working directory")
var cwd: String = "/"
@Option(
name: .customLong("entrypoint"),
help: """
Override the image's ENTRYPOINT with a single executable \
(Example: --entrypoint /bin/ls). The image's CMD is still \
appended; pass a trailing command to replace it.
"""
)
var entrypointOverride: String?
/// Command to run in the container. When omitted the image's
/// ENTRYPOINT + CMD is used.
@Argument(parsing: .captureForPassthrough)
var arguments: [String] = ["/bin/sh"]
var arguments: [String] = []
func run() async throws {
let kernel = Kernel(
@@ -124,13 +136,22 @@ extension Application {
)
let sigwinchStream = AsyncSignalHandler.create(notify: [SIGWINCH])
// Get the actual image so we can parse out the ENTRYPOINT
let image = try await manager.imageStore.get(reference: imageReference, pull: true)
let processArguments = try await Self.resolveArguments(
arguments,
entrypointOverride: entrypointOverride,
image: image,
imageReference: imageReference
)
let current = try Terminal.current
try current.setraw()
defer { current.tryReset() }
let container = try await manager.create(
id,
reference: imageReference,
image: image,
rootfsSizeInBytes: fsSizeInMB.mib(),
readOnly: readOnly,
networking: true
@@ -138,7 +159,7 @@ extension Application {
config.cpus = cpus
config.memoryInBytes = memory.mib()
config.process.setTerminalIO(terminal: current)
config.process.arguments = arguments
config.process.arguments = processArguments
config.process.workingDirectory = cwd
for mount in self.mounts {
@@ -245,6 +266,25 @@ extension Application {
).first!
.appendingPathComponent("com.apple.containerization")
}()
/// Resolve the command from the image config.
static func resolveArguments(
_ arguments: [String],
entrypointOverride: String?,
image: Containerization.Image,
imageReference: String
) async throws -> [String] {
// We hardcode linux arm64 for the kernel above, then proceed to use Platform.current in
// ContainerManager.create. This means it will always default have to be arm64, so we
// read that config directly.
let imageConfig = try await image.config(for: .arm64).config
return try Application.resolveProcessArguments(
arguments: arguments,
entrypointOverride: entrypointOverride,
imageConfig: imageConfig,
imageReference: imageReference
)
}
}
}
#endif
@@ -385,8 +425,20 @@ extension Application {
@Option(name: .long, help: "Current working directory")
var cwd: String = "/"
@Option(
name: .customLong("entrypoint"),
help: """
Override the image's ENTRYPOINT with a single executable \
(Example: --entrypoint /bin/ls). The image's CMD is still \
appended; pass a trailing command to replace it.
"""
)
var entrypointOverride: String?
/// Command to run in the container. When omitted the image's
/// ENTRYPOINT + CMD is used.
@Argument(parsing: .captureForPassthrough)
var arguments: [String] = ["/bin/sh"]
var arguments: [String] = []
func run() async throws {
#if arch(arm64)
@@ -471,7 +523,12 @@ extension Application {
if let imageConfig {
processConfig = .init(from: imageConfig)
}
processConfig.arguments = arguments
processConfig.arguments = try Application.resolveProcessArguments(
arguments: arguments,
entrypointOverride: entrypointOverride,
imageConfig: imageConfig,
imageReference: imageReference
)
processConfig.workingDirectory = cwd
if let hostTerminal {
processConfig.setTerminalIO(terminal: hostTerminal)
+43
View File
@@ -45,6 +45,49 @@ extension Application {
}
return parsedLabels
}
/// Parse the ENTRYPOINT and CMD from an `ImageConfig` into the command that
/// is run.
///
/// If arguments are present, they replace CMD and are appended to ENTRYPOINT
///
/// - Parameters:
/// - arguments: trailing command; replaces CMD when non-empty.
/// - entrypointOverride: single executable replacing ENTRYPOINT. CMD is
/// still appended, so an override alone does not change the arguments
/// the process receives.
/// - imageConfig: source of ENTRYPOINT and CMD. Nil declares neither.
/// - imageReference: names the image in the error.
/// - Throws: when the image declares neither and no command was given, or
/// when the override is empty.
static func resolveProcessArguments(
arguments: [String],
entrypointOverride: String?,
imageConfig: ImageConfig?,
imageReference: String
) throws -> [String] {
guard entrypointOverride != "" else {
throw ContainerizationError(
.invalidArgument,
message: "--entrypoint requires a non-empty path; omit the flag to use the image's entrypoint"
)
}
let imageEntrypoint = imageConfig?.entrypoint ?? []
let imageCommand = imageConfig?.cmd ?? []
let entrypoint = entrypointOverride.map { [$0] } ?? imageEntrypoint
let command = arguments.isEmpty ? imageCommand : arguments
let resolved = entrypoint + command
guard !resolved.isEmpty else {
throw ContainerizationError(
.invalidArgument,
message: "image \(imageReference) declares no entrypoint or cmd; pass a command to run"
)
}
return resolved
}
}
extension Containerization.Mount {
+228
View File
@@ -0,0 +1,228 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization project authors.
//
// 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 ContainerizationOCI
import Foundation
import Testing
@testable import cctl
/// `cctl run` resolves the container command as `ENTRYPOINT + (arguments ?? CMD)`.
/// Trailing arguments replace CMD but are appended to ENTRYPOINT
@Suite("cctl run command resolution")
struct RunArgumentsTests {
/// One scenario. A nil `expected` means the resolution must fail.
struct Case: Sendable, CustomTestStringConvertible {
let name: String
let imageConfig: ImageConfig?
let arguments: [String]
var entrypointOverride: String? = nil
let expected: [String]?
var testDescription: String { name }
}
private static let cases: [Case] = [
// Entrypoint and cmd both present.
.init(
name: "no arguments uses entrypoint and cmd",
imageConfig: ImageConfig(entrypoint: ["/bin/echo"], cmd: ["default"]),
arguments: [],
expected: ["/bin/echo", "default"]
),
.init(
name: "arguments replace cmd but keep entrypoint",
imageConfig: ImageConfig(entrypoint: ["/bin/echo"], cmd: ["default"]),
arguments: ["override"],
expected: ["/bin/echo", "override"]
),
// The regression that motivated this rule: replacing the whole command left
// the guest trying to exec `--nbd-url`.
.init(
name: "flag-shaped arguments append to entrypoint",
imageConfig: ImageConfig(entrypoint: ["/usr/local/bin/format-volume"], cmd: ["default"]),
arguments: ["--nbd-url", "nbd://h:1/v", "--filesystem", "xfs"],
expected: ["/usr/local/bin/format-volume", "--nbd-url", "nbd://h:1/v", "--filesystem", "xfs"]
),
.init(
name: "multi-word entrypoint is preserved in order",
imageConfig: ImageConfig(entrypoint: ["/bin/sh", "-c"], cmd: ["y"]),
arguments: ["x"],
expected: ["/bin/sh", "-c", "x"]
),
// Only one of entrypoint / cmd.
.init(
name: "entrypoint only with no arguments",
imageConfig: ImageConfig(entrypoint: ["/bin/true"]),
arguments: [],
expected: ["/bin/true"]
),
.init(
name: "entrypoint only with arguments",
imageConfig: ImageConfig(entrypoint: ["/bin/true"]),
arguments: ["--flag"],
expected: ["/bin/true", "--flag"]
),
.init(
name: "cmd only with no arguments",
imageConfig: ImageConfig(cmd: ["/bin/sh"]),
arguments: [],
expected: ["/bin/sh"]
),
// With no entrypoint the arguments are the whole command, so CMD is dropped.
.init(
name: "cmd only with arguments replaces cmd entirely",
imageConfig: ImageConfig(cmd: ["/bin/sh"]),
arguments: ["/bin/echo", "hi"],
expected: ["/bin/echo", "hi"]
),
// Nothing declared by the image, but the caller supplied the command outright.
.init(
name: "no entrypoint or cmd but arguments given",
imageConfig: ImageConfig(),
arguments: ["/bin/echo", "hi"],
expected: ["/bin/echo", "hi"]
),
.init(
name: "missing image config with arguments",
imageConfig: nil,
arguments: ["/bin/echo"],
expected: ["/bin/echo"]
),
// Explicitly empty arrays must behave exactly like absent ones.
.init(
name: "empty arrays with arguments behave as absent",
imageConfig: ImageConfig(entrypoint: [], cmd: []),
arguments: ["/bin/echo"],
expected: ["/bin/echo"]
),
// Nothing to run.
.init(
name: "no entrypoint, no cmd, no arguments fails",
imageConfig: ImageConfig(),
arguments: [],
expected: nil
),
.init(
name: "missing image config and no arguments fails",
imageConfig: nil,
arguments: [],
expected: nil
),
.init(
name: "empty arrays with no arguments fails",
imageConfig: ImageConfig(entrypoint: [], cmd: []),
arguments: [],
expected: nil
),
// --entrypoint replaces the image's ENTRYPOINT. CMD is still appended,
// so an override alone does not change the arguments the process gets.
.init(
name: "override replaces entrypoint and keeps cmd",
imageConfig: ImageConfig(entrypoint: ["/app"], cmd: ["--serve"]),
arguments: [],
entrypointOverride: "/bin/ls",
expected: ["/bin/ls", "--serve"]
),
.init(
name: "override with trailing arguments replaces cmd",
imageConfig: ImageConfig(entrypoint: ["/app"], cmd: ["--serve"]),
arguments: ["-l"],
entrypointOverride: "/bin/ls",
expected: ["/bin/ls", "-l"]
),
// A multi-word entrypoint has to come through as override + arguments,
// since the override itself is a single executable.
.init(
name: "override supplies the executable and arguments the rest",
imageConfig: ImageConfig(entrypoint: ["/app"], cmd: ["--serve"]),
arguments: ["foo.py"],
entrypointOverride: "python3",
expected: ["python3", "foo.py"]
),
// An override supplies a command even when the image declares nothing,
// so this case can no longer fail.
.init(
name: "override on an image with no entrypoint or cmd",
imageConfig: ImageConfig(),
arguments: [],
entrypointOverride: "/bin/true",
expected: ["/bin/true"]
),
.init(
name: "override on a cmd-only image",
imageConfig: ImageConfig(cmd: ["/bin/sh"]),
arguments: [],
entrypointOverride: "/usr/bin/strace",
expected: ["/usr/bin/strace", "/bin/sh"]
),
.init(
name: "override with a missing image config",
imageConfig: nil,
arguments: [],
entrypointOverride: "/bin/true",
expected: ["/bin/true"]
),
// An empty override would exec "" in the guest.
.init(
name: "empty override fails",
imageConfig: ImageConfig(entrypoint: ["/app"], cmd: ["--serve"]),
arguments: [],
entrypointOverride: "",
expected: nil
),
]
@Test("resolves", arguments: cases)
func resolves(_ testCase: Case) throws {
let resolve = {
try Application.resolveProcessArguments(
arguments: testCase.arguments,
entrypointOverride: testCase.entrypointOverride,
imageConfig: testCase.imageConfig,
imageReference: "test:latest"
)
}
guard let expected = testCase.expected else {
#expect(throws: (any Error).self) { try resolve() }
return
}
let resolved = try resolve()
#expect(resolved == expected)
}
@Test func errorNamesTheOffendingImage() {
do {
_ = try Application.resolveProcessArguments(
arguments: [],
entrypointOverride: nil,
imageConfig: ImageConfig(),
imageReference: "registry.example/thing:v2"
)
Issue.record("expected a failure for an image with no entrypoint or cmd")
} catch {
#expect("\(error)".contains("registry.example/thing:v2"))
}
}
}