diff --git a/Sources/PlaybookClient/Constants.swift b/Sources/PlaybookClient/Constants.swift index 7633b0c..9f9d56a 100644 --- a/Sources/PlaybookClient/Constants.swift +++ b/Sources/PlaybookClient/Constants.swift @@ -3,6 +3,9 @@ public extension PlaybookClient { @_spi(Internal) enum Constants { public static let defaultInstallationPath = "~/.local/share/hpa/playbook" + public static let inventoryFileName = "inventory.ini" + public static let playbookCommand = "ansible-playbook" + public static let playbookFileName = "main.yml" public static let playbookRepoUrl = "https://git.housh.dev/michael/ansible-hpa-playbook.git" public static let playbookRepoVersion = "main" } diff --git a/Sources/PlaybookClient/PlaybookClient+RunPlaybook.swift b/Sources/PlaybookClient/PlaybookClient+RunPlaybook.swift new file mode 100644 index 0000000..6d8b1d3 --- /dev/null +++ b/Sources/PlaybookClient/PlaybookClient+RunPlaybook.swift @@ -0,0 +1,205 @@ +import CommandClient +import ConfigurationClient +import Dependencies +import Foundation + +extension PlaybookClient.RunPlaybook.BuildOptions { + + func run() async throws { + try await shared.run { arguments, _ in + let projectDirectory = projectDirectory + ?? ProcessInfo.processInfo.environment["PWD"] + + guard let projectDirectory else { + throw PlaybookClientError.projectDirectoryNotFound + } + + arguments.append(contentsOf: [ + "--tags", "build-project", + "--extra-vars", "project_dir=\(projectDirectory)" + ]) + + if let extraOptions = shared.extraOptions { + arguments.append(contentsOf: extraOptions) + } + } + } +} + +extension PlaybookClient.RunPlaybook.CreateOptions { + + func run() async throws { + try await shared.run { arguments, configuration in + let json = try createJSONData(configuration: configuration) + + arguments.append(contentsOf: [ + "--tags", "setup-project", + "--extra-vars", "project_dir=\(projectDirectory)", + "--extra-vars", "'\(json)'" + ]) + + if let extraOptions = shared.extraOptions { + arguments.append(contentsOf: extraOptions) + } + } + } +} + +extension PlaybookClient.RunPlaybook.SharedRunOptions { + + func run(_ apply: @Sendable @escaping (inout [String], Configuration) throws -> Void) async throws { + @Dependency(\.commandClient) var commandClient + + try await commandClient.run( + logging: loggingOptions, + quiet: quiet, + shell: shell + ) { + @Dependency(\.logger) var logger + @Dependency(\.configurationClient) var configurationClient + + let configuration = try await configurationClient.findAndLoad() + + var arguments = try await PlaybookClient.RunPlaybook.makeCommonArguments( + configuration: configuration, + inventoryFilePath: inventoryFilePath + ) + + try apply(&arguments, configuration) + + return arguments + } + } +} + +@_spi(Internal) +public extension PlaybookClient.RunPlaybook { + + static func ensuredInventoryPath( + _ optionalInventoryPath: String?, + configuration: Configuration, + playbookDirectory: String + ) -> String { + guard let path = optionalInventoryPath else { + guard let path = configuration.playbook?.inventory else { + return "\(playbookDirectory)/\(PlaybookClient.Constants.inventoryFileName)" + } + return path + } + return path + } + + static func makeCommonArguments( + configuration: Configuration, + inventoryFilePath: String? + ) async throws -> [String] { + @Dependency(\.logger) var logger + + let playbookDirectory = try await PlaybookClient.Repository.findDirectory(configuration: configuration) + let playbookPath = "\(playbookDirectory)/\(PlaybookClient.Constants.playbookFileName)" + logger.trace("Playbook path: \(playbookPath)") + + let inventoryPath = ensuredInventoryPath( + inventoryFilePath, + configuration: configuration, + playbookDirectory: playbookDirectory + ) + logger.trace("Inventory path: \(inventoryPath)") + + var arguments = [ + PlaybookClient.Constants.playbookCommand, playbookPath, + "--inventory", inventoryPath + ] + + if let defaultArgs = configuration.args { + arguments.append(contentsOf: defaultArgs) + } + + if configuration.useVaultArgs, let vaultArgs = configuration.vault.args { + arguments.append(contentsOf: vaultArgs) + } + + logger.trace("Common arguments: \(inventoryPath)") + return arguments + } + +} + +// +// NOTE: We're not using the `Coders` client because we generally do not +// want the output to be `prettyPrinted` or anything, unless we're running +// tests, so we use a supplied json encoder. +// +extension PlaybookClient.RunPlaybook.CreateOptions { + + func createJSONData( + configuration: Configuration, + encoder: JSONEncoder = .init() + ) throws -> Data { + @Dependency(\.logger) var logger + + let templateDir = template.directory ?? configuration.template.directory + let templateRepo = template.url ?? configuration.template.url + let version = template.version ?? configuration.template.version + + logger.debug(""" + (\(useLocalTemplateDirectory), \(String(describing: templateDir)), \(String(describing: templateRepo))) + """) + + switch (useLocalTemplateDirectory, templateDir, templateRepo) { + case (true, .none, _): + // User supplied they wanted to use a local template directory, but we could not find + // the path set from command line or in configuration. + throw PlaybookClientError.templateDirectoryNotFound + case let (false, _, .some(repo)): + // User did not supply they wanted to use a local template directory, and we found a repo url that was + // either set by the command line or found in the configuration. + logger.debug("Using repo.") + return try encoder.encode(TemplateRepo(repo: repo, version: version)) + case let (true, .some(templateDir), _): + // User supplied they wanted to use a local template directory, and we found the template directory + // either set by the command line or in the configuration. + logger.debug("Using template directory.") + return try encoder.encode(TemplateDirJson(path: templateDir)) + case let (false, .some(templateDir), _): + // User supplied they did not wanted to use a local template directory, and we found the template directory + // either set by the command line or in the configuration, and no repo was found / handled previously. + logger.debug("Using template directory.") + return try encoder.encode(TemplateDirJson(path: templateDir)) + case (_, .none, .none): + // We could not find a repo or template directory. + throw PlaybookClientError.templateDirectoryOrRepoNotSpecified + } + } +} + +private struct TemplateDirJson: Encodable { + + let template: Template + + init(path: String) { + self.template = .init(path: path) + } + + struct Template: Encodable { + let path: String + } +} + +private struct TemplateRepo: Encodable { + + let template: Template + + init(repo: String, version: String?) { + self.template = .init(repo: .init(url: repo, version: version ?? "main")) + } + + struct Template: Encodable { + let repo: Repo + } + + struct Repo: Encodable { + let url: String + let version: String + } +} diff --git a/Sources/PlaybookClient/PlaybookClient.swift b/Sources/PlaybookClient/PlaybookClient.swift index ef0b7f7..763596e 100644 --- a/Sources/PlaybookClient/PlaybookClient.swift +++ b/Sources/PlaybookClient/PlaybookClient.swift @@ -1,3 +1,4 @@ +import CommandClient import ConfigurationClient import Dependencies import DependenciesMacros @@ -17,6 +18,7 @@ public extension DependencyValues { @DependencyClient public struct PlaybookClient: Sendable { public var repository: Repository + public var run: RunPlaybook } public extension PlaybookClient { @@ -33,23 +35,131 @@ public extension PlaybookClient { public func directory() async throws -> String { try await directory(nil) } + } +} - public static var liveValue: Self { - .init { - try await installPlaybook(configuration: $0) - } directory: { - try await findDirectory(configuration: $0) +public extension PlaybookClient { + + @DependencyClient + struct RunPlaybook: Sendable { + public var buildProject: @Sendable (BuildOptions) async throws -> Void + public var createProject: @Sendable (CreateOptions) async throws -> Void + + public struct SharedRunOptions: Equatable, Sendable { + public let extraOptions: [String]? + public let inventoryFilePath: String? + public let loggingOptions: LoggingOptions + public let quiet: Bool + public let shell: String? + + public init( + extraOptions: [String]?, + inventoryFilePath: String?, + loggingOptions: LoggingOptions, + quiet: Bool, + shell: String? + ) { + self.extraOptions = extraOptions + self.inventoryFilePath = inventoryFilePath + self.loggingOptions = loggingOptions + self.quiet = quiet + self.shell = shell + } + } + + @dynamicMemberLookup + public struct BuildOptions: Equatable, Sendable { + public let projectDirectory: String? + public let shared: SharedRunOptions + + public init( + extraOptions: [String]?, + inventoryFilePath: String?, + loggingOptions: LoggingOptions, + quiet: Bool, + shell: String?, + projectDirectory: String + ) { + self.projectDirectory = projectDirectory + self.shared = .init( + extraOptions: extraOptions, + inventoryFilePath: inventoryFilePath, + loggingOptions: loggingOptions, + quiet: quiet, + shell: shell + ) + } + + public subscript(dynamicMember keyPath: KeyPath) -> T { + shared[keyPath: keyPath] + } + } + + @dynamicMemberLookup + public struct CreateOptions: Equatable, Sendable { + public let projectDirectory: String + public let shared: SharedRunOptions + public let template: Configuration.Template + public let useLocalTemplateDirectory: Bool + + public init( + extraOptions: [String]?, + inventoryFilePath: String?, + loggingOptions: LoggingOptions, + projectDirectory: String, + quiet: Bool, + shell: String?, + template: Configuration.Template, + useLocalTemplateDirectory: Bool + ) { + self.projectDirectory = projectDirectory + self.template = template + self.useLocalTemplateDirectory = useLocalTemplateDirectory + self.shared = .init( + extraOptions: extraOptions, + inventoryFilePath: inventoryFilePath, + loggingOptions: loggingOptions, + quiet: quiet, + shell: shell + ) + } + + public subscript(dynamicMember keyPath: KeyPath) -> T { + shared[keyPath: keyPath] } } } } -extension PlaybookClient: DependencyKey { - public static let testValue: PlaybookClient = Self(repository: Repository()) +extension PlaybookClient.Repository: DependencyKey { + public static var liveValue: Self { + .init { + try await installPlaybook(configuration: $0) + } directory: { + try await findDirectory(configuration: $0) + } + } +} - public static var liveValue: PlaybookClient { +extension PlaybookClient.RunPlaybook: DependencyKey { + public static var liveValue: PlaybookClient.RunPlaybook { .init( - repository: .liveValue + buildProject: { try await $0.run() }, + createProject: { try await $0.run() } + ) + } +} + +extension PlaybookClient: DependencyKey { + public static let testValue: PlaybookClient = Self( + repository: Repository(), + run: RunPlaybook() + ) + + public static var liveValue: PlaybookClient { + .init( + repository: .liveValue, + run: .liveValue ) } } diff --git a/Sources/PlaybookClient/PlaybookClientError.swift b/Sources/PlaybookClient/PlaybookClientError.swift new file mode 100644 index 0000000..c90073c --- /dev/null +++ b/Sources/PlaybookClient/PlaybookClientError.swift @@ -0,0 +1,7 @@ +import Foundation + +enum PlaybookClientError: Error { + case projectDirectoryNotFound + case templateDirectoryNotFound + case templateDirectoryOrRepoNotSpecified +}