Skip to content

Commit a99cb4a

Browse files
authored
[image-save-load]: support for stdin/stdout (apple#734)
- Closes apple#559 - Facilitates easy transfer between container applications that can use OCI tarfiles.
1 parent 88a473e commit a99cb4a

File tree

3 files changed

+118
-7
lines changed

3 files changed

+118
-7
lines changed

Sources/ContainerCommands/Image/ImageLoad.swift

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,39 @@ extension Application {
3434
transform: { str in
3535
URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false)
3636
})
37-
var input: String
37+
var input: String?
3838

3939
@OptionGroup
4040
var global: Flags.Global
4141

4242
public func run() async throws {
43-
guard FileManager.default.fileExists(atPath: input) else {
44-
print("File does not exist \(input)")
45-
Application.exit(withError: ArgumentParser.ExitCode(1))
43+
let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).tar")
44+
defer {
45+
try? FileManager.default.removeItem(at: tempFile)
46+
}
47+
48+
// Read from stdin; otherwise read from the input file
49+
if input == nil {
50+
guard FileManager.default.createFile(atPath: tempFile.path(), contents: nil) else {
51+
throw ContainerizationError(.internalError, message: "unable to create temporary file")
52+
}
53+
54+
guard let fileHandle = try? FileHandle(forWritingTo: tempFile) else {
55+
throw ContainerizationError(.internalError, message: "unable to open temporary file for writing")
56+
}
57+
58+
let bufferSize = 4096
59+
while true {
60+
let chunk = FileHandle.standardInput.readData(ofLength: bufferSize)
61+
if chunk.isEmpty { break }
62+
fileHandle.write(chunk)
63+
}
64+
try fileHandle.close()
65+
} else {
66+
guard FileManager.default.fileExists(atPath: input!) else {
67+
print("File does not exist \(input!)")
68+
Application.exit(withError: ArgumentParser.ExitCode(1))
69+
}
4670
}
4771

4872
let progressConfig = try ProgressConfig(
@@ -57,7 +81,7 @@ extension Application {
5781
progress.start()
5882

5983
progress.set(description: "Loading tar archive")
60-
let loaded = try await ClientImage.load(from: input)
84+
let loaded = try await ClientImage.load(from: input ?? tempFile.path())
6185

6286
let taskManager = ProgressTaskCoordinator()
6387
let unpackTask = await taskManager.startTask()

Sources/ContainerCommands/Image/ImageSave.swift

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ extension Application {
4646
transform: { str in
4747
URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false)
4848
})
49-
var output: String
49+
var output: String?
5050

5151
@Option(
5252
help: "Platform for the saved image (format: os/arch[/variant], takes precedence over --os and --arch)"
@@ -88,9 +88,35 @@ extension Application {
8888

8989
guard images.count == references.count else {
9090
throw ContainerizationError(.invalidArgument, message: "failed to save image(s)")
91+
}
92+
93+
// Write to stdout; otherwise write to the output file
94+
if output == nil {
95+
let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).tar")
96+
defer {
97+
try? FileManager.default.removeItem(at: tempFile)
98+
}
99+
100+
guard FileManager.default.createFile(atPath: tempFile.path(), contents: nil) else {
101+
throw ContainerizationError(.internalError, message: "unable to create temporary file")
102+
}
91103

104+
try await ClientImage.save(references: references, out: tempFile.path(), platform: p)
105+
106+
guard let fileHandle = try? FileHandle(forReadingFrom: tempFile) else {
107+
throw ContainerizationError(.internalError, message: "unable to open temporary file for reading")
108+
}
109+
110+
let bufferSize = 4096
111+
while true {
112+
let chunk = fileHandle.readData(ofLength: bufferSize)
113+
if chunk.isEmpty { break }
114+
FileHandle.standardOutput.write(chunk)
115+
}
116+
try fileHandle.close()
117+
} else {
118+
try await ClientImage.save(references: references, out: output!, platform: p)
92119
}
93-
try await ClientImage.save(references: references, out: output, platform: p)
94120

95121
progress.finish()
96122
for reference in references {

Tests/CLITests/Subcommands/Images/TestCLIImages.swift

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,4 +359,65 @@ extension TestCLIImagesCommand {
359359
return
360360
}
361361
}
362+
363+
@Test func testImageSaveAndLoadStdinStdout() throws {
364+
do {
365+
// 1. pull image
366+
try doPull(imageName: alpine)
367+
try doPull(imageName: busybox)
368+
369+
// 2. Tag image so we can safely remove later
370+
let alpineRef: Reference = try Reference.parse(alpine)
371+
let alpineTagged = "\(alpineRef.name):testImageSaveAndLoadStdinStdout"
372+
try doImageTag(image: alpine, newName: alpineTagged)
373+
let alpineTaggedImagePresent = try isImagePresent(targetImage: alpineTagged)
374+
#expect(alpineTaggedImagePresent, "expected to see image \(alpineTagged) tagged")
375+
376+
let busyboxRef: Reference = try Reference.parse(busybox)
377+
let busyboxTagged = "\(busyboxRef.name):testImageSaveAndLoadStdinStdout"
378+
try doImageTag(image: busybox, newName: busyboxTagged)
379+
let busyboxTaggedImagePresent = try isImagePresent(targetImage: busyboxTagged)
380+
#expect(busyboxTaggedImagePresent, "expected to see image \(busyboxTagged) tagged")
381+
382+
// 3. save the image and output to stdout
383+
let saveArgs = [
384+
"image",
385+
"save",
386+
alpineTagged,
387+
busyboxTagged,
388+
]
389+
let (stdoutData, _, error, status) = try run(arguments: saveArgs)
390+
if status != 0 {
391+
throw CLIError.executionFailed("command failed: \(error)")
392+
}
393+
394+
// 4. remove the image through container
395+
try doRemoveImages(images: [alpineTagged, busyboxTagged])
396+
397+
// 5. verify image is no longer present
398+
let alpineImageRemoved = try !isImagePresent(targetImage: alpineTagged)
399+
#expect(alpineImageRemoved, "expected image \(alpineTagged) to be removed")
400+
let busyboxImageRemoved = try !isImagePresent(targetImage: busyboxTagged)
401+
#expect(busyboxImageRemoved, "expected image \(busyboxTagged) to be removed")
402+
403+
// 6. load the tarball from the stdout data as stdin
404+
let loadArgs = [
405+
"image",
406+
"load",
407+
]
408+
let (_, _, loadErr, loadStatus) = try run(arguments: loadArgs, stdin: stdoutData)
409+
if loadStatus != 0 {
410+
throw CLIError.executionFailed("command failed: \(loadErr)")
411+
}
412+
413+
// 7. verify image is in the list again
414+
let alpineImagePresent = try isImagePresent(targetImage: alpineTagged)
415+
#expect(alpineImagePresent, "expected \(alpineTagged) to be present")
416+
let busyboxImagePresent = try isImagePresent(targetImage: busyboxTagged)
417+
#expect(busyboxImagePresent, "expected \(busyboxTagged) to be present")
418+
} catch {
419+
Issue.record("failed to save and load image \(error)")
420+
return
421+
}
422+
}
362423
}

0 commit comments

Comments
 (0)