feat: Begins vault commands, adds nodes for rendering discussion documentation.

This commit is contained in:
2024-11-30 14:41:25 -05:00
parent 2c551e33d3
commit 4b84c19198
19 changed files with 432 additions and 153 deletions

View File

@@ -7,10 +7,10 @@ import ShellClient
struct Application: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "hpa",
abstract: "\("A utility for working with ansible hpa playbook.".blue)",
commandName: Constants.appName,
abstract: createAbstract("A utility for working with ansible hpa playbook."),
subcommands: [
BuildCommand.self, CreateCommand.self, UtilsCommand.self
BuildCommand.self, CreateCommand.self, VaultCommand.self, UtilsCommand.self
]
)

View File

@@ -6,7 +6,7 @@ struct BuildCommand: AsyncParsableCommand {
static let commandName = "build"
static let configuration = CommandConfiguration.playbookCommandConfiguration(
static let configuration = CommandConfiguration.playbook(
commandName: commandName,
abstract: "Build a home performance assesment project.",
examples: (label: "Build Project", example: "\(commandName) /path/to/project")

View File

@@ -8,7 +8,7 @@ struct CreateCommand: AsyncParsableCommand {
static let commandName = "create"
static let configuration = CommandConfiguration.playbookCommandConfiguration(
static let configuration = CommandConfiguration.playbook(
commandName: commandName,
abstract: "Create a home performance assesment project.",
examples: (

View File

@@ -1,35 +0,0 @@
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.
"""
)
}
}

View File

@@ -0,0 +1,108 @@
import ArgumentParser
import Rainbow
extension CommandConfiguration {
static func create(
commandName: String,
abstract: String,
usesExtraArgs: Bool = true,
discussion nodes: [Node]
) -> Self {
.init(
commandName: commandName,
abstract: createAbstract(abstract),
discussion: Discussion(nodes: nodes, usesExtraArgs: usesExtraArgs).render()
)
}
static func playbook(
commandName: String,
abstract: String,
parentCommand: String? = nil,
examples: (label: String, example: String)...
) -> Self {
.create(
commandName: commandName,
abstract: abstract,
discussion: [.note(label: "Most options are not required if you have a configuration file setup.")]
+ examples.nodes(parentCommand)
+ [.seeAlso(label: "Ansible playbook options.", command: "ansible-playbook")]
)
}
}
func createAbstract(_ string: String) -> String {
"\(string.blue)"
}
struct Discussion {
var nodes: [Node]
init(usesExtraArgs: Bool = true, _ nodes: Node...) {
self.init(nodes: nodes, usesExtraArgs: usesExtraArgs)
}
init(nodes: [Node], usesExtraArgs: Bool = true) {
var nodes = nodes
if let firstExampleIndex = nodes.firstIndex(where: \.isExampleNode) {
nodes.insert(.exampleHeading, at: firstExampleIndex)
}
if usesExtraArgs {
if let lastExampleIndex = nodes.lastIndex(where: \.isExampleNode) {
let last = nodes[lastExampleIndex]
if case let .example(_, example, parent) = last {
nodes.insert(
.example(label: "Passing extra args.", example: example, parentCommand: parent),
at: nodes.index(after: lastExampleIndex)
)
}
}
}
self.nodes = nodes
}
func render() -> String {
nodes.map { $0.render() }.joined(separator: "\n")
}
}
enum Node: Equatable {
case note(header: String = "NOTE:", label: String, appendNewLine: Bool = true)
case example(label: String, example: String, parentCommand: String?)
case seeAlso(label: String, command: String, appendHelpToCommand: Bool = true)
static var exampleHeading: Self { .note(header: "Examples:", label: "Some common examples.", appendNewLine: false) }
var isExampleNode: Bool {
if case .example = self { return true }
return false
}
func render() -> String {
switch self {
case let .note(header, note, appendNewLine):
return "\(header.yellow) \(note.italic)\(appendNewLine ? "\n" : "")"
case let .example(label: label, example: example, parentCommand: parent):
return """
\(label.green.italic)
$ \(Constants.appName) \(parent ?? "")\(parent != nil ? " \(example)" : example)
"""
case let .seeAlso(label: label, command: command, appendHelpToCommand: appendHelp):
return """
\("See Also:".yellow) \(label.italic)
$ \(command)\(appendHelp ? " --help" : "")
"""
}
}
}
private extension Array where Element == (label: String, example: String) {
func nodes(_ parentCommand: String?) -> [Node] {
map { .example(label: $0.label, example: $0.example, parentCommand: parentCommand) }
}
}

View File

@@ -0,0 +1,17 @@
import Rainbow
// Constant string values.
enum Constants {
static let appName = "hpa"
static let playbookFileName = "main.yml"
static let inventoryFileName = "inventory.ini"
static let importantExtraArgsNote = """
\("IMPORTANT NOTE".bold.underline): \("""
Any extra arguments to pass to the underlying command invocation have to
be at the end with `--` before any arguments otherwise there will
be an "Unkown option" error. See examples above.
""".italic
)
""".red
}

View File

@@ -50,4 +50,8 @@ struct GlobalOptions: ParsableArguments {
set { basic[keyPath: keyPath] = newValue }
}
subscript<T>(dynamicMember keyPath: KeyPath<BasicGlobalOptions, T>) -> T {
basic[keyPath: keyPath]
}
}

View File

@@ -0,0 +1,53 @@
import Dependencies
import Logging
extension Logger.Level {
/// Set the log level based on the user's options supplied.
init(globals: BasicGlobalOptions, quietOnlyPlaybook: Bool) {
if quietOnlyPlaybook || !globals.quiet {
switch globals.verbose {
case 0:
self = .info
case 1:
self = .debug
case 2...:
self = .trace
default:
self = .info
}
}
self = .info
}
}
func withSetupLogger(
commandName: String,
globals: BasicGlobalOptions,
quietOnlyPlaybook: Bool = false,
dependencies setupDependencies: (inout DependencyValues) -> Void = { _ in },
operation: @escaping () async throws -> Void
) async rethrows {
try await withDependencies {
$0.logger = .init(label: "\("hpa".yellow)")
$0.logger.logLevel = .init(globals: globals, quietOnlyPlaybook: quietOnlyPlaybook)
$0.logger[metadataKey: "command"] = "\(commandName.blue)"
} operation: {
try await operation()
}
}
func withSetupLogger(
commandName: String,
globals: GlobalOptions,
dependencies setupDependencies: (inout DependencyValues) -> Void = { _ in },
operation: @escaping () async throws -> Void
) async rethrows {
try await withSetupLogger(
commandName: commandName,
globals: globals.basic,
quietOnlyPlaybook: globals.quietOnlyPlaybook,
dependencies: setupDependencies,
operation: operation
)
}

View File

@@ -6,101 +6,6 @@ import Logging
import Rainbow
import ShellClient
extension BasicGlobalOptions {
var shellOrDefault: ShellCommand.Shell {
guard let shell else { return .zsh(useDashC: true) }
return .custom(path: shell, useDashC: true)
}
}
extension GlobalOptions {
var shellOrDefault: ShellCommand.Shell { basic.shellOrDefault }
}
func ensureString(
globals: GlobalOptions,
configuration: Configuration,
globalsKeyPath: KeyPath<GlobalOptions, String?>,
configurationKeyPath: KeyPath<Configuration, String?>
) throws -> String {
if let global = globals[keyPath: globalsKeyPath] {
return global
}
guard let configuration = configuration[keyPath: configurationKeyPath] else {
throw RunPlaybookError.playbookNotFound
}
return configuration
}
func withSetupLogger(
commandName: String,
globals: BasicGlobalOptions,
quietOnlyPlaybook: Bool = false,
dependencies setupDependencies: (inout DependencyValues) -> Void = { _ in },
operation: @escaping () async throws -> Void
) async rethrows {
try await withDependencies {
$0.logger = .init(label: "\("hpa".yellow)")
if quietOnlyPlaybook || !globals.quiet {
switch globals.verbose {
case 0:
$0.logger.logLevel = .info
case 1:
$0.logger.logLevel = .debug
case 2...:
$0.logger.logLevel = .trace
default:
$0.logger.logLevel = .info
}
}
$0.logger[metadataKey: "command"] = "\(commandName.blue)"
} operation: {
try await operation()
}
}
func withSetupLogger(
commandName: String,
globals: GlobalOptions,
dependencies setupDependencies: (inout DependencyValues) -> Void = { _ in },
operation: @escaping () async throws -> Void
) async rethrows {
try await withSetupLogger(
commandName: commandName,
globals: globals.basic,
quietOnlyPlaybook: globals.quietOnlyPlaybook,
dependencies: setupDependencies,
operation: operation
)
}
func runPlaybook(
commandName: String,
globals: GlobalOptions,
configuration: Configuration? = nil,
extraArgs: [String],
_ args: String...
) async throws {
try await runPlaybook(
commandName: commandName,
globals: globals,
configuration: configuration,
extraArgs: extraArgs,
args
)
}
private extension CliClient {
func ensuredConfiguration(_ configuration: Configuration?) throws -> Configuration {
guard let configuration else {
return try loadConfiguration()
}
return configuration
}
}
func runPlaybook(
commandName: String,
globals: GlobalOptions,
@@ -124,19 +29,20 @@ func runPlaybook(
globalsKeyPath: \.playbookDir,
configurationKeyPath: \.playbookDir
)
let playbook = "\(playbookDir)/main.yml"
let playbook = "\(playbookDir)/\(Constants.playbookFileName)"
let inventory = (try? ensureString(
globals: globals,
configuration: configuration,
globalsKeyPath: \.inventoryPath,
configurationKeyPath: \.inventoryPath
)) ?? "\(playbookDir)/inventory.ini"
)) ?? "\(playbookDir)/\(Constants.inventoryFileName)"
let defaultArgs = configuration.defaultPlaybookArgs ?? []
let defaultArgs = (configuration.defaultPlaybookArgs ?? [])
+ (configuration.defaultVaultArgs ?? [])
try await cliClient.runCommand(
quiet: globals.quietOnlyPlaybook ? true : globals.basic.quiet,
quiet: globals.quietOnlyPlaybook ? true : globals.quiet,
shell: globals.shellOrDefault,
[
"ansible-playbook", playbook,
@@ -148,6 +54,54 @@ func runPlaybook(
}
}
func runPlaybook(
commandName: String,
globals: GlobalOptions,
configuration: Configuration? = nil,
extraArgs: [String],
_ args: String...
) async throws {
try await runPlaybook(
commandName: commandName,
globals: globals,
configuration: configuration,
extraArgs: extraArgs,
args
)
}
extension CliClient {
func ensuredConfiguration(_ configuration: Configuration?) throws -> Configuration {
guard let configuration else {
return try loadConfiguration()
}
return configuration
}
}
extension BasicGlobalOptions {
var shellOrDefault: ShellCommand.Shell {
guard let shell else { return .zsh(useDashC: true) }
return .custom(path: shell, useDashC: true)
}
}
func ensureString(
globals: GlobalOptions,
configuration: Configuration,
globalsKeyPath: KeyPath<GlobalOptions, String?>,
configurationKeyPath: KeyPath<Configuration, String?>
) throws -> String {
if let global = globals[keyPath: globalsKeyPath] {
return global
}
guard let configuration = configuration[keyPath: configurationKeyPath] else {
throw RunPlaybookError.playbookNotFound
}
return configuration
}
enum RunPlaybookError: Error {
case playbookNotFound
case configurationError

View File

@@ -0,0 +1,41 @@
import Dependencies
import ShellClient
func runVault(
commandName: String,
options: VaultOptions,
_ args: [String]
) async throws {
try await withSetupLogger(commandName: commandName, globals: options.globals) {
@Dependency(\.cliClient) var cliClient
@Dependency(\.logger) var logger
logger.debug("Begin run vault: \(options)")
let configuration = try cliClient.ensuredConfiguration(nil)
logger.debug("Configuration: \(configuration)")
let path: String
if let file = options.file {
path = file
} else {
path = try cliClient.findVaultFileInCurrentDirectory()
}
logger.debug("Vault path: \(path)")
let defaultArgs = configuration.defaultVaultArgs ?? []
try await cliClient.runCommand(
quiet: options.quiet,
shell: options.shellOrDefault,
["ansible-vault"]
+ args
+ defaultArgs
+ options.extraArgs
+ [path]
)
fatalError()
}
}

View File

@@ -4,9 +4,10 @@ struct CreateProjectTemplateCommand: AsyncParsableCommand {
static let commandName = "create-template"
static let configuration = CommandConfiguration.playbookCommandConfiguration(
static let configuration = CommandConfiguration.playbook(
commandName: commandName,
abstract: "Create a home performance assesment project template.",
parentCommand: UtilsCommand.commandName,
examples: (label: "Create Template", example: "\(commandName) /path/to/project-template")
)

View File

@@ -8,7 +8,7 @@ struct GenerateConfigurationCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: commandName,
abstract: "\("Generate a local configuration file.".blue)",
abstract: createAbstract("Generate a local configuration file."),
discussion: """
\("NOTE:".yellow) If a directory is not supplied then a configuration file will be created

View File

@@ -0,0 +1,23 @@
import ArgumentParser
struct DecryptCommand: AsyncParsableCommand {
static let commandName = "decrypt"
static let configuration = CommandConfiguration(
commandName: commandName,
abstract: createAbstract("Decrypt a vault file.")
)
@OptionGroup var options: VaultOptions
@Option(
name: .shortAndLong,
help: "Output file."
)
var output: String?
mutating func run() async throws {
fatalError()
}
}

View File

@@ -0,0 +1,23 @@
import ArgumentParser
struct EncryptCommand: AsyncParsableCommand {
static let commandName = "encrypt"
static let configuration = CommandConfiguration(
commandName: commandName,
abstract: createAbstract("Encrypt a vault file.")
)
@OptionGroup var options: VaultOptions
@Option(
name: .shortAndLong,
help: "Output file."
)
var output: String?
mutating func run() async throws {
fatalError()
}
}

View File

@@ -0,0 +1,14 @@
import ArgumentParser
struct VaultCommand: AsyncParsableCommand {
static let commandName = "vault"
static let configuration = CommandConfiguration(
commandName: commandName,
abstract: createAbstract("Vault commands."),
subcommands: [
EncryptCommand.self, DecryptCommand.self
]
)
}

View File

@@ -0,0 +1,31 @@
import ArgumentParser
// Holds the common options for vault commands, as they all share the
// same structure.
@dynamicMemberLookup
struct VaultOptions: ParsableArguments {
@OptionGroup var globals: BasicGlobalOptions
@Option(
name: .shortAndLong,
help: "The vault file path.",
completion: .file()
)
var file: String?
@Argument(
help: "Extra arguments to pass to the vault command."
)
var extraArgs: [String] = []
subscript<T>(dynamicMember keyPath: WritableKeyPath<BasicGlobalOptions, T>) -> T {
get { globals[keyPath: keyPath] }
set { globals[keyPath: keyPath] = newValue }
}
subscript<T>(dynamicMember keyPath: KeyPath<BasicGlobalOptions, T>) -> T {
globals[keyPath: keyPath]
}
}