Skip to content

Commit 2eab672

Browse files
committed
Add content subcommand
This command can be used to interact with the content store XPC via simple commands. Since many container tools are written in Go, it's not easy to interact with the XPC service from other tools. Go has good support for unix domain sockets, but Mach ports (and XPC) are another story. The content store is very useful to enable incremental image loading. The closest alternative ("container image load") requires materialization of all blobs in a tar file. With the content store, we can check which blobs are already available, and only load missing ones. This will enable faster development workflows in tools like rules_img: https://github.com/bazel-contrib/rules_img. A similar move is being made in Docker. The containerd content store used to be limited to the containerd socket. This upstream issue will expose the containerd content store directly via Docker: moby/moby#44369
1 parent f0eb131 commit 2eab672

File tree

6 files changed

+363
-0
lines changed

6 files changed

+363
-0
lines changed

Sources/ContainerCommands/Application.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,12 +192,14 @@ public struct Application: AsyncParsableCommand {
192192
guard #available(macOS 26, *) else {
193193
return [
194194
BuilderCommand.self,
195+
ContentCommand.self,
195196
SystemCommand.self,
196197
]
197198
}
198199

199200
return [
200201
BuilderCommand.self,
202+
ContentCommand.self,
201203
NetworkCommand.self,
202204
SystemCommand.self,
203205
]
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2025 Apple Inc. and the container project authors.
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 ArgumentParser
18+
import ContainerClient
19+
import ContainerImagesServiceClient
20+
import ContainerizationError
21+
import Foundation
22+
23+
extension Application {
24+
public struct ContentCat: AsyncParsableCommand {
25+
public init() {}
26+
27+
public static let configuration = CommandConfiguration(
28+
commandName: "cat",
29+
abstract: "Output the contents of a blob from the content store"
30+
)
31+
32+
@OptionGroup
33+
var global: Flags.Global
34+
35+
@Option(name: .shortAndLong, help: "Path to write blob content (writes to stdout if not specified)")
36+
var output: String?
37+
38+
@Argument(help: "Blob digest to output")
39+
var digest: String
40+
41+
public func run() async throws {
42+
let client = RemoteContentStoreClient()
43+
guard let content = try await client.get(digest: digest) else {
44+
throw ContainerizationError(.notFound, message: "blob \(digest)")
45+
}
46+
47+
let data = try content.data()
48+
49+
if let outputPath = output {
50+
let outputURL = URL(fileURLWithPath: outputPath)
51+
try data.write(to: outputURL)
52+
53+
let formatter = ByteCountFormatter()
54+
formatter.countStyle = .file
55+
let formattedSize = formatter.string(fromByteCount: Int64(data.count))
56+
57+
print("Wrote \(formattedSize) to \(outputPath)")
58+
} else {
59+
FileHandle.standardOutput.write(data)
60+
}
61+
}
62+
}
63+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2025 Apple Inc. and the container project authors.
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 ArgumentParser
18+
19+
extension Application {
20+
public struct ContentCommand: AsyncParsableCommand {
21+
public init() {}
22+
23+
public static let configuration = CommandConfiguration(
24+
commandName: "content",
25+
abstract: "Low-level content store operations",
26+
subcommands: [
27+
ContentHead.self,
28+
ContentDelete.self,
29+
ContentCat.self,
30+
ContentIngest.self,
31+
]
32+
)
33+
}
34+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2025 Apple Inc. and the container project authors.
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 ArgumentParser
18+
import ContainerClient
19+
import ContainerImagesServiceClient
20+
import ContainerizationError
21+
import Foundation
22+
23+
extension Application {
24+
public struct ContentDelete: AsyncParsableCommand {
25+
public init() {}
26+
27+
public static let configuration = CommandConfiguration(
28+
commandName: "delete",
29+
abstract: "Remove a blob from the content store",
30+
aliases: ["rm"]
31+
)
32+
33+
@OptionGroup
34+
var global: Flags.Global
35+
36+
@Argument(help: "Blob digest to delete")
37+
var digest: String
38+
39+
public func run() async throws {
40+
let client = RemoteContentStoreClient()
41+
let digestWithoutPrefix = digest.replacingOccurrences(of: "sha256:", with: "")
42+
let (deleted, size) = try await client.delete(digests: [digestWithoutPrefix])
43+
44+
guard deleted.contains(digestWithoutPrefix) else {
45+
throw ContainerizationError(.notFound, message: "blob \(digest)")
46+
}
47+
48+
let formatter = ByteCountFormatter()
49+
formatter.countStyle = .file
50+
let freed = formatter.string(fromByteCount: Int64(size))
51+
52+
print("Deleted \(digest)")
53+
print("Reclaimed \(freed) in disk space")
54+
}
55+
}
56+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2025 Apple Inc. and the container project authors.
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 ArgumentParser
18+
import ContainerClient
19+
import ContainerImagesServiceClient
20+
import ContainerizationError
21+
import Foundation
22+
23+
extension Application {
24+
public struct ContentHead: AsyncParsableCommand {
25+
public init() {}
26+
27+
public static let configuration = CommandConfiguration(
28+
commandName: "head",
29+
abstract: "Display metadata about a blob in the content store"
30+
)
31+
32+
@OptionGroup
33+
var global: Flags.Global
34+
35+
@Option(name: .long, help: "Format of the output (json or table)")
36+
var format: ListFormat = .table
37+
38+
@Argument(help: "Blob digest to inspect")
39+
var digest: String
40+
41+
public func run() async throws {
42+
let client = RemoteContentStoreClient()
43+
guard let content = try await client.get(digest: digest) else {
44+
throw ContainerizationError(.notFound, message: "blob \(digest)")
45+
}
46+
47+
let size = try content.size()
48+
49+
if format == .json {
50+
struct BlobInfo: Codable {
51+
let digest: String
52+
let size: UInt64
53+
}
54+
let info = BlobInfo(digest: digest, size: size)
55+
let encoder = JSONEncoder()
56+
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
57+
let data = try encoder.encode(info)
58+
guard let jsonString = String(data: data, encoding: .utf8) else {
59+
throw ContainerizationError(.internalError, message: "Failed to encode JSON output")
60+
}
61+
print(jsonString)
62+
} else {
63+
// Machine-readable default: digest-size
64+
print("\(digest)-\(size)")
65+
}
66+
}
67+
}
68+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2025 Apple Inc. and the container project authors.
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 ArgumentParser
18+
import ContainerClient
19+
import ContainerImagesServiceClient
20+
import ContainerizationError
21+
import Crypto
22+
import Foundation
23+
24+
extension Application {
25+
public struct ContentIngest: AsyncParsableCommand {
26+
public init() {}
27+
28+
public static let configuration = CommandConfiguration(
29+
commandName: "ingest",
30+
abstract: "Add a blob to the content store from a file or stdin"
31+
)
32+
33+
@OptionGroup
34+
var global: Flags.Global
35+
36+
@Option(name: .shortAndLong, help: "Path to read content from (reads from stdin if not specified)")
37+
var input: String?
38+
39+
@Option(name: .shortAndLong, help: "Expected blob digest (computed and verified if not provided)")
40+
var digest: String?
41+
42+
public func run() async throws {
43+
let client = RemoteContentStoreClient()
44+
let inputPath = self.input
45+
let expectedDigest = self.digest
46+
47+
// Show hint when reading from stdin
48+
if inputPath == nil {
49+
FileHandle.standardError.write(Data("Reading from stdin...\n".utf8))
50+
}
51+
52+
// Start a new ingest session
53+
let (sessionId, ingestDir) = try await client.newIngestSession()
54+
55+
// Hash the data as we ingest it
56+
var hasher = SHA256()
57+
var ingestedDigest: String = ""
58+
59+
do {
60+
// Determine temporary digest for filename (use expected or placeholder)
61+
let tempDigestForFilename: String
62+
if let expected = expectedDigest {
63+
tempDigestForFilename = expected.replacingOccurrences(of: "sha256:", with: "")
64+
} else {
65+
// Use a temporary name, we'll compute the real digest
66+
tempDigestForFilename = "temp-\(UUID().uuidString)"
67+
}
68+
69+
let blobPath = ingestDir.appendingPathComponent(tempDigestForFilename)
70+
71+
// Open source handle
72+
let sourceHandle: FileHandle
73+
if let inputPath = inputPath {
74+
let inputURL = URL(fileURLWithPath: inputPath)
75+
sourceHandle = try FileHandle(forReadingFrom: inputURL)
76+
} else {
77+
sourceHandle = FileHandle.standardInput
78+
}
79+
defer {
80+
if inputPath != nil {
81+
try? sourceHandle.close()
82+
}
83+
}
84+
85+
// Create destination file
86+
FileManager.default.createFile(atPath: blobPath.path, contents: nil)
87+
let destHandle = try FileHandle(forWritingTo: blobPath)
88+
defer { try? destHandle.close() }
89+
90+
// Stream data while computing hash
91+
let bufferSize = 1024 * 1024 // 1 MB
92+
while let chunk = try sourceHandle.read(upToCount: bufferSize), !chunk.isEmpty {
93+
hasher.update(data: chunk)
94+
try destHandle.write(contentsOf: chunk)
95+
}
96+
97+
// Compute final digest
98+
let digestValue = hasher.finalize()
99+
ingestedDigest = "sha256:\(digestValue.map { String(format: "%02x", $0) }.joined())"
100+
101+
// Verify against expected digest if provided
102+
if let expectedDigest = expectedDigest {
103+
if ingestedDigest != expectedDigest {
104+
// Digest mismatch - cancel ingest
105+
try await client.cancelIngestSession(sessionId)
106+
throw ContainerizationError(
107+
.invalidArgument,
108+
message: "Digest mismatch: expected \(expectedDigest), got \(ingestedDigest)"
109+
)
110+
}
111+
}
112+
113+
// If we used a temp filename, rename it to the correct digest
114+
if tempDigestForFilename.starts(with: "temp-") {
115+
let correctDigestFilename = ingestedDigest.replacingOccurrences(of: "sha256:", with: "")
116+
let correctBlobPath = ingestDir.appendingPathComponent(correctDigestFilename)
117+
try FileManager.default.moveItem(at: blobPath, to: correctBlobPath)
118+
}
119+
120+
// Complete the ingest session
121+
let ingested = try await client.completeIngestSession(sessionId)
122+
let digestWithoutPrefix = ingestedDigest.replacingOccurrences(of: "sha256:", with: "")
123+
124+
guard ingested.contains(digestWithoutPrefix) else {
125+
throw ContainerizationError(
126+
.internalError,
127+
message: "ingested blob not found in content store"
128+
)
129+
}
130+
131+
print("Ingested \(ingestedDigest)")
132+
133+
} catch {
134+
// Cancel the ingest session on any error
135+
try? await client.cancelIngestSession(sessionId)
136+
throw error
137+
}
138+
}
139+
}
140+
}

0 commit comments

Comments
 (0)