From e06ef37d25058a43824deeca27db6e20f9989045 Mon Sep 17 00:00:00 2001 From: saehejkang Date: Mon, 6 Oct 2025 23:23:18 -0700 Subject: [PATCH 1/2] [image-save-load]: support for stdin/stdout --- .../ContainerCommands/Image/ImageLoad.swift | 32 ++++++++-- .../ContainerCommands/Image/ImageSave.swift | 29 ++++++++- .../Subcommands/Images/TestCLIImages.swift | 61 +++++++++++++++++++ 3 files changed, 116 insertions(+), 6 deletions(-) diff --git a/Sources/ContainerCommands/Image/ImageLoad.swift b/Sources/ContainerCommands/Image/ImageLoad.swift index e5b4f497..ec592b09 100644 --- a/Sources/ContainerCommands/Image/ImageLoad.swift +++ b/Sources/ContainerCommands/Image/ImageLoad.swift @@ -34,14 +34,38 @@ extension Application { transform: { str in URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) }) - var input: String + var input: String? @OptionGroup var global: Flags.Global public func run() async throws { - guard FileManager.default.fileExists(atPath: input) else { - print("File does not exist \(input)") + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).tar") + defer { + try? FileManager.default.removeItem(at: tempFile) + } + + // Read from stdin + if input == nil { + guard FileManager.default.createFile(atPath: tempFile.path(), contents: nil) else { + throw ContainerizationError(.internalError, message: "unable to create temporary file") + } + + guard let outputHandle = try? FileHandle(forWritingTo: tempFile) else { + throw ContainerizationError(.internalError, message: "unable to open temporary file for writing") + } + + let bufferSize = 4096 + while true { + let chunk = FileHandle.standardInput.readData(ofLength: bufferSize) + if chunk.isEmpty { break } + outputHandle.write(chunk) + } + try outputHandle.close() + } + + guard FileManager.default.fileExists(atPath: input ?? tempFile.path()) else { + print("File does not exist \(input ?? tempFile.path())") Application.exit(withError: ArgumentParser.ExitCode(1)) } @@ -57,7 +81,7 @@ extension Application { progress.start() progress.set(description: "Loading tar archive") - let loaded = try await ClientImage.load(from: input) + let loaded = try await ClientImage.load(from: input ?? tempFile.path()) let taskManager = ProgressTaskCoordinator() let unpackTask = await taskManager.startTask() diff --git a/Sources/ContainerCommands/Image/ImageSave.swift b/Sources/ContainerCommands/Image/ImageSave.swift index b7b9e0f1..07ec52f9 100644 --- a/Sources/ContainerCommands/Image/ImageSave.swift +++ b/Sources/ContainerCommands/Image/ImageSave.swift @@ -46,7 +46,7 @@ extension Application { transform: { str in URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) }) - var output: String + var output: String? @Option( help: "Platform for the saved image (format: os/arch[/variant], takes precedence over --os and --arch)" @@ -90,7 +90,32 @@ extension Application { throw ContainerizationError(.invalidArgument, message: "failed to save image(s)") } - try await ClientImage.save(references: references, out: output, platform: p) + + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).tar") + defer { + try? FileManager.default.removeItem(at: tempFile) + } + + guard FileManager.default.createFile(atPath: tempFile.path(), contents: nil) else { + throw ContainerizationError(.internalError, message: "unable to create temporary file") + } + + try await ClientImage.save(references: references, out: output ?? tempFile.path(), platform: p) + + // Write to stdout + if output == nil { + guard let outputHandle = try? FileHandle(forReadingFrom: tempFile) else { + throw ContainerizationError(.internalError, message: "unable to open temporary file for reading") + } + + let bufferSize = 4096 + while true { + let chunk = outputHandle.readData(ofLength: bufferSize) + if chunk.isEmpty { break } + FileHandle.standardOutput.write(chunk) + } + try outputHandle.close() + } progress.finish() for reference in references { diff --git a/Tests/CLITests/Subcommands/Images/TestCLIImages.swift b/Tests/CLITests/Subcommands/Images/TestCLIImages.swift index 4a936ab4..5f7800e8 100644 --- a/Tests/CLITests/Subcommands/Images/TestCLIImages.swift +++ b/Tests/CLITests/Subcommands/Images/TestCLIImages.swift @@ -359,4 +359,65 @@ extension TestCLIImagesCommand { return } } + + @Test func testImageSaveAndLoadStdinStdout() throws { + do { + // 1. pull image + try doPull(imageName: alpine) + try doPull(imageName: busybox) + + // 2. Tag image so we can safely remove later + let alpineRef: Reference = try Reference.parse(alpine) + let alpineTagged = "\(alpineRef.name):testImageSaveAndLoadStdinStdout" + try doImageTag(image: alpine, newName: alpineTagged) + let alpineTaggedImagePresent = try isImagePresent(targetImage: alpineTagged) + #expect(alpineTaggedImagePresent, "expected to see image \(alpineTagged) tagged") + + let busyboxRef: Reference = try Reference.parse(busybox) + let busyboxTagged = "\(busyboxRef.name):testImageSaveAndLoadStdinStdout" + try doImageTag(image: busybox, newName: busyboxTagged) + let busyboxTaggedImagePresent = try isImagePresent(targetImage: busyboxTagged) + #expect(busyboxTaggedImagePresent, "expected to see image \(busyboxTagged) tagged") + + // 3. save the image and output to stdout + let saveArgs = [ + "image", + "save", + alpineTagged, + busyboxTagged, + ] + let (stdoutData, _, error, status) = try run(arguments: saveArgs) + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + + // 4. remove the image through container + try doRemoveImages(images: [alpineTagged, busyboxTagged]) + + // 5. verify image is no longer present + let alpineImageRemoved = try !isImagePresent(targetImage: alpineTagged) + #expect(alpineImageRemoved, "expected image \(alpineTagged) to be removed") + let busyboxImageRemoved = try !isImagePresent(targetImage: busyboxTagged) + #expect(busyboxImageRemoved, "expected image \(busyboxTagged) to be removed") + + // 6. load the tarball from the stdout data as stdin + let loadArgs = [ + "image", + "load", + ] + let (_, _, loadErr, loadStatus) = try run(arguments: loadArgs, stdin: stdoutData) + if loadStatus != 0 { + throw CLIError.executionFailed("command failed: \(loadErr)") + } + + // 7. verify image is in the list again + let alpineImagePresent = try isImagePresent(targetImage: alpineTagged) + #expect(alpineImagePresent, "expected \(alpineTagged) to be present") + let busyboxImagePresent = try isImagePresent(targetImage: busyboxTagged) + #expect(busyboxImagePresent, "expected \(busyboxTagged) to be present") + } catch { + Issue.record("failed to save and load image \(error)") + return + } + } } From 7cb3e87a2bd4944100ae577f5ebc3582f67ec170 Mon Sep 17 00:00:00 2001 From: saehejkang Date: Mon, 17 Nov 2025 15:49:22 -0800 Subject: [PATCH 2/2] updates to load/save command logic --- .../ContainerCommands/Image/ImageLoad.swift | 18 ++++++------ .../ContainerCommands/Image/ImageSave.swift | 29 ++++++++++--------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/Sources/ContainerCommands/Image/ImageLoad.swift b/Sources/ContainerCommands/Image/ImageLoad.swift index ec592b09..ff0f46c3 100644 --- a/Sources/ContainerCommands/Image/ImageLoad.swift +++ b/Sources/ContainerCommands/Image/ImageLoad.swift @@ -45,13 +45,13 @@ extension Application { try? FileManager.default.removeItem(at: tempFile) } - // Read from stdin + // Read from stdin; otherwise read from the input file if input == nil { guard FileManager.default.createFile(atPath: tempFile.path(), contents: nil) else { throw ContainerizationError(.internalError, message: "unable to create temporary file") } - guard let outputHandle = try? FileHandle(forWritingTo: tempFile) else { + guard let fileHandle = try? FileHandle(forWritingTo: tempFile) else { throw ContainerizationError(.internalError, message: "unable to open temporary file for writing") } @@ -59,14 +59,14 @@ extension Application { while true { let chunk = FileHandle.standardInput.readData(ofLength: bufferSize) if chunk.isEmpty { break } - outputHandle.write(chunk) + fileHandle.write(chunk) + } + try fileHandle.close() + } else { + guard FileManager.default.fileExists(atPath: input!) else { + print("File does not exist \(input!)") + Application.exit(withError: ArgumentParser.ExitCode(1)) } - try outputHandle.close() - } - - guard FileManager.default.fileExists(atPath: input ?? tempFile.path()) else { - print("File does not exist \(input ?? tempFile.path())") - Application.exit(withError: ArgumentParser.ExitCode(1)) } let progressConfig = try ProgressConfig( diff --git a/Sources/ContainerCommands/Image/ImageSave.swift b/Sources/ContainerCommands/Image/ImageSave.swift index 07ec52f9..0681bb85 100644 --- a/Sources/ContainerCommands/Image/ImageSave.swift +++ b/Sources/ContainerCommands/Image/ImageSave.swift @@ -88,33 +88,34 @@ extension Application { guard images.count == references.count else { throw ContainerizationError(.invalidArgument, message: "failed to save image(s)") - } - let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).tar") - defer { - try? FileManager.default.removeItem(at: tempFile) - } + // Write to stdout; otherwise write to the output file + if output == nil { + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).tar") + defer { + try? FileManager.default.removeItem(at: tempFile) + } - guard FileManager.default.createFile(atPath: tempFile.path(), contents: nil) else { - throw ContainerizationError(.internalError, message: "unable to create temporary file") - } + guard FileManager.default.createFile(atPath: tempFile.path(), contents: nil) else { + throw ContainerizationError(.internalError, message: "unable to create temporary file") + } - try await ClientImage.save(references: references, out: output ?? tempFile.path(), platform: p) + try await ClientImage.save(references: references, out: tempFile.path(), platform: p) - // Write to stdout - if output == nil { - guard let outputHandle = try? FileHandle(forReadingFrom: tempFile) else { + guard let fileHandle = try? FileHandle(forReadingFrom: tempFile) else { throw ContainerizationError(.internalError, message: "unable to open temporary file for reading") } let bufferSize = 4096 while true { - let chunk = outputHandle.readData(ofLength: bufferSize) + let chunk = fileHandle.readData(ofLength: bufferSize) if chunk.isEmpty { break } FileHandle.standardOutput.write(chunk) } - try outputHandle.close() + try fileHandle.close() + } else { + try await ClientImage.save(references: references, out: output!, platform: p) } progress.finish()