Skip to content

Commit 1d1fac5

Browse files
authored
plugin: Add resource bundles defined in Package.swift to container images (#78)
Motivation ---------- A target in `Package.swift` can include [resources](https://developer.apple.com/documentation/packagedescription/target/resources). When the target is built, the resources are copied into a bundle directory and a [`Bundle`](https://developer.apple.com/documentation/foundation/bundle) class is generated, giving the executable a convenient way to retrieve them at run time. A target with resources should be able to access them when it is packaged in a container image. For example, a web server might bundle resources such as images or fonts. Fixes #48 Modifications ------------- * Add a new `--resource` parameter to `containertool`. This specifies a resource bundle directory which is added to the container image using the directory archiving support added to the `Tar` module in #74. When `containertool` is used alone, multiple `--resource` flags can be specified and will be added to the image in the order in which they appear on the command line. A target in `Package.swift` can only define one resource bundle, so when used through the plugin only one bundle will be added. * Update the plugin to pass the `--resource` flag to `containertool` when an executable target has resources. Result ------ If a target includes resources, the resource bundle will be copied into the container image at a path where the generated `Bundle` class can find it. If a target does not include resources, the container image will only include the executable. Test Plan --------- * Existing tests continue to pass. * New integration tests verify that all expected resources are present.
1 parent 4c6f38d commit 1d1fac5

File tree

9 files changed

+260
-38
lines changed

9 files changed

+260
-38
lines changed

.github/workflows/integration_tests.yml

+22
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,28 @@ jobs:
3535
run: |
3636
swift test
3737
38+
containertool-resources-test:
39+
name: Containertool resources test
40+
runs-on: ubuntu-latest
41+
services:
42+
registry:
43+
image: registry:2
44+
ports:
45+
- 5000:5000
46+
steps:
47+
- name: Checkout repository
48+
uses: actions/checkout@v4
49+
with:
50+
persist-credentials: false
51+
52+
- name: Mark the workspace as safe
53+
# https://github.com/actions/checkout/issues/766
54+
run: git config --global --add safe.directory ${GITHUB_WORKSPACE}
55+
56+
- name: Check plugin streaming output is reassembled and printed properly
57+
run: |
58+
scripts/test-containertool-resources.sh
59+
3860
plugin-streaming-output-test:
3961
name: Plugin streaming output test
4062
runs-on: ubuntu-latest

.github/workflows/interop_tests.yml

+3-7
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,10 @@ name: Interop tests
22

33
on:
44
workflow_call:
5-
# inputs:
6-
# example:
7-
# required: true
8-
# type: string
95

106
jobs:
117
layering-test:
12-
name: Layering test
8+
name: Containertool layering test
139
runs-on: ubuntu-latest
1410
services:
1511
registry:
@@ -45,7 +41,7 @@ jobs:
4541
grep second second.payload
4642
4743
elf-detection-test:
48-
name: ELF detection test
44+
name: Containertool ELF detection test
4945
runs-on: ubuntu-latest
5046
services:
5147
registry:
@@ -71,4 +67,4 @@ jobs:
7167
# Run the test script
7268
- name: Test ELF detection
7369
run: |
74-
scripts/test-elf-detection.sh
70+
scripts/test-containertool-elf-detection.sh

Plugins/ContainerImageBuilder/main.swift

+11-3
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,18 @@ extension PluginError: CustomStringConvertible {
8989

9090
for built in builtExecutables { Diagnostics.remark("Built product: \(built.url.path)") }
9191

92+
let resources = builtExecutables[0].url
93+
.deletingLastPathComponent()
94+
.appendingPathComponent(
95+
"\(context.package.displayName)_\(productName).resources"
96+
)
97+
9298
// Run a command line helper to upload the image
93-
let helper = try context.tool(named: "containertool")
94-
let helperURL = helper.url
95-
let helperArgs = extractor.remainingArguments + builtExecutables.map { $0.url.path }
99+
let helperURL = try context.tool(named: "containertool").url
100+
let helperArgs =
101+
(FileManager.default.fileExists(atPath: resources.path) ? ["--resources", resources.path] : [])
102+
+ extractor.remainingArguments
103+
+ builtExecutables.map { $0.url.path }
96104
let helperEnv = ProcessInfo.processInfo.environment.filter { $0.key.starts(with: "CONTAINERTOOL_") }
97105

98106
let err = Pipe()

Sources/containertool/ELFDetect.swift

+20-2
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+
}
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

+15-7
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,24 @@ extension RegistryClient {
3636
}
3737
}
3838

39+
typealias DiffID = String
40+
struct ImageLayer {
41+
var descriptor: ContentDescriptor
42+
var diffID: DiffID
43+
}
44+
3945
// A layer is a tarball, optionally compressed using gzip or zstd
4046
// See https://github.com/opencontainers/image-spec/blob/main/media-types.md
41-
func uploadImageLayer(
47+
func uploadLayer(
4248
repository: String,
43-
layer: Data,
49+
contents: [UInt8],
4450
mediaType: String = "application/vnd.oci.image.layer.v1.tar+gzip"
45-
) async throws -> ContentDescriptor {
46-
// The layer blob is the gzipped tarball
47-
let blob = Data(gzip([UInt8](layer)))
48-
log("Uploading application layer")
49-
return try await putBlob(repository: repository, mediaType: mediaType, data: blob)
51+
) async throws -> ImageLayer {
52+
// The diffID is the hash of the unzipped layer tarball
53+
let diffID = digest(of: contents)
54+
// The layer blob is the gzipped tarball; the descriptor is the hash of this gzipped blob
55+
let blob = Data(gzip(contents))
56+
let descriptor = try await putBlob(repository: repository, mediaType: mediaType, data: blob)
57+
return ImageLayer(descriptor: descriptor, diffID: diffID)
5058
}
5159
}

Sources/containertool/containertool.swift

+37-19
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,29 +150,39 @@ 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 resourceTardiff = try Archive().appendingRecursively(atPath: resourceDir).bytes
158+
let resourceLayer = try await destination.uploadLayer(
159+
repository: destination_image.repository,
160+
contents: resourceTardiff
161+
)
154162

155-
// MARK: Upload the application layer
163+
if verbose {
164+
log("resource layer: \(resourceLayer.descriptor.digest) (\(resourceLayer.descriptor.size) bytes)")
165+
}
166+
167+
resourceLayers.append(resourceLayer)
168+
}
156169

157-
let application_layer = try await destination.uploadImageLayer(
170+
// MARK: Upload the application layer
171+
let applicationLayer = try await destination.uploadLayer(
158172
repository: destination_image.repository,
159-
layer: Data(tardiff)
173+
contents: try Archive().appendingFile(at: executableURL).bytes
160174
)
161-
162-
if verbose { log("application layer: \(application_layer.digest) (\(application_layer.size) bytes)") }
175+
if verbose {
176+
log("application layer: \(applicationLayer.descriptor.digest) (\(applicationLayer.descriptor.size) bytes)")
177+
}
163178

164179
// MARK: Create the application configuration
165-
166180
let timestamp = Date(timeIntervalSince1970: 0).ISO8601Format()
167181

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

0 commit comments

Comments
 (0)