diff --git a/Package.swift b/Package.swift index 0151873..294a890 100644 --- a/Package.swift +++ b/Package.swift @@ -12,7 +12,8 @@ let package = Package( .library(name: "CommandClient", targets: ["CommandClient"]), .library(name: "ConfigurationClient", targets: ["ConfigurationClient"]), .library(name: "FileClient", targets: ["FileClient"]), - .library(name: "PlaybookClient", targets: ["PlaybookClient"]) + .library(name: "PlaybookClient", targets: ["PlaybookClient"]), + .library(name: "VaultClient", targets: ["VaultClient"]) ], dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), @@ -75,6 +76,7 @@ let package = Package( .target( name: "TestSupport", dependencies: [ + "CommandClient", .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "ShellClient", package: "swift-shell-client") ] @@ -124,6 +126,21 @@ let package = Package( "PlaybookClient", "TestSupport" ] + ), + .target( + name: "VaultClient", + dependencies: [ + "CommandClient", + "ConfigurationClient", + "FileClient", + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "DependenciesMacros", package: "swift-dependencies"), + .product(name: "ShellClient", package: "swift-shell-client") + ] + ), + .testTarget( + name: "VaultClientTests", + dependencies: ["VaultClient", "TestSupport"] ) ] ) diff --git a/Sources/ConfigurationClient/ConfigurationClient.swift b/Sources/ConfigurationClient/ConfigurationClient.swift index 0f5b0c2..9e35228 100644 --- a/Sources/ConfigurationClient/ConfigurationClient.swift +++ b/Sources/ConfigurationClient/ConfigurationClient.swift @@ -59,6 +59,16 @@ extension ConfigurationClient: DependencyKey { public static var liveValue: Self { .live(environment: ProcessInfo.processInfo.environment) } + + @_spi(Internal) + public static func mock(_ configuration: Configuration = .mock) -> Self { + .init( + find: { throw MockFindError() }, + generate: Self().generate, + load: { _ in configuration }, + write: Self().write + ) + } } struct LiveConfigurationClient { @@ -229,3 +239,5 @@ enum ConfigurationError: Error { case decodingError case fileExists(path: String) } + +struct MockFindError: Error {} diff --git a/Sources/ConfigurationClient/File.swift b/Sources/ConfigurationClient/File.swift index 13666bf..821e044 100644 --- a/Sources/ConfigurationClient/File.swift +++ b/Sources/ConfigurationClient/File.swift @@ -1,3 +1,4 @@ +import FileClient import Foundation /// Represents a file location and type on disk for a configuration file. @@ -49,11 +50,3 @@ public enum File: Equatable, Sendable { return .toml(fileUrl) } } - -@_spi(Internal) -public extension URL { - - var cleanFilePath: String { - absoluteString.replacing("file://", with: "") - } -} diff --git a/Sources/TestSupport/TestSupport.swift b/Sources/TestSupport/TestSupport.swift index 6888e7d..dbbbf46 100644 --- a/Sources/TestSupport/TestSupport.swift +++ b/Sources/TestSupport/TestSupport.swift @@ -1,3 +1,4 @@ +@_spi(Internal) @_exported import CommandClient @_exported import Dependencies import Foundation import Logging @@ -7,6 +8,35 @@ public protocol TestCase {} public extension TestCase { + static var loggingOptions: LoggingOptions { + let levelString = ProcessInfo.processInfo.environment["LOG_LEVEL"] ?? "debug" + let logLevel = Logger.Level(rawValue: levelString) ?? .debug + return .init(commandName: "\(Self.self)", logLevel: logLevel) + } + + func withCapturingCommandClient( + _ key: String, + dependencies setupDependencies: @escaping (inout DependencyValues) -> Void = { _ in }, + run: @Sendable @escaping () async throws -> Void, + assert: @Sendable @escaping (CommandClient.RunCommandOptions) -> Void + ) async throws { + let captured = CommandClient.CapturingClient() + try await withDependencies { + $0.commandClient = .capturing(captured) + setupDependencies(&$0) + } operation: { + try await Self.loggingOptions.withLogger { + try await run() + + guard let options = await captured.options else { + throw TestSupportError.optionsNotSet + } + + assert(options) + } + } + } + func withTestLogger( key: String, logLevel: Logger.Level = .debug, @@ -131,3 +161,7 @@ public func withTestLogger( try await operation() } } + +enum TestSupportError: Error { + case optionsNotSet +} diff --git a/Sources/VaultClient/VaultClient.swift b/Sources/VaultClient/VaultClient.swift new file mode 100644 index 0000000..b70a301 --- /dev/null +++ b/Sources/VaultClient/VaultClient.swift @@ -0,0 +1,164 @@ +import CommandClient +import ConfigurationClient +import Dependencies +import DependenciesMacros +import FileClient + +// TODO: Add edit / view routes, possibly create? + +public extension DependencyValues { + var vaultClient: VaultClient { + get { self[VaultClient.self] } + set { self[VaultClient.self] = newValue } + } +} + +@DependencyClient +public struct VaultClient: Sendable { + public var run: @Sendable (RunOptions) async throws -> Void + + public struct RunOptions: Equatable, Sendable { + + public let extraOptions: [String]? + public let loggingOptions: LoggingOptions + public let outputFilePath: String? + public let quiet: Bool + public let route: Route + public let shell: String? + public let vaultFilePath: String? + + public init( + _ route: Route, + extraOptions: [String]? = nil, + loggingOptions: LoggingOptions, + outputFilePath: String? = nil, + quiet: Bool = false, + shell: String? = nil, + vaultFilePath: String? = nil + ) { + self.extraOptions = extraOptions + self.loggingOptions = loggingOptions + self.outputFilePath = outputFilePath + self.quiet = quiet + self.route = route + self.shell = shell + self.vaultFilePath = vaultFilePath + } + + public enum Route: String, Equatable, Sendable { + case encrypt + case decrypt + + @_spi(Internal) + public var verb: String { rawValue } + } + } +} + +extension VaultClient: DependencyKey { + + public static let testValue: VaultClient = Self() + + public static var liveValue: VaultClient { + .init( + run: { try await $0.run() } + ) + } +} + +@_spi(Internal) +public extension VaultClient { + enum Constants { + public static let vaultCommand = "ansible-vault" + } +} + +extension VaultClient.RunOptions { + + func run() async throws { + @Dependency(\.commandClient) var commandClient + + try await commandClient.run( + logging: loggingOptions, + quiet: quiet, + shell: shell + ) { + @Dependency(\.configurationClient) var configurationClient + @Dependency(\.fileClient) var fileClient + @Dependency(\.logger) var logger + + let configuration = try await configurationClient.findAndLoad() + logger.trace("Configuration: \(configuration)") + + var vaultFilePath: String? = vaultFilePath + + if vaultFilePath == nil { + vaultFilePath = try await fileClient + .findVaultFileInCurrentDirectory()? + .cleanFilePath + } + + guard let vaultFilePath else { + throw VaultClientError.vaultFileNotFound + } + + logger.trace("Vault file: \(vaultFilePath)") + + var arguments = [ + VaultClient.Constants.vaultCommand, + route.verb + ] + + if let outputFilePath { + arguments.append(contentsOf: ["--output", outputFilePath]) + } + + if let extraOptions { + arguments.append(contentsOf: extraOptions) + } + + if let vaultArgs = configuration.vault.args { + arguments.append(contentsOf: vaultArgs) + } + + if arguments.contains("encrypt"), + !arguments.contains("--encrypt-vault-id"), + let id = configuration.vault.encryptId + { + arguments.append(contentsOf: ["--encrypt-vault-id", id]) + } + + arguments.append(vaultFilePath) + + logger.trace("Arguments: \(arguments)") + + return arguments + } + } +} + +// extension VaultClient.RunOptions.Route { +// +// var arguments: [String] { +// let outputFile: String? +// var arguments: [String] +// +// switch self { +// case let .decrypt(outputFile: output): +// outputFile = output +// arguments = ["decrypt"] +// case let .encrypt(outputFile: output): +// outputFile = output +// arguments = ["encrypt"] +// } +// +// if let outputFile { +// arguments.append(contentsOf: ["--output", outputFile]) +// } +// return arguments +// } +// } + +enum VaultClientError: Error { + case vaultFileNotFound +} diff --git a/Tests/PlaybookClientTests/PlaybookClientTests.swift b/Tests/PlaybookClientTests/PlaybookClientTests.swift index 9656b0b..d8d4e16 100644 --- a/Tests/PlaybookClientTests/PlaybookClientTests.swift +++ b/Tests/PlaybookClientTests/PlaybookClientTests.swift @@ -1,6 +1,6 @@ import CodersClient @_spi(Internal) import CommandClient -import ConfigurationClient +@_spi(Internal) import ConfigurationClient import Dependencies import FileClient import Foundation @@ -12,12 +12,6 @@ import TestSupport @Suite("PlaybookClientTests") struct PlaybookClientTests: TestCase { - static let loggingOptions: LoggingOptions = { - let levelString = ProcessInfo.processInfo.environment["LOG_LEVEL"] ?? "debug" - let logLevel = Logger.Level(rawValue: levelString) ?? .debug - return .init(commandName: "PlaybookClientTests", logLevel: logLevel) - }() - static var sharedRunOptions: PlaybookClient.RunPlaybook.SharedRunOptions { .init(loggingOptions: loggingOptions) } @@ -268,15 +262,6 @@ extension Result where Failure == TestError { static var failing: Self { .failure(TestError()) } } -extension ConfigurationClient { - static func mock(_ configuration: Configuration) -> Self { - var mock = Self.testValue - mock.find = { throw TestError() } - mock.load = { _ in configuration } - return mock - } -} - struct TestError: Error {} extension Tag { diff --git a/Tests/VaultClientTests/VaultClientTests.swift b/Tests/VaultClientTests/VaultClientTests.swift new file mode 100644 index 0000000..e40d3d2 --- /dev/null +++ b/Tests/VaultClientTests/VaultClientTests.swift @@ -0,0 +1,114 @@ +@_spi(Internal) import ConfigurationClient +import FileClient +import Foundation +import Testing +import TestSupport +@_spi(Internal) import VaultClient + +@Suite("VaultClientTests") +struct VaultClientTests: TestCase { + + @Test( + arguments: TestOptions.testCases + ) + func decrypt(input: TestOptions) async throws { + try await withCapturingCommandClient("decrypt") { + $0.configurationClient = .mock(input.configuration) + $0.fileClient.findVaultFileInCurrentDirectory = { URL(filePath: "/vault.yml") } + $0.vaultClient = .liveValue + } run: { + @Dependency(\.vaultClient) var vaultClient + + try await vaultClient.run(.init( + .decrypt, + extraOptions: input.extraOptions, + loggingOptions: Self.loggingOptions, + outputFilePath: input.outputFilePath, + vaultFilePath: input.vaultFilePath + )) + } assert: { options in + + #expect(options.arguments == input.expected(.decrypt)) + } + } + + @Test( + arguments: TestOptions.testCases + ) + func encrypt(input: TestOptions) async throws { + try await withCapturingCommandClient("decrypt") { + $0.configurationClient = .mock(input.configuration) + $0.fileClient.findVaultFileInCurrentDirectory = { URL(filePath: "/vault.yml") } + $0.vaultClient = .liveValue + } run: { + @Dependency(\.vaultClient) var vaultClient + + try await vaultClient.run(.init( + .encrypt, + extraOptions: input.extraOptions, + loggingOptions: Self.loggingOptions, + outputFilePath: input.outputFilePath, + vaultFilePath: input.vaultFilePath + )) + } assert: { options in + #expect(options.arguments == input.expected(.encrypt)) + } + } + +} + +struct TestOptions: Sendable { + let configuration: Configuration + let extraOptions: [String]? + let outputFilePath: String? + let vaultFilePath: String? + + init( + configuration: Configuration = .init(), + extraOptions: [String]? = nil, + outputFilePath: String? = nil, + vaultFilePath: String? = nil + ) { + self.configuration = configuration + self.extraOptions = extraOptions + self.outputFilePath = outputFilePath + self.vaultFilePath = vaultFilePath + } + + func expected(_ route: VaultClient.RunOptions.Route) -> [String] { + var expected = [ + "ansible-vault", "\(route.verb)" + ] + + if let outputFilePath { + expected.append(contentsOf: ["--output", outputFilePath]) + } + + if let extraOptions { + expected.append(contentsOf: extraOptions) + } + + if let vaultArgs = configuration.vault.args { + expected.append(contentsOf: vaultArgs) + } + + if route == .encrypt, + let id = configuration.vault.encryptId + { + expected.append(contentsOf: ["--encrypt-vault-id", id]) + } + + expected.append(vaultFilePath ?? "/vault.yml") + + return expected + } + + static let testCases: [Self] = [ + TestOptions(vaultFilePath: "/vault.yml"), + TestOptions(extraOptions: ["--verbose"]), + TestOptions(configuration: .mock), + TestOptions(outputFilePath: "/output.yml") + ] +} + +struct TestError: Error {}