Create unpacker protocol + ext4 unpacker (#151)

Creates a new Unpacker protocol that defines a single method

```
func unpack(_ image: Image, for platform: Platform, at path: URL, progress: ProgressHandler?) async throws -> Mount
```

This change also removes the `unpack(...)` method from the Image type.

Before
```
let mount = try await image.unpack(for: platform, at: path)
```
After
```
let unpacker = EXT4Unpacker(blockSizeInBytes: 2.gib())
let mount = try await unpacker.unpack(image, for: platform, at: path)
```

---------

Signed-off-by: Aditya Ramani <a_ramani@apple.com>
This commit is contained in:
Aditya Ramani
2025-06-30 11:06:38 -07:00
committed by GitHub
parent 631676b855
commit bdba5b5740
7 changed files with 146 additions and 98 deletions
@@ -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
@@ -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
}
@@ -0,0 +1,81 @@
//===----------------------------------------------------------------------===//
// 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)
let compression: ContainerizationArchive.Filter
switch layer.mediaType {
case MediaTypes.imageLayer, MediaTypes.dockerImageLayer:
compression = .none
case MediaTypes.imageLayerGzip, MediaTypes.dockerImageLayerGzip:
compression = .gzip
default:
throw ContainerizationError(.unsupported, message: "Media type \(layer.mediaType) not supported.")
}
try filesystem.unpack(
source: content.path,
format: .paxRestricted,
compression: compression,
progress: progress
)
}
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
}
}
@@ -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.
public 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
}
+2 -1
View File
@@ -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(
+2 -5
View File
@@ -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(
+19 -4
View File
@@ -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 a new directory to unpack the 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(.exists, message: "Directory already 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)
}
}