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/APIServer/APIServer+Start.swift
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ extension APIServer {
routes[XPCRoute.containerCopyIn] = XPCServer.route(harness.copyIn)
routes[XPCRoute.containerCopyOut] = XPCServer.route(harness.copyOut)
routes[XPCRoute.containerExport] = XPCServer.route(harness.export)
routes[XPCRoute.containerCommit] = XPCServer.route(harness.commit)
routes[XPCRoute.containerClean] = XPCServer.route(harness.clean)

return service
Expand Down
28 changes: 3 additions & 25 deletions Sources/ContainerCommands/Container/ContainerCommit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
import ArgumentParser
import ContainerAPIClient
import Foundation
import SystemPackage

extension Application {
public struct ContainerCommit: AsyncLoggableCommand {
Expand All @@ -40,30 +39,9 @@ extension Application {

public func run() async throws {
let client = ContainerClient()
let containerSystemConfig = try await ClientHealthCheck.loadContainerSystemConfig()
let normalizedReference = try ClientImage.normalizeReference(reference, containerSystemConfig: containerSystemConfig)
let snapshot = try await client.get(id: id)
let platform = snapshot.configuration.platform
let sourceImage = try await ClientImage(description: snapshot.configuration.image).config(for: platform)
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)

try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer {
try? FileManager.default.removeItem(at: tempDir)
}

let rootfsArchive = tempDir.appendingPathComponent("rootfs.tar")
try await client.export(id: id, archive: rootfsArchive)

let imageArchive = try OCIImageArchive.create(
from: rootfsArchive,
in: tempDir,
reference: normalizedReference,
container: snapshot,
sourceImage: sourceImage
)

try await ImageLoad.load(from: FilePath(imageArchive.path()), log: log)
let image = try await client.commit(id: id, reference: reference)
try await image.unpack(platform: nil)
print(image.reference)
}
}
}
24 changes: 24 additions & 0 deletions Sources/Services/ContainerAPIService/Client/ContainerClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -405,4 +405,28 @@ public struct ContainerClient: Sendable {
)
}
}

/// Create an image from a container's current filesystem and configuration.
public func commit(id: String, reference: String) async throws -> ClientImage {
let request = XPCMessage(route: .containerCommit)
request.set(key: .id, value: id)
request.set(key: .commitImageReference, value: reference)

do {
let response = try await xpcSend(message: request, timeout: .seconds(300))
guard let data = response.dataNoCopy(key: .committedImage) else {
throw ContainerizationError(.empty, message: "image description not received")
}
let description = try JSONDecoder().decode(ImageDescription.self, from: data)
return ClientImage(description: description)
} catch let error as ContainerizationError {
throw error
} catch {
throw ContainerizationError(
.internalError,
message: "failed to commit container \(id)",
cause: error
)
}
}
}
5 changes: 5 additions & 0 deletions Sources/Services/ContainerAPIService/Client/XPC+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ public enum XPCKeys: String {
case plugin
/// Archive path to export rootfs
case archive
/// Image reference used by container commit
case commitImageReference
/// Image description returned by container commit
case committedImage
/// Special-case environment variables recomputed on each container start
case dynamicEnv

Expand Down Expand Up @@ -165,6 +169,7 @@ public enum XPCRoute: String {
case containerCopyIn
case containerCopyOut
case containerExport
case containerCommit
case containerClean

case pluginLoad
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,25 @@ public struct ContainersHarness: Sendable {
return message.reply()
}

@Sendable
public func commit(_ message: XPCMessage) async throws -> XPCMessage {
guard let id = message.string(key: .id) else {
throw ContainerizationError(.invalidArgument, message: "id cannot be empty")
}
guard ManagedContainer.nameValid(id) else {
throw ContainerizationError(.invalidArgument, message: "container ID \(id) is not a valid container ID")
}
guard let reference = message.string(key: .commitImageReference) else {
throw ContainerizationError(.invalidArgument, message: "image reference cannot be empty")
}

let image = try await service.commit(id: id, reference: reference)
let data = try JSONEncoder().encode(image)
let reply = message.reply()
reply.set(key: .committedImage, value: data)
return reply
}

@Sendable
public func clean(_ message: XPCMessage) async throws -> XPCMessage {
let id = message.string(key: .id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,36 @@ public actor ContainersService {
}
}

public func commit(id: String, reference: String) async throws -> ImageDescription {
let state = try self._getContainerState(id: id)
let snapshot = state.snapshot
let normalizedReference = try ClientImage.normalizeReference(reference, containerSystemConfig: containerSystemConfig)
let sourceImage = try await ClientImage(description: snapshot.configuration.image)
.config(for: snapshot.configuration.platform)

let temporaryDirectory = FileManager.default.uniqueTemporaryDirectory()
try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true)
defer {
try? FileManager.default.removeItem(at: temporaryDirectory)
}

let rootfsArchive = temporaryDirectory.appendingPathComponent("rootfs.tar")
try await exportRootfs(id: id, archive: rootfsArchive)

let imageArchive = try OCIImageArchive.create(
from: rootfsArchive,
in: temporaryDirectory,
reference: normalizedReference,
container: snapshot,
sourceImage: sourceImage
)
let result = try await ClientImage.load(from: imageArchive.path())
guard let image = result.images.first(where: { $0.reference == normalizedReference }) else {
throw ContainerizationError(.internalError, message: "committed image was not returned by image load")
}
return image.description
}

public func clean(id: String) async throws {
self.log.debug("\(#function)")

Expand Down
9 changes: 7 additions & 2 deletions Tests/IntegrationTests/Containers/TestCLICommit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ struct TestCLICommitCommand {
#expect(try f.isImagePresent(reference))
try assertCommittedImageMetadata(f, reference: reference)

try await f.withContainer(image: reference) { committedName in
try await f.withContainer(image: reference, tag: "committed", containerArgs: []) { committedName in
let output = try f.doExec(committedName, cmd: ["cat", "/committed-file"])
#expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == expected)

Expand All @@ -82,9 +82,14 @@ struct TestCLICommitCommand {

let decoder = JSONDecoder()
let index = try decoder.decode(Index.self, from: Data(contentsOf: URL(filePath: extractedDirectory.appending("index.json").string)))
let manifestDescriptor = try #require(index.manifests.first)
var manifestDescriptor = try #require(index.manifests.first)
#expect(manifestDescriptor.annotations?["org.opencontainers.image.ref.name"] == reference)

if manifestDescriptor.mediaType == MediaTypes.index {
let nestedIndex = try decoder.decode(Index.self, from: blobData(manifestDescriptor, in: extractedDirectory))
manifestDescriptor = try #require(nestedIndex.manifests.first)
}

let manifest = try decoder.decode(Manifest.self, from: blobData(manifestDescriptor, in: extractedDirectory))
#expect(manifest.layers.count == 1)

Expand Down