diff --git a/.gitignore b/.gitignore index 3d2eb60..57515c9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ DerivedData/ .netrc .nvim/* .swiftpm/* +hpa.toml diff --git a/Package.swift b/Package.swift index 2865f51..21b23ca 100644 --- a/Package.swift +++ b/Package.swift @@ -1,5 +1,4 @@ // swift-tools-version: 6.0 -// The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -8,7 +7,9 @@ let package = Package( platforms: [.macOS(.v14)], products: [ .executable(name: "hpa", targets: ["hpa"]), - .library(name: "CliClient", targets: ["CliClient"]) + .library(name: "CliClient", targets: ["CliClient"]), + .library(name: "CodersClient", targets: ["CodersClient"]), + .library(name: "ConfigurationClient", targets: ["ConfigurationClient"]) ], dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), @@ -22,6 +23,7 @@ let package = Package( name: "hpa", dependencies: [ "CliClient", + "ConfigurationClient", .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "CliDoc", package: "swift-cli-doc"), .product(name: "Dependencies", package: "swift-dependencies"), @@ -31,9 +33,11 @@ let package = Package( .target( name: "CliClient", dependencies: [ + "CodersClient", .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "DependenciesMacros", package: "swift-dependencies"), - .product(name: "ShellClient", package: "swift-shell-client") + .product(name: "ShellClient", package: "swift-shell-client"), + .product(name: "TOMLKit", package: "TOMLKit") ] ), .testTarget( @@ -48,6 +52,48 @@ let package = Package( .copy("Resources/vault.yml"), .copy("Resources/hpa-playbook") ] + ), + .target( + name: "CodersClient", + dependencies: [ + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "DependenciesMacros", package: "swift-dependencies"), + .product(name: "TOMLKit", package: "TOMLKit") + ] + ), + .target( + name: "TestSupport", + dependencies: [ + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "ShellClient", package: "swift-shell-client") + ] + ), + .target( + name: "ConfigurationClient", + dependencies: [ + "CodersClient", + "FileClient", + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "DependenciesMacros", package: "swift-dependencies"), + .product(name: "ShellClient", package: "swift-shell-client") + ], + resources: [ + .copy("Resources/hpa.toml") + ] + ), + .testTarget( + name: "ConfigurationClientTests", + dependencies: [ + "ConfigurationClient", + "TestSupport" + ] + ), + .target( + name: "FileClient", + dependencies: [ + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "DependenciesMacros", package: "swift-dependencies") + ] ) ] ) diff --git a/Sources/CliClient/CliClient.swift b/Sources/CliClient/CliClient.swift index 9fb1f75..de1146e 100644 --- a/Sources/CliClient/CliClient.swift +++ b/Sources/CliClient/CliClient.swift @@ -3,8 +3,6 @@ import DependenciesMacros import Foundation import ShellClient -// TODO: Drop support for non-json configuration. - public extension DependencyValues { var cliClient: CliClient { get { self[CliClient.self] } @@ -14,11 +12,7 @@ public extension DependencyValues { @DependencyClient public struct CliClient: Sendable { - public var decoder: @Sendable () -> JSONDecoder = { .init() } - public var encoder: @Sendable () -> JSONEncoder = { .init() } - public var loadConfiguration: @Sendable () throws -> Configuration public var runCommand: @Sendable ([String], Bool, ShellCommand.Shell) async throws -> Void - public var createConfiguration: @Sendable (_ path: String, _ json: Bool) throws -> Void public var findVaultFileInCurrentDirectory: @Sendable () throws -> String public func runCommand( @@ -40,30 +34,12 @@ public struct CliClient: Sendable { extension CliClient: DependencyKey { - // swiftlint:disable function_body_length public static func live( - decoder: JSONDecoder = .init(), - encoder: JSONEncoder = .init(), env: [String: String] ) -> Self { - @Dependency(\.fileClient) var fileClient @Dependency(\.logger) var logger - return .init { - decoder - } encoder: { - encoder - } loadConfiguration: { - let url = try findConfigurationFiles(env: env) - var env = env - - logger.trace("Loading configuration from: \(url)") - guard let config = try fileClient.loadFile(url, &env, decoder) else { - return try .fromEnv(env) - } - return config - - } runCommand: { args, quiet, shell in + return .init { args, quiet, shell in @Dependency(\.asyncShellClient) var shellClient if !quiet { try await shellClient.foreground(.init( @@ -80,37 +56,15 @@ extension CliClient: DependencyKey { args )) } - } createConfiguration: { path, json in - - // Early out if a file exists at the path already. - guard !fileClient.fileExists(path) else { - throw CliClientError.fileExistsAtPath(path) - } - - var path = path - let data: Data - - if !json { - // Write the default env template. - data = Data(Configuration.fileTemplate.utf8) - } else { - if !path.contains(".json") { - path += ".json" - } - data = try jsonEncoder.encode(Configuration.mock) - } - - try fileClient.write(path, data) } findVaultFileInCurrentDirectory: { - guard let url = try fileClient.findVaultFileInCurrentDirectory() else { - throw CliClientError.vaultFileNotFound - } - return path(for: url) + fatalError() +// guard let url = try fileClient.findVaultFileInCurrentDirectory() else { +// throw CliClientError.vaultFileNotFound +// } +// return path(for: url) } } - // swiftlint:enable function_body_length - public static var liveValue: CliClient { .live(env: ProcessInfo.processInfo.environment) } diff --git a/Sources/CliClient/Configuration.swift b/Sources/CliClient/Configuration.swift deleted file mode 100644 index 5230d5b..0000000 --- a/Sources/CliClient/Configuration.swift +++ /dev/null @@ -1,194 +0,0 @@ -import Dependencies -import Foundation -import ShellClient - -public struct Configuration2: Codable, Equatable, Sendable { - - public let playbook: Playbook - public let template: Template - public let vault: Vault - - public init( - playbook: Playbook, - template: Template, - vault: Vault - ) { - self.playbook = playbook - self.template = template - self.vault = vault - } - - public static var mock: Self { - .init(playbook: .mock, template: .mock, vault: .mock) - } - - public struct Playbook: Codable, Equatable, Sendable { - public let directory: String? - public let inventory: String? - public let args: [String]? - public let useVaultArgs: Bool - - public init( - directory: String? = nil, - inventory: String? = nil, - args: [String]? = nil, - useVaultArgs: Bool = false - ) { - self.directory = directory - self.inventory = inventory - self.args = args - self.useVaultArgs = useVaultArgs - } - - public static var mock: Self { - .init( - directory: "/path/to/local/playbook-directory", - inventory: "/path/to/local/inventory.ini", - args: [], - useVaultArgs: true - ) - } - } - - public struct Template: Codable, Equatable, Sendable { - let url: String? - let version: String? - let directory: String? - - public init( - url: String? = nil, - version: String? = nil, - directory: String? = nil - ) { - self.url = url - self.version = version - self.directory = directory - } - - public static var mock: Self { - .init( - url: "https://git.example.com/consult-template.git", - version: "main", - directory: "/path/to/local/template-directory" - ) - } - } - - public struct Vault: Codable, Equatable, Sendable { - public let args: [String]? - public let encryptId: String? - - public init( - args: [String]? = nil, - encryptId: String? = nil - ) { - self.args = args - self.encryptId = encryptId - } - - public static var mock: Self { - .init( - args: [ - "--vault-id=myId@$SCRIPTS/vault-gopass-client" - ], - encryptId: "myId" - ) - } - } -} - -/// Represents the configurable items for the command line tool. -/// -/// -public struct Configuration: Codable, Equatable, Sendable { - - public let playbookDir: String? - public let inventoryPath: String? - public let templateRepo: String? - public let templateRepoVersion: String? - public let templateDir: String? - public let defaultPlaybookArgs: [String]? - public let defaultVaultArgs: [String]? - public let defaultVaultEncryptId: String? - - fileprivate enum CodingKeys: String, CodingKey { - case playbookDir = "HPA_PLAYBOOK_DIR" - case inventoryPath = "HPA_DEFAULT_INVENTORY" - case templateRepo = "HPA_TEMPLATE_REPO" - case templateRepoVersion = "HPA_TEMPLATE_VERSION" - case templateDir = "HPA_TEMPLATE_DIR" - case defaultPlaybookArgs = "HPA_DEFAULT_PLAYBOOK_ARGS" - case defaultVaultArgs = "HPA_DEFAULT_VAULT_ARGS" - case defaultVaultEncryptId = "HPA_DEFAULT_VAULT_ENCRYPT_ID" - } - - public static func fromEnv( - _ env: [String: String] - ) throws -> Self { - @Dependency(\.logger) var logger - - logger.trace("Creating configuration from env...") - - let hpaValues: [String: String] = env.filter { $0.key.contains("HPA") } - logger.debug("HPA env vars: \(hpaValues)") - - return Configuration( - playbookDir: hpaValues.value(for: .playbookDir), - inventoryPath: hpaValues.value(for: .inventoryPath), - templateRepo: hpaValues.value(for: .templateRepo), - templateRepoVersion: hpaValues.value(for: .templateRepoVersion), - templateDir: hpaValues.value(for: .templateDir), - defaultPlaybookArgs: hpaValues.array(for: .defaultPlaybookArgs), - defaultVaultArgs: hpaValues.array(for: .defaultVaultArgs), - defaultVaultEncryptId: hpaValues.value(for: .defaultVaultEncryptId) - ) - } - - static var mock: Self { - .init( - playbookDir: "/path/to/playbook", - inventoryPath: "/path/to/inventory.ini", - templateRepo: "https://git.example.com/consult-template.git", - templateRepoVersion: "main", - templateDir: "/path/to/local/template", - defaultPlaybookArgs: ["--tags", "debug"], - defaultVaultArgs: ["--vault-id=myId@$SCRIPTS/vault-gopass-client"], - defaultVaultEncryptId: "myId" - ) - } - - static var fileTemplate: String { - """ - # vi: ft=sh - - # Example configuration, uncomment the lines and set the values appropriate for your - # usage. - - # Set this to the location of the ansible-hpa-playbook on your local machine. - #HPA_PLAYBOOK_DIR="/path/to/ansible-hpa-playbook" - - # Set this to the location of a template repository, which is used to create new assessment projects. - #HPA_TEMPLATE_REPO="https://git.example.com/your/template.git" - - # Specify a branch, version, or sha of the template repository. - #HPA_TEMPLATE_VERSION="main" # branch, version, or sha - - # Set this to a location of a template directory to use to create new projects. - #HPA_TEMPLATE_DIR="/path/to/local/template" - - # Extra arguments that get passed directly to the ansible-playbook command. - #HPA_DEFAULT_PLAYBOOK_ARGS="--vault-id=consults@$SCRIPTS/vault-gopass-client" - - """ - } -} - -extension [String: String] { - fileprivate func value(for codingKey: Configuration.CodingKeys) -> String? { - self[codingKey.rawValue] - } - - fileprivate func array(for codingKey: Configuration.CodingKeys) -> [String]? { - value(for: codingKey).flatMap { $0.split(separator: ",").map(String.init) } - } -} diff --git a/Sources/CliClient/Constants.swift b/Sources/CliClient/Constants.swift new file mode 100644 index 0000000..65dcedd --- /dev/null +++ b/Sources/CliClient/Constants.swift @@ -0,0 +1,13 @@ +/// Holds keys associated with environment values. +public enum EnvironmentKey { + public static let xdgConfigHomeKey = "XDG_CONFIG_HOME" + public static let hpaConfigDirKey = "HPA_CONFIG_DIR" + public static let hpaConfigFileKey = "HPA_CONFIG_FILE" +} + +/// Holds keys associated with hpa configuration files. +public enum HPAKey { + public static let defaultConfigHome = "~/.config/\(Self.hpaConfigDirectoryName)/\(Self.defaultConfigFileName)" + public static let defaultConfigFileName = "config.toml" + public static let hpaConfigDirectoryName = "hpa" +} diff --git a/Sources/CliClient/FileClient.swift b/Sources/CliClient/FileClient.swift index c9cd4ce..d7adcf5 100644 --- a/Sources/CliClient/FileClient.swift +++ b/Sources/CliClient/FileClient.swift @@ -1,159 +1,183 @@ -import Dependencies -import DependenciesMacros -import Foundation - -@_spi(Internal) -public extension DependencyValues { - var fileClient: FileClient { - get { self[FileClient.self] } - set { self[FileClient.self] = newValue } - } -} - -@_spi(Internal) -@DependencyClient -public struct FileClient: Sendable { - /// Loads a file at the given path into the environment unless it can decode it as json, - /// at which point it will return the decoded file contents as a ``Configuration`` item. - public var loadFile: @Sendable (URL, inout [String: String], JSONDecoder) throws -> Configuration? - - /// Returns the user's home directory path. - public var homeDir: @Sendable () -> URL = { URL(string: "~/")! } - - /// Check if a path is a directory. - public var isDirectory: @Sendable (URL) -> Bool = { _ in false } - - /// Check if a path is a readable. - public var isReadable: @Sendable (URL) -> Bool = { _ in false } - - /// Check if a file exists at the path. - public var fileExists: @Sendable (String) -> Bool = { _ in false } - - public var findVaultFile: @Sendable (String) throws -> URL? - - /// Write data to a file. - public var write: @Sendable (String, Data) throws -> Void - - public func findVaultFileInCurrentDirectory() throws -> URL? { - try findVaultFile(".") - } -} - -@_spi(Internal) -extension FileClient: DependencyKey { - public static let testValue: FileClient = Self() - - public static func live(fileManager: FileManager = .default) -> Self { - let client = LiveFileClient(fileManager: fileManager) - return Self( - loadFile: { try client.loadFile(at: $0, into: &$1, decoder: $2) }, - homeDir: { client.homeDir }, - isDirectory: { client.isDirectory(url: $0) }, - isReadable: { client.isReadable(url: $0) }, - fileExists: { client.fileExists(at: $0) }, - findVaultFile: { try client.findVaultFile(in: $0) }, - write: { path, data in - try data.write(to: URL(filePath: path)) - } - ) - } - - public static let liveValue = Self.live() -} - -private struct LiveFileClient: @unchecked Sendable { - - private let fileManager: FileManager - - init(fileManager: FileManager) { - self.fileManager = fileManager - } - - var homeDir: URL { fileManager.homeDirectoryForCurrentUser } - - func isDirectory(url: URL) -> Bool { - var isDirectory: ObjCBool = false - fileManager.fileExists(atPath: path(for: url), isDirectory: &isDirectory) - return isDirectory.boolValue - } - - func isReadable(url: URL) -> Bool { - fileManager.isReadableFile(atPath: path(for: url)) - } - - func fileExists(at path: String) -> Bool { - fileManager.fileExists(atPath: path) - } - - // func findVaultFileInCurrentDirectory() throws -> URL? { - - func findVaultFile(in filePath: String) throws -> URL? { - let urls = try fileManager - .contentsOfDirectory(at: URL(filePath: filePath), includingPropertiesForKeys: nil) - - if let vault = urls.firstVaultFile { - return vault - } - - // check for folders that end with "vars" and search those next. - for folder in urls.filter({ $0.absoluteString.hasSuffix("vars/") }) { - let files = try fileManager.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil) - if let vault = files.firstVaultFile { - return vault - } - } - - // Fallback to check all sub-folders - for folder in urls.filter({ self.isDirectory(url: $0) && !$0.absoluteString.hasSuffix("vars/") }) { - let files = try fileManager.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil) - if let vault = files.firstVaultFile { - return vault - } - } - return nil - } - - func loadFile( - at url: URL, - into env: inout [String: String], - decoder: JSONDecoder - ) throws -> Configuration? { - @Dependency(\.logger) var logger - logger.trace("Begin load file for: \(path(for: url))") - - if url.absoluteString.hasSuffix(".json") { - // Handle json file. - let data = try Data(contentsOf: url) - return try decoder.decode(Configuration.self, from: data) - } - - let string = try String(contentsOfFile: path(for: url), encoding: .utf8) - - logger.trace("Loaded file contents: \(string)") - - let lines = string.split(separator: "\n") - for line in lines { - logger.trace("Line: \(line)") - let strippedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) - let splitLine = strippedLine.split(separator: "=").map { - $0.replacingOccurrences(of: "\"", with: "") - } - logger.trace("Split Line: \(splitLine)") - guard splitLine.count >= 2 else { continue } - - if splitLine.count > 2 { - let rest = splitLine.dropFirst() - env[String(splitLine[0])] = String(rest.joined(separator: "=")) - } else { - env[String(splitLine[0])] = String(splitLine[1]) - } - } - return nil - } -} - -private extension Array where Element == URL { - var firstVaultFile: URL? { - first { $0.absoluteString.hasSuffix("vault.yml") } - } -} +// import CodersClient +// import Dependencies +// import DependenciesMacros +// import Foundation +// import TOMLKit +// +// @_spi(Internal) +// public extension DependencyValues { +// var fileClient: FileClient { +// get { self[FileClient.self] } +// set { self[FileClient.self] = newValue } +// } +// } +// +// @_spi(Internal) +// @DependencyClient +// public struct FileClient: Sendable { +// +// /// Load a file as `Data`. +// public var loadFile: @Sendable (URL) throws -> Data +// +// /// Returns the user's home directory path. +// public var homeDir: @Sendable () -> URL = { URL(string: "~/")! } +// +// /// Check if a path is a directory. +// public var isDirectory: @Sendable (URL) -> Bool = { _ in false } +// +// /// Check if a path is a readable. +// public var isReadable: @Sendable (URL) -> Bool = { _ in false } +// +// /// Check if a file exists at the path. +// public var fileExists: @Sendable (String) -> Bool = { _ in false } +// +// public var findVaultFile: @Sendable (String) throws -> URL? +// +// /// Write data to a file. +// public var write: @Sendable (File, Data) throws -> Void +// +// public func findVaultFileInCurrentDirectory() throws -> URL? { +// try findVaultFile(".") +// } +// +// public func loadFile(_ file: File) throws -> Data { +// try loadFile(file.url) +// } +// +// public func loadConfiguration( +// _ file: File +// ) throws -> Configuration { +// @Dependency(\.coders) var coders +// +// let data = try loadFile(file) +// +// switch file { +// case .json: +// return try coders.jsonDecoder().decode(Configuration.self, from: data) +// case .toml: +// guard let string = String(data: data, encoding: .utf8) else { +// throw DecodingError() +// } +// return try coders.tomlDecoder().decode(Configuration.self, from: string) +// } +// } +// } +// +// @_spi(Internal) +// extension FileClient: DependencyKey { +// public static let testValue: FileClient = Self() +// +// public static func live(fileManager: FileManager = .default) -> Self { +// let client = LiveFileClient(fileManager: fileManager) +// return Self( +// loadFile: { try Data(contentsOf: $0) }, +// homeDir: { client.homeDir }, +// isDirectory: { client.isDirectory(url: $0) }, +// isReadable: { client.isReadable(url: $0) }, +// fileExists: { client.fileExists(at: $0) }, +// findVaultFile: { try client.findVaultFile(in: $0) }, +// write: { file, data in +// try data.write(to: file.url) +// } +// ) +// } +// +// public static let liveValue = Self.live() +// } +// +// private struct LiveFileClient: @unchecked Sendable { +// +// private let fileManager: FileManager +// +// init(fileManager: FileManager) { +// self.fileManager = fileManager +// } +// +// var homeDir: URL { fileManager.homeDirectoryForCurrentUser } +// +// func isDirectory(url: URL) -> Bool { +// var isDirectory: ObjCBool = false +// fileManager.fileExists(atPath: path(for: url), isDirectory: &isDirectory) +// return isDirectory.boolValue +// } +// +// func isReadable(url: URL) -> Bool { +// fileManager.isReadableFile(atPath: path(for: url)) +// } +// +// func fileExists(at path: String) -> Bool { +// fileManager.fileExists(atPath: path) +// } +// +// func findVaultFile(in filePath: String) throws -> URL? { +// let urls = try fileManager +// .contentsOfDirectory(at: URL(filePath: filePath), includingPropertiesForKeys: nil) +// +// if let vault = urls.firstVaultFile { +// return vault +// } +// +// // check for folders that end with "vars" and search those next. +// for folder in urls.filter({ $0.absoluteString.hasSuffix("vars/") }) { +// let files = try fileManager.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil) +// if let vault = files.firstVaultFile { +// return vault +// } +// } +// +// // Fallback to check all sub-folders +// for folder in urls.filter({ self.isDirectory(url: $0) && !$0.absoluteString.hasSuffix("vars/") }) { +// let files = try fileManager.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil) +// if let vault = files.firstVaultFile { +// return vault +// } +// } +// return nil +// } +// +// func loadFile( +// at url: URL, +// into env: inout [String: String], +// decoder: JSONDecoder +// ) throws -> Configuration? { +// @Dependency(\.logger) var logger +// logger.trace("Begin load file for: \(path(for: url))") +// +// if url.absoluteString.hasSuffix(".json") { +// // Handle json file. +// let data = try Data(contentsOf: url) +// return try decoder.decode(Configuration.self, from: data) +// } +// +// let string = try String(contentsOfFile: path(for: url), encoding: .utf8) +// +// logger.trace("Loaded file contents: \(string)") +// +// let lines = string.split(separator: "\n") +// for line in lines { +// logger.trace("Line: \(line)") +// let strippedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) +// let splitLine = strippedLine.split(separator: "=").map { +// $0.replacingOccurrences(of: "\"", with: "") +// } +// logger.trace("Split Line: \(splitLine)") +// guard splitLine.count >= 2 else { continue } +// +// if splitLine.count > 2 { +// let rest = splitLine.dropFirst() +// env[String(splitLine[0])] = String(rest.joined(separator: "=")) +// } else { +// env[String(splitLine[0])] = String(splitLine[1]) +// } +// } +// return nil +// } +// } +// +// struct DecodingError: Error {} +// +// private extension Array where Element == URL { +// var firstVaultFile: URL? { +// first { $0.absoluteString.hasSuffix("vault.yml") } +// } +// } diff --git a/Sources/CliClient/Helpers.swift b/Sources/CliClient/Helpers.swift index 060edb8..a971985 100644 --- a/Sources/CliClient/Helpers.swift +++ b/Sources/CliClient/Helpers.swift @@ -1,110 +1,116 @@ -import Dependencies -import Foundation -import ShellClient - -@_spi(Internal) -public func findConfigurationFiles( - env: [String: String] = ProcessInfo.processInfo.environment -) throws -> URL { - @Dependency(\.logger) var logger - @Dependency(\.fileClient) var fileClient - - logger.debug("Begin find configuration files.") - logger.trace("Env: \(env)") - - // Check for environment variable pointing to a directory that the - // the configuration lives. - if let pwd = env["PWD"], - let url = fileClient.checkUrl(.file(pwd, ".hparc")) - { - logger.debug("Found configuration in current working directory.") - return url - } - - // Check for environment variable pointing to a file that the - // the configuration lives. - if let configFile = env["HPA_CONFIG_FILE"], - let url = fileClient.checkUrl(.file(configFile)) - { - logger.debug("Found configuration from hpa config file env var.") - return url - } - - // Check for environment variable pointing to a directory that the - // the configuration lives. - if let configHome = env["HPA_CONFIG_HOME"], - let url = fileClient.checkUrl(.directory(configHome)) - { - logger.debug("Found configuration from hpa config home env var.") - return url - } - - // Check home directory for a `.hparc` file. - if let url = fileClient.checkUrl(.file(fileClient.homeDir().appending(path: ".hparc"))) { - logger.debug("Found configuration in home directory") - return url - } - - // Check in xdg config home, under an hpa-playbook directory. - if let xdgConfigHome = env["XDG_CONFIG_HOME"], - let url = fileClient.checkUrl(.directory(xdgConfigHome, "hpa-playbook")) - { - logger.debug("XDG Config url: \(url.absoluteString)") - return url - } - - // We could not find configuration in any usual places. - throw ConfigurationError.configurationNotFound -} - -@_spi(Internal) -public func path(for url: URL) -> String { - url.absoluteString.replacing("file://", with: "") -} - -enum ConfigurationError: Error { - case configurationNotFound -} - -private extension FileClient { - - enum ConfigurationUrlCheck { - case file(URL) - case directory(URL) - - static func file(_ path: String) -> Self { .file(URL(filePath: path)) } - - static func file(_ paths: String...) -> Self { - var url = URL(filePath: paths[0]) - url = paths.dropFirst().reduce(into: url) { $0.append(path: $1) } - return .file(url) - } - - static func directory(_ path: String) -> Self { .directory(URL(filePath: path)) } - static func directory(_ paths: String...) -> Self { - var url = URL(filePath: paths[0]) - url = paths.dropFirst().reduce(into: url) { $0.append(path: $1) } - return .directory(url) - } - } - - func checkUrl(_ check: ConfigurationUrlCheck) -> URL? { - switch check { - case let .file(url): - if isReadable(url) { return url } - return nil - case let .directory(url): - return findConfigurationInDirectory(url) - } - } - - func findConfigurationInDirectory(_ url: URL) -> URL? { - for file in ["config", "config.json"] { - let fileUrl = url.appending(path: file) - if isReadable(fileUrl) { - return fileUrl - } - } - return nil - } -} +// import Dependencies +// import Foundation +// import ShellClient +// +// @_spi(Internal) +// public func findConfigurationFiles( +// env: [String: String] = ProcessInfo.processInfo.environment +// ) throws -> File { +// @Dependency(\.logger) var logger +// @Dependency(\.fileClient) var fileClient +// +// logger.debug("Begin find configuration files.") +// logger.trace("Env: \(env)") +// +// // Check for environment variable pointing to a directory that the +// // the configuration lives. +// if let pwd = env["PWD"], +// let file = fileClient.checkUrl(.directory(pwd)) +// { +// logger.debug("Found configuration in current working directory.") +// return file +// } +// +// // Check for environment variable pointing to a file that the +// // the configuration lives. +// if let configFile = env[EnvironmentKey.hpaConfigFileKey], +// let file = fileClient.checkUrl(.file(configFile)) +// { +// logger.debug("Found configuration from hpa config file env var.") +// return file +// } +// +// // Check for environment variable pointing to a directory that the +// // the configuration lives. +// if let configHome = env[EnvironmentKey.hpaConfigDirKey], +// let file = fileClient.checkUrl(.directory(configHome)) +// { +// logger.debug("Found configuration from hpa config home env var.") +// return file +// } +// +// // Check home directory for a `.hparc` file. +// if let file = fileClient.checkUrl(.directory(fileClient.homeDir())) { +// logger.debug("Found configuration in home directory") +// return file +// } +// +// // Check in xdg config home, under an hpa-playbook directory. +// if let xdgConfigHome = env[EnvironmentKey.xdgConfigHomeKey], +// let file = fileClient.checkUrl(.directory(xdgConfigHome, HPAKey.hpaConfigDirectoryName)) +// { +// logger.debug("XDG Config url: \(file.path)") +// return file +// } +// +// // We could not find configuration in any usual places. +// throw ConfigurationError.configurationNotFound +// } +// +// @_spi(Internal) +// public func path(for url: URL) -> String { +// url.absoluteString.replacing("file://", with: "") +// } +// +// enum ConfigurationError: Error { +// case configurationNotFound +// } +// +// private extension FileClient { +// +// static let validFileNames = [ +// "config.json", "config.toml", +// ".hparc.json", ".hparc.toml" +// ] +// +// enum ConfigurationUrlCheck { +// case file(URL) +// case directory(URL) +// +// static func file(_ path: String) -> Self { .file(URL(filePath: path)) } +// +// static func file(_ paths: String...) -> Self { +// var url = URL(filePath: paths[0]) +// url = paths.dropFirst().reduce(into: url) { $0.append(path: $1) } +// return .file(url) +// } +// +// static func directory(_ path: String) -> Self { .directory(URL(filePath: path)) } +// +// static func directory(_ paths: String...) -> Self { +// var url = URL(filePath: paths[0]) +// url = paths.dropFirst().reduce(into: url) { $0.append(path: $1) } +// return .directory(url) +// } +// } +// +// func checkUrl(_ check: ConfigurationUrlCheck) -> File? { +// switch check { +// case let .file(url): +// if isReadable(url) { return .init(url) } +// return nil +// case let .directory(url): +// return findConfigurationInDirectory(url) +// } +// } +// +// func findConfigurationInDirectory(_ url: URL) -> File? { +// for file in Self.validFileNames { +// let fileUrl = url.appending(path: file) +// if isReadable(fileUrl) { +// return .init(fileUrl) +// } +// } +// return nil +// } +// } diff --git a/Sources/CodersClient/Coders.swift b/Sources/CodersClient/Coders.swift new file mode 100644 index 0000000..cc51bdd --- /dev/null +++ b/Sources/CodersClient/Coders.swift @@ -0,0 +1,33 @@ +import Dependencies +import DependenciesMacros +import Foundation +import TOMLKit + +public extension DependencyValues { + var coders: Coders { + get { self[Coders.self] } + set { self[Coders.self] = newValue } + } +} + +@DependencyClient +public struct Coders: Sendable { + public var jsonDecoder: @Sendable () -> JSONDecoder = { .init() } + public var jsonEncoder: @Sendable () -> JSONEncoder = { .init() } + public var tomlDecoder: @Sendable () -> TOMLDecoder = { .init() } + public var tomlEncoder: @Sendable () -> TOMLEncoder = { .init() } +} + +extension Coders: DependencyKey { + + public static var testValue: Self { Self() } + + public static var liveValue: Self { + .init( + jsonDecoder: { JSONDecoder() }, + jsonEncoder: { JSONEncoder() }, + tomlDecoder: { TOMLDecoder() }, + tomlEncoder: { TOMLEncoder() } + ) + } +} diff --git a/Sources/ConfigurationClient/Configuration.swift b/Sources/ConfigurationClient/Configuration.swift new file mode 100644 index 0000000..5840c07 --- /dev/null +++ b/Sources/ConfigurationClient/Configuration.swift @@ -0,0 +1,188 @@ +import Foundation + +/// Represents configurable settings for the command line tool. +public struct Configuration: Codable, Equatable, Sendable { + public let args: [String]? + public let useVaultArgs: Bool + public let playbook: Playbook? + public let template: Template + public let vault: Vault + + public init( + args: [String]? = nil, + useVaultArgs: Bool = true, + playbook: Playbook? = nil, + template: Template = .init(), + vault: Vault = .init() + ) { + self.args = args + self.useVaultArgs = useVaultArgs + self.playbook = playbook + self.template = template + self.vault = vault + } + + public static var mock: Self { + .init( + args: [], + useVaultArgs: true, + playbook: nil, + template: .mock, + vault: .mock + ) + } + + public struct Playbook: Codable, Equatable, Sendable { + public let directory: String? + public let inventory: String? + + public init( + directory: String? = nil, + inventory: String? = nil + ) { + self.directory = directory + self.inventory = inventory + } + + public static var mock: Self { .init() } + } + + public struct Template: Codable, Equatable, Sendable { + public let url: String? + public let version: String? + public let directory: String? + + public init( + url: String? = nil, + version: String? = nil, + directory: String? = nil + ) { + self.url = url + self.version = version + self.directory = directory + } + + public static var mock: Self { + .init( + url: "https://git.example.com/consult-template.git", + version: "1.0.0", + directory: "/path/to/local/template-directory" + ) + } + } + + public struct Vault: Codable, Equatable, Sendable { + public let args: [String]? + public let encryptId: String? + + public init( + args: [String]? = nil, + encryptId: String? = nil + ) { + self.args = args + self.encryptId = encryptId + } + + public static var mock: Self { + .init( + args: [ + "--vault-id=myId@$SCRIPTS/vault-gopass-client" + ], + encryptId: "myId" + ) + } + } +} + +// public struct Configuration: Codable, Equatable, Sendable { +// +// public let playbookDir: String? +// public let inventoryPath: String? +// public let templateRepo: String? +// public let templateRepoVersion: String? +// public let templateDir: String? +// public let defaultPlaybookArgs: [String]? +// public let defaultVaultArgs: [String]? +// public let defaultVaultEncryptId: String? +// +// fileprivate enum CodingKeys: String, CodingKey { +// case playbookDir = "HPA_PLAYBOOK_DIR" +// case inventoryPath = "HPA_DEFAULT_INVENTORY" +// case templateRepo = "HPA_TEMPLATE_REPO" +// case templateRepoVersion = "HPA_TEMPLATE_VERSION" +// case templateDir = "HPA_TEMPLATE_DIR" +// case defaultPlaybookArgs = "HPA_DEFAULT_PLAYBOOK_ARGS" +// case defaultVaultArgs = "HPA_DEFAULT_VAULT_ARGS" +// case defaultVaultEncryptId = "HPA_DEFAULT_VAULT_ENCRYPT_ID" +// } +// +// public static func fromEnv( +// _ env: [String: String] +// ) throws -> Self { +// @Dependency(\.logger) var logger +// +// logger.trace("Creating configuration from env...") +// +// let hpaValues: [String: String] = env.filter { $0.key.contains("HPA") } +// logger.debug("HPA env vars: \(hpaValues)") +// +// return Configuration( +// playbookDir: hpaValues.value(for: .playbookDir), +// inventoryPath: hpaValues.value(for: .inventoryPath), +// templateRepo: hpaValues.value(for: .templateRepo), +// templateRepoVersion: hpaValues.value(for: .templateRepoVersion), +// templateDir: hpaValues.value(for: .templateDir), +// defaultPlaybookArgs: hpaValues.array(for: .defaultPlaybookArgs), +// defaultVaultArgs: hpaValues.array(for: .defaultVaultArgs), +// defaultVaultEncryptId: hpaValues.value(for: .defaultVaultEncryptId) +// ) +// } +// +// static var mock: Self { +// .init( +// playbookDir: "/path/to/playbook", +// inventoryPath: "/path/to/inventory.ini", +// templateRepo: "https://git.example.com/consult-template.git", +// templateRepoVersion: "main", +// templateDir: "/path/to/local/template", +// defaultPlaybookArgs: ["--tags", "debug"], +// defaultVaultArgs: ["--vault-id=myId@$SCRIPTS/vault-gopass-client"], +// defaultVaultEncryptId: "myId" +// ) +// } +// +// static var fileTemplate: String { +// """ +// # vi: ft=sh +// +// # Example configuration, uncomment the lines and set the values appropriate for your +// # usage. +// +// # Set this to the location of the ansible-hpa-playbook on your local machine. +// #HPA_PLAYBOOK_DIR="/path/to/ansible-hpa-playbook" +// +// # Set this to the location of a template repository, which is used to create new assessment projects. +// #HPA_TEMPLATE_REPO="https://git.example.com/your/template.git" +// +// # Specify a branch, version, or sha of the template repository. +// #HPA_TEMPLATE_VERSION="main" # branch, version, or sha +// +// # Set this to a location of a template directory to use to create new projects. +// #HPA_TEMPLATE_DIR="/path/to/local/template" +// +// # Extra arguments that get passed directly to the ansible-playbook command. +// #HPA_DEFAULT_PLAYBOOK_ARGS="--vault-id=consults@$SCRIPTS/vault-gopass-client" +// +// """ +// } +// } +// +// extension [String: String] { +// fileprivate func value(for codingKey: Configuration.CodingKeys) -> String? { +// self[codingKey.rawValue] +// } +// +// fileprivate func array(for codingKey: Configuration.CodingKeys) -> [String]? { +// value(for: codingKey).flatMap { $0.split(separator: ",").map(String.init) } +// } +// } diff --git a/Sources/ConfigurationClient/ConfigurationClient.swift b/Sources/ConfigurationClient/ConfigurationClient.swift new file mode 100644 index 0000000..aa325cf --- /dev/null +++ b/Sources/ConfigurationClient/ConfigurationClient.swift @@ -0,0 +1,212 @@ +import CodersClient +import Dependencies +import DependenciesMacros +import FileClient +import Foundation +import ShellClient + +public extension DependencyValues { + var configuration: ConfigurationClient { + get { self[ConfigurationClient.self] } + set { self[ConfigurationClient.self] = newValue } + } +} + +@DependencyClient +public struct ConfigurationClient: Sendable { + public var find: @Sendable () async throws -> File + var generate: @Sendable (File, Bool) async throws -> Void + public var load: @Sendable (File?) async throws -> Configuration + var write: @Sendable (File, Configuration, Bool) async throws -> Void + + public func findAndLoad() async throws -> Configuration { + let file = try? await find() + return try await load(file) + } + + public func generate( + at file: File, + force: Bool = false + ) async throws { + try await generate(file, force) + } + + public func write( + _ configuration: Configuration, + to file: File, + force: Bool = false + ) async throws { + try await write(file, configuration, force) + } +} + +extension ConfigurationClient: DependencyKey { + public static var testValue: Self { Self() } + + public static func live(environment: [String: String]) -> Self { + let liveClient = LiveConfigurationClient(environment: environment) + return .init { + try await liveClient.find() + } generate: { file, force in + try await liveClient.generate(at: file, force: force) + } load: { file in + try await liveClient.load(file: file) + } write: { file, configuration, force in + try await liveClient.write(configuration, to: file, force: force) + } + } + + public static var liveValue: Self { + .live(environment: ProcessInfo.processInfo.environment) + } +} + +struct LiveConfigurationClient { + + private let environment: [String: String] + + @Dependency(\.coders) var coders + @Dependency(\.fileClient) var fileManager + @Dependency(\.logger) var logger + + let validFileNames = [ + "config.json", "config.toml", + ".hparc.json", ".hparc.toml" + ] + + init(environment: [String: String]) { + self.environment = environment + } + + func find() async throws -> File { + logger.debug("Begin find configuration.") + + if let pwd = environment["PWD"], + let file = await findInDirectory(pwd) + { + logger.debug("Found configuration in pwd: \(file.path)") + return file + } + + if let configHome = environment.hpaConfigHome, + let file = await findInDirectory(configHome) + { + logger.debug("Found configuration in config home env var: \(file.path)") + return file + } + + if let configFile = environment.hpaConfigFile, + isReadable(configFile), + let file = File(configFile) + { + logger.debug("Found configuration in config file env var: \(file.path)") + return file + } + + if let file = await findInDirectory(fileManager.homeDirectory()) { + logger.debug("Found configuration in home directory: \(file.path)") + return file + } + + if let file = await findInDirectory("\(environment.xdgConfigHome)/\(HPAKey.configDirName)") { + logger.debug("Found configuration in xdg config directory: \(file.path)") + return file + } + + throw ConfigurationError.configurationNotFound + } + + func generate(at file: File, force: Bool) async throws { + if !force { + guard !fileManager.fileExists(file.url) else { + throw ConfigurationError.fileExists(path: file.path) + } + } + + if case .toml = file { + // In the case of toml, we copy the internal resource that includes + // usage comments in the file. + guard let resourceFile = Bundle.module.url( + forResource: HPAKey.resourceFileName, + withExtension: HPAKey.resourceFileExtension + ) else { + throw ConfigurationError.resourceNotFound + } + + try await fileManager.copy(resourceFile, file.url) + } else { + // Json does not allow comments, so we write the mock configuration + // to the file path. + try await write(.mock, to: file, force: force) + } + } + + func load(file: File?) async throws -> Configuration { + guard let file else { return .init() } + let data = try await fileManager.load(file.url) + + switch file { + case .json: + return try coders.jsonDecoder().decode(Configuration.self, from: data) + case .toml: + guard let string = String(data: data, encoding: .utf8) else { + throw ConfigurationError.decodingError + } + return try coders.tomlDecoder().decode(Configuration.self, from: string) + } + } + + func write( + _ configuration: Configuration, + to file: File, + force: Bool + ) async throws { + if !force { + guard !fileManager.fileExists(file.url) else { + throw ConfigurationError.fileExists(path: file.path) + } + } + + let data: Data + + switch file { + case .json: + data = try coders.jsonEncoder().encode(configuration) + case .toml: + let string = try coders.tomlEncoder().encode(configuration) + data = Data(string.utf8) + } + + try await fileManager.write(data, file.url) + } + + private func findInDirectory(_ directory: URL) async -> File? { + for file in validFileNames { + let url = directory.appending(path: file) + if isReadable(url), let file = File(url) { + return file + } + } + return nil + } + + private func findInDirectory(_ directory: String) async -> File? { + await findInDirectory(URL(filePath: directory)) + } + + private func isReadable(_ file: URL) -> Bool { + FileManager.default.isReadableFile(atPath: file.cleanFilePath) + } + + private func isReadable(_ file: String) -> Bool { + isReadable(URL(filePath: file)) + } + +} + +enum ConfigurationError: Error { + case configurationNotFound + case resourceNotFound + case decodingError + case fileExists(path: String) +} diff --git a/Sources/ConfigurationClient/Constants.swift b/Sources/ConfigurationClient/Constants.swift new file mode 100644 index 0000000..5deb41a --- /dev/null +++ b/Sources/ConfigurationClient/Constants.swift @@ -0,0 +1,28 @@ +@_spi(Internal) +public enum EnvironmentKey { + static let xdgConfigHome = "XDG_CONFIG_HOME" + static let hpaConfigHome = "HPA_CONFIG_HOME" + static let hpaConfigFile = "HPA_CONFIG_FILE" +} + +@_spi(Internal) +public enum HPAKey { + public static let configDirName = "hpa" + public static let resourceFileName = "hpa" + public static let resourceFileExtension = "toml" + public static let defaultFileName = "config.toml" +} + +extension [String: String] { + var xdgConfigHome: String { + self[EnvironmentKey.xdgConfigHome] ?? "~/.config" + } + + var hpaConfigHome: String? { + self[EnvironmentKey.hpaConfigHome] + } + + var hpaConfigFile: String? { + self[EnvironmentKey.hpaConfigFile] + } +} diff --git a/Sources/ConfigurationClient/File.swift b/Sources/ConfigurationClient/File.swift new file mode 100644 index 0000000..6cf5c82 --- /dev/null +++ b/Sources/ConfigurationClient/File.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Represents a file location and type on disk for a configuration file. +public enum File: Equatable, Sendable { + + case json(URL) + case toml(URL) + + public init?(_ url: URL) { + if url.cleanFilePath.hasSuffix("json") { + self = .json(url) + } else if url.cleanFilePath.hasSuffix("toml") { + self = .toml(url) + } else { + return nil + } + } + + public init?(_ path: String) { + self.init(URL(filePath: path)) + } + + public static func json(_ path: String) -> Self { + .json(URL(filePath: path)) + } + + public static func toml(_ path: String) -> Self { + .toml(URL(filePath: path)) + } + + public var url: URL { + switch self { + case let .json(url): return url + case let .toml(url): return url + } + } + + public var path: String { + url.cleanFilePath + } + + public static var `default`: Self { + .toml("~/.config/\(HPAKey.configDirName)/\(HPAKey.defaultFileName)") + } +} + +@_spi(Internal) +public extension URL { + + var cleanFilePath: String { + absoluteString.replacing("file://", with: "") + } +} diff --git a/Sources/FileClient/FileClient.swift b/Sources/FileClient/FileClient.swift new file mode 100644 index 0000000..0d98ed0 --- /dev/null +++ b/Sources/FileClient/FileClient.swift @@ -0,0 +1,63 @@ +import Dependencies +import DependenciesMacros +import Foundation + +public extension DependencyValues { + var fileClient: FileClient { + get { self[FileClient.self] } + set { self[FileClient.self] = newValue } + } +} + +@DependencyClient +public struct FileClient: Sendable { + public var copy: @Sendable (URL, URL) async throws -> Void + public var fileExists: @Sendable (URL) -> Bool = { _ in true } + public var homeDirectory: @Sendable () -> URL = { URL(filePath: "~/") } + public var load: @Sendable (URL) async throws -> Data + public var write: @Sendable (Data, URL) async throws -> Void +} + +extension FileClient: DependencyKey { + public static let testValue: FileClient = Self() + + public static var liveValue: Self { + let manager = LiveFileClient() + return .init { + try await manager.copy($0, to: $1) + } fileExists: { url in + manager.fileExists(at: url) + } homeDirectory: { + manager.homeDirectory() + } load: { url in + try await manager.load(from: url) + } write: { data, url in + try await manager.write(data, to: url) + } + } +} + +struct LiveFileClient: Sendable { + + private var manager: FileManager { FileManager.default } + + func copy(_ url: URL, to toUrl: URL) async throws { + try manager.copyItem(at: url, to: toUrl) + } + + func fileExists(at url: URL) -> Bool { + manager.fileExists(atPath: url.absoluteString.replacing("file://", with: "")) + } + + func homeDirectory() -> URL { + manager.homeDirectoryForCurrentUser + } + + func load(from url: URL) async throws -> Data { + try Data(contentsOf: url) + } + + func write(_ data: Data, to url: URL) async throws { + try data.write(to: url) + } +} diff --git a/Sources/TestSupport/TestSupport.swift b/Sources/TestSupport/TestSupport.swift new file mode 100644 index 0000000..5b359fa --- /dev/null +++ b/Sources/TestSupport/TestSupport.swift @@ -0,0 +1,130 @@ +@_exported import Dependencies +@_exported import ShellClient + +public protocol TestCase {} + +public extension TestCase { + func withTestLogger( + key: String, + logLevel: Logger.Level = .debug, + operation: @escaping @Sendable () throws -> Void + ) rethrows { + try TestSupport.withTestLogger( + key: key, + label: "\(Self.self)", + logLevel: logLevel + ) { + try operation() + } + } + + func withTestLogger( + key: String, + logLevel: Logger.Level = .debug, + dependencies setupDependencies: @escaping (inout DependencyValues) -> Void, + operation: @escaping @Sendable () throws -> Void + ) rethrows { + try TestSupport.withTestLogger( + key: key, + label: "\(Self.self)", + logLevel: logLevel, + dependencies: setupDependencies + ) { + try operation() + } + } + + func withTestLogger( + key: String, + logLevel: Logger.Level = .debug, + operation: @escaping @Sendable () async throws -> Void + ) async rethrows { + try await TestSupport.withTestLogger( + key: key, + label: "\(Self.self)", + logLevel: logLevel + ) { + try await operation() + } + } + + func withTestLogger( + key: String, + logLevel: Logger.Level = .debug, + dependencies setupDependencies: @escaping (inout DependencyValues) -> Void, + operation: @escaping @Sendable () async throws -> Void + ) async rethrows { + try await TestSupport.withTestLogger( + key: key, + label: "\(Self.self)", + logLevel: logLevel, + dependencies: setupDependencies + ) { + try await operation() + } + } +} + +public func withTestLogger( + key: String, + label: String, + logLevel: Logger.Level = .debug, + operation: @escaping @Sendable () throws -> Void +) rethrows { + try withDependencies { + $0.logger = .init(label: label) + $0.logger[metadataKey: "test"] = "\(key)" + $0.logger.logLevel = logLevel + } operation: { + try operation() + } +} + +public func withTestLogger( + key: String, + label: String, + logLevel: Logger.Level = .debug, + dependencies setupDependencies: @escaping (inout DependencyValues) -> Void, + operation: @escaping @Sendable () throws -> Void +) rethrows { + try withDependencies { + $0.logger = .init(label: label) + $0.logger[metadataKey: "test"] = "\(key)" + $0.logger.logLevel = logLevel + setupDependencies(&$0) + } operation: { + try operation() + } +} + +public func withTestLogger( + key: String, + label: String, + logLevel: Logger.Level = .debug, + operation: @escaping @Sendable () async throws -> Void +) async rethrows { + try await withDependencies { + $0.logger = .init(label: label) + $0.logger[metadataKey: "test"] = "\(key)" + $0.logger.logLevel = logLevel + } operation: { + try await operation() + } +} + +public func withTestLogger( + key: String, + label: String, + logLevel: Logger.Level = .debug, + dependencies setupDependencies: @escaping (inout DependencyValues) -> Void, + operation: @escaping @Sendable () async throws -> Void +) async rethrows { + try await withDependencies { + $0.logger = .init(label: label) + $0.logger[metadataKey: "test"] = "\(key)" + $0.logger.logLevel = logLevel + setupDependencies(&$0) + } operation: { + try await operation() + } +} diff --git a/Sources/hpa/CreateCommand.swift b/Sources/hpa/CreateCommand.swift index 638b984..549d4c6 100644 --- a/Sources/hpa/CreateCommand.swift +++ b/Sources/hpa/CreateCommand.swift @@ -1,5 +1,6 @@ import ArgumentParser import CliClient +import ConfigurationClient import Dependencies import Foundation import Logging @@ -61,12 +62,14 @@ struct CreateCommand: AsyncParsableCommand { private func _run() async throws { try await withSetupLogger(commandName: Self.commandName, globals: globals) { + @Dependency(\.coders) var coders @Dependency(\.cliClient) var cliClient + @Dependency(\.configuration) var configurationClient @Dependency(\.logger) var logger - let encoder = cliClient.encoder() + let encoder = coders.jsonEncoder() - let configuration = try cliClient.loadConfiguration() + let configuration = try await configurationClient.findAndLoad() logger.debug("Configuration: \(configuration)") @@ -102,9 +105,9 @@ private func parseOptions( logger: Logger, encoder: JSONEncoder ) throws -> Data { - let templateDir = command.templateDir ?? configuration.templateDir - let templateRepo = command.repo ?? configuration.templateRepo - let version = (command.branch ?? configuration.templateRepoVersion) ?? "main" + let templateDir = command.templateDir ?? configuration.template.directory + let templateRepo = command.repo ?? configuration.template.url + let version = (command.branch ?? configuration.template.version) ?? "main" logger.debug(""" (\(command.localTemplateDir), \(String(describing: templateDir)), \(String(describing: templateRepo))) diff --git a/Sources/hpa/Internal/RunPlaybook.swift b/Sources/hpa/Internal/RunPlaybook.swift index ccdfce7..00dc61d 100644 --- a/Sources/hpa/Internal/RunPlaybook.swift +++ b/Sources/hpa/Internal/RunPlaybook.swift @@ -1,5 +1,6 @@ import ArgumentParser import CliClient +import ConfigurationClient import Dependencies import Foundation import Logging @@ -15,11 +16,12 @@ func runPlaybook( ) async throws { try await withSetupLogger(commandName: commandName, globals: globals) { @Dependency(\.cliClient) var cliClient + @Dependency(\.configuration) var configurationClient @Dependency(\.logger) var logger logger.debug("Begin run playbook: \(globals)") - let configuration = try cliClient.ensuredConfiguration(configuration) + let configuration = try await configurationClient.findAndLoad() logger.debug("Configuration: \(configuration)") @@ -27,7 +29,7 @@ func runPlaybook( globals: globals, configuration: configuration, globalsKeyPath: \.playbookDir, - configurationKeyPath: \.playbookDir + configurationKeyPath: \.playbook?.directory ) let playbook = "\(playbookDir)/\(Constants.playbookFileName)" @@ -35,11 +37,11 @@ func runPlaybook( globals: globals, configuration: configuration, globalsKeyPath: \.inventoryPath, - configurationKeyPath: \.inventoryPath + configurationKeyPath: \.playbook?.inventory )) ?? "\(playbookDir)/\(Constants.inventoryFileName)" - let defaultArgs = (configuration.defaultPlaybookArgs ?? []) - + (configuration.defaultVaultArgs ?? []) + let defaultArgs = (configuration.args ?? []) + + (configuration.useVaultArgs ? configuration.vault.args ?? [] : []) try await cliClient.runCommand( quiet: globals.quietOnlyPlaybook ? true : globals.quiet, @@ -70,15 +72,6 @@ func runPlaybook( ) } -extension CliClient { - func ensuredConfiguration(_ configuration: Configuration?) throws -> Configuration { - guard let configuration else { - return try loadConfiguration() - } - return configuration - } -} - extension BasicGlobalOptions { var shellOrDefault: ShellCommand.Shell { diff --git a/Sources/hpa/Internal/RunVault.swift b/Sources/hpa/Internal/RunVault.swift index 39cb5ed..74510b8 100644 --- a/Sources/hpa/Internal/RunVault.swift +++ b/Sources/hpa/Internal/RunVault.swift @@ -1,3 +1,4 @@ +import ConfigurationClient import Dependencies import ShellClient @@ -8,11 +9,12 @@ func runVault( ) async throws { try await withSetupLogger(commandName: commandName, globals: options.globals) { @Dependency(\.cliClient) var cliClient + @Dependency(\.configuration) var configurationClient @Dependency(\.logger) var logger logger.debug("Begin run vault: \(options)") - let configuration = try cliClient.ensuredConfiguration(nil) + let configuration = try await configurationClient.findAndLoad() logger.debug("Configuration: \(configuration)") let path: String @@ -24,7 +26,7 @@ func runVault( logger.debug("Vault path: \(path)") - let defaultArgs = configuration.defaultVaultArgs ?? [] + let defaultArgs = configuration.vault.args ?? [] var vaultArgs = ["ansible-vault"] + args @@ -34,7 +36,7 @@ func runVault( if args.contains("encrypt"), !vaultArgs.contains("--encrypt-vault-id"), - let id = configuration.defaultVaultEncryptId + let id = configuration.vault.encryptId { vaultArgs.append(contentsOf: ["--encrypt-vault-id", id]) } diff --git a/Sources/hpa/UtilsCommands/GenerateConfigCommand.swift b/Sources/hpa/UtilsCommands/GenerateConfigCommand.swift index 038e846..2a304b7 100644 --- a/Sources/hpa/UtilsCommands/GenerateConfigCommand.swift +++ b/Sources/hpa/UtilsCommands/GenerateConfigCommand.swift @@ -48,6 +48,7 @@ struct GenerateConfigurationCommand: AsyncParsableCommand { try await _run() } + // FIX: private func _run() async throws { try await withSetupLogger(commandName: Self.commandName, globals: globals) { @Dependency(\.cliClient) var cliClient @@ -66,7 +67,8 @@ struct GenerateConfigurationCommand: AsyncParsableCommand { actualPath = "\(path)/config" } - try cliClient.createConfiguration(actualPath, json) + fatalError() + // try cliClient.createConfiguration(actualPath, json) } } } diff --git a/Tests/CliClientTests/CliClientTests.swift b/Tests/CliClientTests/CliClientTests.swift index 7d07c89..d99e81e 100644 --- a/Tests/CliClientTests/CliClientTests.swift +++ b/Tests/CliClientTests/CliClientTests.swift @@ -1,154 +1,124 @@ -@_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) - } - } - +// @_spi(Internal) import CliClient +// import Dependencies +// import Foundation +// import ShellClient +// import Testing +// import TOMLKit +// +// @Suite("CliClientTests") +// struct CliClientTests { +// // @Test -// func writeToml() throws { -// let encoded: String = try TOMLEncoder().encode(Configuration2.mock) -// try encoded.write(to: URL(filePath: "hpa.toml"), atomically: true, encoding: .utf8) +// 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) +// // } +// } -func withTestLogger( - key: String, - label: String = "CliClientTests", - logLevel: Logger.Level = .debug, - operation: @escaping @Sendable () throws -> Void -) rethrows { - try withDependencies { - $0.logger = .init(label: label) - $0.logger[metadataKey: "test"] = "\(key)" - $0.logger.logLevel = logLevel - } operation: { - try operation() - } -} -func withTestLogger( - key: String, - label: String = "CliClientTests", - logLevel: Logger.Level = .debug, - dependencies setupDependencies: @escaping (inout DependencyValues) -> Void, - operation: @escaping @Sendable () throws -> Void -) rethrows { - try withDependencies { - $0.logger = .init(label: label) - $0.logger[metadataKey: "test"] = "\(key)" - $0.logger.logLevel = logLevel - setupDependencies(&$0) - } operation: { - try operation() - } -} diff --git a/Tests/ConfigurationClientTests/ConfigurationClientTests.swift b/Tests/ConfigurationClientTests/ConfigurationClientTests.swift new file mode 100644 index 0000000..9495fe4 --- /dev/null +++ b/Tests/ConfigurationClientTests/ConfigurationClientTests.swift @@ -0,0 +1,190 @@ +@_spi(Internal) import ConfigurationClient +import Foundation +import Testing +import TestSupport + +@Suite("ConfigurationClientTests") +struct ConfigurationClientTests: TestCase { + + @Test + func sanity() { + withTestLogger(key: "sanity") { + @Dependency(\.logger) var logger + logger.debug("Testing sanity.") + #expect(Bool(true)) + } + } + + @Test(arguments: ["config.toml", "config.json"]) + func generateConfigFile(fileName: String) async throws { + try await withTestLogger(key: "generateConfigFile") { + $0.fileClient = .liveValue + } operation: { + @Dependency(\.logger) var logger + @Dependency(\.fileClient) var fileClient + let configuration = ConfigurationClient.liveValue + + try await withTemporaryDirectory { tempDir in + let tempFile = tempDir.appending(path: fileName) + try await configuration.generate(at: File(tempFile)!, force: false) + #expect(FileManager.default.fileExists(atPath: tempFile.cleanFilePath)) + #expect(fileClient.fileExists(tempFile)) + + // Ensure that we do not overwrite files if they exist. + do { + try await configuration.generate(at: File(tempFile)!, force: false) + #expect(Bool(false)) + } catch { + #expect(Bool(true)) + } + } + } + } + + @Test(arguments: ["config.toml", "config.json", nil]) + func loadConfigFile(fileName: String?) async throws { + try await withTestLogger(key: "generateConfigFile") { + $0.fileClient = .liveValue + } operation: { + @Dependency(\.logger) var logger + @Dependency(\.fileClient) var fileClient + let configuration = ConfigurationClient.liveValue + + guard let fileName else { + let loaded = try await configuration.load(nil) + #expect(loaded == .init()) + return + } + + try await withGeneratedConfigFile(named: fileName, client: configuration) { file in + let loaded = try await configuration.load(file) + #expect(loaded == .mock) + } + } + } + + @Test(arguments: ["config.toml", "config.json", ".hparc.json", ".hparc.toml"]) + func findConfiguration(fileName: String) async throws { + try await withTestLogger(key: "findConfiguration") { + $0.fileClient = .liveValue + } operation: { + @Dependency(\.logger) var logger + @Dependency(\.fileClient) var fileClient + let client = ConfigurationClient.liveValue + + try await withGeneratedConfigFile(named: fileName, client: client) { file in + for environment in generateFindEnvironments(file: file) { + if let home = environment["HOME"] { + try await withDependencies { + $0.fileClient.homeDirectory = { URL(filePath: home) } + } operation: { + let configuration = ConfigurationClient.live(environment: environment) + let found = try await configuration.find() + #expect(found == file) + } + } else { + let configuration = ConfigurationClient.live(environment: environment) + let found = try await configuration.find() + #expect(found == file) + } + } + } + } + } + + @Test(arguments: ["config.toml", "config.json", ".hparc.json", ".hparc.toml"]) + func findXdgConfiguration(fileName: String) async throws { + try await withTestLogger(key: "findXdgConfiguration") { + $0.fileClient = .liveValue + } operation: { + @Dependency(\.logger) var logger + @Dependency(\.fileClient) var fileClient + let client = ConfigurationClient.liveValue + + try await withGeneratedXDGConfigFile(named: fileName, client: client) { file, xdgDir in + let environment = ["XDG_CONFIG_HOME": xdgDir.cleanFilePath] + let configuration = ConfigurationClient.live(environment: environment) + let found = try await configuration.find() + #expect(found == file) + } + } + } + + @Test + func testFailingFind() async { + await withTestLogger(key: "testFailingFind") { + $0.fileClient = .liveValue + } operation: { + await withTemporaryDirectory { tempDir in + let environment = [ + "PWD": tempDir.cleanFilePath, + "HPA_CONFIG_HOME": tempDir.cleanFilePath + ] + let configuration = ConfigurationClient.live(environment: environment) + do { + _ = try await configuration.find() + #expect(Bool(false)) + } catch { + #expect(Bool(true)) + } + } + } + } +} + +func generateFindEnvironments(file: File) -> [[String: String]] { + let directory = file.url.deletingLastPathComponent().cleanFilePath + + return [ + ["PWD": directory], + ["HPA_CONFIG_HOME": directory], + ["HPA_CONFIG_FILE": file.path], + ["HOME": directory] + ] +} + +// swiftlint:disable force_try +func withTemporaryDirectory( + _ operation: @Sendable (URL) async throws -> Void +) async rethrows { + let dir = FileManager.default.temporaryDirectory + let tempDir = dir.appending(path: UUID().uuidString) + + try! FileManager.default.createDirectory( + atPath: tempDir.cleanFilePath, + withIntermediateDirectories: false + ) + try await operation(tempDir) + try! FileManager.default.removeItem(at: tempDir) +} + +// swiftlint:enable force_try + +func withGeneratedConfigFile( + named fileName: String, + client: ConfigurationClient, + _ operation: @Sendable (File) async throws -> Void +) async rethrows { + try await withTemporaryDirectory { tempDir in + let file = File(tempDir.appending(path: fileName))! + try await client.generate(at: file) + try await operation(file) + } +} + +func withGeneratedXDGConfigFile( + named fileName: String, + client: ConfigurationClient, + _ operation: @Sendable (File, URL) async throws -> Void +) async rethrows { + try await withTemporaryDirectory { tempDir in + let xdgDir = tempDir.appending(path: HPAKey.configDirName) + try FileManager.default.createDirectory( + atPath: xdgDir.cleanFilePath, + withIntermediateDirectories: false + ) + let file = File(xdgDir.appending(path: fileName))! + try await client.generate(at: file) + try await operation(file, tempDir) + } +}