feat: Moves logging setup and generate-json for the create command to cli-client module.

This commit is contained in:
2024-12-12 11:16:22 -05:00
parent ce6eb3ec2f
commit 7b30b78b67
14 changed files with 449 additions and 230 deletions

View File

@@ -23,7 +23,11 @@ public extension CliClient {
try await runCommand(quiet: quiet, shell: shell, args)
}
func runPlaybookCommand(_ options: PlaybookOptions) async throws {
func runPlaybookCommand(
_ options: PlaybookOptions,
logging loggingOptions: LoggingOptions
) async throws {
try await withLogger(loggingOptions) {
@Dependency(\.configurationClient) var configurationClient
@Dependency(\.logger) var logger
@@ -62,8 +66,13 @@ public extension CliClient {
arguments
)
}
}
func runVaultCommand(_ options: VaultOptions) async throws {
func runVaultCommand(
_ options: VaultOptions,
logging loggingOptions: LoggingOptions
) async throws {
try await withLogger(loggingOptions) {
@Dependency(\.configurationClient) var configurationClient
@Dependency(\.fileClient) var fileClient
@Dependency(\.logger) var logger
@@ -99,6 +108,7 @@ public extension CliClient {
arguments
)
}
}
}
@_spi(Internal)
@@ -162,9 +172,4 @@ public extension FileClient {
}
}
enum CliClientError: Error {
case playbookDirectoryNotFound
case vaultFileNotFound
}
extension ShellCommand.Shell: @retroactive @unchecked Sendable {}

View File

@@ -0,0 +1,9 @@
import Foundation
public enum CliClientError: Error {
case encodingError
case playbookDirectoryNotFound
case templateDirectoryNotFound
case templateDirectoryOrRepoNotSpecified
case vaultFileNotFound
}

View File

@@ -0,0 +1,80 @@
import ConfigurationClient
import Dependencies
import Foundation
// 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.
func createJSONData(
_ options: CliClient.GenerateJsonOptions,
logging loggingOptions: CliClient.LoggingOptions,
encoder: JSONEncoder = .init()
) async throws -> Data {
try await CliClient.withLogger(loggingOptions) {
@Dependency(\.logger) var logger
@Dependency(\.configurationClient) var configurationClient
let configuration = try await configurationClient.findAndLoad()
let templateDir = options.templateDirectory ?? configuration.template.directory
let templateRepo = options.templateRepo ?? configuration.template.url
let version = options.version ?? configuration.template.version
logger.debug("""
(\(options.useLocalTemplateDirectory), \(String(describing: templateDir)), \(String(describing: templateRepo)))
""")
switch (options.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 CliClientError.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 CliClientError.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: repo, version: version ?? "main")
}
struct Template: Encodable {
let repo: String
let version: String
}
}

View File

@@ -4,9 +4,6 @@ import DependenciesMacros
import Foundation
import ShellClient
// TODO: Add logging options and setup in this module and remove from the
// executable `hpa` module.
public extension DependencyValues {
var cliClient: CliClient {
get { self[CliClient.self] }
@@ -16,11 +13,49 @@ public extension DependencyValues {
@DependencyClient
public struct CliClient: Sendable {
public var runCommand: @Sendable (RunCommandOptions) async throws -> Void
public var generateJSON: @Sendable (GenerateJsonOptions, LoggingOptions, JSONEncoder) async throws -> String
public func generateJSON(
_ options: GenerateJsonOptions,
logging loggingOptions: LoggingOptions,
encoder jsonEncoder: JSONEncoder = .init()
) async throws -> String {
try await generateJSON(options, loggingOptions, jsonEncoder)
}
}
public extension CliClient {
struct GenerateJsonOptions: Equatable, Sendable {
let templateDirectory: String?
let templateRepo: String?
let version: String?
let useLocalTemplateDirectory: Bool
public init(
templateDirectory: String?,
templateRepo: String?,
version: String?,
useLocalTemplateDirectory: Bool
) {
self.templateDirectory = templateDirectory
self.templateRepo = templateRepo
self.version = version
self.useLocalTemplateDirectory = useLocalTemplateDirectory
}
}
struct LoggingOptions: Equatable, Sendable {
let commandName: String
let logLevel: Logger.Level
public init(commandName: String, logLevel: Logger.Level) {
self.commandName = commandName
self.logLevel = logLevel
}
}
struct PlaybookOptions: Sendable, Equatable {
let arguments: [String]
let configuration: Configuration?
@@ -109,6 +144,12 @@ extension CliClient: DependencyKey {
options.arguments
))
}
} generateJSON: { options, loggingOptions, encoder in
let data = try await createJSONData(options, logging: loggingOptions, encoder: encoder)
guard let string = String(data: data, encoding: .utf8) else {
throw CliClientError.encodingError
}
return string
}
}
@@ -121,6 +162,8 @@ extension CliClient: DependencyKey {
public static func capturing(_ client: CapturingClient) -> Self {
.init { options in
await client.set(options)
} generateJSON: {
try await Self().generateJSON($0, $1, $2)
}
}

View File

@@ -0,0 +1,28 @@
import Dependencies
import Logging
import ShellClient
public extension CliClient {
@discardableResult
func withLogger<T>(
_ options: LoggingOptions,
operation: @Sendable @escaping () async throws -> T
) async rethrows -> T {
try await Self.withLogger(options, operation: operation)
}
@discardableResult
static func withLogger<T>(
_ options: LoggingOptions,
operation: @Sendable @escaping () async throws -> T
) async rethrows -> T {
try await withDependencies {
$0.logger = .init(label: "\(Constants.executableName)")
$0.logger.logLevel = options.logLevel
$0.logger[metadataKey: "command"] = "\(options.commandName.blue)"
} operation: {
try await operation()
}
}
}

View File

@@ -38,18 +38,17 @@ struct BuildCommand: AsyncParsableCommand {
}
private func _run() async throws {
try await withSetupLogger(commandName: Self.commandName, globals: globals) {
@Dependency(\.cliClient) var cliClient
try await cliClient.runPlaybookCommand(
globals.playbookOptions(
arguments: [
"--tags", "build-project",
"--extra-vars", "project_dir=\(self.projectDir)"
"--extra-vars", "project_dir=\(projectDir)"
],
configuration: nil
),
logging: globals.loggingOptions(commandName: Self.commandName)
)
)
}
}
}

View File

@@ -61,126 +61,46 @@ 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(\.configurationClient) var configurationClient
let loggingOptions = globals.loggingOptions(commandName: Self.commandName)
try await cliClient.withLogger(loggingOptions) {
@Dependency(\.logger) var logger
let encoder = coders.jsonEncoder()
let configuration = try await configurationClient.findAndLoad()
logger.debug("Configuration: \(configuration)")
let jsonData = try parseOptions(
command: self,
configuration: configuration,
logger: logger,
encoder: encoder
let json = try await cliClient.generateJSON(
generateJsonOptions,
logging: loggingOptions
)
guard let jsonString = String(data: jsonData, encoding: .utf8) else {
throw CreateError.encodingError
}
logger.debug("JSON string: \(jsonString)")
logger.debug("JSON string: \(json)")
let arguments = [
"--tags", "setup-project",
"--extra-vars", "project_dir=\(self.projectDir)",
"--extra-vars", "'\(jsonString)'"
"--extra-vars", "'\(json)'"
] + extraArgs
try await cliClient.runPlaybookCommand(
globals.playbookOptions(
arguments: arguments,
configuration: configuration
configuration: nil
),
logging: loggingOptions
)
}
}
}
private extension CreateCommand {
var generateJsonOptions: CliClient.GenerateJsonOptions {
.init(
templateDirectory: templateDir,
templateRepo: repo,
version: branch,
useLocalTemplateDirectory: localTemplateDir
)
// try await runPlaybook(
// commandName: Self.commandName,
// globals: self.globals,
// configuration: configuration,
// extraArgs: extraArgs,
// "--tags", "setup-project",
// "--extra-vars", "project_dir=\(self.projectDir)",
// "--extra-vars", "'\(jsonString)'"
// )
}
}
}
private func parseOptions(
command: CreateCommand,
configuration: Configuration,
logger: Logger,
encoder: JSONEncoder
) throws -> Data {
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)))
""")
switch (command.localTemplateDir, 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 CreateError.templateDirNotFound
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 CreateError.templateDirOrRepoNotSpecified
}
}
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: repo, version: version ?? "main")
}
struct Template: Encodable {
let repo: String
let version: String
}
}
enum CreateError: Error {
case encodingError
case templateDirNotFound
case templateDirOrRepoNotSpecified
}

View File

@@ -1,4 +1,5 @@
import ArgumentParser
import CliClient
struct BasicGlobalOptions: ParsableArguments {
@Flag(
@@ -55,3 +56,21 @@ struct GlobalOptions: ParsableArguments {
}
}
extension GlobalOptions {
func loggingOptions(commandName: String) -> CliClient.LoggingOptions {
.init(
commandName: commandName,
logLevel: .init(globals: basic, quietOnlyPlaybook: quietOnlyPlaybook)
)
}
}
extension BasicGlobalOptions {
func loggingOptions(commandName: String) -> CliClient.LoggingOptions {
.init(
commandName: commandName,
logLevel: .init(globals: self, quietOnlyPlaybook: false)
)
}
}

View File

@@ -1,7 +1,5 @@
import Dependencies
import Logging
// TODO: Move some of this to the cli-client module.
extension Logger.Level {
/// Set the log level based on the user's options supplied.
@@ -22,34 +20,3 @@ extension Logger.Level {
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

@@ -50,9 +50,8 @@ struct GenerateConfigurationCommand: AsyncParsableCommand {
// FIX:
private func _run() async throws {
try await withSetupLogger(commandName: Self.commandName, globals: globals) {
@Dependency(\.cliClient) var cliClient
try await cliClient.withLogger(globals.loggingOptions(commandName: Self.commandName)) {
let actualPath: String
// if let path {

View File

@@ -29,7 +29,8 @@ struct DecryptCommand: AsyncParsableCommand {
}
try await cliClient.runVaultCommand(
options.vaultOptions(arguments: args, configuration: nil)
options.vaultOptions(arguments: args, configuration: nil),
logging: options.loggingOptions(commandName: Self.commandName)
)
// try await runVault(

View File

@@ -28,7 +28,8 @@ struct EncryptCommand: AsyncParsableCommand {
args.append(contentsOf: ["--output", output])
}
try await cliClient.runVaultCommand(
options.vaultOptions(arguments: args, configuration: nil)
options.vaultOptions(arguments: args, configuration: nil),
logging: options.loggingOptions(commandName: Self.commandName)
)
// try await runVault(

View File

@@ -1,4 +1,5 @@
import ArgumentParser
import CliClient
// Holds the common options for vault commands, as they all share the
// same structure.
@@ -29,3 +30,9 @@ struct VaultOptions: ParsableArguments {
}
}
extension VaultOptions {
func loggingOptions(commandName: String) -> CliClient.LoggingOptions {
globals.loggingOptions(commandName: commandName)
}
}

View File

@@ -10,6 +10,12 @@ import TestSupport
@Suite("CliClientTests")
struct CliClientTests: TestCase {
static let loggingOptions: CliClient.LoggingOptions = {
let levelString = ProcessInfo.processInfo.environment["LOGGING_LEVEL"] ?? "debug"
let logLevel = Logger.Level(rawValue: levelString) ?? .debug
return .init(commandName: "CliClientTests", logLevel: logLevel)
}()
@Test
func capturingClient() async throws {
let captured = CliClient.CapturingClient()
@@ -35,7 +41,10 @@ struct CliClientTests: TestCase {
@Dependency(\.cliClient) var cliClient
let configuration = Configuration.mock
try await cliClient.runVaultCommand(.init(arguments: [argument], quiet: false, shell: nil))
try await cliClient.runVaultCommand(
.init(arguments: [argument], quiet: false, shell: nil),
logging: Self.loggingOptions
)
let shell = await captured.shell
#expect(shell == .zsh(useDashC: true))
@@ -74,11 +83,14 @@ struct CliClientTests: TestCase {
try await withMockConfiguration(captured, configuration: configuration, key: "runPlaybook") {
@Dependency(\.cliClient) var cliClient
try await cliClient.runPlaybookCommand(.init(
try await cliClient.runPlaybookCommand(
.init(
arguments: [],
quiet: false,
shell: nil
))
),
logging: Self.loggingOptions
)
let expectedArguments = [
"ansible-playbook", "playbook/main.yml",
@@ -156,6 +168,118 @@ struct CliClientTests: TestCase {
}
}
@Test(
arguments: [
GenerateJsonTestOption(
options: .init(
templateDirectory: nil,
templateRepo: nil,
version: nil,
useLocalTemplateDirectory: true
),
configuration: nil,
expectation: .failing
),
GenerateJsonTestOption(
options: .init(
templateDirectory: nil,
templateRepo: nil,
version: nil,
useLocalTemplateDirectory: false
),
configuration: nil,
expectation: .failing
),
GenerateJsonTestOption(
options: .init(
templateDirectory: "template",
templateRepo: nil,
version: nil,
useLocalTemplateDirectory: true
),
configuration: nil,
expectation: .success("""
{
"template" : {
"path" : "template"
}
}
""")
),
GenerateJsonTestOption(
options: .init(
templateDirectory: nil,
templateRepo: nil,
version: nil,
useLocalTemplateDirectory: false
),
configuration: .init(template: .init(directory: "template")),
expectation: .success("""
{
"template" : {
"path" : "template"
}
}
""")
),
GenerateJsonTestOption(
options: .init(
templateDirectory: nil,
templateRepo: "https://git.example.com/template.git",
version: nil,
useLocalTemplateDirectory: false
),
configuration: nil,
expectation: .success("""
{
"template" : {
"repo" : "https://git.example.com/template.git",
"version" : "main"
}
}
""")
),
GenerateJsonTestOption(
options: .init(
templateDirectory: nil,
templateRepo: nil,
version: nil,
useLocalTemplateDirectory: false
),
configuration: .init(template: .init(url: "https://git.example.com/template.git", version: "v0.1.0")),
expectation: .success("""
{
"template" : {
"repo" : "https://git.example.com/template.git",
"version" : "v0.1.0"
}
}
""")
)
]
)
func generateJson(input: GenerateJsonTestOption) async {
await withTestLogger(key: "generateJson") {
$0.configurationClient = .mock(input.configuration ?? .init())
$0.cliClient = .liveValue
} operation: {
@Dependency(\.cliClient) var cliClient
let json = try? await cliClient.generateJSON(
input.options,
logging: Self.loggingOptions,
encoder: jsonEncoder
)
switch input.expectation {
case let .success(expected):
#expect(json == expected)
case .failing:
#expect(json == nil)
}
}
}
func withMockConfiguration(
_ capturing: CliClient.CapturingClient,
configuration: Configuration = .mock,
@@ -174,6 +298,17 @@ struct CliClientTests: TestCase {
}
}
struct GenerateJsonTestOption: Sendable {
let options: CliClient.GenerateJsonOptions
let configuration: Configuration?
let expectation: GenerateJsonExpectation
}
enum GenerateJsonExpectation: Sendable {
case failing
case success(String)
}
extension ConfigurationClient {
static func mock(_ configuration: Configuration) -> Self {
var mock = Self.testValue
@@ -184,3 +319,9 @@ extension ConfigurationClient {
}
struct TestError: Error {}
let jsonEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes, .sortedKeys]
return encoder
}()