Skip to content

Commit bdba5b5

Browse files
authored
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>
1 parent 631676b commit bdba5b5

File tree

7 files changed

+146
-98
lines changed

7 files changed

+146
-98
lines changed

Sources/Containerization/Image/Image.swift

Lines changed: 0 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,6 @@ import ContainerizationOCI
1919
import ContainerizationOS
2020
import Foundation
2121

22-
#if os(macOS)
23-
import ContainerizationArchive
24-
import ContainerizationEXT4
25-
import SystemPackage
26-
import ContainerizationExtras
27-
#endif
28-
2922
/// Type representing an OCI container image.
3023
public struct Image: Sendable {
3124
private let contentStore: ContentStore
@@ -135,83 +128,3 @@ public struct Image: Sendable {
135128
return content
136129
}
137130
}
138-
139-
#if os(macOS)
140-
141-
extension Image {
142-
/// Unpack the image into a filesystem.
143-
public func unpack(for platform: Platform, at path: URL, blockSizeInBytes: UInt64 = 512.gib(), progress: ProgressHandler? = nil) async throws -> Mount {
144-
let blockPath = try prepareUnpackPath(path: path)
145-
let manifest = try await loadManifest(platform: platform)
146-
return try await unpackContents(
147-
path: blockPath,
148-
manifest: manifest,
149-
blockSizeInBytes: blockSizeInBytes,
150-
progress: progress
151-
)
152-
}
153-
154-
private func loadManifest(platform: Platform) async throws -> Manifest {
155-
let manifest = try await descriptor(for: platform)
156-
guard let m: Manifest = try await self.contentStore.get(digest: manifest.digest) else {
157-
throw ContainerizationError(.notFound, message: "content not found \(manifest.digest)")
158-
}
159-
return m
160-
}
161-
162-
private func prepareUnpackPath(path: URL) throws -> String {
163-
let blockPath = path.absolutePath()
164-
guard !FileManager.default.fileExists(atPath: blockPath) else {
165-
throw ContainerizationError(.exists, message: "block device already exists at \(blockPath)")
166-
}
167-
return blockPath
168-
}
169-
170-
private func unpackContents(path: String, manifest: Manifest, blockSizeInBytes: UInt64, progress: ProgressHandler?) async throws -> Mount {
171-
let filesystem = try EXT4.Formatter(FilePath(path), minDiskSize: blockSizeInBytes)
172-
defer { try? filesystem.close() }
173-
174-
for layer in manifest.layers {
175-
try Task.checkCancellation()
176-
guard let content = try await self.contentStore.get(digest: layer.digest) else {
177-
throw ContainerizationError(.notFound, message: "Content with digest \(layer.digest)")
178-
}
179-
180-
switch layer.mediaType {
181-
case MediaTypes.imageLayer, MediaTypes.dockerImageLayer:
182-
try filesystem.unpack(
183-
source: content.path,
184-
format: .paxRestricted,
185-
compression: .none,
186-
progress: progress
187-
)
188-
case MediaTypes.imageLayerGzip, MediaTypes.dockerImageLayerGzip:
189-
try filesystem.unpack(
190-
source: content.path,
191-
format: .paxRestricted,
192-
compression: .gzip,
193-
progress: progress
194-
)
195-
default:
196-
throw ContainerizationError(.unsupported, message: "Media type \(layer.mediaType) not supported.")
197-
}
198-
}
199-
200-
return .block(
201-
format: "ext4",
202-
source: path,
203-
destination: "/",
204-
options: []
205-
)
206-
}
207-
}
208-
209-
#else
210-
211-
extension Image {
212-
public func unpack(for platform: Platform, at path: URL, blockSizeInBytes: UInt64 = 512.gib()) async throws -> Mount {
213-
throw ContainerizationError(.unsupported, message: "Image unpack unsupported on current platform")
214-
}
215-
}
216-
217-
#endif

Sources/Containerization/Image/InitImage.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ public struct InitImage: Sendable {
3434
extension InitImage {
3535
/// Unpack the initial filesystem for the desired platform at a given path.
3636
public func initBlock(at: URL, for platform: SystemPlatform) async throws -> Mount {
37-
var fs = try await image.unpack(for: platform.ociPlatform(), at: at, blockSizeInBytes: 512.mib())
37+
let unpacker = EXT4Unpacker(blockSizeInBytes: 512.mib())
38+
var fs = try await unpacker.unpack(self.image, for: platform.ociPlatform(), at: at)
3839
fs.options = ["ro"]
3940
return fs
4041
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2025 Apple Inc. and the Containerization project authors. All rights reserved.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import ContainerizationError
18+
import ContainerizationExtras
19+
import ContainerizationOCI
20+
import Foundation
21+
22+
#if os(macOS)
23+
import ContainerizationArchive
24+
import ContainerizationEXT4
25+
import SystemPackage
26+
#endif
27+
28+
public struct EXT4Unpacker: Unpacker {
29+
let blockSizeInBytes: UInt64
30+
31+
public init(blockSizeInBytes: UInt64) {
32+
self.blockSizeInBytes = blockSizeInBytes
33+
}
34+
35+
public func unpack(_ image: Image, for platform: Platform, at path: URL, progress: ProgressHandler? = nil) async throws -> Mount {
36+
#if !os(macOS)
37+
throw ContainerizationError(.unsupported, message: "Cannot unpack an image on current platform")
38+
#else
39+
let blockPath = try prepareUnpackPath(path: path)
40+
let manifest = try await image.manifest(for: platform)
41+
let filesystem = try EXT4.Formatter(FilePath(path), minDiskSize: blockSizeInBytes)
42+
defer { try? filesystem.close() }
43+
44+
for layer in manifest.layers {
45+
try Task.checkCancellation()
46+
let content = try await image.getContent(digest: layer.digest)
47+
48+
let compression: ContainerizationArchive.Filter
49+
switch layer.mediaType {
50+
case MediaTypes.imageLayer, MediaTypes.dockerImageLayer:
51+
compression = .none
52+
case MediaTypes.imageLayerGzip, MediaTypes.dockerImageLayerGzip:
53+
compression = .gzip
54+
default:
55+
throw ContainerizationError(.unsupported, message: "Media type \(layer.mediaType) not supported.")
56+
}
57+
try filesystem.unpack(
58+
source: content.path,
59+
format: .paxRestricted,
60+
compression: compression,
61+
progress: progress
62+
)
63+
}
64+
65+
return .block(
66+
format: "ext4",
67+
source: blockPath,
68+
destination: "/",
69+
options: []
70+
)
71+
#endif
72+
}
73+
74+
private func prepareUnpackPath(path: URL) throws -> String {
75+
let blockPath = path.absolutePath()
76+
guard !FileManager.default.fileExists(atPath: blockPath) else {
77+
throw ContainerizationError(.exists, message: "block device already exists at \(blockPath)")
78+
}
79+
return blockPath
80+
}
81+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2025 Apple Inc. and the Containerization project authors. All rights reserved.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import ContainerizationExtras
18+
import ContainerizationOCI
19+
import Foundation
20+
21+
/// The `Unpacker` protocol defines a standardized interface that involves
22+
/// decompressing, extracting image layers and preparing it for use.
23+
///
24+
/// The `Unpacker` is responsible for managing the lifecycle of the
25+
/// unpacking process, including any temporary files or resources, until the
26+
/// `Mount` object is produced.
27+
public protocol Unpacker {
28+
29+
/// Unpacks the provided image to a specified path for a given platform.
30+
///
31+
/// This asynchronous method should handle the entire unpacking process, from reading
32+
/// the `Image` layers for the given `Platform` via its `Manifest`,
33+
/// to making the extracted contents available as a `Mount`.
34+
/// Implementations of this method may apply platform-specific optimizations
35+
/// or transformations during the unpacking.
36+
///
37+
/// Progress updates can be observed via the optional `progress` handler.
38+
func unpack(_ image: Image, for platform: Platform, at path: URL, progress: ProgressHandler?) async throws -> Mount
39+
40+
}

Sources/Integration/Suite.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,8 @@ struct IntegrationSuite: AsyncParsableCommand {
125125
let fs: Containerization.Mount = try await {
126126
let fsPath = Self.testDir.appending(component: "rootfs.ext4")
127127
do {
128-
return try await image.unpack(for: platform, at: fsPath)
128+
let unpacker = EXT4Unpacker(blockSizeInBytes: 2.gib())
129+
return try await unpacker.unpack(image, for: platform, at: fsPath)
129130
} catch let err as ContainerizationError {
130131
if err.code == .exists {
131132
return .block(

Sources/cctl/ContainerStore.swift

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,8 @@ struct ContainerStore: Sendable {
9696
let imageBlock: Containerization.Mount = try await {
9797
let source = self.root.appendingPathComponent(blockName)
9898
do {
99-
return try await image.unpack(
100-
for: .current,
101-
at: source,
102-
blockSizeInBytes: fsSizeInBytes
103-
)
99+
let unpacker = EXT4Unpacker(blockSizeInBytes: fsSizeInBytes)
100+
return try await unpacker.unpack(image, for: .current, at: source)
104101
} catch let err as ContainerizationError {
105102
if err.code == .exists {
106103
return .block(

Sources/cctl/ImageCommand.swift

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,13 @@ extension Application {
9898

9999
@Option(name: .customLong("platform"), help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platformString: String?
100100

101+
@Option(
102+
name: .customLong("unpack-path"), help: "Path to a new directory to unpack the image into",
103+
transform: { str in
104+
URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false)
105+
})
106+
var unpackPath: String?
107+
101108
@Flag(help: "Pull via plain text http") var http: Bool = false
102109

103110
func run() async throws {
@@ -126,11 +133,20 @@ extension Application {
126133
}
127134

128135
print("image pulled")
136+
guard let unpackPath else {
137+
return
138+
}
139+
guard !FileManager.default.fileExists(atPath: unpackPath) else {
140+
throw ContainerizationError(.exists, message: "Directory already exists at \(unpackPath)")
141+
}
142+
let unpackUrl = URL(filePath: unpackPath)
143+
try FileManager.default.createDirectory(at: unpackUrl, withIntermediateDirectories: true)
144+
145+
let unpacker = EXT4Unpacker.init(blockSizeInBytes: 2.gib())
129146

130-
let tempDir = FileManager.default.uniqueTemporaryDirectory(create: true)
131147
if let platform {
132148
let name = platform.description.replacingOccurrences(of: "/", with: "-")
133-
let _ = try await image.unpack(for: platform, at: tempDir.appending(component: name))
149+
let _ = try await unpacker.unpack(image, for: platform, at: unpackUrl.appending(component: name))
134150
} else {
135151
for descriptor in try await image.index().manifests {
136152
if let referenceType = descriptor.annotations?["vnd.docker.reference.type"], referenceType == "attestation-manifest" {
@@ -140,11 +156,10 @@ extension Application {
140156
continue
141157
}
142158
let name = descPlatform.description.replacingOccurrences(of: "/", with: "-")
143-
let _ = try await image.unpack(for: descPlatform, at: tempDir.appending(component: name))
159+
let _ = try await unpacker.unpack(image, for: descPlatform, at: unpackUrl.appending(component: name))
144160
print("created snapshot for platform \(descPlatform.description)")
145161
}
146162
}
147-
try? FileManager.default.removeItem(at: tempDir)
148163
}
149164
}
150165

0 commit comments

Comments
 (0)