feat: Merges dev
All checks were successful
CI / Run Tests (push) Successful in 2m43s

This commit is contained in:
2024-12-17 15:55:36 -05:00
parent 857177032c
commit faa28749bc
88 changed files with 4513 additions and 2301 deletions

View File

@@ -0,0 +1,224 @@
import Foundation
// NOTE: When adding items, then the 'hpa.toml' resource file needs to be updated.
/// Represents configurable settings for the application.
public struct Configuration: Codable, Equatable, Sendable {
/// Default arguments / options that can get passed into
/// ansible-playbook commands.
public let args: [String]?
/// Whether to use the vault arguments as defaults that get passed into
/// the ansible-playbook commands.
public let useVaultArgs: Bool
/// Configuration for when generating files from templated directories.
public let generate: Generate?
/// Configuration of the ansible-playbook, these are more for developing the
/// playbook locally and generally not needed by the user.
public let playbook: Playbook?
/// Template configuration options.
public let template: Template
/// Ansible-vault configuration options.
public let vault: Vault
public init(
args: [String]? = nil,
useVaultArgs: Bool = true,
generate: Generate? = nil,
playbook: Playbook? = nil,
template: Template = .init(),
vault: Vault = .init()
) {
self.args = args
self.useVaultArgs = useVaultArgs
self.generate = generate
self.playbook = playbook
self.template = template
self.vault = vault
}
public static var mock: Self {
.init(
args: [],
useVaultArgs: true,
playbook: nil,
template: .mock,
vault: .mock
)
}
/// Configuration options for running `pandoc` commands that generate files from
/// a templated directory.
///
/// ## NOTE: Most of these can also be set by the template repository variables.
///
///
public struct Generate: Codable, Equatable, Sendable {
/// Specifiy the name of the build directory, generally `.build`.
public let buildDirectory: String?
/// Specifiy the files used in the `pandoc` command to generate a final output file.
///
/// - SeeAlso: `pandoc --help`
public let files: [String]?
/// Specifiy the files used in the `pandoc` command to include in the header.
///
/// - SeeAlso: `pandoc --help`
public let includeInHeader: [String]?
/// The default name of the output file, this does not require the file extension as we will
/// add it based on the command that is called. Generally this is 'Report'.
///
public let outputFileName: String?
/// The default pdf engine to use when generating pdf files using `pandoc`. Generally
/// this is 'xelatex'.
public let pdfEngine: String?
public init(
buildDirectory: String? = nil,
files: [String]? = nil,
includeInHeader: [String]? = nil,
outputFileName: String? = nil,
pdfEngine: String? = nil
) {
self.buildDirectory = buildDirectory
self.files = files
self.includeInHeader = includeInHeader
self.outputFileName = outputFileName
self.pdfEngine = pdfEngine
}
/// Represents the default configuration for generating files using `pandoc`.
public static let `default` = Self.mock
/// Represents mock configuration for generating files using `pandoc`.
public static var mock: Self {
.init(
buildDirectory: ".build",
files: ["Report.md", "Definitions.md"],
includeInHeader: ["head.tex", "footer.tex"],
outputFileName: "Report",
pdfEngine: "xelatex"
)
}
}
/// Configuration options for the ansible-hpa-playbook. The playbook is
/// the primary driver of templating files, generating repository templates, and building
/// projects.
///
/// ## NOTE: These are generally only used for local development of the playbook.
///
///
public struct Playbook: Codable, Equatable, Sendable {
/// The directory location of the ansible playbook.
public let directory: String?
/// The inventory file name / location.
public let inventory: String?
/// The playbook version, branch, or tag.
public let version: String?
public init(
directory: String? = nil,
inventory: String? = nil,
version: String? = nil
) {
self.directory = directory
self.inventory = inventory
self.version = version
}
public static var mock: Self { .init() }
}
/// Configuration settings for the user's template repository or directory.
///
/// A template is what is used to create projects for a user. Generally they setup
/// their own template by customizing the default template to their needs. This template
/// can then be stored as a git repository or on the local file system.
///
/// The template will hold variables and files that are used to generate the final output
/// files using `pandoc`. Generally the template will hold files that may only need setup once,
/// such as header files, footer files, and definitions.
///
/// The project directory contains dynamic variables / files that need edited in order
/// to generate the final output. This allows the project directory to remain smaller so the user
/// can more easily focus on the tasks required to generate the final output files.
public struct Template: Codable, Equatable, Sendable {
/// A url of the template when it is a remote git repository.
public let url: String?
/// The version, branch, or tag of the remote repository.
public let version: String?
/// The local directory that contains the template on the local file system.
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"
)
}
}
/// Configuration for `ansible-vault` commands. Ansible vault is used to encrypt and
/// decrypt sensitive data. This allows for variables, such as customer name and address
/// to be stored along with the project in an encrypted file so that it is safer to store them.
///
/// These may also be used in general `ansible-playbook` commands if the `useVaultArgs` is set
/// to `true` in a users configuration.
///
public struct Vault: Codable, Equatable, Sendable {
/// A list of arguments / options that get passed to the `ansible-vault` command.
///
/// - SeeAlso: `ansible-vault --help`
public let args: [String]?
/// An id that is used during encrypting `ansible-vault` files.
///
/// - SeeAlso: `ansible-vault encrypt --help`
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"
)
}
}
}

View File

@@ -0,0 +1,349 @@
import CodersClient
import Dependencies
import DependenciesMacros
import FileClient
import Foundation
import ShellClient
public extension DependencyValues {
/// Interacts with the user's configuration.
var configurationClient: ConfigurationClient {
get { self[ConfigurationClient.self] }
set { self[ConfigurationClient.self] = newValue }
}
}
/// Represents actions that can be taken on user's configuration files.
///
///
@DependencyClient
public struct ConfigurationClient: Sendable {
/// Find the user's configuration, searches in the current directory and default
/// locations where configuration can be stored. An error is thrown if no configuration
/// is found.
public var find: @Sendable () async throws -> File
/// Generate a configuration file for the user.
public var generate: @Sendable (GenerateOptions) async throws -> String
/// Load a configuration file from the given file location. If the file is
/// not provided then we return an empty configuraion item.
public var load: @Sendable (File?) async throws -> Configuration
/// Write the configuration to the given file, optionally forcing an overwrite of
/// the file.
///
/// ## NOTE: This uses the `fileClient.write` under the hood, so if you need to control
/// what happens during tests, then you can customize the behavior of the fileClient.
///
public var write: @Sendable (WriteOptions) async throws -> Void
/// Find the user's configuration and load it.
public func findAndLoad() async throws -> Configuration {
let file = try? await find()
return try await load(file)
}
/// Write the configuration to the given file, optionally forcing an overwrite of
/// the file. If a file already exists and force is not supplied then we will create
/// a backup of the existing file.
///
/// ## NOTE: This uses the `fileClient.write` under the hood, so if you need to control
/// what happens during tests, then you can customize the behavior of the fileClient.
///
/// - Parameters:
/// - configuration: The configuration to save.
/// - file: The file location and type to save.
/// - force: Force overwritting if a file already exists.
public func write(
_ configuration: Configuration,
to file: File,
force: Bool = false
) async throws {
try await write(.init(configuration, to: file, force: force))
}
/// Represents the options to generate a configuration file for a user.
public struct GenerateOptions: Equatable, Sendable {
/// Force generation, overwritting existing file if it exists.
public let force: Bool
/// Generate a `json` file instead of the default `toml` file.
public let json: Bool
/// The path to generate a file in.
public let path: Path?
public init(
force: Bool = false,
json: Bool = false,
path: Path? = nil
) {
self.force = force
self.json = json
self.path = path
}
/// Represents the path option for generating a configuration file for a user.
///
/// This can either be a full file path or a directory. If a directory is supplied
/// then we will use the default file name of 'config' and add the extension dependning
/// on if the caller wants a `json` or `toml` file.
public enum Path: Equatable, Sendable {
case file(File)
case directory(String)
}
}
/// Represents the options required to write a configuration file.
public struct WriteOptions: Equatable, Sendable {
/// The configuration to wrtie to the file path.
public let configuration: Configuration
/// The file path to write the configuration to.
public let file: File
/// Force overwritting an existing file, if it exists.
public let force: Bool
public init(
_ configuration: Configuration,
to file: File,
force: Bool
) {
self.configuration = configuration
self.file = file
self.force = 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(
find: { try await liveClient.find() },
generate: { try await liveClient.generate($0) },
load: { try await liveClient.load(file: $0) },
write: { try await liveClient.write($0) }
)
}
public static var liveValue: Self {
.live(environment: ProcessInfo.processInfo.environment)
}
@_spi(Internal)
public static func mock(_ configuration: Configuration = .mock) -> Self {
.init(
find: { throw MockFindError() },
generate: Self().generate,
load: { _ in configuration },
write: Self().write
)
}
}
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(_ options: ConfigurationClient.GenerateOptions) async throws -> String {
@Dependency(\.logger) var logger
let file: File
if let path = options.path {
switch path {
case let .file(requestedFile):
file = requestedFile
case let .directory(directory):
file = .init("\(directory)/\(HPAKey.defaultFileNameWithoutExtension).\(options.json ? "json" : "toml")")!
}
} else {
let configDir = "\(environment.xdgConfigHome)/\(HPAKey.configDirName)"
file = .init("\(configDir)/\(HPAKey.defaultFileName)")!
}
logger.debug("Begin generating configuration: \(file.path), force: \(options.force)")
let expandedPath = file.path.replacingOccurrences(
of: "~",
with: fileManager.homeDirectory().cleanFilePath
)
let fileUrl = URL(filePath: expandedPath)
let exists = fileManager.fileExists(fileUrl)
if !options.force, exists {
try await createBackup(file.url)
}
let fileDirectory = fileUrl.deletingLastPathComponent()
let directoryExists = try await fileManager.isDirectory(fileDirectory)
if !directoryExists {
logger.debug("Creating directory at: \(fileDirectory.cleanFilePath)")
try await fileManager.createDirectory(fileDirectory)
}
// TODO: The hpa file needs to be copied somewhere on the system during install and
// not use bundle, as it only works if the tool was built on the users system.
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, fileUrl)
} else {
// Json does not allow comments, so we write the mock configuration
// to the file path.
try await write(.init(.mock, to: File(fileUrl)!, force: options.force))
}
return fileUrl.cleanFilePath
}
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(
_ options: ConfigurationClient.WriteOptions
) async throws {
let configuration = options.configuration
let file = options.file
let force = options.force
let exists = fileManager.fileExists(file.url)
if !force, exists {
try await createBackup(file.url)
}
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 createBackup(_ url: URL) async throws {
let backupUrl = url.appendingPathExtension("back")
logger.warning("File exists, creating a backup of the existing file at: \(backupUrl.cleanFilePath)")
try await fileManager.copy(url, backupUrl)
try await fileManager.delete(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)
}
struct MockFindError: Error {}

View File

@@ -0,0 +1,32 @@
/// Represents keys in the environment that can be used to locate a user's
/// configuration file.
@_spi(Internal)
public enum EnvironmentKey {
static let xdgConfigHome = "XDG_CONFIG_HOME"
static let hpaConfigHome = "HPA_CONFIG_HOME"
static let hpaConfigFile = "HPA_CONFIG_FILE"
}
/// Represents keys that are used internally for directory names, file names, etc.
@_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"
public static let defaultFileNameWithoutExtension = "config"
}
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,65 @@
import FileClient
import Foundation
/// Represents a file location and type on disk for a configuration file.
///
/// Currently the supported formats are `json` or `toml`. Toml is the default /
/// preferred format because it allows comments in the file and is easy to understand.
///
public enum File: Equatable, Sendable {
case json(URL)
case toml(URL)
/// Attempts to create a file with the given url.
///
/// ## NOTE: There are no checks on if a file / path actually exists or not.
///
/// If the file does not have a suffix of `json` or `toml` then
/// we will return `nil`.
public init?(_ url: URL) {
if url.cleanFilePath.hasSuffix("json") {
self = .json(url)
} else if url.cleanFilePath.hasSuffix("toml") {
self = .toml(url)
} else {
return nil
}
}
/// Attempts to create a file with the given path.
///
/// ## NOTE: There are no checks on if a file / path actually exists or not.
///
/// If the file does not have a suffix of `json` or `toml` then
/// we will return `nil`.
public init?(_ path: String) {
self.init(URL(filePath: path))
}
/// Get the url of the file.
public var url: URL {
switch self {
case let .json(url): return url
case let .toml(url): return url
}
}
/// Get the path string of the file.
public var path: String {
url.cleanFilePath
}
/// Represents the default file path for a user's configuration.
///
/// Which is `~/.config/hpa/config.toml`
public static var `default`: Self {
let fileUrl = FileManager.default
.homeDirectoryForCurrentUser
.appending(path: ".config")
.appending(path: HPAKey.configDirName)
.appending(path: HPAKey.defaultFileName)
return .toml(fileUrl)
}
}

View File

@@ -0,0 +1,72 @@
# NOTE:
# Configuration settings for the `hpa` command line tool.
# You can delete settings that are not applicable to your use case.
# Default arguments / options that get passed into `ansible-playbook` commands.
# WARNING: Do not put arguments / options that contain spaces in the same string,
# they should be separate strings, for example do not do something like
# ['--tags debug'], instead use ['--tags', 'debug'].
#
args = []
# Set to true if you want to pass the vault args to `ansible-playbook` commands.
useVaultArgs = true
# NOTE:
# Configuration for running the generate command(s). This allows custimizations
# to the files that get used to generate the final output (generally a pdf).
# See `pandoc --help`. Below are the defaults that get used, which only need
# adjusted if your template does not follow the default template design or if
# you add extra files to your template that need to be included in the final
# output. Also be aware that any of the files specified in the `files` or
# `includeInHeader` options, need to be inside the `buildDirectory` when generating
# the final output file.
# [generate]
# this relative to the project directory.
# buildDirectory = '.build'
# pdfEngine = 'xelatex'
# includeInHeader = [
# 'head.tex',
# 'footer.tex'
# ]
# files = [
# 'Report.md',
# 'Definitions.md'
# ]
# outputFileName = 'Report'
# NOTE:
# These are more for local development of the ansible playbook and should not be needed
# in most cases. Uncomment the lines if you want to customize the playbook and use it
# instead of the provided / default playbook.
#[playbook]
#directory = '/path/to/local/playbook-directory'
#inventory = '/path/to/local/inventory.ini'
#version = 'main'
# NOTE:
# These are to declare where your template files are either on your local system or
# a remote git repository.
[template]
# The directory path on your local system to the template files.
directory = '/path/to/local/template-directory'
# The url to a git repository that contains your template files.
url = 'https://git.example.com/consult-template.git'
# The version, tag, branch, or sha of the template files to clone from the remote
# template repository. In general it is best practice to use a version instead of a
# branch.
version = '1.0.0'
# NOTE:
# Holds settings for `ansible-vault` commands.
[vault]
# Arguments to pass to commands that use `ansible-vault`, such as encrypting or decrypting
# files.
args = [ '--vault-id=myId@$SCRIPTS/vault-gopass-client' ]
# An id to use when encrypting `ansible-vault` files.
encryptId = 'myId'