diff --git a/.swiftlint.yml b/.swiftlint.yml index 8df1f05..cd25531 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,6 +1,7 @@ disabled_rules: - closing_brace - fuction_body_length + - opening_brace included: - Sources diff --git a/Sources/CliClient/CliClient.swift b/Sources/CliClient/CliClient.swift index aaa45db..c7dda6b 100644 --- a/Sources/CliClient/CliClient.swift +++ b/Sources/CliClient/CliClient.swift @@ -56,7 +56,7 @@ extension CliClient: DependencyKey { logger.trace("Loading configuration from: \(url)") try fileClient.loadFile(url, &env, decoder) - return try .fromEnv(env, encoder: encoder, decoder: decoder) + return try .fromEnv(env) } runCommand: { args, quiet, shell in @Dependency(\.asyncShellClient) var shellClient diff --git a/Sources/CliClient/Configuration.swift b/Sources/CliClient/Configuration.swift index 9f9bf1b..76ee76f 100644 --- a/Sources/CliClient/Configuration.swift +++ b/Sources/CliClient/Configuration.swift @@ -10,9 +10,9 @@ public struct Configuration: Codable { public let templateRepo: String? public let templateRepoVersion: String? public let templateDir: String? - public let defaultPlaybookArgs: String? + public let defaultPlaybookArgs: [String]? - private enum CodingKeys: String, CodingKey { + fileprivate enum CodingKeys: String, CodingKey { case playbookDir = "HPA_PLAYBOOK_DIR" case inventoryPath = "HPA_DEFAULT_INVENTORY" case templateRepo = "HPA_TEMPLATE_REPO" @@ -22,23 +22,26 @@ public struct Configuration: Codable { } public static func fromEnv( - _ env: [String: String], - encoder: JSONEncoder = .init(), - decoder: JSONDecoder = .init() + _ env: [String: String] ) throws -> Self { @Dependency(\.logger) var logger logger.trace("Creating configuration from env...") - // logger.debug("\(env)") - let hpaValues = env.reduce(into: [String: String]()) { partial, next in - if next.key.contains("HPA") { - partial[next.key] = next.value - } - } + let hpaValues: [String: String] = env.filter { $0.key.contains("HPA") } logger.debug("HPA env vars: \(hpaValues)") - let data = try encoder.encode(env) - return try decoder.decode(Configuration.self, from: data) + + let defaultArgs: [String]? = hpaValues.value(key: .defaultPlaybookArgs) + .flatMap { $0.split(separator: ",").map(String.init) } + + return Configuration( + playbookDir: hpaValues.value(key: .playbookDir), + inventoryPath: hpaValues.value(key: .inventoryPath), + templateRepo: hpaValues.value(key: .templateRepo), + templateRepoVersion: hpaValues.value(key: .templateRepoVersion), + templateDir: hpaValues.value(key: .templateDir), + defaultPlaybookArgs: defaultArgs + ) } static var mock: Self { @@ -48,7 +51,7 @@ public struct Configuration: Codable { templateRepo: "https://git.example.com/consult-template.git", templateRepoVersion: "main", templateDir: "/path/to/local/template", - defaultPlaybookArgs: "--vault-id=myId@$SCRIPTS/vault-gopass-client" + defaultPlaybookArgs: ["--vault-id=myId@$SCRIPTS/vault-gopass-client"] ) } @@ -77,3 +80,9 @@ public struct Configuration: Codable { """ } } + +extension [String: String] { + fileprivate func value(key codingKey: Configuration.CodingKeys) -> String? { + self[codingKey.rawValue] + } +} diff --git a/Sources/CliClient/FileClient.swift b/Sources/CliClient/FileClient.swift index 283442c..8bacb1e 100644 --- a/Sources/CliClient/FileClient.swift +++ b/Sources/CliClient/FileClient.swift @@ -13,7 +13,6 @@ public extension DependencyValues { @_spi(Internal) @DependencyClient public struct FileClient: Sendable { - public var contentsOfDirectory: @Sendable (URL) throws -> [URL] public var loadFile: @Sendable (URL, inout [String: String], JSONDecoder) throws -> Void public var homeDir: @Sendable () -> URL = { URL(string: "~/")! } public var isDirectory: @Sendable (URL) -> Bool = { _ in false } @@ -29,7 +28,6 @@ extension FileClient: DependencyKey { public static func live(fileManager: FileManager = .default) -> Self { let client = LiveFileClient(fileManager: fileManager) return Self( - contentsOfDirectory: { try client.contentsOfDirectory(url: $0) }, loadFile: { try client.loadFile(at: $0, into: &$1, decoder: $2) }, homeDir: { client.homeDir }, isDirectory: { client.isDirectory(url: $0) }, @@ -64,10 +62,6 @@ private struct LiveFileClient: @unchecked Sendable { fileManager.isReadableFile(atPath: path(for: url)) } - func contentsOfDirectory(url: URL) throws -> [URL] { - try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) - } - func fileExists(at path: String) -> Bool { fileManager.fileExists(atPath: path) } @@ -76,7 +70,7 @@ private struct LiveFileClient: @unchecked Sendable { @Dependency(\.logger) var logger logger.trace("Begin load file for: \(path(for: url))") - if url.absoluteString.contains(".json") { + if url.absoluteString.hasSuffix(".json") { // Handle json file. let data = try Data(contentsOf: url) let dict = (try? decoder.decode([String: String].self, from: data)) ?? [:] diff --git a/Sources/CliClient/Helpers.swift b/Sources/CliClient/Helpers.swift index f469da4..9360830 100644 --- a/Sources/CliClient/Helpers.swift +++ b/Sources/CliClient/Helpers.swift @@ -12,49 +12,45 @@ public func findConfigurationFiles( logger.debug("Begin find configuration files.") logger.trace("Env: \(env)") - let homeDir = fileClient.homeDir() - // Check for environment variable pointing to a directory that the // the configuration lives. - if let configHome = env["HPA_CONFIG_HOME"] { - let url = URL(filePath: configHome).appending(path: "config") - - if fileClient.isReadable(url) { - logger.debug("Found configuration from hpa config home env var.") - return url - } + if let pwd = env["PWD"], + let url = fileClient.checkUrl(.file(pwd, ".hparc")) + { + logger.debug("Found configuration in current working directory.") + return url } - // Check home directory for a `.hparc` file. - let url = homeDir.appending(path: ".hparc") - if fileClient.isReadable(url) { - logger.debug("Found configuration in home directory") + // 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 pwd = env["PWD"] { - let url = URL(filePath: "\(pwd)").appending(path: ".hparc") - if fileClient.isReadable(url) { - logger.debug("Found configuration in current working directory.") - return url - } + 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"] { - logger.debug("XDG Config Home: \(xdgConfigHome)") - - let url = URL(filePath: "\(xdgConfigHome)") - .appending(path: "hpa-playbook") - .appending(path: "config") - + if let xdgConfigHome = env["XDG_CONFIG_HOME"], + let url = fileClient.checkUrl(.directory(xdgConfigHome, "hpa-playbook")) + { logger.debug("XDG Config url: \(url.absoluteString)") - - if fileClient.isReadable(url) { - return url - } + return url } // We could not find configuration in any usual places. @@ -68,3 +64,46 @@ func path(for url: URL) -> String { 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 + } +} diff --git a/Sources/hpa/Application.swift b/Sources/hpa/Application.swift index 17db3b5..7bbbe62 100644 --- a/Sources/hpa/Application.swift +++ b/Sources/hpa/Application.swift @@ -8,10 +8,9 @@ struct Application: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "hpa", - abstract: "A utility for working with ansible hpa playbook.", + abstract: "\("A utility for working with ansible hpa playbook.".blue)", subcommands: [ - BuildCommand.self, CreateCommand.self, CreateProjectTemplateCommand.self, - GenerateConfigurationCommand.self + BuildCommand.self, CreateCommand.self, UtilsCommand.self ] ) diff --git a/Sources/hpa/CreateTemplateCommand.swift b/Sources/hpa/CreateTemplateCommand.swift deleted file mode 100644 index e5db1c1..0000000 --- a/Sources/hpa/CreateTemplateCommand.swift +++ /dev/null @@ -1,15 +0,0 @@ -import ArgumentParser - -struct CreateProjectTemplateCommand: AsyncParsableCommand { - - static let configuration = CommandConfiguration( - commandName: "create-project-template", - abstract: "Create a home performance assesment project template." - ) - - @OptionGroup var globals: GlobalOptions - - mutating func run() async throws { - fatalError() - } -} diff --git a/Sources/hpa/Helpers/CommandConfigurationExtensions.swift b/Sources/hpa/Helpers/CommandConfigurationExtensions.swift new file mode 100644 index 0000000..f76ba6d --- /dev/null +++ b/Sources/hpa/Helpers/CommandConfigurationExtensions.swift @@ -0,0 +1,35 @@ +import ArgumentParser + +extension CommandConfiguration { + static func playbookCommandConfiguration( + commandName: String, + abstract: String, + examples: (label: String, example: String)... + ) -> Self { + guard examples.count > 0 else { + fatalError("Did not supply any examples.") + } + return Self( + commandName: commandName, + abstract: "\(abstract.blue)", + discussion: """ + \("NOTE:".yellow) Most options are not required if you have a configuration file setup. + + \("Examples:".yellow) + + \(examples.map { "\($0.label.green.italic)\n $ hpa \($0.example)" }.joined(separator: "\n")) + + \("Passing extra args to the playbook.".green.italic) + $ hpa \(examples[0].example) -- --vault-id "myId@$SCRIPTS/vault-gopass-client" + + \("See Also:".yellow) \("Ansible playbook options.".italic) + + $ ansible-playbook --help + + \("IMPORTANT NOTE:".red) Any extra arguments to pass to the playbook invocation have to + be at the end with `--` before any arguments otherwise there will + be an "Unkown option" error. See examples above. + """ + ) + } +} diff --git a/Sources/hpa/Helpers.swift b/Sources/hpa/Helpers/Helpers.swift similarity index 73% rename from Sources/hpa/Helpers.swift rename to Sources/hpa/Helpers/Helpers.swift index 2cffed8..118b738 100644 --- a/Sources/hpa/Helpers.swift +++ b/Sources/hpa/Helpers/Helpers.swift @@ -6,40 +6,6 @@ import Logging import Rainbow import ShellClient -extension CommandConfiguration { - static func playbookCommandConfiguration( - commandName: String, - abstract: String, - examples: (label: String, example: String)... - ) -> Self { - guard examples.count > 0 else { - fatalError("Did not supply any examples.") - } - return Self( - commandName: commandName, - abstract: "\(abstract.blue)", - discussion: """ - \("NOTE:".yellow) Most options are not required if you have a configuration file setup. - - \("Examples:".yellow) - - \(examples.map { "\($0.label.green.italic)\n $ hpa \($0.example)" }.joined(separator: "\n")) - - \("Passing extra args to the playbook.".green.italic) - $ hpa \(examples[0].example) -- --vault-id "myId@$SCRIPTS/vault-gopass-client" - - \("See Also:".yellow) \("Ansible playbook options.".italic) - - $ ansible-playbook --help - - \("IMPORTANT NOTE:".red) Any extra arguments to pass to the playbook invocation have to - be at the end with `--` before any arguments otherwise there will - be an "Unkown option" error. See examples above. - """ - ) - } -} - extension BasicGlobalOptions { var shellOrDefault: ShellCommand.Shell { @@ -167,19 +133,17 @@ func runPlaybook( configurationKeyPath: \.inventoryPath )) ?? "\(playbookDir)/inventory.ini" - var playbookArgs = [ - "ansible-playbook", playbook, - "--inventory", inventory - ] - - if let defaultArgs = configuration.defaultPlaybookArgs { - playbookArgs.append(defaultArgs) - } + let defaultArgs = configuration.defaultPlaybookArgs ?? [] try await cliClient.runCommand( quiet: globals.quietOnlyPlaybook ? true : globals.basic.quiet, shell: globals.shellOrDefault, - playbookArgs + args + extraArgs + [ + "ansible-playbook", playbook, + "--inventory", inventory + ] + args + + extraArgs + + defaultArgs ) } } diff --git a/Sources/hpa/UtilsCommands/CreateTemplateCommand.swift b/Sources/hpa/UtilsCommands/CreateTemplateCommand.swift new file mode 100644 index 0000000..afbd968 --- /dev/null +++ b/Sources/hpa/UtilsCommands/CreateTemplateCommand.swift @@ -0,0 +1,56 @@ +import ArgumentParser + +struct CreateProjectTemplateCommand: AsyncParsableCommand { + + static let commandName = "create-template" + + static let configuration = CommandConfiguration.playbookCommandConfiguration( + commandName: commandName, + abstract: "Create a home performance assesment project template.", + examples: (label: "Create Template", example: "\(commandName) /path/to/project-template") + ) + + @OptionGroup var globals: GlobalOptions + + @Option( + name: .shortAndLong, + help: "Customize the directory where template variables are stored." + ) + var templateVars: String? + + @Flag( + name: .long, + help: "Do not generate ansible-vault variables file." + ) + var noVault: Bool = false + + @Argument( + help: "Path to the project template directory.", + completion: .directory + ) + var path: String + + @Argument( + help: "Extra arguments passed to the playbook." + ) + var extraArgs: [String] = [] + + mutating func run() async throws { + let varsDir = templateVars != nil + ? ["--extra-vars", "repo_vars_dir=\(templateVars!)"] + : [] + + let vault = noVault ? ["--extra-vars", "use_vault=false"] : [] + + try await runPlaybook( + commandName: Self.commandName, + globals: globals, + extraArgs: extraArgs, + [ + "--tags", "repo-template", + "--extra-vars", "output_dir=\(path)" + ] + varsDir + + vault + ) + } +} diff --git a/Sources/hpa/GenerateConfigCommand.swift b/Sources/hpa/UtilsCommands/GenerateConfigCommand.swift similarity index 100% rename from Sources/hpa/GenerateConfigCommand.swift rename to Sources/hpa/UtilsCommands/GenerateConfigCommand.swift diff --git a/Sources/hpa/UtilsCommands/UtilsCommand.swift b/Sources/hpa/UtilsCommands/UtilsCommand.swift new file mode 100644 index 0000000..3e79de3 --- /dev/null +++ b/Sources/hpa/UtilsCommands/UtilsCommand.swift @@ -0,0 +1,17 @@ +import ArgumentParser + +struct UtilsCommand: AsyncParsableCommand { + static let commandName = "utils" + + static let configuration = CommandConfiguration( + commandName: commandName, + abstract: "\("Utility commands.".blue)", + discussion: """ + These are commands that are generally only run on occasion / less frequently used. + """, + subcommands: [ + CreateProjectTemplateCommand.self, GenerateConfigurationCommand.self + ] + ) + +}