feat: Breaking out more dependencies.

This commit is contained in:
2024-12-10 17:08:17 -05:00
parent 87390c4b63
commit 92cd6afa2b
20 changed files with 1408 additions and 691 deletions

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ DerivedData/
.netrc
.nvim/*
.swiftpm/*
hpa.toml

View File

@@ -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")
]
)
]
)

View File

@@ -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)
}

View File

@@ -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) }
}
}

View File

@@ -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"
}

View File

@@ -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") }
// }
// }

View File

@@ -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
// }
// }

View File

@@ -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() }
)
}
}

View File

@@ -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) }
// }
// }

View File

@@ -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)
}

View File

@@ -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]
}
}

View File

@@ -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: "")
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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)))

View File

@@ -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 {

View File

@@ -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])
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}