diff --git a/Sources/Containerization/Image/Image.swift b/Sources/Containerization/Image/Image.swift index f3cf578..9fc57d1 100644 --- a/Sources/Containerization/Image/Image.swift +++ b/Sources/Containerization/Image/Image.swift @@ -19,13 +19,6 @@ import ContainerizationOCI import ContainerizationOS import Foundation -#if os(macOS) -import ContainerizationArchive -import ContainerizationEXT4 -import SystemPackage -import ContainerizationExtras -#endif - /// Type representing an OCI container image. public struct Image: Sendable { private let contentStore: ContentStore @@ -135,83 +128,3 @@ public struct Image: Sendable { return content } } - -#if os(macOS) - -extension Image { - /// Unpack the image into a filesystem. - public func unpack(for platform: Platform, at path: URL, blockSizeInBytes: UInt64 = 512.gib(), progress: ProgressHandler? = nil) async throws -> Mount { - let blockPath = try prepareUnpackPath(path: path) - let manifest = try await loadManifest(platform: platform) - return try await unpackContents( - path: blockPath, - manifest: manifest, - blockSizeInBytes: blockSizeInBytes, - progress: progress - ) - } - - private func loadManifest(platform: Platform) async throws -> Manifest { - let manifest = try await descriptor(for: platform) - guard let m: Manifest = try await self.contentStore.get(digest: manifest.digest) else { - throw ContainerizationError(.notFound, message: "content not found \(manifest.digest)") - } - return m - } - - private func prepareUnpackPath(path: URL) throws -> String { - let blockPath = path.absolutePath() - guard !FileManager.default.fileExists(atPath: blockPath) else { - throw ContainerizationError(.exists, message: "block device already exists at \(blockPath)") - } - return blockPath - } - - private func unpackContents(path: String, manifest: Manifest, blockSizeInBytes: UInt64, progress: ProgressHandler?) async throws -> Mount { - let filesystem = try EXT4.Formatter(FilePath(path), minDiskSize: blockSizeInBytes) - defer { try? filesystem.close() } - - for layer in manifest.layers { - try Task.checkCancellation() - guard let content = try await self.contentStore.get(digest: layer.digest) else { - throw ContainerizationError(.notFound, message: "Content with digest \(layer.digest)") - } - - switch layer.mediaType { - case MediaTypes.imageLayer, MediaTypes.dockerImageLayer: - try filesystem.unpack( - source: content.path, - format: .paxRestricted, - compression: .none, - progress: progress - ) - case MediaTypes.imageLayerGzip, MediaTypes.dockerImageLayerGzip: - try filesystem.unpack( - source: content.path, - format: .paxRestricted, - compression: .gzip, - progress: progress - ) - default: - throw ContainerizationError(.unsupported, message: "Media type \(layer.mediaType) not supported.") - } - } - - return .block( - format: "ext4", - source: path, - destination: "/", - options: [] - ) - } -} - -#else - -extension Image { - public func unpack(for platform: Platform, at path: URL, blockSizeInBytes: UInt64 = 512.gib()) async throws -> Mount { - throw ContainerizationError(.unsupported, message: "Image unpack unsupported on current platform") - } -} - -#endif diff --git a/Sources/Containerization/Image/InitImage.swift b/Sources/Containerization/Image/InitImage.swift index 252a10a..bf0eb9d 100644 --- a/Sources/Containerization/Image/InitImage.swift +++ b/Sources/Containerization/Image/InitImage.swift @@ -34,7 +34,8 @@ public struct InitImage: Sendable { extension InitImage { /// Unpack the initial filesystem for the desired platform at a given path. public func initBlock(at: URL, for platform: SystemPlatform) async throws -> Mount { - var fs = try await image.unpack(for: platform.ociPlatform(), at: at, blockSizeInBytes: 512.mib()) + let unpacker = EXT4Unpacker(blockSizeInBytes: 512.mib()) + var fs = try await unpacker.unpack(self.image, for: platform.ociPlatform(), at: at) fs.options = ["ro"] return fs } diff --git a/Sources/Containerization/Image/Unpacker/EXT4Unpacker.swift b/Sources/Containerization/Image/Unpacker/EXT4Unpacker.swift new file mode 100644 index 0000000..d679e75 --- /dev/null +++ b/Sources/Containerization/Image/Unpacker/EXT4Unpacker.swift @@ -0,0 +1,84 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization project authors. All rights reserved. +// +// 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 ContainerizationError +import ContainerizationExtras +import ContainerizationOCI +import Foundation + +#if os(macOS) +import ContainerizationArchive +import ContainerizationEXT4 +import SystemPackage +#endif + +public struct EXT4Unpacker: Unpacker { + let blockSizeInBytes: UInt64 + + public init(blockSizeInBytes: UInt64) { + self.blockSizeInBytes = blockSizeInBytes + } + + public func unpack(_ image: Image, for platform: Platform, at path: URL, progress: ProgressHandler? = nil) async throws -> Mount { + #if !os(macOS) + throw ContainerizationError(.unsupported, message: "Cannot unpack an image on current platform") + #else + let blockPath = try prepareUnpackPath(path: path) + let manifest = try await image.manifest(for: platform) + let filesystem = try EXT4.Formatter(FilePath(path), minDiskSize: blockSizeInBytes) + defer { try? filesystem.close() } + + for layer in manifest.layers { + try Task.checkCancellation() + let content = try await image.getContent(digest: layer.digest) + + switch layer.mediaType { + case MediaTypes.imageLayer, MediaTypes.dockerImageLayer: + try filesystem.unpack( + source: content.path, + format: .paxRestricted, + compression: .none, + progress: progress + ) + case MediaTypes.imageLayerGzip, MediaTypes.dockerImageLayerGzip: + try filesystem.unpack( + source: content.path, + format: .paxRestricted, + compression: .gzip, + progress: progress + ) + default: + throw ContainerizationError(.unsupported, message: "Media type \(layer.mediaType) not supported.") + } + } + + return .block( + format: "ext4", + source: blockPath, + destination: "/", + options: [] + ) + #endif + } + + private func prepareUnpackPath(path: URL) throws -> String { + let blockPath = path.absolutePath() + guard !FileManager.default.fileExists(atPath: blockPath) else { + throw ContainerizationError(.exists, message: "block device already exists at \(blockPath)") + } + return blockPath + } +} diff --git a/Sources/Containerization/Image/Unpacker/Unpacker.swift b/Sources/Containerization/Image/Unpacker/Unpacker.swift new file mode 100644 index 0000000..3d900cf --- /dev/null +++ b/Sources/Containerization/Image/Unpacker/Unpacker.swift @@ -0,0 +1,40 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization project authors. All rights reserved. +// +// 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 ContainerizationExtras +import ContainerizationOCI +import Foundation + +/// The `Unpacker` protocol defines a standardized interface that involves +/// decompressing, extracting image layers and preparing it for use. +/// +/// The `Unpacker` is responsible for managing the lifecycle of the +/// unpacking process, including any temporary files or resources, until the +/// `Mount` object is produced. +protocol Unpacker { + + /// Unpacks the provided image to a specified path for a given platform. + /// + /// This asynchronous method should handle the entire unpacking process, from reading + /// the `Image` layers for the given `Platform` via its `Manifest`, + /// to making the extracted contents available as a `Mount`. + /// Implementations of this method may apply platform-specific optimizations + /// or transformations during the unpacking. + /// + /// Progress updates can be observed via the optional `progress` handler. + func unpack(_ image: Image, for platform: Platform, at path: URL, progress: ProgressHandler?) async throws -> Mount + +} diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 9eb7731..c840da4 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -125,7 +125,8 @@ struct IntegrationSuite: AsyncParsableCommand { let fs: Containerization.Mount = try await { let fsPath = Self.testDir.appending(component: "rootfs.ext4") do { - return try await image.unpack(for: platform, at: fsPath) + let unpacker = EXT4Unpacker(blockSizeInBytes: 2.gib()) + return try await unpacker.unpack(image, for: platform, at: fsPath) } catch let err as ContainerizationError { if err.code == .exists { return .block( diff --git a/Sources/cctl/ContainerStore.swift b/Sources/cctl/ContainerStore.swift index 13b0187..a3b85eb 100644 --- a/Sources/cctl/ContainerStore.swift +++ b/Sources/cctl/ContainerStore.swift @@ -96,11 +96,8 @@ struct ContainerStore: Sendable { let imageBlock: Containerization.Mount = try await { let source = self.root.appendingPathComponent(blockName) do { - return try await image.unpack( - for: .current, - at: source, - blockSizeInBytes: fsSizeInBytes - ) + let unpacker = EXT4Unpacker(blockSizeInBytes: fsSizeInBytes) + return try await unpacker.unpack(image, for: .current, at: source) } catch let err as ContainerizationError { if err.code == .exists { return .block( diff --git a/Sources/cctl/ImageCommand.swift b/Sources/cctl/ImageCommand.swift index 5b06403..8f1b1d7 100644 --- a/Sources/cctl/ImageCommand.swift +++ b/Sources/cctl/ImageCommand.swift @@ -98,6 +98,13 @@ extension Application { @Option(name: .customLong("platform"), help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platformString: String? + @Option( + name: .customLong("unpack-path"), help: "Path to unpack image into", + transform: { str in + URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) + }) + var unpackPath: String? + @Flag(help: "Pull via plain text http") var http: Bool = false func run() async throws { @@ -126,11 +133,20 @@ extension Application { } print("image pulled") + guard let unpackPath else { + return + } + guard !FileManager.default.fileExists(atPath: unpackPath) else { + throw ContainerizationError(.invalidArgument, message: "File exists at \(unpackPath)") + } + let unpackUrl = URL(filePath: unpackPath) + try FileManager.default.createDirectory(at: unpackUrl, withIntermediateDirectories: true) + + let unpacker = EXT4Unpacker.init(blockSizeInBytes: 2.gib()) - let tempDir = FileManager.default.uniqueTemporaryDirectory(create: true) if let platform { let name = platform.description.replacingOccurrences(of: "/", with: "-") - let _ = try await image.unpack(for: platform, at: tempDir.appending(component: name)) + let _ = try await unpacker.unpack(image, for: platform, at: unpackUrl.appending(component: name)) } else { for descriptor in try await image.index().manifests { if let referenceType = descriptor.annotations?["vnd.docker.reference.type"], referenceType == "attestation-manifest" { @@ -140,11 +156,10 @@ extension Application { continue } let name = descPlatform.description.replacingOccurrences(of: "/", with: "-") - let _ = try await image.unpack(for: descPlatform, at: tempDir.appending(component: name)) + let _ = try await unpacker.unpack(image, for: descPlatform, at: unpackUrl.appending(component: name)) print("created snapshot for platform \(descPlatform.description)") } } - try? FileManager.default.removeItem(at: tempDir) } }