From c033d1c46fb02901f44e23c1d5b4c3329c64ca23 Mon Sep 17 00:00:00 2001 From: Bortniak Volodymyr Date: Fri, 19 Dec 2025 15:13:28 +0100 Subject: [PATCH] adds support of reading env from named pipes --- Sources/ContainerClient/Parser.swift | 18 ++--- .../Network/NetworkPrune.swift | 66 +++++++++++++++++++ .../Subcommands/Run/TestCLIRunCommand.swift | 62 +++++++++++++++++ Tests/ContainerClientTests/ParserTest.swift | 41 +++++++++++- 4 files changed, 177 insertions(+), 10 deletions(-) create mode 100644 Sources/ContainerCommands/Network/NetworkPrune.swift diff --git a/Sources/ContainerClient/Parser.swift b/Sources/ContainerClient/Parser.swift index 6f985584b..5397c06a8 100644 --- a/Sources/ContainerClient/Parser.swift +++ b/Sources/ContainerClient/Parser.swift @@ -110,17 +110,19 @@ public struct Parser { // This is a somewhat faithful Go->Swift port of Moby's envfile // parsing in the cli: // https://github.com/docker/cli/blob/f5a7a3c72eb35fc5ba9c4d65a2a0e2e1bd216bf2/pkg/kvfile/kvfile.go#L81 - guard FileManager.default.fileExists(atPath: path) else { - throw ContainerizationError( - .notFound, - message: "envfile at \(path) not found" - ) - } - guard let data = FileManager.default.contents(atPath: path) else { + let data: Data + do { + // Use FileHandle to support named pipes (FIFOs) and process substitutions + // like --env-file <(echo "KEY=value") + let fileHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: path)) + defer { try? fileHandle.close() } + data = try fileHandle.readToEnd() ?? Data() + } catch { throw ContainerizationError( .invalidArgument, - message: "failed to read envfile at \(path)" + message: "failed to read envfile at \(path)", + cause: error ) } diff --git a/Sources/ContainerCommands/Network/NetworkPrune.swift b/Sources/ContainerCommands/Network/NetworkPrune.swift new file mode 100644 index 000000000..ccbf13132 --- /dev/null +++ b/Sources/ContainerCommands/Network/NetworkPrune.swift @@ -0,0 +1,66 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ArgumentParser +import ContainerClient +import Foundation + +extension Application.NetworkCommand { + public struct NetworkPrune: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( + commandName: "prune", + abstract: "Remove networks with no container connections" + ) + + @OptionGroup + var global: Flags.Global + + public func run() async throws { + let allContainers = try await ClientContainer.list() + let allNetworks = try await ClientNetwork.list() + + var networksInUse = Set() + for container in allContainers { + for network in container.configuration.networks { + networksInUse.insert(network.network) + } + } + + let networksToPrune = allNetworks.filter { network in + network.id != ClientNetwork.defaultNetworkName && !networksInUse.contains(network.id) + } + + var prunedNetworks = [String]() + + for network in networksToPrune { + do { + try await ClientNetwork.delete(id: network.id) + prunedNetworks.append(network.id) + } catch { + // Note: This failure may occur due to a race condition between the network/ + // container collection above and a container run command that attaches to a + // network listed in the networksToPrune collection. + log.error("Failed to prune network \(network.id): \(error)") + } + } + + for name in prunedNetworks { + print(name) + } + } + } +} diff --git a/Tests/CLITests/Subcommands/Run/TestCLIRunCommand.swift b/Tests/CLITests/Subcommands/Run/TestCLIRunCommand.swift index 0087fa927..34fe541c1 100644 --- a/Tests/CLITests/Subcommands/Run/TestCLIRunCommand.swift +++ b/Tests/CLITests/Subcommands/Run/TestCLIRunCommand.swift @@ -578,6 +578,68 @@ class TestCLIRunCommand: CLITest { } } + @Test func testRunCommandEnvFileFromNamedPipe() throws { + do { + let name = getTestName() + let pipePath = FileManager.default.temporaryDirectory.appendingPathComponent("envfile-pipe\(UUID().uuidString)") + + // create pipe + let result = mkfifo(pipePath.path(), 0o600) + guard result == 0 else { + Issue.record("failed to create named pipe: \(String(cString: strerror(errno)))") + return + } + + defer { + try? FileManager.default.removeItem(at: pipePath) + } + + let content = """ + FOO=bar + BAR=baz + """ + + let group = DispatchGroup() + + group.enter() + DispatchQueue.global().async { + do { + let handle = try FileHandle(forWritingTo: pipePath) + try handle.write(contentsOf: Data(content.utf8)) + try handle.close() + } catch { + Issue.record(error) + return + } + + group.leave() + } + + try doLongRun(name: name, args: ["--env-file", pipePath.path()]) + defer { + try? doStop(name: name) + } + + group.wait() + + let inspectResult = try inspectContainer(name) + let expected = [ + "FOO=bar", + "BAR=baz", + ] + + for item in expected { + #expect( + inspectResult.configuration.initProcess.environment.contains(item), + "expected environment variable \(item) not found" + ) + } + try doStop(name: name) + } catch { + Issue.record(error) + } + } + func getDefaultDomain() throws -> String? { let (_, output, err, status) = try run(arguments: ["system", "property", "get", "dns.domain"]) try #require(status == 0, "default DNS domain retrieval returned status \(status): \(err)") diff --git a/Tests/ContainerClientTests/ParserTest.swift b/Tests/ContainerClientTests/ParserTest.swift index 8868e1c14..961ae6fb5 100644 --- a/Tests/ContainerClientTests/ParserTest.swift +++ b/Tests/ContainerClientTests/ParserTest.swift @@ -493,10 +493,12 @@ struct ParserTest { #expect { _ = try Parser.envFile(path: "/nonexistent/foo_bar_baz") } throws: { error in - guard let error = error as? ContainerizationError else { + guard let error = error as? ContainerizationError, + let cause = error.cause + else { return false } - return error.description.contains("not found") + return String(describing: cause).contains("No such file or directory") } } @@ -579,6 +581,41 @@ struct ParserTest { } } + @Test + func testParseEnvFileFromNamedPipe() throws { + let pipePath = FileManager.default.temporaryDirectory + .appendingPathComponent("envfile-pipe-\(UUID().uuidString)") + + // Create a named pipe (FIFO) + let result = mkfifo(pipePath.path, 0o600) + guard result == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EPERM) + } + defer { try? FileManager.default.removeItem(at: pipePath) } + + let group = DispatchGroup() + + group.enter() + DispatchQueue.global().async { + do { + let handle = try FileHandle(forWritingTo: pipePath) + try handle.write(contentsOf: "SECRET_KEY=value123\n".data(using: .utf8)!) + try handle.close() + } catch { + Issue.record(error) + } + group.leave() + } + + // Read from pipe (blocks until writer connects) + let lines = try Parser.envFile(path: pipePath.path) + + // Wait for write to complete + group.wait() + + #expect(lines == ["SECRET_KEY=value123"]) + } + // MARK: Network Parser Tests @Test