diff --git a/Package.swift b/Package.swift index ed54705..ff4c09f 100644 --- a/Package.swift +++ b/Package.swift @@ -39,21 +39,14 @@ let package = Package( "ConfigurationClient", .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "DependenciesMacros", package: "swift-dependencies"), - .product(name: "ShellClient", package: "swift-shell-client"), - .product(name: "TOMLKit", package: "TOMLKit") + .product(name: "ShellClient", package: "swift-shell-client") ] ), .testTarget( name: "CliClientTests", dependencies: [ "CliClient", - .product(name: "TOMLKit", package: "TOMLKit") - ], - resources: [ - .copy("Resources/config.json"), - .copy("Resources/.hparc"), - .copy("Resources/vault.yml"), - .copy("Resources/hpa-playbook") + "TestSupport" ] ), .target( diff --git a/Sources/CliClient/CliClient.swift b/Sources/CliClient/CliClient+Commands.swift similarity index 59% rename from Sources/CliClient/CliClient.swift rename to Sources/CliClient/CliClient+Commands.swift index d6cdf60..078c993 100644 --- a/Sources/CliClient/CliClient.swift +++ b/Sources/CliClient/CliClient+Commands.swift @@ -5,34 +5,25 @@ import FileClient import Foundation import ShellClient -public extension DependencyValues { - var cliClient: CliClient { - get { self[CliClient.self] } - set { self[CliClient.self] = newValue } - } -} +public extension CliClient { -@DependencyClient -public struct CliClient: Sendable { - public var runCommand: @Sendable ([String], Bool, ShellCommand.Shell) async throws -> Void - - public func runCommand( + func runCommand( quiet: Bool, shell: ShellCommand.Shell, _ args: [String] ) async throws { - try await runCommand(args, quiet, shell) + try await runCommand(.init(arguments: args, quiet: quiet, shell: shell)) } - public func runCommand( + func runCommand( quiet: Bool, shell: ShellCommand.Shell, _ args: String... ) async throws { - try await runCommand(args, quiet, shell) + try await runCommand(quiet: quiet, shell: shell, args) } - public func runPlaybookCommand(_ options: PlaybookOptions) async throws { + func runPlaybookCommand(_ options: PlaybookOptions) async throws { @Dependency(\.configurationClient) var configurationClient @Dependency(\.logger) var logger @@ -46,7 +37,7 @@ public struct CliClient: Sendable { let inventoryPath = ensuredInventoryPath( options.inventoryFilePath, configuration: configuration, - playboodDirectory: playbookDirectory + playbookDirectory: playbookDirectory ) logger.trace("Inventory path: \(inventoryPath)") @@ -72,7 +63,7 @@ public struct CliClient: Sendable { ) } - public func runVaultCommand(_ options: VaultOptions) async throws { + func runVaultCommand(_ options: VaultOptions) async throws { @Dependency(\.configurationClient) var configurationClient @Dependency(\.fileClient) var fileClient @Dependency(\.logger) var logger @@ -98,6 +89,10 @@ public struct CliClient: Sendable { arguments.append(contentsOf: ["--encrypt-vault-id", id]) } + arguments.append(vaultFilePath) + + logger.trace("Running vault command with arguments: \(arguments)") + try await runCommand( quiet: options.quiet, shell: options.shell.shellOrDefault, @@ -106,89 +101,6 @@ public struct CliClient: Sendable { } } -public extension CliClient { - struct PlaybookOptions: Sendable, Equatable { - let arguments: [String] - let configuration: Configuration? - let inventoryFilePath: String? - let playbookDirectory: String? - let quiet: Bool - let shell: String? - - public init( - arguments: [String], - configuration: Configuration? = nil, - inventoryFilePath: String? = nil, - playbookDirectory: String? = nil, - quiet: Bool, - shell: String? = nil - ) { - self.arguments = arguments - self.configuration = configuration - self.inventoryFilePath = inventoryFilePath - self.playbookDirectory = playbookDirectory - self.quiet = quiet - self.shell = shell - } - } - - struct VaultOptions: Equatable, Sendable { - let arguments: [String] - let configuration: Configuration? - let quiet: Bool - let shell: String? - let vaultFilePath: String? - - public init( - arguments: [String], - configuration: Configuration? = nil, - quiet: Bool, - shell: String?, - vaultFilePath: String? = nil - ) { - self.arguments = arguments - self.configuration = configuration - self.quiet = quiet - self.shell = shell - self.vaultFilePath = vaultFilePath - } - } -} - -extension CliClient: DependencyKey { - - public static func live( - env: [String: String] - ) -> Self { - @Dependency(\.logger) var logger - - return .init { args, quiet, shell in - @Dependency(\.asyncShellClient) var shellClient - if !quiet { - try await shellClient.foreground(.init( - shell: shell, - environment: ProcessInfo.processInfo.environment, - in: nil, - args - )) - } else { - try await shellClient.background(.init( - shell: shell, - environment: ProcessInfo.processInfo.environment, - in: nil, - args - )) - } - } - } - - public static var liveValue: CliClient { - .live(env: ProcessInfo.processInfo.environment) - } - - public static let testValue: CliClient = Self() -} - private extension ConfigurationClient { func ensuredConfiguration(_ optionalConfig: Configuration?) async throws -> Configuration { guard let config = optionalConfig else { @@ -222,11 +134,11 @@ private extension Optional where Wrapped == String { private func ensuredInventoryPath( _ optionalInventoryPath: String?, configuration: Configuration, - playboodDirectory: String + playbookDirectory: String ) -> String { guard let path = optionalInventoryPath else { guard let path = configuration.playbook?.inventory else { - return "\(playboodDirectory)/\(Constants.inventoryFileName)" + return "\(playbookDirectory)/\(Constants.inventoryFileName)" } return path } @@ -250,3 +162,5 @@ enum CliClientError: Error { case playbookDirectoryNotFound case vaultFileNotFound } + +extension ShellCommand.Shell: @retroactive @unchecked Sendable {} diff --git a/Sources/CliClient/Interface.swift b/Sources/CliClient/Interface.swift new file mode 100644 index 0000000..9979ce9 --- /dev/null +++ b/Sources/CliClient/Interface.swift @@ -0,0 +1,139 @@ +import ConfigurationClient +import Dependencies +import DependenciesMacros +import Foundation +import ShellClient + +public extension DependencyValues { + var cliClient: CliClient { + get { self[CliClient.self] } + set { self[CliClient.self] = newValue } + } +} + +@DependencyClient +public struct CliClient: Sendable { + + public var runCommand: @Sendable (RunCommandOptions) async throws -> Void +} + +public extension CliClient { + struct PlaybookOptions: Sendable, Equatable { + let arguments: [String] + let configuration: Configuration? + let inventoryFilePath: String? + let playbookDirectory: String? + let quiet: Bool + let shell: String? + + public init( + arguments: [String], + configuration: Configuration? = nil, + inventoryFilePath: String? = nil, + playbookDirectory: String? = nil, + quiet: Bool, + shell: String? = nil + ) { + self.arguments = arguments + self.configuration = configuration + self.inventoryFilePath = inventoryFilePath + self.playbookDirectory = playbookDirectory + self.quiet = quiet + self.shell = shell + } + } + + struct RunCommandOptions: Sendable, Equatable { + public let arguments: [String] + public let quiet: Bool + public let shell: ShellCommand.Shell + + public init( + arguments: [String], + quiet: Bool, + shell: ShellCommand.Shell + ) { + self.arguments = arguments + self.quiet = quiet + self.shell = shell + } + } + + struct VaultOptions: Equatable, Sendable { + let arguments: [String] + let configuration: Configuration? + let quiet: Bool + let shell: String? + let vaultFilePath: String? + + public init( + arguments: [String], + configuration: Configuration? = nil, + quiet: Bool, + shell: String?, + vaultFilePath: String? = nil + ) { + self.arguments = arguments + self.configuration = configuration + self.quiet = quiet + self.shell = shell + self.vaultFilePath = vaultFilePath + } + } +} + +extension CliClient: DependencyKey { + + public static func live( + env: [String: String] + ) -> Self { + @Dependency(\.logger) var logger + + return .init { options in + @Dependency(\.asyncShellClient) var shellClient + if !options.quiet { + try await shellClient.foreground(.init( + shell: options.shell, + environment: ProcessInfo.processInfo.environment, + in: nil, + options.arguments + )) + } else { + try await shellClient.background(.init( + shell: options.shell, + environment: ProcessInfo.processInfo.environment, + in: nil, + options.arguments + )) + } + } + } + + public static var liveValue: CliClient { + .live(env: ProcessInfo.processInfo.environment) + } + + public static let testValue: CliClient = Self() + + public static func capturing(_ client: CapturingClient) -> Self { + .init { options in + await client.set(options) + } + } + + public actor CapturingClient: Sendable { + public private(set) var quiet: Bool? + public private(set) var shell: ShellCommand.Shell? + public private(set) var arguments: [String]? + + public init() {} + + public func set( + _ options: RunCommandOptions + ) { + quiet = options.quiet + shell = options.shell + arguments = options.arguments + } + } +} diff --git a/Tests/CliClientTests/CliClientTests.swift b/Tests/CliClientTests/CliClientTests.swift index d99e81e..310af56 100644 --- a/Tests/CliClientTests/CliClientTests.swift +++ b/Tests/CliClientTests/CliClientTests.swift @@ -1,124 +1,121 @@ -// @_spi(Internal) import CliClient -// import Dependencies -// import Foundation -// import ShellClient -// import Testing -// import TOMLKit -// -// @Suite("CliClientTests") -// struct CliClientTests { -// -// @Test -// func testLiveFileClient() { -// withTestLogger(key: "testFindConfigPaths", logLevel: .trace) { -// $0.fileClient = .liveValue -// } operation: { -// @Dependency(\.fileClient) var fileClient -// let homeDir = fileClient.homeDir() -// #expect(fileClient.isDirectory(homeDir)) -// #expect(fileClient.isReadable(homeDir)) -// } -// } -// -// @Test -// func testFindConfigPaths() throws { -// withTestLogger(key: "testFindConfigPaths", logLevel: .trace) { -// $0.fileClient = .liveValue -// } operation: { -// @Dependency(\.logger) var logger -// let configURL = Bundle.module.url(forResource: "config", withExtension: "json")! -// var env = [ -// "HPA_CONFIG_FILE": path(for: configURL) -// ] -// var url = try? findConfigurationFiles(env: env) -// #expect(url != nil) -// -// env["HPA_CONFIG_FILE"] = nil -// env["HPA_CONFIG_HOME"] = path(for: configURL.deletingLastPathComponent()) -// url = try? findConfigurationFiles(env: env) -// #expect(url != nil) -// -// env["HPA_CONFIG_HOME"] = nil -// env["PWD"] = path(for: configURL.deletingLastPathComponent()) -// url = try? findConfigurationFiles(env: env) -// #expect(url != nil) -// -// env["PWD"] = nil -// env["XDG_CONFIG_HOME"] = path(for: configURL.deletingLastPathComponent()) -// url = try? findConfigurationFiles(env: env) -// #expect(url != nil) -// -// withDependencies { -// $0.fileClient.homeDir = { configURL.deletingLastPathComponent() } -// } operation: { -// url = try? findConfigurationFiles(env: [:]) -// #expect(url != nil) -// } -// -// url = try? findConfigurationFiles(env: [:]) -// #expect(url == nil) -// } -// } -// -// // @Test -// // func loadConfiguration() throws { -// // let configURL = Bundle.module.url(forResource: "config", withExtension: "json")! -// // let configData = try Data(contentsOf: configURL) -// // let decodedConfig = try JSONDecoder().decode(Configuration.self, from: configData) -// // -// // try withTestLogger(key: "loadConfiguration", logLevel: .debug) { -// // $0.fileClient = .liveValue -// // } operation: { -// // @Dependency(\.logger) var logger -// // let client = CliClient.live(env: ["HPA_CONFIG_FILE": path(for: configURL)]) -// // let config = try client.loadConfiguration() -// // #expect(config == decodedConfig) -// // } -// // } -// -// @Test(arguments: ["config", "config.json"]) -// func createConfiguration(filePath: String) throws { -// try withTestLogger(key: "createConfiguration", logLevel: .trace) { -// $0.fileClient = .liveValue -// } operation: { -// let client = CliClient.liveValue -// let tempDir = FileManager.default.temporaryDirectory -// -// let tempPath = path(for: tempDir.appending(path: filePath)) -// -// try client.createConfiguration(path: tempPath, json: filePath.contains(".json")) -// -// #expect(FileManager.default.fileExists(atPath: tempPath)) -// -// do { -// try client.createConfiguration(path: tempPath, json: true) -// #expect(Bool(false)) -// } catch { -// #expect(Bool(true)) -// } -// -// try FileManager.default.removeItem(atPath: tempPath) -// } -// } -// -// @Test -// func findVaultFile() throws { -// try withTestLogger(key: "findVaultFile", logLevel: .trace) { -// $0.fileClient = .liveValue -// } operation: { -// @Dependency(\.fileClient) var fileClient -// let vaultUrl = Bundle.module.url(forResource: "vault", withExtension: "yml")! -// let vaultDir = vaultUrl.deletingLastPathComponent() -// let url = try fileClient.findVaultFile(path(for: vaultDir)) -// #expect(url == vaultUrl) -// } -// } -// -// // @Test -// // func writeToml() throws { -// // let encoded: String = try TOMLEncoder().encode(Configuration.mock) -// // try encoded.write(to: URL(filePath: "hpa.toml"), atomically: true, encoding: .utf8) -// // } -// } +import CliClient +import ConfigurationClient +import Dependencies +import Foundation +import ShellClient +import Testing +import TestSupport +@Suite("CliClientTests") +struct CliClientTests: TestCase { + @Test + func capturingClient() async throws { + let captured = CliClient.CapturingClient() + let client = CliClient.capturing(captured) + try await client.runCommand(quiet: false, shell: .zsh(), "foo", "bar") + + let quiet = await captured.quiet! + #expect(quiet == false) + + let shell = await captured.shell + #expect(shell == .zsh()) + + let arguments = await captured.arguments! + #expect(arguments == ["foo", "bar"]) + } + + @Test(arguments: ["encrypt", "decrypt"]) + func runVault(argument: String) async throws { + let captured = CliClient.CapturingClient() + try await withMockConfiguration(captured, key: "runVault") { + $0.fileClient.findVaultFileInCurrentDirectory = { URL(filePath: "vault.yml") } + } operation: { + @Dependency(\.cliClient) var cliClient + let configuration = Configuration.mock + + try await cliClient.runVaultCommand(.init(arguments: [argument], quiet: false, shell: nil)) + + let shell = await captured.shell + #expect(shell == .zsh(useDashC: true)) + + let vaultPath = URL(filePath: #file) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appending(path: "vault.yml") + + var encryptArgs: [String] = [] + if argument == "encrypt", let id = configuration.vault.encryptId { + encryptArgs = ["--encrypt-vault-id", id] + } + + let expectedArguments = [ + "ansible-vault", argument + ] + configuration.vault.args! + + encryptArgs + + [vaultPath.cleanFilePath] + + let arguments = await captured.arguments + #expect(arguments == expectedArguments) + } + } + + @Test(arguments: [ + Configuration( + args: ["--tags", "debug"], + useVaultArgs: true, + playbook: .init(directory: "playbook", inventory: nil), + vault: .mock + ) + ]) + func runPlaybook(configuration: Configuration) async throws { + let captured = CliClient.CapturingClient() + try await withMockConfiguration(captured, configuration: configuration, key: "runPlaybook") { + @Dependency(\.cliClient) var cliClient + + try await cliClient.runPlaybookCommand(.init( + arguments: [], + quiet: false, + shell: nil + )) + + let expectedArguments = [ + "ansible-playbook", "playbook/main.yml", + "--inventory", "playbook/inventory.ini", + "--tags", "debug", + "--vault-id=myId@$SCRIPTS/vault-gopass-client" + ] + + let arguments = await captured.arguments + #expect(arguments == expectedArguments) + } + } + + func withMockConfiguration( + _ capturing: CliClient.CapturingClient, + configuration: Configuration = .mock, + key: String, + logLevel: Logger.Level = .trace, + depednencies setupDependencies: @escaping (inout DependencyValues) -> Void = { _ in }, + operation: @Sendable @escaping () async throws -> Void + ) async rethrows { + try await withTestLogger(key: key, logLevel: logLevel) { + $0.configurationClient = .mock(configuration) + $0.cliClient = .capturing(capturing) + setupDependencies(&$0) + } operation: { + try await operation() + } + } +} + +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 {} diff --git a/Tests/CliClientTests/Resources/.hparc b/Tests/CliClientTests/Resources/.hparc deleted file mode 100644 index e40fbd5..0000000 --- a/Tests/CliClientTests/Resources/.hparc +++ /dev/null @@ -1,15 +0,0 @@ -{ - "HPA_TEMPLATE_VERSION" : "main", - "HPA_TEMPLATE_DIR" : "/path/to/local/template", - "HPA_PLAYBOOK_DIR" : "/path/to/playbook", - "HPA_DEFAULT_VAULT_ARGS" : [ - "--vault-id=myId@$SCRIPTS/vault-gopass-client" - ], - "HPA_TEMPLATE_REPO" : "https://git.example.com/consult-template.git", - "HPA_DEFAULT_PLAYBOOK_ARGS" : [ - "--tags", - "debug" - ], - "HPA_DEFAULT_VAULT_ENCRYPT_ID" : "myId", - "HPA_DEFAULT_INVENTORY" : "/path/to/inventory.ini" -} diff --git a/Tests/CliClientTests/Resources/config.json b/Tests/CliClientTests/Resources/config.json deleted file mode 100644 index a53c938..0000000 --- a/Tests/CliClientTests/Resources/config.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "HPA_TEMPLATE_VERSION" : "main", - "HPA_TEMPLATE_DIR" : "/path/to/local/template", - "HPA_PLAYBOOK_DIR" : "/path/to/playbook", - "HPA_DEFAULT_VAULT_ARGS" : [ - "--vault-id=myId@$SCRIPTS/vault-gopass-client" - ], - "HPA_TEMPLATE_REPO" : "https://git.example.com/consult-template.git", - "HPA_DEFAULT_PLAYBOOK_ARGS" : [ - "--tags", - "debug" - ], - "HPA_DEFAULT_VAULT_ENCRYPT_ID" : "myId", - "HPA_DEFAULT_INVENTORY" : "/path/to/inventory.ini" -} \ No newline at end of file diff --git a/Tests/CliClientTests/Resources/hpa-playbook/config.json b/Tests/CliClientTests/Resources/hpa-playbook/config.json deleted file mode 100644 index a53c938..0000000 --- a/Tests/CliClientTests/Resources/hpa-playbook/config.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "HPA_TEMPLATE_VERSION" : "main", - "HPA_TEMPLATE_DIR" : "/path/to/local/template", - "HPA_PLAYBOOK_DIR" : "/path/to/playbook", - "HPA_DEFAULT_VAULT_ARGS" : [ - "--vault-id=myId@$SCRIPTS/vault-gopass-client" - ], - "HPA_TEMPLATE_REPO" : "https://git.example.com/consult-template.git", - "HPA_DEFAULT_PLAYBOOK_ARGS" : [ - "--tags", - "debug" - ], - "HPA_DEFAULT_VAULT_ENCRYPT_ID" : "myId", - "HPA_DEFAULT_INVENTORY" : "/path/to/inventory.ini" -} \ No newline at end of file diff --git a/Tests/CliClientTests/Resources/vault.yml b/Tests/CliClientTests/Resources/vault.yml deleted file mode 100644 index 0c3f712..0000000 --- a/Tests/CliClientTests/Resources/vault.yml +++ /dev/null @@ -1,2 +0,0 @@ ---- -# this is just here for testing, doesn't need to be encoded.