From 8706321243d73d8ea1d6784e83f4806dbd8d0bfc Mon Sep 17 00:00:00 2001 From: saehejkang <20051028+saehejkang@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:23:54 -0400 Subject: [PATCH] container commit api --- Sources/APIServer/APIServer+Start.swift | 1 + .../Container/ContainerCommit.swift | 28 ++--------------- .../Client/ContainerClient.swift | 24 +++++++++++++++ .../ContainerAPIService/Client/XPC+.swift | 5 ++++ .../Server/Containers/ContainersHarness.swift | 19 ++++++++++++ .../Server/Containers/ContainersService.swift | 30 +++++++++++++++++++ .../Server/Containers}/OCIImageArchive.swift | 0 .../Containers/TestCLICommit.swift | 9 ++++-- 8 files changed, 89 insertions(+), 27 deletions(-) rename Sources/{ContainerCommands/Image => Services/ContainerAPIService/Server/Containers}/OCIImageArchive.swift (100%) diff --git a/Sources/APIServer/APIServer+Start.swift b/Sources/APIServer/APIServer+Start.swift index 0c1b82a91a..6bf5392359 100644 --- a/Sources/APIServer/APIServer+Start.swift +++ b/Sources/APIServer/APIServer+Start.swift @@ -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 diff --git a/Sources/ContainerCommands/Container/ContainerCommit.swift b/Sources/ContainerCommands/Container/ContainerCommit.swift index 26a703b8e2..247a6b18d4 100644 --- a/Sources/ContainerCommands/Container/ContainerCommit.swift +++ b/Sources/ContainerCommands/Container/ContainerCommit.swift @@ -17,7 +17,6 @@ import ArgumentParser import ContainerAPIClient import Foundation -import SystemPackage extension Application { public struct ContainerCommit: AsyncLoggableCommand { @@ -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) } } } diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift index b52538d94f..d37eeb982c 100644 --- a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -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 + ) + } + } } diff --git a/Sources/Services/ContainerAPIService/Client/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift index 7fdd835370..f333f1e649 100644 --- a/Sources/Services/ContainerAPIService/Client/XPC+.swift +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -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 @@ -165,6 +169,7 @@ public enum XPCRoute: String { case containerCopyIn case containerCopyOut case containerExport + case containerCommit case containerClean case pluginLoad diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift index 72f78b337b..832db11d6e 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift @@ -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) diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index 42e228951c..4076b7cb2f 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -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)") diff --git a/Sources/ContainerCommands/Image/OCIImageArchive.swift b/Sources/Services/ContainerAPIService/Server/Containers/OCIImageArchive.swift similarity index 100% rename from Sources/ContainerCommands/Image/OCIImageArchive.swift rename to Sources/Services/ContainerAPIService/Server/Containers/OCIImageArchive.swift diff --git a/Tests/IntegrationTests/Containers/TestCLICommit.swift b/Tests/IntegrationTests/Containers/TestCLICommit.swift index bbad3eb59f..dc28fd003a 100644 --- a/Tests/IntegrationTests/Containers/TestCLICommit.swift +++ b/Tests/IntegrationTests/Containers/TestCLICommit.swift @@ -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) @@ -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)