Skip to content
Merged
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
9 changes: 9 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,15 @@ let package = Package(
name: "AgentIsolationDockerRuntime", condition: .when(traits: ["ContainerRuntimeDocker"])),
]
),
.testTarget(
name: "AgentIsolationAppleContainerRuntimeTests",
dependencies: [
"AgentIsolation",
.target(
name: "AgentIsolationAppleContainerRuntime",
condition: .when(traits: ["ContainerRuntimeAppleContainer"])),
]
),
.testTarget(
name: "AgentcIntegrationTests",
dependencies: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,9 @@
guard let store = imageStore else {
throw AppleContainerRuntimeError.notPrepared
}
let resolvedRef = Self.normalizedDockerHubRef(ref)
do {
let image = try await store.pull(reference: ref, platform: .current)
let image = try await store.pull(reference: resolvedRef, platform: .current)
return AppleContainerImage(ref: ref, digest: image.digest)
} catch {
// Pull failure — image may not exist or network error
Expand All @@ -72,19 +73,24 @@
guard let store = imageStore else {
throw AppleContainerRuntimeError.notPrepared
}
do {
let image = try await store.get(reference: ref)
// Try the ref as given first (image may have been pulled with the full name)
if let image = try? await store.get(reference: ref) {
return AppleContainerImage(ref: ref, digest: image.digest)
}
// Fall back to the normalized Docker Hub reference for bare names
let resolvedRef = Self.normalizedDockerHubRef(ref)
if resolvedRef != ref, let image = try? await store.get(reference: resolvedRef) {
return AppleContainerImage(ref: ref, digest: image.digest)
} catch {
return nil
}
return nil
}

public func removeImage(ref: String) async throws {
guard let store = imageStore else {
throw AppleContainerRuntimeError.notPrepared
}
try await store.delete(reference: ref, performCleanup: true)
let resolvedRef = Self.normalizedDockerHubRef(ref)
try await store.delete(reference: resolvedRef, performCleanup: true)
}

public func removeImage(digest: String) async throws {
Expand Down Expand Up @@ -114,9 +120,11 @@

let containerID = UUID().uuidString.lowercased()

let resolvedRef = Self.normalizedDockerHubRef(imageRef)

let container = try await manager.create(
containerID,
reference: imageRef,
reference: resolvedRef,
rootfsSizeInBytes: UInt64(8).gib()
) { containerConfig in
containerConfig.cpus = configuration.cpuCount
Expand Down Expand Up @@ -193,6 +201,37 @@
try container.manager.delete(container.id)
}

// MARK: - Image Reference Normalization

/// Normalizes a bare image reference to a fully qualified Docker Hub reference.
/// e.g., "swift:6.3" → "docker.io/library/swift:6.3",
/// "user/repo:tag" → "docker.io/user/repo:tag".
/// Already-qualified references (containing a registry domain) are returned as-is.
static func normalizedDockerHubRef(_ ref: String) -> String {
// Strip tag (@sha256:...) or tag (:tag) to isolate the name portion
let name: String
if let atIndex = ref.firstIndex(of: "@") {
name = String(ref[..<atIndex])
} else {
name = ref
}

guard let slashIndex = name.firstIndex(of: "/") else {
// No slash → bare name like "swift:6.3"
return "docker.io/library/\(ref)"
}

let firstComponent = name[..<slashIndex]
// A registry domain contains a dot, a colon (port), or is "localhost"
if firstComponent.contains(".") || firstComponent.contains(":") || firstComponent == "localhost"
{
return ref
}

// Has a slash but no registry (e.g., "user/repo:tag")
return "docker.io/\(ref)"
}

// MARK: - Kernel

private func getOrDownloadKernel() async throws -> Kernel {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
#if canImport(Containerization)
import AgentIsolation
@testable import AgentIsolationAppleContainerRuntime
import Foundation
import Testing

// MARK: - Image Reference Normalization Unit Tests

@Suite("AppleContainerRuntime Image Ref Normalization")
struct ImageRefNormalizationTests {

// MARK: - Bare names (no slash, no registry)

@Test("Bare name with tag is normalized to docker.io/library/")
func bareNameWithTag() {
let result = AppleContainerRuntime.normalizedDockerHubRef("swift:6.3")
#expect(result == "docker.io/library/swift:6.3")
}

@Test("Bare name with 'latest' tag is normalized")
func bareNameLatest() {
let result = AppleContainerRuntime.normalizedDockerHubRef("ubuntu:latest")
#expect(result == "docker.io/library/ubuntu:latest")
}

@Test("Bare name without tag is normalized")
func bareNameNoTag() {
let result = AppleContainerRuntime.normalizedDockerHubRef("alpine")
#expect(result == "docker.io/library/alpine")
}

@Test("Bare name with numeric tag is normalized")
func bareNameNumericTag() {
let result = AppleContainerRuntime.normalizedDockerHubRef("ubuntu:22.04")
#expect(result == "docker.io/library/ubuntu:22.04")
}

// MARK: - User namespaces (slash, no registry domain)

@Test("User namespace with tag is normalized to docker.io/")
func userNamespaceWithTag() {
let result = AppleContainerRuntime.normalizedDockerHubRef("myuser/myimage:v1")
#expect(result == "docker.io/myuser/myimage:v1")
}

@Test("User namespace without tag is normalized")
func userNamespaceNoTag() {
let result = AppleContainerRuntime.normalizedDockerHubRef("myuser/myimage")
#expect(result == "docker.io/myuser/myimage")
}

// MARK: - Fully qualified references (returned unchanged)

@Test("docker.io/library/ reference is unchanged")
func dockerIoLibrary() {
let ref = "docker.io/library/swift:6.3"
let result = AppleContainerRuntime.normalizedDockerHubRef(ref)
#expect(result == ref)
}

@Test("docker.io user namespace is unchanged")
func dockerIoUser() {
let ref = "docker.io/myuser/myimage:latest"
let result = AppleContainerRuntime.normalizedDockerHubRef(ref)
#expect(result == ref)
}

@Test("ghcr.io reference is unchanged")
func ghcrReference() {
let ref = "ghcr.io/apple/containerization/vminit:0.29.0"
let result = AppleContainerRuntime.normalizedDockerHubRef(ref)
#expect(result == ref)
}

@Test("Custom registry with port is unchanged")
func registryWithPort() {
let ref = "myregistry:5000/myimage:v2"
let result = AppleContainerRuntime.normalizedDockerHubRef(ref)
#expect(result == ref)
}

@Test("localhost registry is unchanged")
func localhostRegistry() {
let ref = "localhost/myimage:latest"
let result = AppleContainerRuntime.normalizedDockerHubRef(ref)
#expect(result == ref)
}

@Test("localhost with port registry is unchanged")
func localhostWithPort() {
let ref = "localhost:5000/myimage:latest"
let result = AppleContainerRuntime.normalizedDockerHubRef(ref)
#expect(result == ref)
}

// MARK: - Digest references

@Test("Bare name with digest is normalized")
func bareNameWithDigest() {
let ref = "alpine@sha256:abcdef1234567890"
let result = AppleContainerRuntime.normalizedDockerHubRef(ref)
#expect(result == "docker.io/library/alpine@sha256:abcdef1234567890")
}

@Test("Fully qualified reference with digest is unchanged")
func qualifiedWithDigest() {
let ref = "ghcr.io/user/image@sha256:abcdef1234567890"
let result = AppleContainerRuntime.normalizedDockerHubRef(ref)
#expect(result == ref)
}

// MARK: - Edge cases

@Test("Nested path with registry domain is unchanged")
func nestedPath() {
let ref = "registry.example.com/org/team/image:v1"
let result = AppleContainerRuntime.normalizedDockerHubRef(ref)
#expect(result == ref)
}

@Test("Reference with only a dot-less custom domain is treated as user namespace")
func noDotDomain() {
// "myregistry/myimage:v1" looks like a Docker Hub user namespace
// since "myregistry" has no dot, colon, or "localhost" — same as Docker behavior
let ref = "myregistry/myimage:v1"
let result = AppleContainerRuntime.normalizedDockerHubRef(ref)
#expect(result == "docker.io/myregistry/myimage:v1")
}
}

// MARK: - AppleContainerRuntime Unit Tests

@Suite("AppleContainerRuntime")
struct AppleContainerRuntimeUnitTests {

@Test("AppleContainerImage conforms to ContainerRuntimeImage")
func imageConformance() {
var image = AppleContainerImage(ref: "swift:6.3", digest: "sha256:abc123")
#expect(image.ref == "swift:6.3")
#expect(image.digest == "sha256:abc123")

image.ref = "docker.io/library/swift:6.3"
image.digest = "sha256:def456"
#expect(image.ref == "docker.io/library/swift:6.3")
#expect(image.digest == "sha256:def456")
}

@Test("AppleContainerContainer conforms to ContainerRuntimeContainer")
func containerConformance() {
let _: any ContainerRuntimeContainer.Type = AppleContainerContainer.self
}

@Test("AppleContainerRuntimeError has descriptive messages")
func errorDescriptions() {
let error = AppleContainerRuntimeError.notPrepared
let description = error.errorDescription ?? ""
#expect(!description.isEmpty)
#expect(description.contains("prepare"))
}

@Test("pullImage throws when not prepared")
func pullImageNotPrepared() async {
let config = ContainerRuntimeConfiguration(storagePath: "/tmp/test-apple-container")
let runtime = AppleContainerRuntime(config: config)
await #expect(throws: AppleContainerRuntimeError.self) {
_ = try await runtime.pullImage(ref: "alpine:latest")
}
}

@Test("inspectImage throws when not prepared")
func inspectImageNotPrepared() async {
let config = ContainerRuntimeConfiguration(storagePath: "/tmp/test-apple-container")
let runtime = AppleContainerRuntime(config: config)
await #expect(throws: AppleContainerRuntimeError.self) {
_ = try await runtime.inspectImage(ref: "alpine:latest")
}
}

@Test("removeImage throws when not prepared")
func removeImageNotPrepared() async {
let config = ContainerRuntimeConfiguration(storagePath: "/tmp/test-apple-container")
let runtime = AppleContainerRuntime(config: config)
await #expect(throws: AppleContainerRuntimeError.self) {
try await runtime.removeImage(ref: "alpine:latest")
}
}

@Test("AppleContainerRuntime is Sendable")
func runtimeSendable() {
let config = ContainerRuntimeConfiguration(storagePath: "/tmp/test-apple-container")
let runtime = AppleContainerRuntime(config: config)
let _: any Sendable = runtime
}

@Test("AppleContainerImage is Sendable")
func imageSendable() {
let image = AppleContainerImage(ref: "test:latest", digest: "sha256:abc")
let _: any Sendable = image
}
}
#endif
50 changes: 50 additions & 0 deletions Tests/AgentcIntegrationTests/BaseImageIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,56 @@ struct BaseImageIntegrationTests {
#expect(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) == "agent")
}

// MARK: - Bare image references (no registry prefix)

@Test("Runs echo on bare alpine:latest (no docker.io/library/ prefix)")
func bareAlpineEcho() async throws {
let result = await runAgentc(
args: [
"sh",
"--profile", sharedProfile,
"--configurations-dir", sharedConfigurationsDir,
"--image", "alpine:latest",
"--no-update-image",
"--", "echo", "hello-from-bare-alpine",
]
)
#expect(result.exitCode == 0)
#expect(result.stdout.contains("hello-from-bare-alpine"))
}

@Test("Runs echo on bare debian:latest (no docker.io/library/ prefix)")
func bareDebianEcho() async throws {
let result = await runAgentc(
args: [
"sh",
"--profile", sharedProfile,
"--configurations-dir", sharedConfigurationsDir,
"--image", "debian:latest",
"--no-update-image",
"--", "echo", "hello-from-bare-debian",
]
)
#expect(result.exitCode == 0)
#expect(result.stdout.contains("hello-from-bare-debian"))
}

@Test("Runs echo on bare swift:6.3 (no docker.io/library/ prefix)")
func bareSwiftEcho() async throws {
let result = await runAgentc(
args: [
"sh",
"--profile", sharedProfile,
"--configurations-dir", sharedConfigurationsDir,
"--image", "swift:6.3",
"--no-update-image",
"--", "echo", "hello-from-bare-swift",
]
)
#expect(result.exitCode == 0)
#expect(result.stdout.contains("hello-from-bare-swift"))
}

// MARK: - --respect-image-entrypoint

@Test("--respect-image-entrypoint skips bootstrap")
Expand Down
Loading