Skip to content

Commit 95077c6

Browse files
committed
containertool: Support resource directories
1 parent 7caaff7 commit 95077c6

File tree

4 files changed

+155
-23
lines changed

4 files changed

+155
-23
lines changed

Sources/containertool/ELFDetect.swift

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
//
1313
//===----------------------------------------------------------------------===//
1414

15+
import class Foundation.FileHandle
16+
import struct Foundation.URL
17+
1518
struct ArrayField<T: Collection> where T.Element == UInt8 {
1619
var start: Int
1720
var count: Int
@@ -52,6 +55,11 @@ extension Array where Element == UInt8 {
5255
/// architecture and operating system ABI for which that object
5356
/// was created.
5457
struct ELF: Equatable {
58+
/// Minimum ELF header length is 52 bytes for a 32-bit ELF header.
59+
/// A 64-bit header is 64 bytes. A potential header must be at
60+
/// least 52 bytes or it cannot possibly be an ELF header.
61+
static let minHeaderLength = 52
62+
5563
/// Multibyte ELF fields are stored in the native endianness of the target system.
5664
/// This field records the endianness of objects in the file.
5765
enum Endianness: UInt8 {
@@ -189,7 +197,7 @@ extension ELF {
189197
/// Object type: 2 bytes
190198
static let EI_TYPE = IntField<UInt16>(start: 0x10)
191199

192-
//l Machine ISA (processor architecture): 2 bytes
200+
/// Machine ISA (processor architecture): 2 bytes
193201
static let EI_MACHINE = IntField<UInt16>(start: 0x12)
194202
}
195203

@@ -205,7 +213,7 @@ extension ELF {
205213
static func read(_ bytes: [UInt8]) -> ELF? {
206214
// An ELF file starts with a magic number which is the same in either endianness.
207215
// The only defined ELF header version is 1.
208-
guard bytes.count > 0x13, bytes[Field.EI_MAGIC] == ELFMagic, bytes[Field.EI_VERSION] == 1 else {
216+
guard bytes.count >= minHeaderLength, bytes[Field.EI_MAGIC] == ELFMagic, bytes[Field.EI_VERSION] == 1 else {
209217
return nil
210218
}
211219

@@ -226,3 +234,13 @@ extension ELF {
226234
)
227235
}
228236
}
237+
238+
extension ELF {
239+
static func read(at path: URL) throws -> ELF? {
240+
let handle = try FileHandle(forReadingFrom: path)
241+
guard let header = try handle.read(upToCount: minHeaderLength) else {
242+
return nil
243+
}
244+
return ELF.read([UInt8](header))
245+
}
246+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftContainerPlugin open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import class Foundation.FileManager
16+
import struct Foundation.Data
17+
import struct Foundation.FileAttributeType
18+
import struct Foundation.URL
19+
20+
import Tar
21+
22+
extension URL {
23+
var isDirectory: Bool {
24+
(try? resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true
25+
}
26+
}
27+
28+
extension Archive {
29+
/// Append a file or directory tree to the archive. Directory trees are appended recursively.
30+
/// Parameters:
31+
/// - root: The path to the file or directory to add.
32+
/// Returns: A new archive made by appending `root` to the receiver.
33+
public func appendingRecursively(atPath root: String) throws -> Self {
34+
let url = URL(fileURLWithPath: root)
35+
if url.isDirectory {
36+
return try self.appendingDirectoryTree(at: url)
37+
} else {
38+
return try self.appendingFile(at: url)
39+
}
40+
}
41+
42+
/// Append a single file to the archive.
43+
/// Parameters:
44+
/// - path: The path to the file to add.
45+
/// Returns: A new archive made by appending `path` to the receiver.
46+
func appendingFile(at path: URL) throws -> Self {
47+
try self.appendingFile(name: path.lastPathComponent, data: try [UInt8](Data(contentsOf: path)))
48+
}
49+
50+
/// Recursively append a single directory tree to the archive.
51+
/// Parameters:
52+
/// - root: The path to the directory to add.
53+
/// Returns: A new archive made by appending `root` to the receiver.
54+
func appendingDirectoryTree(at root: URL) throws -> Self {
55+
var ret = self
56+
57+
guard let enumerator = FileManager.default.enumerator(atPath: root.path) else {
58+
throw ("Unable to read \(root.path)")
59+
}
60+
61+
for case let subpath as String in enumerator {
62+
// https://developer.apple.com/documentation/foundation/filemanager/1410452-attributesofitem
63+
// https://developer.apple.com/documentation/foundation/fileattributekey
64+
65+
guard let filetype = enumerator.fileAttributes?[.type] as? FileAttributeType else {
66+
throw ("Unable to get file type for \(subpath)")
67+
}
68+
69+
switch filetype {
70+
case .typeRegular:
71+
let resource = try [UInt8](Data(contentsOf: root.appending(path: subpath)))
72+
try ret.appendFile(name: subpath, prefix: root.lastPathComponent, data: resource)
73+
74+
case .typeDirectory:
75+
try ret.appendDirectory(name: subpath, prefix: root.lastPathComponent)
76+
77+
default:
78+
throw "Resource file \(subpath) of type \(filetype) is not supported"
79+
}
80+
}
81+
82+
return ret
83+
}
84+
}

Sources/containertool/Extensions/RegistryClient+Layers.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,11 @@ extension RegistryClient {
4040
// See https://github.com/opencontainers/image-spec/blob/main/media-types.md
4141
func uploadImageLayer(
4242
repository: String,
43-
layer: Data,
43+
layer: [UInt8],
4444
mediaType: String = "application/vnd.oci.image.layer.v1.tar+gzip"
4545
) async throws -> ContentDescriptor {
4646
// The layer blob is the gzipped tarball
47-
let blob = Data(gzip([UInt8](layer)))
48-
log("Uploading application layer")
47+
let blob = Data(gzip(layer))
4948
return try await putBlob(repository: repository, mediaType: mediaType, data: blob)
5049
}
5150
}

Sources/containertool/containertool.swift

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti
3838
@Argument(help: "Executable to package")
3939
private var executable: String
4040

41+
@Option(help: "Resource bundle directory")
42+
private var resources: [String] = []
43+
4144
@Option(help: "Username")
4245
private var username: String?
4346

@@ -72,9 +75,6 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti
7275
let baseimage = try ImageReference(fromString: from, defaultRegistry: defaultRegistry)
7376
var destination_image = try ImageReference(fromString: repository, defaultRegistry: defaultRegistry)
7477

75-
let executableURL = URL(fileURLWithPath: executable)
76-
let payload = try Data(contentsOf: executableURL)
77-
7878
let authProvider: AuthorizationProvider?
7979
if !netrc {
8080
authProvider = nil
@@ -87,8 +87,9 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti
8787
authProvider = try NetrcAuthorizationProvider(defaultNetrc)
8888
}
8989

90-
// Create clients for the source and destination registries
91-
// The base image may be stored on a different registry, so two clients are needed.
90+
// MARK: Create registry clients
91+
92+
// The base image may be stored on a different registry to the final destination, so two clients are needed.
9293
// `scratch` is a special case and requires no source client.
9394
let source: RegistryClient?
9495
if from == "scratch" {
@@ -113,7 +114,10 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti
113114

114115
// MARK: Find the base image
115116

116-
let elfheader = ELF.read([UInt8](payload))
117+
// Try to detect the architecture of the application executable so a suitable base image can be selected.
118+
// This reduces the risk of accidentally creating an image which stacks an aarch64 executable on top of an x86_64 base image.
119+
let executableURL = URL(fileURLWithPath: executable)
120+
let elfheader = try ELF.read(at: executableURL)
117121
let architecture =
118122
architecture
119123
?? ProcessInfo.processInfo.environment["CONTAINERTOOL_ARCHITECTURE"]
@@ -146,20 +150,30 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti
146150
if verbose { log("Using scratch as base image") }
147151
}
148152

149-
// MARK: Build the application layer
153+
// MARK: Upload resource layers
150154

151-
let payload_name = executableURL.lastPathComponent
152-
let tardiff = try tar([UInt8](payload), filename: payload_name)
153-
log("Built application layer")
155+
var resourceLayers: [RegistryClient.ImageLayer] = []
156+
for resourceDir in resources {
157+
let layer = try await destination.uploadLayer(
158+
repository: destination_image.repository,
159+
contents: try Archive().appendingRecursively(atPath: resourceDir).bytes
160+
)
154161

155-
// MARK: Upload the application layer
162+
if verbose {
163+
log("resource layer: \(layer.descriptor.digest) (\(layer.descriptor.size) bytes)")
164+
}
156165

157-
let application_layer = try await destination.uploadImageLayer(
166+
resourceLayers.append(layer)
167+
}
168+
169+
// MARK: Upload the application layer
170+
let applicationLayer = try await destination.uploadLayer(
158171
repository: destination_image.repository,
159-
layer: Data(tardiff)
172+
contents: try Archive().appendingFile(at: executableURL).bytes
160173
)
161-
162-
if verbose { log("application layer: \(application_layer.digest) (\(application_layer.size) bytes)") }
174+
if verbose {
175+
log("application layer: \(applicationLayer.descriptor.digest) (\(applicationLayer.descriptor.size) bytes)")
176+
}
163177

164178
// MARK: Create the application configuration
165179

@@ -168,7 +182,7 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti
168182
// Inherit the configuration of the base image - UID, GID, environment etc -
169183
// and override the entrypoint.
170184
var inherited_config = baseimage_config.config ?? .init()
171-
inherited_config.Entrypoint = ["/\(payload_name)"]
185+
inherited_config.Entrypoint = ["/\(executableURL.lastPathComponent)"]
172186
inherited_config.Cmd = []
173187
inherited_config.WorkingDir = "/"
174188

@@ -182,7 +196,9 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti
182196
// The diff_id is the digest of the _uncompressed_ layer archive.
183197
// It is used by the runtime, which might not store the layers in
184198
// the compressed form in which it received them from the registry.
185-
diff_ids: baseimage_config.rootfs.diff_ids + [digest(of: tardiff)]
199+
diff_ids: baseimage_config.rootfs.diff_ids
200+
+ resourceLayers.map { $0.diffID }
201+
+ [applicationLayer.diffID]
186202
),
187203
history: [.init(created: timestamp, created_by: "containertool")]
188204
)
@@ -200,7 +216,9 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti
200216
schemaVersion: 2,
201217
mediaType: "application/vnd.oci.image.manifest.v1+json",
202218
config: config_blob,
203-
layers: baseimage_manifest.layers + [application_layer]
219+
layers: baseimage_manifest.layers
220+
+ resourceLayers.map { $0.descriptor }
221+
+ [applicationLayer.descriptor]
204222
)
205223

206224
// MARK: Upload base image
@@ -237,3 +255,16 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti
237255
print(destination_image)
238256
}
239257
}
258+
259+
extension RegistryClient {
260+
typealias DiffID = String
261+
struct ImageLayer {
262+
var descriptor: ContentDescriptor
263+
var diffID: DiffID
264+
}
265+
func uploadLayer(repository: String, contents: [UInt8]) async throws -> ImageLayer {
266+
let diffID = digest(of: contents)
267+
let descriptor = try await self.uploadImageLayer(repository: repository, layer: contents)
268+
return ImageLayer(descriptor: descriptor, diffID: diffID)
269+
}
270+
}

0 commit comments

Comments
 (0)