Compare commits
37 Commits
a8d35fed37
...
0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
558054464c
|
|||
|
54c07886ad
|
|||
|
f8e89ed0fa
|
|||
|
f596975bbc
|
|||
|
805100fa43
|
|||
|
f7f168b7fd
|
|||
|
99459a0a71
|
|||
|
f89efc8c5e
|
|||
|
85b285347b
|
|||
|
8c402f3f5f
|
|||
|
1429c51821
|
|||
|
1302b15ee2
|
|||
|
da810d0a45
|
|||
|
35d9422f07
|
|||
|
601869d457
|
|||
|
6d0108da0c
|
|||
|
bc0b740f95
|
|||
|
303cdef84b
|
|||
|
b5afc77428
|
|||
|
3f56dda568
|
|||
|
d1b3379815
|
|||
|
b557a60fa3
|
|||
|
5f4ef3b5b5
|
|||
|
ba1e61d99e
|
|||
|
bd56660683
|
|||
|
a480e942bc
|
|||
|
2b265a4ca5
|
|||
|
56a0bca00c
|
|||
|
7b30b78b67
|
|||
|
ce6eb3ec2f
|
|||
|
8302ede99e
|
|||
|
d9e91538fb
|
|||
|
c1a14ea855
|
|||
|
ddb5e6767a
|
|||
|
9c784d4dcb
|
|||
|
92cd6afa2b
|
|||
|
87390c4b63
|
20
.gitea/workflows/ci.yaml
Normal file
20
.gitea/workflows/ci.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "dev"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: https://git.housh.dev/actions/setup-just@v1
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Run Test
|
||||
run: just test-docker
|
||||
68
.gitea/workflows/release.yml
Executable file
68
.gitea/workflows/release.yml
Executable file
@@ -0,0 +1,68 @@
|
||||
#
|
||||
name: Create and publish a Docker image
|
||||
|
||||
# Configures this workflow to run every time a change is pushed to the branch called `release`.
|
||||
on:
|
||||
push:
|
||||
branches: ['release']
|
||||
tags:
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
|
||||
# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds.
|
||||
env:
|
||||
REGISTRY: git.housh.dev
|
||||
IMAGE_NAME: ${{ gitea.repository }}
|
||||
|
||||
# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu.
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
# Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
#
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
# Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.CONTAINER_TOKEN }}
|
||||
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=sha
|
||||
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages.
|
||||
# It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository.
|
||||
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
# This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see "[AUTOTITLE](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds)."
|
||||
# - name: Generate artifact attestation
|
||||
# uses: actions/attest-build-provenance@v1
|
||||
# with:
|
||||
# subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
|
||||
# subject-digest: ${{ steps.push.outputs.digest }}
|
||||
# push-to-registry: true
|
||||
# github-token: ${{ secrets.CONTAINER_TOKEN }}
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -6,3 +6,7 @@ DerivedData/
|
||||
.swiftpm/configuration/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
.nvim/*
|
||||
.swiftpm/*
|
||||
./hpa.toml
|
||||
./Version.*
|
||||
|
||||
@@ -2,6 +2,7 @@ disabled_rules:
|
||||
- closing_brace
|
||||
- fuction_body_length
|
||||
- opening_brace
|
||||
- nesting
|
||||
|
||||
included:
|
||||
- Sources
|
||||
|
||||
31
.swiftpm/swift-hpa-Package.xctestplan
Normal file
31
.swiftpm/swift-hpa-Package.xctestplan
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"configurations" : [
|
||||
{
|
||||
"id" : "BE1E3DDC-11A9-41D4-B82D-5EF22CCB79D9",
|
||||
"name" : "Configuration 1",
|
||||
"options" : {
|
||||
|
||||
}
|
||||
}
|
||||
],
|
||||
"defaultOptions" : {
|
||||
"testTimeoutsEnabled" : true
|
||||
},
|
||||
"testTargets" : [
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:",
|
||||
"identifier" : "CliClientTests",
|
||||
"name" : "CliClientTests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:",
|
||||
"identifier" : "ConfigurationClientTests",
|
||||
"name" : "ConfigurationClientTests"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 1
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "def70abefdc3133b863ecf24e1b413af00133f95cabea13d0ff42d7283910a58",
|
||||
"originHash" : "bc31b11e5e7d488e0a9c1bf91cb572d29f782bfd8e43f44157036f8f3d282893",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "combine-schedulers",
|
||||
@@ -37,6 +37,15 @@
|
||||
"version" : "0.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-cli-version",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/m-housh/swift-cli-version.git",
|
||||
"state" : {
|
||||
"revision" : "1885a90f622c91ea9bf7a9b3df82831dece8eb7d",
|
||||
"version" : "0.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-clocks",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -55,6 +64,15 @@
|
||||
"version" : "1.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-custom-dump",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-custom-dump.git",
|
||||
"state" : {
|
||||
"revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
|
||||
"version" : "1.3.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-dependencies",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -100,6 +118,15 @@
|
||||
"version" : "600.0.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "tomlkit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/LebJe/TOMLKit.git",
|
||||
"state" : {
|
||||
"revision" : "ec6198d37d495efc6acd4dffbd262cdca7ff9b3f",
|
||||
"version" : "0.6.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "xctest-dynamic-overlay",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
121
Package.swift
121
Package.swift
@@ -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,33 +7,143 @@ let package = Package(
|
||||
platforms: [.macOS(.v14)],
|
||||
products: [
|
||||
.executable(name: "hpa", targets: ["hpa"]),
|
||||
.library(name: "CliClient", targets: ["CliClient"])
|
||||
.library(name: "CodersClient", targets: ["CodersClient"]),
|
||||
.library(name: "CommandClient", targets: ["CommandClient"]),
|
||||
.library(name: "ConfigurationClient", targets: ["ConfigurationClient"]),
|
||||
.library(name: "FileClient", targets: ["FileClient"]),
|
||||
.library(name: "PlaybookClient", targets: ["PlaybookClient"]),
|
||||
.library(name: "PandocClient", targets: ["PandocClient"]),
|
||||
.library(name: "VaultClient", targets: ["VaultClient"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
|
||||
.package(url: "https://github.com/pointfreeco/swift-custom-dump.git", from: "1.3.3"),
|
||||
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.5.2"),
|
||||
.package(url: "https://github.com/m-housh/swift-shell-client.git", from: "0.1.0"),
|
||||
.package(url: "https://git.housh.dev/michael/swift-cli-doc.git", from: "0.2.0")
|
||||
.package(url: "https://git.housh.dev/michael/swift-cli-doc.git", from: "0.2.0"),
|
||||
.package(url: "https://github.com/m-housh/swift-cli-version.git", from: "0.1.0"),
|
||||
.package(url: "https://github.com/LebJe/TOMLKit.git", from: "0.5.0")
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "hpa",
|
||||
dependencies: [
|
||||
"CliClient",
|
||||
"ConfigurationClient",
|
||||
"FileClient",
|
||||
"PandocClient",
|
||||
"PlaybookClient",
|
||||
"VaultClient",
|
||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||
.product(name: "CliDoc", package: "swift-cli-doc"),
|
||||
.product(name: "CustomDump", package: "swift-custom-dump"),
|
||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||
.product(name: "ShellClient", package: "swift-shell-client")
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "CliClient",
|
||||
name: "CodersClient",
|
||||
dependencies: [
|
||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||
.product(name: "TOMLKit", package: "TOMLKit")
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "CommandClient",
|
||||
dependencies: [
|
||||
"Constants",
|
||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||
.product(name: "ShellClient", package: "swift-shell-client")
|
||||
]
|
||||
),
|
||||
.testTarget(name: "CliClientTests", dependencies: ["CliClient"])
|
||||
.target(name: "Constants"),
|
||||
.target(
|
||||
name: "TestSupport",
|
||||
dependencies: [
|
||||
"CommandClient",
|
||||
.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")
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "FileClientTests",
|
||||
dependencies: ["FileClient", "TestSupport"]
|
||||
),
|
||||
.target(
|
||||
name: "PandocClient",
|
||||
dependencies: [
|
||||
"CommandClient",
|
||||
"ConfigurationClient",
|
||||
"PlaybookClient",
|
||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||
.product(name: "DependenciesMacros", package: "swift-dependencies")
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "PandocClientTests",
|
||||
dependencies: ["PandocClient", "TestSupport"]
|
||||
),
|
||||
.target(
|
||||
name: "PlaybookClient",
|
||||
dependencies: [
|
||||
"CodersClient",
|
||||
"CommandClient",
|
||||
"ConfigurationClient",
|
||||
"FileClient",
|
||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||
.product(name: "ShellClient", package: "swift-shell-client")
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "PlaybookClientTests",
|
||||
dependencies: [
|
||||
"PlaybookClient",
|
||||
"TestSupport"
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "VaultClient",
|
||||
dependencies: [
|
||||
"CommandClient",
|
||||
"ConfigurationClient",
|
||||
"FileClient",
|
||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||
.product(name: "ShellClient", package: "swift-shell-client")
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "VaultClientTests",
|
||||
dependencies: ["VaultClient", "TestSupport"]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Foundation
|
||||
import ShellClient
|
||||
|
||||
public extension DependencyValues {
|
||||
var cliClient: CliClient {
|
||||
get { self[CliClient.self] }
|
||||
set { self[CliClient.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
quiet: Bool,
|
||||
shell: ShellCommand.Shell,
|
||||
_ args: [String]
|
||||
) async throws {
|
||||
try await runCommand(args, quiet, shell)
|
||||
}
|
||||
|
||||
public func runCommand(
|
||||
quiet: Bool,
|
||||
shell: ShellCommand.Shell,
|
||||
_ args: String...
|
||||
) async throws {
|
||||
try await runCommand(args, quiet, shell)
|
||||
}
|
||||
}
|
||||
|
||||
extension CliClient: DependencyKey {
|
||||
|
||||
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)")
|
||||
try fileClient.loadFile(url, &env, decoder)
|
||||
|
||||
return try .fromEnv(env)
|
||||
|
||||
} runCommand: { args, quiet, shell in
|
||||
@Dependency(\.asyncShellClient) var shellClient
|
||||
if !quiet {
|
||||
try await shellClient.foreground(.init(
|
||||
shell: shell,
|
||||
environment: ProcessInfo.processInfo.environment,
|
||||
in: nil,
|
||||
args
|
||||
))
|
||||
} else {
|
||||
try await shellClient.background(.init(
|
||||
shell: shell,
|
||||
environment: ProcessInfo.processInfo.environment,
|
||||
in: nil,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
public static var liveValue: CliClient {
|
||||
.live(env: ProcessInfo.processInfo.environment)
|
||||
}
|
||||
|
||||
public static let testValue: CliClient = Self()
|
||||
}
|
||||
|
||||
enum CliClientError: Error {
|
||||
case fileExistsAtPath(String)
|
||||
case vaultFileNotFound
|
||||
}
|
||||
|
||||
private let jsonEncoder: JSONEncoder = {
|
||||
var encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]
|
||||
return encoder
|
||||
}()
|
||||
@@ -1,97 +0,0 @@
|
||||
import Dependencies
|
||||
import Foundation
|
||||
import ShellClient
|
||||
|
||||
/// Represents the configuration.
|
||||
public struct Configuration: Codable, 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) }
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
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 {
|
||||
public var loadFile: @Sendable (URL, inout [String: String], JSONDecoder) throws -> Void
|
||||
public var homeDir: @Sendable () -> URL = { URL(string: "~/")! }
|
||||
public var isDirectory: @Sendable (URL) -> Bool = { _ in false }
|
||||
public var isReadable: @Sendable (URL) -> Bool = { _ in false }
|
||||
public var fileExists: @Sendable (String) -> Bool = { _ in false }
|
||||
public var findVaultFileInCurrentDirectory: @Sendable () throws -> URL?
|
||||
public var write: @Sendable (String, Data) throws -> Void
|
||||
}
|
||||
|
||||
@_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) },
|
||||
findVaultFileInCurrentDirectory: { try client.findVaultFileInCurrentDirectory() },
|
||||
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? {
|
||||
let urls = try fileManager.contentsOfDirectory(at: URL(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 {
|
||||
@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)
|
||||
let dict = (try? decoder.decode([String: String].self, from: data)) ?? [:]
|
||||
env.merge(dict, uniquingKeysWith: { $1 })
|
||||
return
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array where Element == URL {
|
||||
var firstVaultFile: URL? {
|
||||
first { $0.absoluteString.hasSuffix("vault.yml") }
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
41
Sources/CodersClient/Coders.swift
Normal file
41
Sources/CodersClient/Coders.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Foundation
|
||||
import TOMLKit
|
||||
|
||||
public extension DependencyValues {
|
||||
|
||||
/// Holds onto decoders and encoders for json and toml files.
|
||||
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: { defaultJsonEncoder },
|
||||
tomlDecoder: { TOMLDecoder() },
|
||||
tomlEncoder: { TOMLEncoder() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private let defaultJsonEncoder: JSONEncoder = {
|
||||
var encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]
|
||||
return encoder
|
||||
}()
|
||||
233
Sources/CommandClient/CommandClient.swift
Normal file
233
Sources/CommandClient/CommandClient.swift
Normal file
@@ -0,0 +1,233 @@
|
||||
import Constants
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Foundation
|
||||
import ShellClient
|
||||
|
||||
public extension DependencyValues {
|
||||
|
||||
/// Runs shell commands.
|
||||
var commandClient: CommandClient {
|
||||
get { self[CommandClient.self] }
|
||||
set { self[CommandClient.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@DependencyClient
|
||||
public struct CommandClient: Sendable {
|
||||
|
||||
/// Runs a shell command.
|
||||
public var runCommand: @Sendable (RunCommandOptions) async throws -> Void
|
||||
|
||||
/// Runs a shell command, sets up logging, and returns an output value.
|
||||
///
|
||||
/// This is useful when you need to give some output value to the caller,
|
||||
/// generally for the ability of that output to be piped into other commands.
|
||||
///
|
||||
@discardableResult
|
||||
public func run<T>(
|
||||
logging logginOptions: LoggingOptions,
|
||||
quiet: Bool = false,
|
||||
shell: String? = nil,
|
||||
in workingDirectory: String? = nil,
|
||||
makeArguments: @Sendable @escaping () async throws -> ([String], T)
|
||||
) async throws -> T {
|
||||
try await logginOptions.withLogger {
|
||||
let (arguments, returnValue) = try await makeArguments()
|
||||
try await self.run(
|
||||
quiet: quiet,
|
||||
shell: shell,
|
||||
arguments
|
||||
)
|
||||
return returnValue
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a shell command and sets up logging.
|
||||
public func run(
|
||||
logging logginOptions: LoggingOptions,
|
||||
quiet: Bool = false,
|
||||
shell: String? = nil,
|
||||
in workingDirectory: String? = nil,
|
||||
makeArguments: @Sendable @escaping () async throws -> [String]
|
||||
) async throws {
|
||||
try await run(
|
||||
logging: logginOptions,
|
||||
quiet: quiet,
|
||||
shell: shell,
|
||||
in: workingDirectory,
|
||||
makeArguments: { try await (makeArguments(), ()) }
|
||||
)
|
||||
}
|
||||
|
||||
/// Runs a shell command.
|
||||
public func run(
|
||||
quiet: Bool = false,
|
||||
shell: String? = nil,
|
||||
in workingDirectory: String? = nil,
|
||||
_ arguments: [String]
|
||||
) async throws {
|
||||
try await runCommand(.init(
|
||||
arguments: arguments,
|
||||
quiet: quiet,
|
||||
shell: shell,
|
||||
workingDirectory: workingDirectory
|
||||
))
|
||||
}
|
||||
|
||||
/// Runs a shell command.
|
||||
public func run(
|
||||
quiet: Bool = false,
|
||||
shell: String? = nil,
|
||||
in workingDirectory: String? = nil,
|
||||
_ arguments: String...
|
||||
) async throws {
|
||||
try await run(
|
||||
quiet: quiet,
|
||||
shell: shell,
|
||||
in: workingDirectory,
|
||||
arguments
|
||||
)
|
||||
}
|
||||
|
||||
public struct RunCommandOptions: Sendable, Equatable {
|
||||
public let arguments: [String]
|
||||
public let quiet: Bool
|
||||
public let shell: String?
|
||||
public let workingDirectory: String?
|
||||
|
||||
public init(
|
||||
arguments: [String],
|
||||
quiet: Bool,
|
||||
shell: String? = nil,
|
||||
workingDirectory: String? = nil
|
||||
) {
|
||||
self.arguments = arguments
|
||||
self.quiet = quiet
|
||||
self.shell = shell
|
||||
self.workingDirectory = workingDirectory
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents logger setup options used when running commands.
|
||||
///
|
||||
/// This set's up metadata tags and keys appropriately for the command being run,
|
||||
/// which can aid in debugging.
|
||||
///
|
||||
public struct LoggingOptions: Equatable, Sendable {
|
||||
|
||||
/// The command name / key that the logger is running for.
|
||||
public let commandName: String
|
||||
|
||||
/// The log level.
|
||||
public let logLevel: Logger.Level
|
||||
|
||||
/// Create the logging options.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - commandName: The command name / key that the logger is running for.
|
||||
/// - logLevel: The log level.
|
||||
public init(commandName: String, logLevel: Logger.Level) {
|
||||
self.commandName = commandName
|
||||
self.logLevel = logLevel
|
||||
}
|
||||
|
||||
/// Perform an operation with a setup logger.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - operation: The operation to perform with the setup logger.
|
||||
@discardableResult
|
||||
public func withLogger<T>(
|
||||
operation: @Sendable @escaping () async throws -> T
|
||||
) async rethrows -> T {
|
||||
try await withDependencies {
|
||||
$0.logger = .init(label: "\(Constants.executableName)")
|
||||
$0.logger.logLevel = logLevel
|
||||
$0.logger[metadataKey: "command"] = "\(commandName.blue)"
|
||||
} operation: {
|
||||
try await operation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CommandClient: DependencyKey {
|
||||
|
||||
public static let testValue: CommandClient = Self()
|
||||
|
||||
public static func live(
|
||||
environment: [String: String]
|
||||
) -> CommandClient {
|
||||
.init { options in
|
||||
@Dependency(\.asyncShellClient) var shellClient
|
||||
if !options.quiet {
|
||||
try await shellClient.foreground(.init(
|
||||
shell: .init(options.shell),
|
||||
environment: environment,
|
||||
in: options.workingDirectory,
|
||||
options.arguments
|
||||
))
|
||||
} else {
|
||||
try await shellClient.background(.init(
|
||||
shell: .init(options.shell),
|
||||
environment: environment,
|
||||
in: options.workingDirectory,
|
||||
options.arguments
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static var liveValue: CommandClient {
|
||||
.live(environment: ProcessInfo.processInfo.environment)
|
||||
}
|
||||
}
|
||||
|
||||
@_spi(Internal)
|
||||
public extension CommandClient {
|
||||
|
||||
/// Create a command client that can capture the arguments / options.
|
||||
///
|
||||
/// This is used for testing.
|
||||
static func capturing(_ client: CapturingClient) -> Self {
|
||||
.init { options in
|
||||
await client.set(options)
|
||||
}
|
||||
}
|
||||
|
||||
/// Captures the arguments / options passed into the command client's run commands.
|
||||
///
|
||||
@dynamicMemberLookup
|
||||
actor CapturingClient: Sendable {
|
||||
public private(set) var options: RunCommandOptions?
|
||||
|
||||
public init() {}
|
||||
|
||||
public func set(
|
||||
_ options: RunCommandOptions
|
||||
) {
|
||||
self.options = options
|
||||
}
|
||||
|
||||
public subscript<T>(dynamicMember keyPath: KeyPath<RunCommandOptions, T>) -> T? {
|
||||
options?[keyPath: keyPath]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ShellCommand.Shell: @retroactive @unchecked Sendable {}
|
||||
|
||||
extension ShellCommand.Shell {
|
||||
init(_ path: String?) {
|
||||
if let path {
|
||||
self = .custom(path: path, useDashC: true)
|
||||
} else {
|
||||
#if os(macOS)
|
||||
self = .zsh(useDashC: true)
|
||||
#else
|
||||
// Generally we're in a docker container when this occurs, which does not have `zsh` installed.
|
||||
self = .sh(useDashC: true)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
224
Sources/ConfigurationClient/Configuration.swift
Normal file
224
Sources/ConfigurationClient/Configuration.swift
Normal 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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
349
Sources/ConfigurationClient/ConfigurationClient.swift
Normal file
349
Sources/ConfigurationClient/ConfigurationClient.swift
Normal 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 {}
|
||||
32
Sources/ConfigurationClient/Constants.swift
Normal file
32
Sources/ConfigurationClient/Constants.swift
Normal 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]
|
||||
}
|
||||
}
|
||||
65
Sources/ConfigurationClient/File.swift
Normal file
65
Sources/ConfigurationClient/File.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
72
Sources/ConfigurationClient/Resources/hpa.toml
Normal file
72
Sources/ConfigurationClient/Resources/hpa.toml
Normal 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'
|
||||
3
Sources/Constants/Constants.swift
Normal file
3
Sources/Constants/Constants.swift
Normal file
@@ -0,0 +1,3 @@
|
||||
public enum Constants {
|
||||
public static let executableName = "hpa"
|
||||
}
|
||||
151
Sources/FileClient/FileClient.swift
Normal file
151
Sources/FileClient/FileClient.swift
Normal file
@@ -0,0 +1,151 @@
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Foundation
|
||||
|
||||
public extension DependencyValues {
|
||||
/// Represents interactions with the file system.
|
||||
///
|
||||
var fileClient: FileClient {
|
||||
get { self[FileClient.self] }
|
||||
set { self[FileClient.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents interactions with the file system.
|
||||
///
|
||||
///
|
||||
@DependencyClient
|
||||
public struct FileClient: Sendable {
|
||||
|
||||
/// Copy an item from one location to another.
|
||||
public var copy: @Sendable (URL, URL) async throws -> Void
|
||||
|
||||
/// Create a directory at the given location.
|
||||
public var createDirectory: @Sendable (URL) async throws -> Void
|
||||
|
||||
/// Delete the item at the given location.
|
||||
public var delete: @Sendable (URL) async throws -> Void
|
||||
|
||||
/// Check if a file exists at the given location.
|
||||
public var fileExists: @Sendable (URL) -> Bool = { _ in true }
|
||||
|
||||
/// Find an ansible-vault file in the given location, checking up to 1 level deep
|
||||
/// in subfolders.
|
||||
public var findVaultFile: @Sendable (URL) async throws -> URL?
|
||||
|
||||
/// Return the user's home directory.
|
||||
public var homeDirectory: @Sendable () -> URL = { URL(filePath: "~/") }
|
||||
|
||||
/// Check if an item is a directory or not.
|
||||
public var isDirectory: @Sendable (URL) async throws -> Bool
|
||||
|
||||
/// Load a file from the given location.
|
||||
public var load: @Sendable (URL) async throws -> Data
|
||||
|
||||
/// Write data to a file at the given location.
|
||||
public var write: @Sendable (Data, URL) async throws -> Void
|
||||
|
||||
/// Find an ansible-vault file in the current directory, checking up to 1 level
|
||||
/// deep in subfolders.
|
||||
public func findVaultFileInCurrentDirectory() async throws -> URL? {
|
||||
try await findVaultFile(URL(filePath: "./"))
|
||||
}
|
||||
}
|
||||
|
||||
extension FileClient: DependencyKey {
|
||||
public static let testValue: FileClient = Self()
|
||||
|
||||
public static var liveValue: Self {
|
||||
let manager = LiveFileClient()
|
||||
return .init(
|
||||
copy: { try await manager.copy($0, to: $1) },
|
||||
createDirectory: { try await manager.creatDirectory($0) },
|
||||
delete: { try await manager.delete($0) },
|
||||
fileExists: { manager.fileExists(at: $0) },
|
||||
findVaultFile: { try await manager.findVaultFile(in: $0) },
|
||||
homeDirectory: { manager.homeDirectory() },
|
||||
isDirectory: { manager.isDirectory($0) },
|
||||
load: { try await manager.load(from: $0) },
|
||||
write: { try await manager.write($0, to: $1) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 creatDirectory(_ url: URL) async throws {
|
||||
try manager.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func delete(_ url: URL) async throws {
|
||||
try manager.removeItem(at: url)
|
||||
}
|
||||
|
||||
func fileExists(at url: URL) -> Bool {
|
||||
manager.fileExists(atPath: url.cleanFilePath)
|
||||
}
|
||||
|
||||
func findVaultFile(in url: URL) async throws -> URL? {
|
||||
guard isDirectory(url) else { return nil }
|
||||
let urls = try manager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)
|
||||
|
||||
guard let vault = urls.firstVaultFile else {
|
||||
// check subfolders, 1 layer deep.
|
||||
let subfolders = urls.filter { isDirectory($0) }
|
||||
for folder in subfolders {
|
||||
let vault = try manager.contentsOfDirectory(
|
||||
at: folder,
|
||||
includingPropertiesForKeys: nil
|
||||
)
|
||||
.firstVaultFile
|
||||
|
||||
if let vault { return vault }
|
||||
}
|
||||
// Didn't find a file.
|
||||
return nil
|
||||
}
|
||||
|
||||
return vault
|
||||
}
|
||||
|
||||
func homeDirectory() -> URL {
|
||||
manager.homeDirectoryForCurrentUser
|
||||
}
|
||||
|
||||
func isDirectory(_ url: URL) -> Bool {
|
||||
var isDirectory: ObjCBool = false
|
||||
manager.fileExists(atPath: url.cleanFilePath, isDirectory: &isDirectory)
|
||||
return isDirectory.boolValue
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array where Element == URL {
|
||||
var firstVaultFile: URL? {
|
||||
first { url in
|
||||
let string = url.absoluteString
|
||||
return string.hasSuffix("vault.yml") || string.hasSuffix("vault.yaml")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension URL {
|
||||
var cleanFilePath: String {
|
||||
absoluteString
|
||||
.replacing("file://", with: "")
|
||||
.replacing("/private", with: "")
|
||||
}
|
||||
}
|
||||
9
Sources/PandocClient/Constants.swift
Normal file
9
Sources/PandocClient/Constants.swift
Normal file
@@ -0,0 +1,9 @@
|
||||
extension PandocClient {
|
||||
|
||||
/// Represents constant string values needed internally.
|
||||
enum Constants {
|
||||
static let pandocCommand = "pandoc"
|
||||
static let defaultOutputFileName = "Report"
|
||||
static let defaultPdfEngine = "xelatex"
|
||||
}
|
||||
}
|
||||
245
Sources/PandocClient/PandocClient+Run.swift
Normal file
245
Sources/PandocClient/PandocClient+Run.swift
Normal file
@@ -0,0 +1,245 @@
|
||||
import CommandClient
|
||||
import ConfigurationClient
|
||||
import Dependencies
|
||||
import Foundation
|
||||
import PlaybookClient
|
||||
|
||||
extension PandocClient.RunOptions {
|
||||
|
||||
func run(
|
||||
_ fileType: PandocClient.FileType,
|
||||
_ environment: [String: String]
|
||||
) async throws -> String {
|
||||
@Dependency(\.commandClient) var commandClient
|
||||
@Dependency(\.logger) var logger
|
||||
@Dependency(\.playbookClient) var playbookClient
|
||||
|
||||
return try await commandClient.run(logging: loggingOptions, quiet: quiet, shell: shell) {
|
||||
let ensuredOptions = try await self.ensuredOptions(fileType)
|
||||
|
||||
let projectDirectory = self.projectDirectory ?? environment["PWD"]
|
||||
|
||||
guard let projectDirectory else {
|
||||
throw ProjectDirectoryNotSpecified()
|
||||
}
|
||||
|
||||
if shouldBuildProject {
|
||||
logger.debug("Building project...")
|
||||
try await playbookClient.run.buildProject(.init(
|
||||
projectDirectory: projectDirectory,
|
||||
shared: .init(
|
||||
extraOptions: nil,
|
||||
inventoryFilePath: nil,
|
||||
loggingOptions: loggingOptions,
|
||||
quiet: quiet,
|
||||
shell: shell
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
let outputDirectory = self.outputDirectory ?? projectDirectory
|
||||
let outputPath = "\(outputDirectory)/\(ensuredOptions.ensuredExtensionFileName)"
|
||||
|
||||
let arguments = ensuredOptions.makeArguments(
|
||||
outputPath: outputPath,
|
||||
projectDirectory: projectDirectory
|
||||
)
|
||||
|
||||
logger.debug("Pandoc arguments: \(arguments)")
|
||||
|
||||
return (arguments, outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
func ensuredOptions(
|
||||
_ fileType: PandocClient.FileType
|
||||
) async throws -> EnsuredPandocOptions {
|
||||
@Dependency(\.configurationClient) var configurationClient
|
||||
@Dependency(\.logger) var logger
|
||||
|
||||
let configuration = try await configurationClient.findAndLoad()
|
||||
logger.debug("Configuration: \(configuration)")
|
||||
|
||||
return try await ensurePandocOptions(
|
||||
configuration: configuration,
|
||||
fileType: fileType,
|
||||
options: self
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension PandocClient.FileType {
|
||||
var fileExtension: String {
|
||||
switch self {
|
||||
case .html: return "html"
|
||||
case .latex: return "tex"
|
||||
case .pdf: return "pdf"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@_spi(Internal)
|
||||
public struct EnsuredPandocOptions: Equatable, Sendable {
|
||||
public let buildDirectory: String
|
||||
public let extraOptions: [String]?
|
||||
public let files: [String]
|
||||
public let includeInHeader: [String]
|
||||
public let outputFileName: String
|
||||
public let outputFileType: PandocClient.FileType
|
||||
public let pdfEngine: String?
|
||||
|
||||
public var ensuredExtensionFileName: String {
|
||||
let extensionString = ".\(outputFileType.fileExtension)"
|
||||
|
||||
if !outputFileName.hasSuffix(extensionString) {
|
||||
return outputFileName + extensionString
|
||||
}
|
||||
return outputFileName
|
||||
}
|
||||
|
||||
func makeArguments(
|
||||
outputPath: String,
|
||||
projectDirectory: String
|
||||
) -> [String] {
|
||||
var arguments = [PandocClient.Constants.pandocCommand]
|
||||
|
||||
arguments += includeInHeader.map {
|
||||
"--include-in-header=\(projectDirectory)/\(buildDirectory)/\($0)"
|
||||
}
|
||||
|
||||
if let pdfEngine {
|
||||
arguments.append("--pdf-engine=\(pdfEngine)")
|
||||
}
|
||||
|
||||
arguments.append("--output=\(outputPath)")
|
||||
|
||||
if let extraOptions {
|
||||
arguments.append(contentsOf: extraOptions)
|
||||
}
|
||||
|
||||
arguments += files.map {
|
||||
"\(projectDirectory)/\(buildDirectory)/\($0)"
|
||||
}
|
||||
|
||||
return arguments
|
||||
}
|
||||
}
|
||||
|
||||
@_spi(Internal)
|
||||
public func ensurePandocOptions(
|
||||
configuration: Configuration,
|
||||
fileType: PandocClient.FileType,
|
||||
options: PandocClient.RunOptions
|
||||
) async throws -> EnsuredPandocOptions {
|
||||
let defaults = Configuration.Generate.default
|
||||
|
||||
return .init(
|
||||
buildDirectory: options.parseBuildDirectory(configuration.generate, defaults),
|
||||
extraOptions: options.extraOptions,
|
||||
files: options.parseFiles(configuration.generate, defaults),
|
||||
includeInHeader: options.parseIncludeInHeader(configuration.generate, defaults),
|
||||
outputFileName: options.parseOutputFileName(configuration.generate, defaults),
|
||||
outputFileType: fileType,
|
||||
pdfEngine: fileType.parsePdfEngine(configuration.generate, defaults)
|
||||
)
|
||||
}
|
||||
|
||||
@_spi(Internal)
|
||||
public extension PandocClient.FileType {
|
||||
func parsePdfEngine(
|
||||
_ configuration: Configuration.Generate?,
|
||||
_ defaults: Configuration.Generate
|
||||
) -> String? {
|
||||
switch self {
|
||||
case .html, .latex:
|
||||
return nil
|
||||
case let .pdf(engine: engine):
|
||||
if let engine {
|
||||
return engine
|
||||
} else if let engine = configuration?.pdfEngine {
|
||||
return engine
|
||||
} else if let engine = defaults.pdfEngine {
|
||||
return engine
|
||||
} else {
|
||||
return PandocClient.Constants.defaultPdfEngine
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@_spi(Internal)
|
||||
public extension PandocClient.RunOptions {
|
||||
func parseFiles(
|
||||
_ configuration: Configuration.Generate?,
|
||||
_ defaults: Configuration.Generate
|
||||
) -> [String] {
|
||||
@Dependency(\.logger) var logger
|
||||
|
||||
if let files = files {
|
||||
return files
|
||||
} else if let files = configuration?.files {
|
||||
return files
|
||||
} else if let files = defaults.files {
|
||||
return files
|
||||
} else {
|
||||
logger.warning("Files not specified, this could lead to errors.")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
func parseIncludeInHeader(
|
||||
_ configuration: Configuration.Generate?,
|
||||
_ defaults: Configuration.Generate
|
||||
) -> [String] {
|
||||
@Dependency(\.logger) var logger
|
||||
|
||||
if let files = includeInHeader {
|
||||
return files
|
||||
} else if let files = configuration?.includeInHeader {
|
||||
return files
|
||||
} else if let files = defaults.includeInHeader {
|
||||
return files
|
||||
} else {
|
||||
logger.warning("Include in header files not specified, this could lead to errors.")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
func parseOutputFileName(
|
||||
_ configuration: Configuration.Generate?,
|
||||
_ defaults: Configuration.Generate
|
||||
) -> String {
|
||||
@Dependency(\.logger) var logger
|
||||
|
||||
if let output = outputFileName {
|
||||
return output
|
||||
} else if let output = configuration?.outputFileName {
|
||||
return output
|
||||
} else if let output = defaults.outputFileName {
|
||||
return output
|
||||
} else {
|
||||
logger.warning("Output file name not specified, this could lead to errors.")
|
||||
return PandocClient.Constants.defaultOutputFileName
|
||||
}
|
||||
}
|
||||
|
||||
func parseBuildDirectory(
|
||||
_ configuration: Configuration.Generate?,
|
||||
_ defaults: Configuration.Generate
|
||||
) -> String {
|
||||
@Dependency(\.logger) var logger
|
||||
|
||||
if let output = buildDirectory {
|
||||
return output
|
||||
} else if let output = configuration?.buildDirectory {
|
||||
return output
|
||||
} else if let output = defaults.buildDirectory {
|
||||
return output
|
||||
} else {
|
||||
logger.warning("Output file name not specified, this could lead to errors.")
|
||||
return ".build"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProjectDirectoryNotSpecified: Error {}
|
||||
141
Sources/PandocClient/PandocClient.swift
Normal file
141
Sources/PandocClient/PandocClient.swift
Normal file
@@ -0,0 +1,141 @@
|
||||
import CommandClient
|
||||
import ConfigurationClient
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Foundation
|
||||
|
||||
public extension DependencyValues {
|
||||
|
||||
/// Represents interactions with the `pandoc` command line application.
|
||||
///
|
||||
/// The `pandoc` command line application is used to generate the final output
|
||||
/// documents from a home performance assessment project.
|
||||
///
|
||||
var pandocClient: PandocClient {
|
||||
get { self[PandocClient.self] }
|
||||
set { self[PandocClient.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@DependencyClient
|
||||
public struct PandocClient: Sendable {
|
||||
|
||||
/// Run a pandoc command.
|
||||
public var run: Run
|
||||
|
||||
/// Represents the pandoc commands that we can run.
|
||||
@DependencyClient
|
||||
public struct Run: Sendable {
|
||||
|
||||
/// Generate a latex file from the given options, returning the path the
|
||||
/// generated file was written to.
|
||||
public var generateLatex: @Sendable (RunOptions) async throws -> String
|
||||
|
||||
/// Generate an html file from the given options, returning the path the
|
||||
/// generated file was written to.
|
||||
public var generateHtml: @Sendable (RunOptions) async throws -> String
|
||||
|
||||
/// Generate a pdf file from the given options, returning the path the
|
||||
/// generated file was written to.
|
||||
public var generatePdf: @Sendable (RunOptions, String?) async throws -> String
|
||||
|
||||
/// Generate a pdf file from the given options, returning the path the
|
||||
/// generated file was written to.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - options: The shared run options.
|
||||
/// - pdfEngine: The pdf-engine to use to generate the pdf file.
|
||||
public func generatePdf(
|
||||
_ options: RunOptions,
|
||||
pdfEngine: String? = nil
|
||||
) async throws -> String {
|
||||
try await generatePdf(options, pdfEngine)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Represents the shared options used to run a `pandoc` command.
|
||||
///
|
||||
///
|
||||
public struct RunOptions: Equatable, Sendable {
|
||||
|
||||
let buildDirectory: String?
|
||||
let extraOptions: [String]?
|
||||
let files: [String]?
|
||||
let loggingOptions: LoggingOptions
|
||||
let includeInHeader: [String]?
|
||||
let outputDirectory: String?
|
||||
let outputFileName: String?
|
||||
let projectDirectory: String?
|
||||
let quiet: Bool
|
||||
let shell: String?
|
||||
let shouldBuildProject: Bool
|
||||
|
||||
/// Create the shared run options.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - buildDirectory: Specify the build directory of the project.
|
||||
/// - extraOptions: Extra arguments / options passed to the `pandoc` command.
|
||||
/// - file: Files used to build the final output.
|
||||
/// - loggingOptions: The logging options used when running the command.
|
||||
/// - includeInHeader: Files to include in the header of the final output document.
|
||||
/// - outputDirectory: Optional output directory for the output file, defaults to current working directory.
|
||||
/// - projectDirectory: Optional project directory, defaults to current working directory.
|
||||
/// - outputFileName: Override the output file name.
|
||||
/// - quiet: Don't log output from the command.
|
||||
/// - shell: Optional shell to use when calling the pandoc command.
|
||||
/// - shouldBuildProject: Build the project prior to generating the output file.
|
||||
public init(
|
||||
buildDirectory: String? = nil,
|
||||
extraOptions: [String]? = nil,
|
||||
files: [String]? = nil,
|
||||
loggingOptions: LoggingOptions,
|
||||
includeInHeader: [String]? = nil,
|
||||
outputDirectory: String? = nil,
|
||||
projectDirectory: String? = nil,
|
||||
outputFileName: String? = nil,
|
||||
quiet: Bool = false,
|
||||
shell: String? = nil,
|
||||
shouldBuild shouldBuildProject: Bool = true
|
||||
) {
|
||||
self.buildDirectory = buildDirectory
|
||||
self.extraOptions = extraOptions
|
||||
self.files = files
|
||||
self.loggingOptions = loggingOptions
|
||||
self.includeInHeader = includeInHeader
|
||||
self.outputDirectory = outputDirectory
|
||||
self.outputFileName = outputFileName
|
||||
self.projectDirectory = projectDirectory
|
||||
self.quiet = quiet
|
||||
self.shell = shell
|
||||
self.shouldBuildProject = shouldBuildProject
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Represents the file types that we can generate output files for.
|
||||
@_spi(Internal)
|
||||
public enum FileType: Equatable, Sendable {
|
||||
case html
|
||||
case latex
|
||||
case pdf(engine: String?)
|
||||
}
|
||||
}
|
||||
|
||||
extension PandocClient: DependencyKey {
|
||||
public static let testValue: PandocClient = Self(run: Run())
|
||||
|
||||
public static func live(environment: [String: String]) -> PandocClient {
|
||||
.init(
|
||||
run: Run(
|
||||
generateLatex: { try await $0.run(.latex, environment) },
|
||||
generateHtml: { try await $0.run(.html, environment) },
|
||||
generatePdf: { try await $0.run(.pdf(engine: $1), environment) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public static var liveValue: PandocClient {
|
||||
.live(environment: ProcessInfo.processInfo.environment)
|
||||
}
|
||||
}
|
||||
14
Sources/PlaybookClient/Constants.swift
Normal file
14
Sources/PlaybookClient/Constants.swift
Normal file
@@ -0,0 +1,14 @@
|
||||
// TODO: Use an actual version tag for playbook repo.
|
||||
// TODO: Use an externally public url for the playbook repo.
|
||||
|
||||
public extension PlaybookClient {
|
||||
@_spi(Internal)
|
||||
enum Constants {
|
||||
public static let defaultInstallationPath = "~/.local/share/hpa/playbook"
|
||||
public static let inventoryFileName = "inventory.ini"
|
||||
public static let playbookCommand = "ansible-playbook"
|
||||
public static let playbookFileName = "main.yml"
|
||||
public static let playbookRepoUrl = "https://git.housh.dev/michael/ansible-hpa-playbook.git"
|
||||
public static let playbookRepoVersion = "main"
|
||||
}
|
||||
}
|
||||
79
Sources/PlaybookClient/PlaybookClient+Repository.swift
Normal file
79
Sources/PlaybookClient/PlaybookClient+Repository.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import CommandClient
|
||||
import ConfigurationClient
|
||||
import Dependencies
|
||||
import FileClient
|
||||
import Foundation
|
||||
import ShellClient
|
||||
|
||||
extension PlaybookClient.Repository {
|
||||
|
||||
static func findDirectory(
|
||||
configuration: Configuration? = nil
|
||||
) async throws -> String {
|
||||
@Dependency(\.configurationClient) var configurationClient
|
||||
|
||||
var configuration: Configuration! = configuration
|
||||
if configuration == nil {
|
||||
configuration = try await configurationClient.findAndLoad()
|
||||
}
|
||||
|
||||
return configuration.playbook?.directory
|
||||
?? PlaybookClient.Constants.defaultInstallationPath
|
||||
}
|
||||
|
||||
static func installPlaybook(
|
||||
configuration: Configuration?
|
||||
) async throws {
|
||||
@Dependency(\.commandClient) var commandClient
|
||||
@Dependency(\.configurationClient) var configurationClient
|
||||
@Dependency(\.fileClient) var fileClient
|
||||
@Dependency(\.logger) var logger
|
||||
|
||||
var configuration: Configuration! = configuration
|
||||
|
||||
if configuration == nil {
|
||||
configuration = try await configurationClient.findAndLoad()
|
||||
}
|
||||
|
||||
let (path, version) = parsePlaybookPathAndVerion(
|
||||
configuration?.playbook
|
||||
)
|
||||
|
||||
let parentDirectory = URL(filePath: path)
|
||||
.deletingLastPathComponent()
|
||||
|
||||
let parentExists = try await fileClient.isDirectory(parentDirectory)
|
||||
if !parentExists {
|
||||
try await fileClient.createDirectory(parentDirectory)
|
||||
}
|
||||
|
||||
let playbookExists = try await fileClient.isDirectory(URL(filePath: path))
|
||||
|
||||
if !playbookExists {
|
||||
logger.debug("Cloning playbook to: \(path)")
|
||||
try await commandClient.run(
|
||||
"git", "clone", "--branch", version,
|
||||
PlaybookClient.Constants.playbookRepoUrl, path
|
||||
)
|
||||
} else {
|
||||
logger.debug("Playbook exists, ensuring it's up to date.")
|
||||
try await commandClient.run(
|
||||
in: path,
|
||||
"git", "pull", "--tags"
|
||||
)
|
||||
try await commandClient.run(
|
||||
in: path,
|
||||
"git", "checkout", version
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func parsePlaybookPathAndVerion(
|
||||
_ configuration: Configuration.Playbook?
|
||||
) -> (path: String, version: String) {
|
||||
return (
|
||||
path: configuration?.directory ?? PlaybookClient.Constants.defaultInstallationPath,
|
||||
version: configuration?.version ?? PlaybookClient.Constants.playbookRepoVersion
|
||||
)
|
||||
}
|
||||
}
|
||||
245
Sources/PlaybookClient/PlaybookClient+RunPlaybook.swift
Normal file
245
Sources/PlaybookClient/PlaybookClient+RunPlaybook.swift
Normal file
@@ -0,0 +1,245 @@
|
||||
import CommandClient
|
||||
import ConfigurationClient
|
||||
import Dependencies
|
||||
import Foundation
|
||||
|
||||
extension PlaybookClient.RunPlaybook.BuildOptions {
|
||||
|
||||
func run() async throws {
|
||||
try await shared.run { arguments, _ in
|
||||
let projectDirectory = projectDirectory
|
||||
?? ProcessInfo.processInfo.environment["PWD"]
|
||||
|
||||
guard let projectDirectory else {
|
||||
throw PlaybookClientError.projectDirectoryNotFound
|
||||
}
|
||||
|
||||
arguments.append(contentsOf: [
|
||||
"--tags", "build-project",
|
||||
"--extra-vars", "project_dir=\(projectDirectory)"
|
||||
])
|
||||
|
||||
if let extraOptions = shared.extraOptions {
|
||||
arguments.append(contentsOf: extraOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PlaybookClient.RunPlaybook.CreateOptions {
|
||||
|
||||
func run(encoder jsonEncoder: JSONEncoder?) async throws {
|
||||
try await shared.run { arguments, configuration in
|
||||
let jsonData = try createJSONData(
|
||||
configuration: configuration,
|
||||
encoder: jsonEncoder
|
||||
)
|
||||
|
||||
guard let json = String(data: jsonData, encoding: .utf8) else {
|
||||
throw PlaybookClientError.encodingError
|
||||
}
|
||||
|
||||
arguments.append(contentsOf: [
|
||||
"--tags", "setup-project",
|
||||
"--extra-vars", "project_dir=\(projectDirectory)",
|
||||
"--extra-vars", "'\(json)'"
|
||||
])
|
||||
|
||||
if let extraOptions = shared.extraOptions {
|
||||
arguments.append(contentsOf: extraOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PlaybookClient.RunPlaybook.GenerateTemplateOptions {
|
||||
func run() async throws -> String {
|
||||
try await shared.run { arguments, _ in
|
||||
arguments.append(contentsOf: [
|
||||
"--tags", "repo-template",
|
||||
"--extra-vars", "output_dir=\(templateDirectory)"
|
||||
])
|
||||
|
||||
if let templateVarsDirectory {
|
||||
arguments.append(contentsOf: ["--extra-vars", "repo_vars_dir=\(templateVarsDirectory)"])
|
||||
}
|
||||
|
||||
if !useVault {
|
||||
arguments.append(contentsOf: ["--extra-vars", "use_vault=false"])
|
||||
}
|
||||
|
||||
return templateDirectory
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PlaybookClient.RunPlaybook.SharedRunOptions {
|
||||
|
||||
@discardableResult
|
||||
func run<T>(
|
||||
_ apply: @Sendable @escaping (inout [String], Configuration) throws -> T
|
||||
) async throws -> T {
|
||||
@Dependency(\.commandClient) var commandClient
|
||||
|
||||
return try await commandClient.run(
|
||||
logging: loggingOptions,
|
||||
quiet: quiet,
|
||||
shell: shell
|
||||
) {
|
||||
@Dependency(\.logger) var logger
|
||||
@Dependency(\.configurationClient) var configurationClient
|
||||
|
||||
let configuration = try await configurationClient.findAndLoad()
|
||||
|
||||
var arguments = try await PlaybookClient.RunPlaybook.makeCommonArguments(
|
||||
configuration: configuration,
|
||||
inventoryFilePath: inventoryFilePath
|
||||
)
|
||||
|
||||
let output = try apply(&arguments, configuration)
|
||||
|
||||
return (arguments, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@_spi(Internal)
|
||||
public extension PlaybookClient.RunPlaybook {
|
||||
|
||||
static func ensuredInventoryPath(
|
||||
_ optionalInventoryPath: String?,
|
||||
configuration: Configuration,
|
||||
playbookDirectory: String
|
||||
) -> String {
|
||||
guard let path = optionalInventoryPath else {
|
||||
guard let path = configuration.playbook?.inventory else {
|
||||
return "\(playbookDirectory)/\(PlaybookClient.Constants.inventoryFileName)"
|
||||
}
|
||||
return path
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
static func makeCommonArguments(
|
||||
configuration: Configuration,
|
||||
inventoryFilePath: String?
|
||||
) async throws -> [String] {
|
||||
@Dependency(\.logger) var logger
|
||||
|
||||
let playbookDirectory = try await PlaybookClient.Repository.findDirectory(configuration: configuration)
|
||||
let playbookPath = "\(playbookDirectory)/\(PlaybookClient.Constants.playbookFileName)"
|
||||
logger.trace("Playbook path: \(playbookPath)")
|
||||
|
||||
let inventoryPath = ensuredInventoryPath(
|
||||
inventoryFilePath,
|
||||
configuration: configuration,
|
||||
playbookDirectory: playbookDirectory
|
||||
)
|
||||
logger.trace("Inventory path: \(inventoryPath)")
|
||||
|
||||
var arguments = [
|
||||
PlaybookClient.Constants.playbookCommand, playbookPath,
|
||||
"--inventory", inventoryPath
|
||||
]
|
||||
|
||||
if let defaultArgs = configuration.args {
|
||||
arguments.append(contentsOf: defaultArgs)
|
||||
}
|
||||
|
||||
if configuration.useVaultArgs, let vaultArgs = configuration.vault.args {
|
||||
arguments.append(contentsOf: vaultArgs)
|
||||
}
|
||||
|
||||
logger.trace("Common arguments: \(inventoryPath)")
|
||||
return arguments
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//
|
||||
// NOTE: We're not using the `Coders` client because we generally do not
|
||||
// want the output to be `prettyPrinted` or anything, unless we're running
|
||||
// tests, so we use a supplied json encoder.
|
||||
//
|
||||
@_spi(Internal)
|
||||
public extension PlaybookClient.RunPlaybook.CreateOptions {
|
||||
|
||||
func createJSONData(
|
||||
configuration: Configuration,
|
||||
encoder: JSONEncoder?
|
||||
) throws -> Data {
|
||||
@Dependency(\.logger) var logger
|
||||
|
||||
let encoder = encoder ?? jsonEncoder
|
||||
|
||||
let templateDir = template?.directory ?? configuration.template.directory
|
||||
let templateRepo = template?.url ?? configuration.template.url
|
||||
let version = template?.version ?? configuration.template.version
|
||||
|
||||
logger.debug("""
|
||||
(\(useLocalTemplateDirectory), \(String(describing: templateDir)), \(String(describing: templateRepo)))
|
||||
""")
|
||||
|
||||
switch (useLocalTemplateDirectory, templateDir, templateRepo) {
|
||||
case (true, .none, _):
|
||||
// User supplied they wanted to use a local template directory, but we could not find
|
||||
// the path set from command line or in configuration.
|
||||
throw PlaybookClientError.templateDirectoryNotFound
|
||||
case let (false, _, .some(repo)):
|
||||
// User did not supply they wanted to use a local template directory, and we found a repo url that was
|
||||
// either set by the command line or found in the configuration.
|
||||
logger.debug("Using repo.")
|
||||
return try encoder.encode(TemplateRepo(repo: repo, version: version))
|
||||
case let (true, .some(templateDir), _):
|
||||
// User supplied they wanted to use a local template directory, and we found the template directory
|
||||
// either set by the command line or in the configuration.
|
||||
logger.debug("Using template directory.")
|
||||
return try encoder.encode(TemplateDirJson(path: templateDir))
|
||||
case let (false, .some(templateDir), _):
|
||||
// User supplied they did not wanted to use a local template directory, and we found the template directory
|
||||
// either set by the command line or in the configuration, and no repo was found / handled previously.
|
||||
logger.debug("Using template directory.")
|
||||
return try encoder.encode(TemplateDirJson(path: templateDir))
|
||||
case (_, .none, .none):
|
||||
// We could not find a repo or template directory.
|
||||
throw PlaybookClientError.templateDirectoryOrRepoNotSpecified
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TemplateDirJson: Encodable {
|
||||
|
||||
let template: Template
|
||||
|
||||
init(path: String) {
|
||||
self.template = .init(path: path)
|
||||
}
|
||||
|
||||
struct Template: Encodable {
|
||||
let path: String
|
||||
}
|
||||
}
|
||||
|
||||
private struct TemplateRepo: Encodable {
|
||||
|
||||
let template: Template
|
||||
|
||||
init(repo: String, version: String?) {
|
||||
self.template = .init(repo: .init(url: repo, version: version ?? "main"))
|
||||
}
|
||||
|
||||
struct Template: Encodable {
|
||||
let repo: Repo
|
||||
}
|
||||
|
||||
struct Repo: Encodable {
|
||||
let url: String
|
||||
let version: String
|
||||
}
|
||||
}
|
||||
|
||||
private let jsonEncoder: JSONEncoder = {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.withoutEscapingSlashes, .sortedKeys]
|
||||
return encoder
|
||||
}()
|
||||
262
Sources/PlaybookClient/PlaybookClient.swift
Normal file
262
Sources/PlaybookClient/PlaybookClient.swift
Normal file
@@ -0,0 +1,262 @@
|
||||
import CommandClient
|
||||
import ConfigurationClient
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import FileClient
|
||||
import Foundation
|
||||
import ShellClient
|
||||
|
||||
// TODO: Add update checks and pull for the playbook.
|
||||
|
||||
public extension DependencyValues {
|
||||
|
||||
/// Manages interactions with the playbook repository as well as `ansible-playbook`
|
||||
/// command line application.
|
||||
///
|
||||
///
|
||||
var playbookClient: PlaybookClient {
|
||||
get { self[PlaybookClient.self] }
|
||||
set { self[PlaybookClient.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@DependencyClient
|
||||
public struct PlaybookClient: Sendable {
|
||||
|
||||
/// Manages interactions with the playbook repository.
|
||||
public var repository: Repository
|
||||
|
||||
/// Run the `ansible-playbook` command line application.
|
||||
public var run: RunPlaybook
|
||||
}
|
||||
|
||||
public extension PlaybookClient {
|
||||
|
||||
/// Manages interactions with the `ansible-hpa-playbook` repository, which is
|
||||
/// used to build and generate home performance assessment projects.
|
||||
@DependencyClient
|
||||
struct Repository: Sendable {
|
||||
|
||||
/// Install the repository based on the given configuration. If configuration is
|
||||
/// not supplied then the default location for the playbook repository is
|
||||
/// `~/.local/share/hpa/playbook`
|
||||
public var install: @Sendable (Configuration?) async throws -> Void
|
||||
|
||||
/// Get the current directory of the playbook repository based on the given
|
||||
/// configuration.
|
||||
public var directory: @Sendable (Configuration?) async throws -> String
|
||||
|
||||
/// Install the playbook in the default location.
|
||||
public func install() async throws {
|
||||
try await install(nil)
|
||||
}
|
||||
|
||||
/// Get the current directory path of the playbook repository.
|
||||
public func directory() async throws -> String {
|
||||
try await directory(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension PlaybookClient {
|
||||
|
||||
/// Runs the `ansible-playbook` command line application with the `ansible-hpa-playbook`.
|
||||
///
|
||||
/// This is used to build and create home performance projects. It can also generate a
|
||||
/// template for a user to customize to their use case.
|
||||
@DependencyClient
|
||||
struct RunPlaybook: Sendable {
|
||||
|
||||
/// Build a home performance assesment project with the given options.
|
||||
public var buildProject: @Sendable (BuildOptions) async throws -> Void
|
||||
|
||||
/// Create a new home performance assesment project with the given options.
|
||||
public var createProject: @Sendable (CreateOptions, JSONEncoder?) async throws -> Void
|
||||
|
||||
/// Generate a user's template from the default home performance template.
|
||||
public var generateTemplate: @Sendable (GenerateTemplateOptions) async throws -> String
|
||||
|
||||
/// Create a new home performance assesment project with the given options.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - options: The options used to create the project.
|
||||
public func createProject(_ options: CreateOptions) async throws {
|
||||
try await createProject(options, nil)
|
||||
}
|
||||
|
||||
/// Represents options that are shared for all the `ansible-playbook` commands.
|
||||
public struct SharedRunOptions: Equatable, Sendable {
|
||||
|
||||
/// Extra arguments / options passed to the `ansible-playbook` command.
|
||||
let extraOptions: [String]?
|
||||
|
||||
/// Specify the inventory file path.
|
||||
let inventoryFilePath: String?
|
||||
|
||||
/// The logging options used when running the command.
|
||||
let loggingOptions: LoggingOptions
|
||||
|
||||
/// Disables log output of the command.
|
||||
let quiet: Bool
|
||||
|
||||
/// Optional shell to use when running the command.
|
||||
let shell: String?
|
||||
|
||||
/// Create the shared options.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - extraOptions: The extra arguments / options to pass to the command.
|
||||
/// - inventoryFilePath: Specify the inventory file path.
|
||||
/// - loggingOptions: The logging options used when running the command.
|
||||
/// - quiet: Disable log output from the command.
|
||||
/// - shell: Specify a shell used when running the command.
|
||||
public init(
|
||||
extraOptions: [String]? = nil,
|
||||
inventoryFilePath: String? = nil,
|
||||
loggingOptions: LoggingOptions,
|
||||
quiet: Bool = false,
|
||||
shell: String? = nil
|
||||
) {
|
||||
self.extraOptions = extraOptions
|
||||
self.inventoryFilePath = inventoryFilePath
|
||||
self.loggingOptions = loggingOptions
|
||||
self.quiet = quiet
|
||||
self.shell = shell
|
||||
}
|
||||
}
|
||||
|
||||
/// Options require when calling the build project command.
|
||||
@dynamicMemberLookup
|
||||
public struct BuildOptions: Equatable, Sendable {
|
||||
|
||||
/// An optional project directory, if not supplied then we will use the current working directory.
|
||||
let projectDirectory: String?
|
||||
|
||||
/// The shared run options.
|
||||
let shared: SharedRunOptions
|
||||
|
||||
/// Create new build options.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - projectDirectory: The optional project directory to build, if not supplied then we'll use the current working directory.
|
||||
/// - shared: The shared run options.
|
||||
public init(
|
||||
projectDirectory: String? = nil,
|
||||
shared: SharedRunOptions
|
||||
) {
|
||||
self.projectDirectory = projectDirectory
|
||||
self.shared = shared
|
||||
}
|
||||
|
||||
public subscript<T>(dynamicMember keyPath: KeyPath<SharedRunOptions, T>) -> T {
|
||||
shared[keyPath: keyPath]
|
||||
}
|
||||
}
|
||||
|
||||
/// Options required when creating a new home performance assessment project.
|
||||
@dynamicMemberLookup
|
||||
public struct CreateOptions: Equatable, Sendable {
|
||||
/// The directory to generate the new project in.
|
||||
let projectDirectory: String
|
||||
/// Shared run options.
|
||||
let shared: SharedRunOptions
|
||||
/// Custom template configuration to use.
|
||||
let template: Configuration.Template?
|
||||
/// Specify whether we should only use a local template directory to create the project from.
|
||||
let useLocalTemplateDirectory: Bool
|
||||
|
||||
/// Create new create options.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - projectDirectory: The directory to generate the project in.
|
||||
/// - shared: The shared run options.
|
||||
/// - template: Custom template configuration used when generating the project.
|
||||
/// - useLocalTemplateDirectory: Whether to use a local template directory, not a template repository.
|
||||
public init(
|
||||
projectDirectory: String,
|
||||
shared: SharedRunOptions,
|
||||
template: Configuration.Template? = nil,
|
||||
useLocalTemplateDirectory: Bool
|
||||
) {
|
||||
self.projectDirectory = projectDirectory
|
||||
self.shared = shared
|
||||
self.template = template
|
||||
self.useLocalTemplateDirectory = useLocalTemplateDirectory
|
||||
}
|
||||
|
||||
public subscript<T>(dynamicMember keyPath: KeyPath<SharedRunOptions, T>) -> T {
|
||||
shared[keyPath: keyPath]
|
||||
}
|
||||
}
|
||||
|
||||
/// Options required when generating a new template repository / directory.
|
||||
@dynamicMemberLookup
|
||||
public struct GenerateTemplateOptions: Equatable, Sendable {
|
||||
/// The shared run options.
|
||||
let shared: SharedRunOptions
|
||||
/// The path to generate the template in.
|
||||
let templateDirectory: String
|
||||
/// Specify the name of the directory for template variables.
|
||||
let templateVarsDirectory: String?
|
||||
/// Specify whether to use `ansible-vault` encrypted variables.
|
||||
let useVault: Bool
|
||||
|
||||
/// Create new generate template options.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - shared: The shared run options
|
||||
/// - templateDirectory: The path to generate the template in.
|
||||
/// - templateVarsDirectory: Specify the name of the directory for template variables.
|
||||
/// - useVault: Specify wheter to use `ansible-vault` encrypted variables.
|
||||
public init(
|
||||
shared: SharedRunOptions,
|
||||
templateDirectory: String,
|
||||
templateVarsDirectory: String? = nil,
|
||||
useVault: Bool = true
|
||||
) {
|
||||
self.shared = shared
|
||||
self.templateDirectory = templateDirectory
|
||||
self.templateVarsDirectory = templateVarsDirectory
|
||||
self.useVault = useVault
|
||||
}
|
||||
|
||||
public subscript<T>(dynamicMember keyPath: KeyPath<SharedRunOptions, T>) -> T {
|
||||
shared[keyPath: keyPath]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PlaybookClient.Repository: DependencyKey {
|
||||
public static var liveValue: Self {
|
||||
.init {
|
||||
try await installPlaybook(configuration: $0)
|
||||
} directory: {
|
||||
try await findDirectory(configuration: $0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PlaybookClient.RunPlaybook: DependencyKey {
|
||||
public static var liveValue: PlaybookClient.RunPlaybook {
|
||||
.init(
|
||||
buildProject: { try await $0.run() },
|
||||
createProject: { try await $0.run(encoder: $1) },
|
||||
generateTemplate: { try await $0.run() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension PlaybookClient: DependencyKey {
|
||||
public static let testValue: PlaybookClient = Self(
|
||||
repository: Repository(),
|
||||
run: RunPlaybook()
|
||||
)
|
||||
|
||||
public static var liveValue: PlaybookClient {
|
||||
.init(
|
||||
repository: .liveValue,
|
||||
run: .liveValue
|
||||
)
|
||||
}
|
||||
}
|
||||
8
Sources/PlaybookClient/PlaybookClientError.swift
Normal file
8
Sources/PlaybookClient/PlaybookClientError.swift
Normal file
@@ -0,0 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
enum PlaybookClientError: Error {
|
||||
case encodingError
|
||||
case projectDirectoryNotFound
|
||||
case templateDirectoryNotFound
|
||||
case templateDirectoryOrRepoNotSpecified
|
||||
}
|
||||
167
Sources/TestSupport/TestSupport.swift
Normal file
167
Sources/TestSupport/TestSupport.swift
Normal file
@@ -0,0 +1,167 @@
|
||||
@_spi(Internal) @_exported import CommandClient
|
||||
@_exported import Dependencies
|
||||
import Foundation
|
||||
import Logging
|
||||
@_exported import ShellClient
|
||||
|
||||
public protocol TestCase {}
|
||||
|
||||
public extension TestCase {
|
||||
|
||||
static var loggingOptions: LoggingOptions {
|
||||
let levelString = ProcessInfo.processInfo.environment["LOG_LEVEL"] ?? "debug"
|
||||
let logLevel = Logger.Level(rawValue: levelString) ?? .debug
|
||||
return .init(commandName: "\(Self.self)", logLevel: logLevel)
|
||||
}
|
||||
|
||||
func withCapturingCommandClient(
|
||||
_ key: String,
|
||||
dependencies setupDependencies: @escaping (inout DependencyValues) -> Void = { _ in },
|
||||
run: @Sendable @escaping () async throws -> Void,
|
||||
assert: @Sendable @escaping (CommandClient.RunCommandOptions) -> Void
|
||||
) async throws {
|
||||
let captured = CommandClient.CapturingClient()
|
||||
try await withDependencies {
|
||||
$0.commandClient = .capturing(captured)
|
||||
setupDependencies(&$0)
|
||||
} operation: {
|
||||
try await Self.loggingOptions.withLogger {
|
||||
try await run()
|
||||
|
||||
guard let options = await captured.options else {
|
||||
throw TestSupportError.optionsNotSet
|
||||
}
|
||||
|
||||
assert(options)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
enum TestSupportError: Error {
|
||||
case optionsNotSet
|
||||
}
|
||||
15
Sources/TestSupport/WithTempDir.swift
Normal file
15
Sources/TestSupport/WithTempDir.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
// swiftlint:disable force_try
|
||||
public func withTemporaryDirectory(
|
||||
_ operation: @Sendable (URL) async throws -> Void
|
||||
) async rethrows {
|
||||
let temporaryDirectory = FileManager.default
|
||||
.temporaryDirectory
|
||||
.appending(path: UUID().uuidString)
|
||||
try! FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: false)
|
||||
try await operation(temporaryDirectory)
|
||||
try! FileManager.default.removeItem(at: temporaryDirectory)
|
||||
}
|
||||
|
||||
// swiftlint:enable force_try
|
||||
177
Sources/VaultClient/VaultClient.swift
Normal file
177
Sources/VaultClient/VaultClient.swift
Normal file
@@ -0,0 +1,177 @@
|
||||
import CommandClient
|
||||
import ConfigurationClient
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import FileClient
|
||||
|
||||
public extension DependencyValues {
|
||||
/// Manages interactions with `ansible-vault` command line application.
|
||||
var vaultClient: VaultClient {
|
||||
get { self[VaultClient.self] }
|
||||
set { self[VaultClient.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@DependencyClient
|
||||
public struct VaultClient: Sendable {
|
||||
/// Run an `ansible-vault` command.
|
||||
public var run: Run
|
||||
|
||||
@DependencyClient
|
||||
public struct Run: Sendable {
|
||||
/// Decrypt an `ansible-vault` file.
|
||||
public var decrypt: @Sendable (RunOptions) async throws -> String
|
||||
/// Encrypt an `ansible-vault` file.
|
||||
public var encrypt: @Sendable (RunOptions) async throws -> String
|
||||
}
|
||||
|
||||
public struct RunOptions: Equatable, Sendable {
|
||||
|
||||
/// Extra arguments / options passed to the `ansible-vault` command.
|
||||
let extraOptions: [String]?
|
||||
/// Logging options to use while running the command.
|
||||
let loggingOptions: LoggingOptions
|
||||
/// The optional output file path.
|
||||
let outputFilePath: String?
|
||||
/// Disable logging output.
|
||||
let quiet: Bool
|
||||
/// Optional shell used to call the command.
|
||||
let shell: String?
|
||||
/// The path to the vault file, if not supplied we will search the current directory for it.
|
||||
let vaultFilePath: String?
|
||||
|
||||
/// Create new run options.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - extraOptions: Extra arguments / options passed to the command.
|
||||
/// - loggingOptions: The logging options used while running the command.
|
||||
/// - outputFilePath: The optional output file path.
|
||||
/// - quiet: Disable logging output of the command.
|
||||
/// - shell: The shell used to call the command.
|
||||
/// - vaultFilePath: The vault file, if not supplied we will search in the current working directory.
|
||||
public init(
|
||||
extraOptions: [String]? = nil,
|
||||
loggingOptions: LoggingOptions,
|
||||
outputFilePath: String? = nil,
|
||||
quiet: Bool = false,
|
||||
shell: String? = nil,
|
||||
vaultFilePath: String? = nil
|
||||
) {
|
||||
self.extraOptions = extraOptions
|
||||
self.loggingOptions = loggingOptions
|
||||
self.outputFilePath = outputFilePath
|
||||
self.quiet = quiet
|
||||
self.shell = shell
|
||||
self.vaultFilePath = vaultFilePath
|
||||
}
|
||||
}
|
||||
|
||||
@_spi(Internal)
|
||||
public enum Route: String, Equatable, Sendable {
|
||||
case decrypt
|
||||
case encrypt
|
||||
|
||||
public var verb: String { rawValue }
|
||||
}
|
||||
}
|
||||
|
||||
extension VaultClient: DependencyKey {
|
||||
|
||||
public static let testValue: VaultClient = Self(run: Run())
|
||||
|
||||
public static var liveValue: VaultClient {
|
||||
.init(
|
||||
run: .init(
|
||||
decrypt: { try await $0.run(route: .decrypt) },
|
||||
encrypt: { try await $0.run(route: .encrypt) }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@_spi(Internal)
|
||||
public extension VaultClient {
|
||||
enum Constants {
|
||||
public static let vaultCommand = "ansible-vault"
|
||||
}
|
||||
}
|
||||
|
||||
extension VaultClient.RunOptions {
|
||||
|
||||
// Sets up the default arguments and runs the `ansible-vault` command,
|
||||
// returning the output file path, which is either supplied by the caller
|
||||
// or found via the `fileClient.findVaultFileInCurrentDirectory`.
|
||||
//
|
||||
// This allows the output to be piped into other commands.
|
||||
//
|
||||
//
|
||||
@discardableResult
|
||||
func run(route: VaultClient.Route) async throws -> String {
|
||||
@Dependency(\.commandClient) var commandClient
|
||||
|
||||
return try await commandClient.run(
|
||||
logging: loggingOptions,
|
||||
quiet: quiet,
|
||||
shell: shell
|
||||
) {
|
||||
@Dependency(\.configurationClient) var configurationClient
|
||||
@Dependency(\.fileClient) var fileClient
|
||||
@Dependency(\.logger) var logger
|
||||
|
||||
var output: String?
|
||||
|
||||
let configuration = try await configurationClient.findAndLoad()
|
||||
logger.trace("Configuration: \(configuration)")
|
||||
|
||||
var vaultFilePath: String? = vaultFilePath
|
||||
|
||||
if vaultFilePath == nil {
|
||||
vaultFilePath = try await fileClient
|
||||
.findVaultFileInCurrentDirectory()?
|
||||
.cleanFilePath
|
||||
}
|
||||
|
||||
guard let vaultFilePath else {
|
||||
throw VaultClientError.vaultFileNotFound
|
||||
}
|
||||
output = vaultFilePath
|
||||
|
||||
logger.trace("Vault file: \(vaultFilePath)")
|
||||
|
||||
var arguments = [
|
||||
VaultClient.Constants.vaultCommand,
|
||||
route.verb
|
||||
]
|
||||
|
||||
if let outputFilePath {
|
||||
arguments.append(contentsOf: ["--output", outputFilePath])
|
||||
output = outputFilePath
|
||||
}
|
||||
|
||||
if let extraOptions {
|
||||
arguments.append(contentsOf: extraOptions)
|
||||
}
|
||||
|
||||
if let vaultArgs = configuration.vault.args {
|
||||
arguments.append(contentsOf: vaultArgs)
|
||||
}
|
||||
|
||||
if arguments.contains("encrypt"),
|
||||
!arguments.contains("--encrypt-vault-id"),
|
||||
let id = configuration.vault.encryptId
|
||||
{
|
||||
arguments.append(contentsOf: ["--encrypt-vault-id", id])
|
||||
}
|
||||
|
||||
arguments.append(vaultFilePath)
|
||||
|
||||
logger.trace("Arguments: \(arguments)")
|
||||
|
||||
return (arguments, output ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum VaultClientError: Error {
|
||||
case vaultFileNotFound
|
||||
}
|
||||
@@ -9,8 +9,13 @@ struct Application: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: Constants.appName,
|
||||
abstract: createAbstract("A utility for working with ansible hpa playbook."),
|
||||
version: VERSION ?? "0.0.0",
|
||||
subcommands: [
|
||||
BuildCommand.self, CreateCommand.self, VaultCommand.self, UtilsCommand.self
|
||||
BuildCommand.self,
|
||||
CreateCommand.self,
|
||||
GenerateCommand.self,
|
||||
VaultCommand.self,
|
||||
UtilsCommand.self
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import ArgumentParser
|
||||
import CliClient
|
||||
import Dependencies
|
||||
import Foundation
|
||||
import PlaybookClient
|
||||
|
||||
struct BuildCommand: AsyncParsableCommand {
|
||||
|
||||
@@ -9,29 +10,38 @@ struct BuildCommand: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration.playbook(
|
||||
commandName: commandName,
|
||||
abstract: "Build a home performance assesment project.",
|
||||
examples: (label: "Build Project", example: "\(commandName) /path/to/project")
|
||||
examples: (
|
||||
label: "Build project when in the project directory.",
|
||||
example: "\(commandName)"
|
||||
),
|
||||
(
|
||||
label: "Build project from outside the project directory.",
|
||||
example: "\(commandName) --project-directory /path/to/project"
|
||||
)
|
||||
)
|
||||
|
||||
@OptionGroup var globals: GlobalOptions
|
||||
|
||||
@Argument(
|
||||
@Option(
|
||||
help: "Path to the project directory.",
|
||||
completion: .directory
|
||||
)
|
||||
var projectDir: String
|
||||
var projectDirectory: String?
|
||||
|
||||
@Argument(
|
||||
help: "Extra arguments passed to the playbook."
|
||||
help: "Extra arguments / options passed to the playbook."
|
||||
)
|
||||
var extraArgs: [String] = []
|
||||
var extraOptions: [String] = []
|
||||
|
||||
mutating func run() async throws {
|
||||
try await runPlaybook(
|
||||
commandName: Self.commandName,
|
||||
globals: globals,
|
||||
extraArgs: extraArgs,
|
||||
"--tags", "build-project",
|
||||
"--extra-vars", "project_dir=\(projectDir)"
|
||||
)
|
||||
@Dependency(\.playbookClient) var playbookClient
|
||||
|
||||
try await playbookClient.run.buildProject(.init(
|
||||
projectDirectory: projectDirectory,
|
||||
shared: globals.sharedPlaybookRunOptions(
|
||||
commandName: Self.commandName,
|
||||
extraOptions: extraOptions
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import ArgumentParser
|
||||
import CliClient
|
||||
import ConfigurationClient
|
||||
import Dependencies
|
||||
import Foundation
|
||||
import Logging
|
||||
import PlaybookClient
|
||||
|
||||
struct CreateCommand: AsyncParsableCommand {
|
||||
|
||||
@@ -53,118 +54,19 @@ struct CreateCommand: AsyncParsableCommand {
|
||||
@Argument(
|
||||
help: "Extra arguments passed to the playbook."
|
||||
)
|
||||
var extraArgs: [String] = []
|
||||
var extraOptions: [String] = []
|
||||
|
||||
mutating func run() async throws {
|
||||
try await _run()
|
||||
}
|
||||
|
||||
private func _run() async throws {
|
||||
try await withSetupLogger(commandName: Self.commandName, globals: globals) {
|
||||
@Dependency(\.cliClient) var cliClient
|
||||
@Dependency(\.logger) var logger
|
||||
|
||||
let encoder = cliClient.encoder()
|
||||
|
||||
let configuration = try cliClient.loadConfiguration()
|
||||
|
||||
logger.debug("Configuration: \(configuration)")
|
||||
|
||||
let jsonData = try parseOptions(
|
||||
command: self,
|
||||
configuration: configuration,
|
||||
logger: logger,
|
||||
encoder: encoder
|
||||
)
|
||||
|
||||
guard let jsonString = String(data: jsonData, encoding: .utf8) else {
|
||||
throw CreateError.encodingError
|
||||
}
|
||||
|
||||
logger.debug("JSON string: \(jsonString)")
|
||||
|
||||
try await runPlaybook(
|
||||
@Dependency(\.playbookClient) var playbookClient
|
||||
try await playbookClient.run.createProject(.init(
|
||||
projectDirectory: projectDir,
|
||||
shared: globals.sharedPlaybookRunOptions(
|
||||
commandName: Self.commandName,
|
||||
globals: self.globals,
|
||||
configuration: configuration,
|
||||
extraArgs: extraArgs,
|
||||
"--tags", "setup-project",
|
||||
"--extra-vars", "project_dir=\(self.projectDir)",
|
||||
"--extra-vars", "'\(jsonString)'"
|
||||
)
|
||||
}
|
||||
extraOptions: extraOptions
|
||||
),
|
||||
template: .init(directory: templateDir),
|
||||
useLocalTemplateDirectory: localTemplateDir
|
||||
))
|
||||
print(projectDir)
|
||||
}
|
||||
}
|
||||
|
||||
private func parseOptions(
|
||||
command: CreateCommand,
|
||||
configuration: Configuration,
|
||||
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"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
14
Sources/hpa/GenerateCommands/GenerateCommand.swift
Normal file
14
Sources/hpa/GenerateCommands/GenerateCommand.swift
Normal file
@@ -0,0 +1,14 @@
|
||||
import ArgumentParser
|
||||
|
||||
struct GenerateCommand: AsyncParsableCommand {
|
||||
static let commandName = "generate"
|
||||
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: commandName,
|
||||
subcommands: [
|
||||
GeneratePdfCommand.self,
|
||||
GenerateLatexCommand.self,
|
||||
GenerateHtmlCommand.self
|
||||
]
|
||||
)
|
||||
}
|
||||
24
Sources/hpa/GenerateCommands/GenerateHtmlCommand.swift
Normal file
24
Sources/hpa/GenerateCommands/GenerateHtmlCommand.swift
Normal file
@@ -0,0 +1,24 @@
|
||||
import ArgumentParser
|
||||
import Dependencies
|
||||
import PandocClient
|
||||
|
||||
// TODO: Need to add a step to build prior to generating file.
|
||||
struct GenerateHtmlCommand: AsyncParsableCommand {
|
||||
static let commandName = "html"
|
||||
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: commandName
|
||||
)
|
||||
|
||||
@OptionGroup var globals: GenerateOptions
|
||||
|
||||
mutating func run() async throws {
|
||||
@Dependency(\.pandocClient) var pandocClient
|
||||
|
||||
let output = try await pandocClient.run.generateHtml(
|
||||
globals.pandocRunOptions(commandName: Self.commandName)
|
||||
)
|
||||
print(output)
|
||||
}
|
||||
|
||||
}
|
||||
22
Sources/hpa/GenerateCommands/GenerateLatexCommand.swift
Normal file
22
Sources/hpa/GenerateCommands/GenerateLatexCommand.swift
Normal file
@@ -0,0 +1,22 @@
|
||||
import ArgumentParser
|
||||
import Dependencies
|
||||
|
||||
// TODO: Need to add a step to build prior to generating file.
|
||||
struct GenerateLatexCommand: AsyncParsableCommand {
|
||||
static let commandName = "latex"
|
||||
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: commandName
|
||||
)
|
||||
|
||||
@OptionGroup var globals: GenerateOptions
|
||||
|
||||
mutating func run() async throws {
|
||||
@Dependency(\.pandocClient) var pandocClient
|
||||
|
||||
let output = try await pandocClient.run.generateLatex(
|
||||
globals.pandocRunOptions(commandName: Self.commandName)
|
||||
)
|
||||
print(output)
|
||||
}
|
||||
}
|
||||
95
Sources/hpa/GenerateCommands/GenerateOptions.swift
Normal file
95
Sources/hpa/GenerateCommands/GenerateOptions.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
import ArgumentParser
|
||||
import CommandClient
|
||||
import PandocClient
|
||||
|
||||
@dynamicMemberLookup
|
||||
struct GenerateOptions: ParsableArguments {
|
||||
|
||||
@OptionGroup var basic: BasicGlobalOptions
|
||||
|
||||
@Option(
|
||||
name: .shortAndLong,
|
||||
help: "Custom build directory path.",
|
||||
completion: .directory
|
||||
)
|
||||
var buildDirectory: String?
|
||||
|
||||
@Option(
|
||||
name: [.short, .customLong("file")],
|
||||
help: "Files used to generate the output, can be specified multiple times.",
|
||||
completion: .file()
|
||||
)
|
||||
var files: [String] = []
|
||||
|
||||
@Option(
|
||||
name: [.customShort("H"), .long],
|
||||
help: "Files to include in the header, can be specified multiple times.",
|
||||
completion: .file()
|
||||
)
|
||||
var includeInHeader: [String] = []
|
||||
|
||||
@Flag(
|
||||
help: "Do not build the project prior to generating the output."
|
||||
)
|
||||
var noBuild: Bool = false
|
||||
|
||||
@Option(
|
||||
name: .shortAndLong,
|
||||
help: "The project directory.",
|
||||
completion: .directory
|
||||
)
|
||||
var projectDirectory: String?
|
||||
|
||||
@Option(
|
||||
name: .shortAndLong,
|
||||
help: "The output directory",
|
||||
completion: .directory
|
||||
)
|
||||
var outputDirectory: String?
|
||||
|
||||
@Option(
|
||||
name: [.customShort("n"), .customLong("name")],
|
||||
help: "Name of the output file."
|
||||
)
|
||||
var outputFileName: String?
|
||||
|
||||
// NOTE: This must be last, both here and in the commands, so if the commands have options of their
|
||||
// own, they must be declared ahead of using the global options.
|
||||
|
||||
@Argument(
|
||||
help: "Extra arguments / options to pass to the underlying pandoc command."
|
||||
)
|
||||
var extraOptions: [String] = []
|
||||
|
||||
subscript<T>(dynamicMember keyPath: WritableKeyPath<BasicGlobalOptions, T>) -> T {
|
||||
get { basic[keyPath: keyPath] }
|
||||
set { basic[keyPath: keyPath] = newValue }
|
||||
}
|
||||
|
||||
subscript<T>(dynamicMember keyPath: KeyPath<BasicGlobalOptions, T>) -> T {
|
||||
basic[keyPath: keyPath]
|
||||
}
|
||||
}
|
||||
|
||||
extension GenerateOptions {
|
||||
|
||||
func loggingOptions(commandName: String) -> LoggingOptions {
|
||||
basic.loggingOptions(commandName: commandName)
|
||||
}
|
||||
|
||||
func pandocRunOptions(commandName: String) -> PandocClient.RunOptions {
|
||||
.init(
|
||||
buildDirectory: buildDirectory,
|
||||
extraOptions: extraOptions.count > 0 ? extraOptions : nil,
|
||||
files: files.count > 0 ? files : nil,
|
||||
loggingOptions: .init(commandName: commandName, logLevel: .init(globals: basic, quietOnlyPlaybook: false)),
|
||||
includeInHeader: includeInHeader.count > 0 ? includeInHeader : nil,
|
||||
outputDirectory: outputDirectory,
|
||||
projectDirectory: projectDirectory,
|
||||
outputFileName: outputFileName,
|
||||
quiet: basic.quiet,
|
||||
shell: basic.shell,
|
||||
shouldBuild: !noBuild
|
||||
)
|
||||
}
|
||||
}
|
||||
32
Sources/hpa/GenerateCommands/GeneratePdfCommand.swift
Normal file
32
Sources/hpa/GenerateCommands/GeneratePdfCommand.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
import ArgumentParser
|
||||
import Dependencies
|
||||
import PandocClient
|
||||
|
||||
// TODO: Need to add a step to build prior to generating file.
|
||||
|
||||
struct GeneratePdfCommand: AsyncParsableCommand {
|
||||
static let commandName = "pdf"
|
||||
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: commandName
|
||||
)
|
||||
|
||||
@Option(
|
||||
name: [.customShort("e"), .customLong("engine")],
|
||||
help: "The pdf engine to use."
|
||||
)
|
||||
var pdfEngine: String?
|
||||
|
||||
@OptionGroup var globals: GenerateOptions
|
||||
|
||||
mutating func run() async throws {
|
||||
@Dependency(\.pandocClient) var pandocClient
|
||||
|
||||
let output = try await pandocClient.run.generatePdf(
|
||||
globals.pandocRunOptions(commandName: Self.commandName),
|
||||
pdfEngine: pdfEngine
|
||||
)
|
||||
|
||||
print(output)
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,13 @@ func createAbstract(_ string: String) -> String {
|
||||
|
||||
extension Usage where Content == AnyTextNode {
|
||||
|
||||
static func `default`(commandName: String, parentCommand: String?) -> Self {
|
||||
static func `default`(
|
||||
commandName: String,
|
||||
parentCommand: String?,
|
||||
usesArguments: Bool = true,
|
||||
usesOptions: Bool = true,
|
||||
usesExtraArguments: Bool = true
|
||||
) -> Self {
|
||||
.init {
|
||||
HStack {
|
||||
HStack {
|
||||
@@ -37,10 +43,16 @@ extension Usage where Content == AnyTextNode {
|
||||
}
|
||||
.color(.blue).bold()
|
||||
|
||||
"[OPTIONS]".color(.green)
|
||||
"[ARGUMENTS]".color(.cyan)
|
||||
"--"
|
||||
"[EXTRA-OPTIONS]".color(.magenta)
|
||||
if usesOptions {
|
||||
"[OPTIONS]".color(.green)
|
||||
}
|
||||
if usesArguments {
|
||||
"[ARGUMENTS]".color(.cyan)
|
||||
}
|
||||
if usesExtraArguments {
|
||||
"--"
|
||||
"[EXTRA-OPTIONS]".color(.magenta)
|
||||
}
|
||||
}
|
||||
|
||||
.eraseToAnyTextNode()
|
||||
|
||||
@@ -3,6 +3,9 @@ import Rainbow
|
||||
// Constant string values.
|
||||
enum Constants {
|
||||
static let appName = "hpa"
|
||||
static let brewPackages = [
|
||||
"ansible", "imagemagick", "pandoc", "texLive"
|
||||
]
|
||||
static let playbookFileName = "main.yml"
|
||||
static let inventoryFileName = "inventory.ini"
|
||||
static let importantExtraArgsNote = """
|
||||
|
||||
@@ -26,13 +26,32 @@ extension Array where Element == Example {
|
||||
))
|
||||
return output
|
||||
}
|
||||
|
||||
func addingPipeToOtherCommand(command: String) -> Self {
|
||||
var output = self
|
||||
output.append((
|
||||
label: "Piping output to other command.",
|
||||
example: "\(command) --quiet | xargs open"
|
||||
))
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
extension ExampleSection where Header == String, Label == String {
|
||||
static func `default`(examples: [Example], usesPassingExtraOptions: Bool = true) -> some TextNode {
|
||||
static func `default`(
|
||||
examples: [Example],
|
||||
usesPassingExtraOptions: Bool = true,
|
||||
usesPipeToOtherCommand: Bool = true
|
||||
) -> some TextNode {
|
||||
var examples = examples
|
||||
if usesPassingExtraOptions, let first = examples.first {
|
||||
examples = examples.addingPassingOptions(command: first.example)
|
||||
|
||||
if let first = examples.first {
|
||||
if usesPassingExtraOptions {
|
||||
examples = examples.addingPassingOptions(command: first.example)
|
||||
}
|
||||
if usesPipeToOtherCommand {
|
||||
examples = examples.addingPipeToOtherCommand(command: first.example)
|
||||
}
|
||||
}
|
||||
return Self(examples: examples) {
|
||||
"EXAMPLES:"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import ArgumentParser
|
||||
import CommandClient
|
||||
import ConfigurationClient
|
||||
import PlaybookClient
|
||||
|
||||
struct BasicGlobalOptions: ParsableArguments {
|
||||
@Flag(
|
||||
@@ -26,10 +29,10 @@ struct BasicGlobalOptions: ParsableArguments {
|
||||
struct GlobalOptions: ParsableArguments {
|
||||
|
||||
@Option(
|
||||
name: .shortAndLong,
|
||||
name: .long,
|
||||
help: "Optional path to the ansible hpa playbook directory."
|
||||
)
|
||||
var playbookDir: String?
|
||||
var playbookDirectory: String?
|
||||
|
||||
@Option(
|
||||
name: .shortAndLong,
|
||||
@@ -55,3 +58,39 @@ struct GlobalOptions: ParsableArguments {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension GlobalOptions {
|
||||
func loggingOptions(commandName: String) -> LoggingOptions {
|
||||
.init(
|
||||
commandName: commandName,
|
||||
logLevel: .init(globals: basic, quietOnlyPlaybook: quietOnlyPlaybook)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension BasicGlobalOptions {
|
||||
func loggingOptions(commandName: String) -> LoggingOptions {
|
||||
.init(
|
||||
commandName: commandName,
|
||||
logLevel: .init(globals: self, quietOnlyPlaybook: false)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension GlobalOptions {
|
||||
func sharedPlaybookRunOptions(
|
||||
commandName: String,
|
||||
extraOptions: [String]?
|
||||
) -> PlaybookClient.RunPlaybook.SharedRunOptions {
|
||||
return .init(
|
||||
extraOptions: extraOptions,
|
||||
inventoryFilePath: inventoryPath,
|
||||
loggingOptions: .init(
|
||||
commandName: commandName,
|
||||
logLevel: .init(globals: basic, quietOnlyPlaybook: quietOnlyPlaybook)
|
||||
),
|
||||
quiet: basic.quiet,
|
||||
shell: basic.shell
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Dependencies
|
||||
import Logging
|
||||
|
||||
extension Logger.Level {
|
||||
@@ -21,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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import ArgumentParser
|
||||
import CliClient
|
||||
import Dependencies
|
||||
import Foundation
|
||||
import Logging
|
||||
import Rainbow
|
||||
import ShellClient
|
||||
|
||||
func runPlaybook(
|
||||
commandName: String,
|
||||
globals: GlobalOptions,
|
||||
configuration: Configuration? = nil,
|
||||
extraArgs: [String],
|
||||
_ args: [String]
|
||||
) async throws {
|
||||
try await withSetupLogger(commandName: commandName, globals: globals) {
|
||||
@Dependency(\.cliClient) var cliClient
|
||||
@Dependency(\.logger) var logger
|
||||
|
||||
logger.debug("Begin run playbook: \(globals)")
|
||||
|
||||
let configuration = try cliClient.ensuredConfiguration(configuration)
|
||||
|
||||
logger.debug("Configuration: \(configuration)")
|
||||
|
||||
let playbookDir = try ensureString(
|
||||
globals: globals,
|
||||
configuration: configuration,
|
||||
globalsKeyPath: \.playbookDir,
|
||||
configurationKeyPath: \.playbookDir
|
||||
)
|
||||
let playbook = "\(playbookDir)/\(Constants.playbookFileName)"
|
||||
|
||||
let inventory = (try? ensureString(
|
||||
globals: globals,
|
||||
configuration: configuration,
|
||||
globalsKeyPath: \.inventoryPath,
|
||||
configurationKeyPath: \.inventoryPath
|
||||
)) ?? "\(playbookDir)/\(Constants.inventoryFileName)"
|
||||
|
||||
let defaultArgs = (configuration.defaultPlaybookArgs ?? [])
|
||||
+ (configuration.defaultVaultArgs ?? [])
|
||||
|
||||
try await cliClient.runCommand(
|
||||
quiet: globals.quietOnlyPlaybook ? true : globals.quiet,
|
||||
shell: globals.shellOrDefault,
|
||||
[
|
||||
"ansible-playbook", playbook,
|
||||
"--inventory", inventory
|
||||
] + args
|
||||
+ extraArgs
|
||||
+ defaultArgs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func runPlaybook(
|
||||
commandName: String,
|
||||
globals: GlobalOptions,
|
||||
configuration: Configuration? = nil,
|
||||
extraArgs: [String],
|
||||
_ args: String...
|
||||
) async throws {
|
||||
try await runPlaybook(
|
||||
commandName: commandName,
|
||||
globals: globals,
|
||||
configuration: configuration,
|
||||
extraArgs: extraArgs,
|
||||
args
|
||||
)
|
||||
}
|
||||
|
||||
extension CliClient {
|
||||
func ensuredConfiguration(_ configuration: Configuration?) throws -> Configuration {
|
||||
guard let configuration else {
|
||||
return try loadConfiguration()
|
||||
}
|
||||
return configuration
|
||||
}
|
||||
}
|
||||
|
||||
extension BasicGlobalOptions {
|
||||
|
||||
var shellOrDefault: ShellCommand.Shell {
|
||||
guard let shell else { return .zsh(useDashC: true) }
|
||||
return .custom(path: shell, useDashC: true)
|
||||
}
|
||||
}
|
||||
|
||||
func ensureString(
|
||||
globals: GlobalOptions,
|
||||
configuration: Configuration,
|
||||
globalsKeyPath: KeyPath<GlobalOptions, String?>,
|
||||
configurationKeyPath: KeyPath<Configuration, String?>
|
||||
) throws -> String {
|
||||
if let global = globals[keyPath: globalsKeyPath] {
|
||||
return global
|
||||
}
|
||||
guard let configuration = configuration[keyPath: configurationKeyPath] else {
|
||||
throw RunPlaybookError.playbookNotFound
|
||||
}
|
||||
return configuration
|
||||
}
|
||||
|
||||
enum RunPlaybookError: Error {
|
||||
case playbookNotFound
|
||||
case configurationError
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import Dependencies
|
||||
import ShellClient
|
||||
|
||||
func runVault(
|
||||
commandName: String,
|
||||
options: VaultOptions,
|
||||
_ args: [String]
|
||||
) async throws {
|
||||
try await withSetupLogger(commandName: commandName, globals: options.globals) {
|
||||
@Dependency(\.cliClient) var cliClient
|
||||
@Dependency(\.logger) var logger
|
||||
|
||||
logger.debug("Begin run vault: \(options)")
|
||||
|
||||
let configuration = try cliClient.ensuredConfiguration(nil)
|
||||
logger.debug("Configuration: \(configuration)")
|
||||
|
||||
let path: String
|
||||
if let file = options.file {
|
||||
path = file
|
||||
} else {
|
||||
path = try cliClient.findVaultFileInCurrentDirectory()
|
||||
}
|
||||
|
||||
logger.debug("Vault path: \(path)")
|
||||
|
||||
let defaultArgs = configuration.defaultVaultArgs ?? []
|
||||
|
||||
var vaultArgs = ["ansible-vault"]
|
||||
+ args
|
||||
+ defaultArgs
|
||||
+ options.extraArgs
|
||||
+ [path]
|
||||
|
||||
if args.contains("encrypt"),
|
||||
!vaultArgs.contains("--encrypt-vault-id"),
|
||||
let id = configuration.defaultVaultEncryptId
|
||||
{
|
||||
vaultArgs.append(contentsOf: ["--encrypt-vault-id", id])
|
||||
}
|
||||
|
||||
try await cliClient.runCommand(
|
||||
quiet: options.quiet,
|
||||
shell: options.shellOrDefault,
|
||||
vaultArgs
|
||||
)
|
||||
}
|
||||
}
|
||||
27
Sources/hpa/UtilsCommands/DumpConfigCommand.swift
Normal file
27
Sources/hpa/UtilsCommands/DumpConfigCommand.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
import ArgumentParser
|
||||
import CliDoc
|
||||
import ConfigurationClient
|
||||
import CustomDump
|
||||
import Dependencies
|
||||
|
||||
struct DumpConfigCommand: AsyncParsableCommand {
|
||||
static let commandName = "dump-config"
|
||||
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: commandName,
|
||||
abstract: createAbstract("Show the current configuration."),
|
||||
usage: .default(commandName: commandName, parentCommand: "utils", usesArguments: false, usesExtraArguments: false),
|
||||
discussion: Discussion {
|
||||
"Useful to debug your configuration settings / make sure they load properly."
|
||||
}
|
||||
)
|
||||
|
||||
func run() async throws {
|
||||
@Dependency(\.configurationClient) var configurationClient
|
||||
|
||||
let configuration = try await configurationClient.findAndLoad()
|
||||
|
||||
customDump(configuration)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import ArgumentParser
|
||||
import CliClient
|
||||
import CliDoc
|
||||
import CommandClient
|
||||
import ConfigurationClient
|
||||
import Dependencies
|
||||
|
||||
struct GenerateConfigurationCommand: AsyncParsableCommand {
|
||||
@@ -15,14 +16,14 @@ struct GenerateConfigurationCommand: AsyncParsableCommand {
|
||||
Note {
|
||||
"""
|
||||
If a directory is not supplied then a configuration file will be created
|
||||
at \("'~/.config/hpa-playbook/config'".yellow).
|
||||
at \("'~/.config/hpa/config.toml'".yellow).
|
||||
"""
|
||||
}
|
||||
VStack {
|
||||
"EXAMPLE:".yellow.bold
|
||||
"Create a directory and generate the configuration".green
|
||||
ShellCommand("mkdir -p ~/.config/hpa-playbook")
|
||||
ShellCommand("hpa generate-config --path ~/.config/hpa-playbook")
|
||||
ShellCommand("mkdir -p ~/.config/hpa")
|
||||
ShellCommand("hpa generate-config --path ~/.config/hpa")
|
||||
}
|
||||
}
|
||||
.separator(.newLine(count: 2))
|
||||
@@ -38,10 +39,16 @@ struct GenerateConfigurationCommand: AsyncParsableCommand {
|
||||
|
||||
@Flag(
|
||||
name: .shortAndLong,
|
||||
help: "Generate a json file, instead of default env style"
|
||||
help: "Generate a json file, instead of the default toml style"
|
||||
)
|
||||
var json: Bool = false
|
||||
|
||||
@Flag(
|
||||
name: .shortAndLong,
|
||||
help: "Force generation, overwriting a file if it exists."
|
||||
)
|
||||
var force: Bool = false
|
||||
|
||||
@OptionGroup var globals: BasicGlobalOptions
|
||||
|
||||
mutating func run() async throws {
|
||||
@@ -49,24 +56,18 @@ struct GenerateConfigurationCommand: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
private func _run() async throws {
|
||||
try await withSetupLogger(commandName: Self.commandName, globals: globals) {
|
||||
@Dependency(\.cliClient) var cliClient
|
||||
@Dependency(\.configurationClient) var configurationClient
|
||||
|
||||
let actualPath: String
|
||||
try await globals.loggingOptions(commandName: Self.commandName).withLogger {
|
||||
@Dependency(\.logger) var logger
|
||||
|
||||
if let path {
|
||||
actualPath = "\(path)/config"
|
||||
} else {
|
||||
let path = "~/.config/hpa-playbook/"
|
||||
try await cliClient.runCommand(
|
||||
quiet: false,
|
||||
shell: globals.shellOrDefault,
|
||||
"mkdir", "-p", path
|
||||
)
|
||||
actualPath = "\(path)/config"
|
||||
}
|
||||
let output = try await configurationClient.generate(.init(
|
||||
force: force,
|
||||
json: json,
|
||||
path: path != nil ? .directory(path!) : nil
|
||||
))
|
||||
|
||||
try cliClient.createConfiguration(actualPath, json)
|
||||
print(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import ArgumentParser
|
||||
import Dependencies
|
||||
import PlaybookClient
|
||||
|
||||
struct GenerateProjectTemplateCommand: AsyncParsableCommand {
|
||||
|
||||
@@ -32,26 +34,23 @@ struct GenerateProjectTemplateCommand: AsyncParsableCommand {
|
||||
var path: String
|
||||
|
||||
@Argument(
|
||||
help: "Extra arguments passed to the playbook."
|
||||
help: "Extra arguments / options passed to the playbook."
|
||||
)
|
||||
var extraArgs: [String] = []
|
||||
var extraOptions: [String] = []
|
||||
|
||||
mutating func run() async throws {
|
||||
let varsDir = templateVars != nil
|
||||
? ["--extra-vars", "repo_vars_dir=\(templateVars!)"]
|
||||
: []
|
||||
@Dependency(\.playbookClient) var playbookClient
|
||||
|
||||
let vault = noVault ? ["--extra-vars", "use_vault=false"] : []
|
||||
let output = try await playbookClient.run.generateTemplate(.init(
|
||||
shared: globals.sharedPlaybookRunOptions(
|
||||
commandName: Self.commandName,
|
||||
extraOptions: extraOptions
|
||||
),
|
||||
templateDirectory: path,
|
||||
templateVarsDirectory: templateVars,
|
||||
useVault: !noVault
|
||||
))
|
||||
|
||||
try await runPlaybook(
|
||||
commandName: Self.commandName,
|
||||
globals: globals,
|
||||
extraArgs: extraArgs,
|
||||
[
|
||||
"--tags", "repo-template",
|
||||
"--extra-vars", "output_dir=\(path)"
|
||||
] + varsDir
|
||||
+ vault
|
||||
)
|
||||
print(output)
|
||||
}
|
||||
}
|
||||
52
Sources/hpa/UtilsCommands/InstallDependenciesCommand.swift
Normal file
52
Sources/hpa/UtilsCommands/InstallDependenciesCommand.swift
Normal file
@@ -0,0 +1,52 @@
|
||||
import ArgumentParser
|
||||
import CliDoc
|
||||
import CommandClient
|
||||
import Dependencies
|
||||
|
||||
struct InstallDependenciesCommand: AsyncParsableCommand {
|
||||
static let commandName: String = "install-dependencies"
|
||||
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: commandName,
|
||||
abstract: createAbstract("Ensure required dependencies are installed"),
|
||||
usage: .default(commandName: commandName, parentCommand: "utils", usesArguments: false),
|
||||
discussion: Discussion {
|
||||
VStack {
|
||||
Note {
|
||||
"Homebrew is required to install dependencies."
|
||||
}
|
||||
HStack {
|
||||
"See Also:".yellow.bold.underline
|
||||
"https://brew.sh"
|
||||
}
|
||||
ImportantNote.passingExtraArgs
|
||||
}
|
||||
.separator(.newLine(count: 2))
|
||||
}
|
||||
)
|
||||
|
||||
@OptionGroup var globals: BasicGlobalOptions
|
||||
|
||||
@Argument(
|
||||
help: "Extra arguments / options to pass to the homebrew command."
|
||||
)
|
||||
var extraOptions: [String] = []
|
||||
|
||||
mutating func run() async throws {
|
||||
@Dependency(\.commandClient) var commandClient
|
||||
@Dependency(\.playbookClient) var playbookClient
|
||||
|
||||
let arguments = [
|
||||
"brew", "install"
|
||||
] + Constants.brewPackages
|
||||
+ extraOptions
|
||||
|
||||
try await commandClient.run(
|
||||
quiet: globals.quiet,
|
||||
shell: globals.shell,
|
||||
arguments
|
||||
)
|
||||
|
||||
try await playbookClient.repository.install()
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,10 @@ struct UtilsCommand: AsyncParsableCommand {
|
||||
These are commands that are generally only run on occasion / less frequently used.
|
||||
""",
|
||||
subcommands: [
|
||||
DumpConfigCommand.self,
|
||||
GenerateProjectTemplateCommand.self,
|
||||
GenerateConfigurationCommand.self
|
||||
GenerateConfigurationCommand.self,
|
||||
InstallDependenciesCommand.self
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import ArgumentParser
|
||||
import Dependencies
|
||||
import VaultClient
|
||||
|
||||
struct DecryptCommand: AsyncParsableCommand {
|
||||
|
||||
@@ -9,23 +11,22 @@ struct DecryptCommand: AsyncParsableCommand {
|
||||
abstract: createAbstract("Decrypt a vault file.")
|
||||
)
|
||||
|
||||
@OptionGroup var options: VaultOptions
|
||||
|
||||
@Option(
|
||||
name: .shortAndLong,
|
||||
help: "Output file."
|
||||
)
|
||||
var output: String?
|
||||
|
||||
@OptionGroup var options: VaultOptions
|
||||
|
||||
mutating func run() async throws {
|
||||
var args = ["decrypt"]
|
||||
if let output {
|
||||
args.append(contentsOf: ["--output", output])
|
||||
}
|
||||
try await runVault(
|
||||
@Dependency(\.vaultClient) var vaultClient
|
||||
|
||||
let output = try await vaultClient.run.decrypt(options.runOptions(
|
||||
commandName: Self.commandName,
|
||||
options: options,
|
||||
args
|
||||
)
|
||||
outputFilePath: output
|
||||
))
|
||||
|
||||
print(output)
|
||||
}
|
||||
}
|
||||
|
||||
1
Sources/hpa/VaultCommands/EditCommand.swift
Normal file
1
Sources/hpa/VaultCommands/EditCommand.swift
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import ArgumentParser
|
||||
import Dependencies
|
||||
import VaultClient
|
||||
|
||||
struct EncryptCommand: AsyncParsableCommand {
|
||||
|
||||
@@ -9,23 +11,22 @@ struct EncryptCommand: AsyncParsableCommand {
|
||||
abstract: createAbstract("Encrypt a vault file.")
|
||||
)
|
||||
|
||||
@OptionGroup var options: VaultOptions
|
||||
|
||||
@Option(
|
||||
name: .shortAndLong,
|
||||
help: "Output file."
|
||||
)
|
||||
var output: String?
|
||||
|
||||
@OptionGroup var options: VaultOptions
|
||||
|
||||
mutating func run() async throws {
|
||||
var args = ["encrypt"]
|
||||
if let output {
|
||||
args.append(contentsOf: ["--output", output])
|
||||
}
|
||||
try await runVault(
|
||||
@Dependency(\.vaultClient) var vaultClient
|
||||
|
||||
let output = try await vaultClient.run.encrypt(options.runOptions(
|
||||
commandName: Self.commandName,
|
||||
options: options,
|
||||
args
|
||||
)
|
||||
outputFilePath: output
|
||||
))
|
||||
|
||||
print(output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ struct VaultCommand: AsyncParsableCommand {
|
||||
.separator(.newLine(count: 2))
|
||||
},
|
||||
subcommands: [
|
||||
EncryptCommand.self, DecryptCommand.self
|
||||
DecryptCommand.self, EncryptCommand.self
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import ArgumentParser
|
||||
import CommandClient
|
||||
import VaultClient
|
||||
|
||||
// Holds the common options for vault commands, as they all share the
|
||||
// same structure.
|
||||
@@ -17,7 +19,7 @@ struct VaultOptions: ParsableArguments {
|
||||
@Argument(
|
||||
help: "Extra arguments to pass to the vault command."
|
||||
)
|
||||
var extraArgs: [String] = []
|
||||
var extraOptions: [String] = []
|
||||
|
||||
subscript<T>(dynamicMember keyPath: WritableKeyPath<BasicGlobalOptions, T>) -> T {
|
||||
get { globals[keyPath: keyPath] }
|
||||
@@ -29,3 +31,23 @@ struct VaultOptions: ParsableArguments {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension VaultOptions {
|
||||
|
||||
func runOptions(
|
||||
commandName: String,
|
||||
outputFilePath: String? = nil
|
||||
) -> VaultClient.RunOptions {
|
||||
.init(
|
||||
extraOptions: extraOptions,
|
||||
loggingOptions: .init(
|
||||
commandName: commandName,
|
||||
logLevel: .init(globals: globals, quietOnlyPlaybook: false)
|
||||
),
|
||||
outputFilePath: outputFilePath,
|
||||
quiet: globals.quiet,
|
||||
shell: globals.shell,
|
||||
vaultFilePath: file
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
2
Sources/hpa/Version.swift
Normal file
2
Sources/hpa/Version.swift
Normal file
@@ -0,0 +1,2 @@
|
||||
// Do not set this variable, it is set during the build process.
|
||||
let VERSION: String? = nil
|
||||
@@ -1,62 +0,0 @@
|
||||
@_spi(Internal) import CliClient
|
||||
import Dependencies
|
||||
import ShellClient
|
||||
import Testing
|
||||
|
||||
@Test
|
||||
func testFindConfigPaths() throws {
|
||||
try withTestLogger(key: "testFindConfigPaths") {
|
||||
$0.fileClient = .liveValue
|
||||
} operation: {
|
||||
@Dependency(\.logger) var logger
|
||||
let urls = try findConfigurationFiles()
|
||||
logger.debug("urls: \(urls)")
|
||||
// #expect(urls.count == 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func loadConfiguration() throws {
|
||||
try withTestLogger(key: "loadConfiguration", logLevel: .debug) {
|
||||
$0.cliClient = .liveValue
|
||||
$0.fileClient = .liveValue
|
||||
} operation: {
|
||||
@Dependency(\.cliClient) var client
|
||||
@Dependency(\.logger) var logger
|
||||
let config = try client.loadConfiguration()
|
||||
logger.debug("\(config)")
|
||||
#expect(config.playbookDir != nil)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
@_spi(Internal) import CliDoc
|
||||
@preconcurrency import Rainbow
|
||||
import Testing
|
||||
import XCTest
|
||||
|
||||
final class CliDocTests: XCTestCase {
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
Rainbow.outputTarget = .console
|
||||
Rainbow.enabled = true
|
||||
}
|
||||
|
||||
func testStringChecks() {
|
||||
let expected = "Foo".green.bold
|
||||
|
||||
XCTAssert("Foo".green.bold == expected)
|
||||
XCTAssert(expected != "Foo")
|
||||
}
|
||||
|
||||
func testRepeatingModifier() {
|
||||
let node = AnyNode {
|
||||
Text("foo").color(.green).style(.bold)
|
||||
"\n".repeating(2)
|
||||
Text("bar").repeating(2, separator: " ")
|
||||
}
|
||||
let expected = """
|
||||
\("foo".green.bold)
|
||||
|
||||
bar bar
|
||||
"""
|
||||
XCTAssert(node.render() == expected)
|
||||
}
|
||||
|
||||
func testGroup1() {
|
||||
let arguments = [
|
||||
(true, "foo bar"),
|
||||
(false, """
|
||||
foo
|
||||
bar
|
||||
""")
|
||||
]
|
||||
|
||||
for (inline, expected) in arguments {
|
||||
let node = AnyNode {
|
||||
Group(separator: inline ? " " : "\n") {
|
||||
Text("foo")
|
||||
Text("bar")
|
||||
}
|
||||
}
|
||||
XCTAssert(node.render() == expected)
|
||||
}
|
||||
}
|
||||
|
||||
func testHeader() {
|
||||
let header = Header("Foo")
|
||||
let expected = "\("Foo".yellow.bold)"
|
||||
XCTAssert(header.render() == expected)
|
||||
|
||||
let header2 = Header {
|
||||
"Foo".yellow.bold
|
||||
}
|
||||
XCTAssert(header2.render() == expected)
|
||||
}
|
||||
|
||||
func testGroup() {
|
||||
let group = Group {
|
||||
Text("foo")
|
||||
Text("bar")
|
||||
}
|
||||
|
||||
XCTAssert(group.render() == "foo bar")
|
||||
|
||||
let group2 = Group(separator: "\n") {
|
||||
Text("foo")
|
||||
Text("bar")
|
||||
}
|
||||
let expected = """
|
||||
foo
|
||||
bar
|
||||
"""
|
||||
XCTAssert(group2.render() == expected)
|
||||
}
|
||||
|
||||
func testLabeledContent() {
|
||||
let node = LabeledContent("Foo") {
|
||||
Text("Bar")
|
||||
}
|
||||
.labelStyle(.green)
|
||||
.labelStyle(.bold)
|
||||
|
||||
let expected = """
|
||||
\("Foo".green.bold)
|
||||
Bar
|
||||
"""
|
||||
XCTAssert(node.render() == expected)
|
||||
}
|
||||
|
||||
func testLabeledContent2() {
|
||||
let node = LabeledContent2 {
|
||||
"Foo"
|
||||
} content: {
|
||||
Text("Bar")
|
||||
}
|
||||
// .labelStyle(.green)
|
||||
// .labelStyle(.bold)
|
||||
|
||||
let expected = """
|
||||
Foo Bar
|
||||
"""
|
||||
XCTAssert(node.render() == expected)
|
||||
print(type(of: node.body))
|
||||
XCTAssertNotNil(node.body as? _ManyNode)
|
||||
}
|
||||
|
||||
func testShellCommand() {
|
||||
let node = ShellCommand {
|
||||
"ls -lah"
|
||||
}
|
||||
let expected = " $ ls -lah"
|
||||
XCTAssert(node.render() == expected)
|
||||
}
|
||||
|
||||
func testDiscussion() {
|
||||
let node = Discussion {
|
||||
Group(separator: "\n") {
|
||||
LabeledContent(separator: " ") {
|
||||
"NOTE:".yellow.bold
|
||||
} content: {
|
||||
"Foo"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let expected = """
|
||||
\("NOTE:".yellow.bold) Foo
|
||||
"""
|
||||
|
||||
XCTAssert(node.render() == expected)
|
||||
}
|
||||
|
||||
func testFooNode() {
|
||||
let foo = Foo()
|
||||
XCTAssertNotNil(foo.body as? LabeledContent)
|
||||
}
|
||||
|
||||
func testGroup2() {
|
||||
let node = Group2 {
|
||||
Text("foo")
|
||||
Text("bar")
|
||||
}
|
||||
print(node.render())
|
||||
XCTAssertNotNil(node.body as? _ManyNode)
|
||||
// XCTAssert(false)
|
||||
}
|
||||
}
|
||||
213
Tests/ConfigurationClientTests/ConfigurationClientTests.swift
Normal file
213
Tests/ConfigurationClientTests/ConfigurationClientTests.swift
Normal file
@@ -0,0 +1,213 @@
|
||||
@_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.coders = .liveValue
|
||||
$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)
|
||||
let output = try await configuration.generate(.init(
|
||||
force: false,
|
||||
json: fileName.hasSuffix("json"),
|
||||
path: .file(File(tempFile)!)
|
||||
))
|
||||
#expect(FileManager.default.fileExists(atPath: tempFile.cleanFilePath))
|
||||
#expect(fileClient.fileExists(tempFile))
|
||||
#expect(output == tempFile.cleanFilePath)
|
||||
|
||||
// Ensure that we do not overwrite files if they exist.
|
||||
do {
|
||||
_ = try await configuration.generate(.init(
|
||||
force: false,
|
||||
json: fileName.hasSuffix("json"),
|
||||
path: .file(File(tempFile)!)
|
||||
))
|
||||
#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.coders = .liveValue
|
||||
$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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func writeCreatesBackupFile() async throws {
|
||||
try await withDependencies {
|
||||
$0.fileClient = .liveValue
|
||||
} operation: {
|
||||
let client = ConfigurationClient.liveValue
|
||||
|
||||
try await withGeneratedConfigFile(named: "config.toml", client: client) { configFile in
|
||||
@Dependency(\.fileClient) var fileClient
|
||||
|
||||
let backupUrl = configFile.url.appendingPathExtension(".back")
|
||||
#expect(fileClient.fileExists(backupUrl) == false)
|
||||
|
||||
let config = Configuration()
|
||||
try await client.write(config, to: configFile)
|
||||
|
||||
#expect(fileClient.fileExists(backupUrl))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
]
|
||||
}
|
||||
|
||||
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(.init(
|
||||
force: true,
|
||||
json: fileName.hasSuffix("json"),
|
||||
path: .file(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(.init(
|
||||
force: true,
|
||||
json: fileName.hasSuffix("json"),
|
||||
path: .file(file)
|
||||
))
|
||||
try await operation(file, tempDir)
|
||||
}
|
||||
}
|
||||
67
Tests/FileClientTests/FileClientTests.swift
Normal file
67
Tests/FileClientTests/FileClientTests.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
import FileClient
|
||||
import Foundation
|
||||
import Testing
|
||||
import TestSupport
|
||||
|
||||
@Suite("FileClientTests")
|
||||
struct FileClientTests {
|
||||
|
||||
@Test
|
||||
func createDirectory() async throws {
|
||||
try await withTemporaryDirectory { url in
|
||||
let fileClient = FileClient.liveValue
|
||||
let tempDir = url.appending(path: "temp")
|
||||
try await fileClient.createDirectory(tempDir)
|
||||
let isDirectory = try await fileClient.isDirectory(tempDir)
|
||||
#expect(isDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: ["vault.yml", "vault.yaml"])
|
||||
func findVaultFile(fileName: String) async throws {
|
||||
try await withTemporaryDirectory { url in
|
||||
let fileClient = FileClient.liveValue
|
||||
|
||||
let vaultFilePath = url.appending(path: fileName)
|
||||
FileManager.default.createFile(atPath: vaultFilePath.cleanFilePath, contents: nil)
|
||||
let output = try await fileClient.findVaultFile(url)!
|
||||
|
||||
#expect(output.cleanFilePath == vaultFilePath.cleanFilePath)
|
||||
|
||||
let nilWhenFileNotDirectory = try await fileClient.findVaultFile(vaultFilePath)
|
||||
#expect(nilWhenFileNotDirectory == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: ["vault.yml", "vault.yaml"])
|
||||
func findVaultFileNestedInSubfolder(fileName: String) async throws {
|
||||
try await withTemporaryDirectory { url in
|
||||
|
||||
let fileClient = FileClient.liveValue
|
||||
let subDir = url.appending(path: "sub")
|
||||
|
||||
try await fileClient.createDirectory(subDir)
|
||||
|
||||
let vaultFilePath = subDir.appending(path: fileName)
|
||||
FileManager.default.createFile(atPath: vaultFilePath.cleanFilePath, contents: nil)
|
||||
let output = try await fileClient.findVaultFile(url)!
|
||||
|
||||
#expect(output.cleanFilePath == vaultFilePath.cleanFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func findVaultFileReturnsNil() async throws {
|
||||
try await withTemporaryDirectory { url in
|
||||
|
||||
let fileClient = FileClient.liveValue
|
||||
let subDir = url.appending(path: "sub")
|
||||
|
||||
try await fileClient.createDirectory(subDir)
|
||||
|
||||
let output = try await fileClient.findVaultFile(url)
|
||||
|
||||
#expect(output == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
333
Tests/PandocClientTests/PandocClientTests.swift
Normal file
333
Tests/PandocClientTests/PandocClientTests.swift
Normal file
@@ -0,0 +1,333 @@
|
||||
@_spi(Internal) import ConfigurationClient
|
||||
@_spi(Internal) import PandocClient
|
||||
import PlaybookClient
|
||||
import Testing
|
||||
import TestSupport
|
||||
|
||||
@Suite("PandocClientTests")
|
||||
struct PandocClientTests: TestCase {
|
||||
|
||||
static let outputDirectory = "/output"
|
||||
static let projectDirectory = "/project"
|
||||
static let defaultFileName = "Report"
|
||||
|
||||
static let expectedIncludeInHeaders = [
|
||||
"--include-in-header=/project/.build/head.tex",
|
||||
"--include-in-header=/project/.build/footer.tex"
|
||||
]
|
||||
|
||||
static let expectedFiles = [
|
||||
"/project/.build/Report.md",
|
||||
"/project/.build/Definitions.md"
|
||||
]
|
||||
|
||||
static var sharedRunOptions: PandocClient.RunOptions {
|
||||
.init(
|
||||
buildDirectory: nil,
|
||||
files: nil,
|
||||
loggingOptions: loggingOptions,
|
||||
includeInHeader: nil,
|
||||
outputDirectory: outputDirectory,
|
||||
projectDirectory: projectDirectory,
|
||||
outputFileName: nil,
|
||||
quiet: false,
|
||||
shell: nil,
|
||||
shouldBuild: true
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func generateLatex() async throws {
|
||||
try await withCapturingCommandClient("generateLatex") {
|
||||
$0.configurationClient = .mock()
|
||||
$0.playbookClient.run.buildProject = { _ in }
|
||||
$0.pandocClient = .liveValue
|
||||
} run: {
|
||||
@Dependency(\.pandocClient) var pandocClient
|
||||
|
||||
let output = try await pandocClient.run.generateLatex(Self.sharedRunOptions)
|
||||
#expect(output == "\(Self.outputDirectory)/\(Self.defaultFileName).tex")
|
||||
|
||||
} assert: { output in
|
||||
let expected = ["pandoc"]
|
||||
+ Self.expectedIncludeInHeaders
|
||||
+ ["--output=\(Self.outputDirectory)/\(Self.defaultFileName).tex"]
|
||||
+ Self.expectedFiles
|
||||
|
||||
#expect(output.arguments == expected)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func generateHtml() async throws {
|
||||
try await withCapturingCommandClient("generateHtml") {
|
||||
$0.configurationClient = .mock()
|
||||
$0.playbookClient.run.buildProject = { _ in }
|
||||
$0.pandocClient = .liveValue
|
||||
} run: {
|
||||
@Dependency(\.pandocClient) var pandocClient
|
||||
|
||||
let output = try await pandocClient.run.generateHtml(Self.sharedRunOptions)
|
||||
#expect(output == "\(Self.outputDirectory)/\(Self.defaultFileName).html")
|
||||
|
||||
} assert: { output in
|
||||
let expected = ["pandoc"]
|
||||
+ Self.expectedIncludeInHeaders
|
||||
+ ["--output=\(Self.outputDirectory)/\(Self.defaultFileName).html"]
|
||||
+ Self.expectedFiles
|
||||
|
||||
#expect(output.arguments == expected)
|
||||
}
|
||||
}
|
||||
|
||||
@Test(
|
||||
arguments: [
|
||||
nil,
|
||||
"lualatex"
|
||||
]
|
||||
)
|
||||
func generatePdf(pdfEngine: String?) async throws {
|
||||
try await withCapturingCommandClient("generatePdf") {
|
||||
$0.configurationClient = .mock()
|
||||
$0.playbookClient.run.buildProject = { _ in }
|
||||
$0.pandocClient = .liveValue
|
||||
} run: {
|
||||
@Dependency(\.pandocClient) var pandocClient
|
||||
|
||||
let output = try await pandocClient.run.generatePdf(Self.sharedRunOptions, pdfEngine: pdfEngine)
|
||||
#expect(output == "\(Self.outputDirectory)/\(Self.defaultFileName).pdf")
|
||||
|
||||
} assert: { output in
|
||||
let expected = ["pandoc"]
|
||||
+ Self.expectedIncludeInHeaders
|
||||
+ ["--pdf-engine=\(pdfEngine ?? "xelatex")"]
|
||||
+ ["--output=\(Self.outputDirectory)/\(Self.defaultFileName).pdf"]
|
||||
+ Self.expectedFiles
|
||||
|
||||
#expect(output.arguments == expected)
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: TestPdfEngine.testCases)
|
||||
func parsePdfEngine(input: TestPdfEngine) {
|
||||
#expect(input.engine == input.expectedEngine)
|
||||
}
|
||||
|
||||
@Test(arguments: TestParseFiles.testCases)
|
||||
func parseFiles(input: TestParseFiles) {
|
||||
#expect(input.parsedFiles == input.expectedFiles)
|
||||
}
|
||||
|
||||
@Test(arguments: TestParseIncludeInHeaderFiles.testCases)
|
||||
func parseInclueInHeaderFiles(input: TestParseIncludeInHeaderFiles) {
|
||||
#expect(input.parsedFiles == input.expectedHeaderFiles)
|
||||
}
|
||||
|
||||
@Test(arguments: TestParseOutputFileName.testCases)
|
||||
func parseOutputFileName(input: TestParseOutputFileName) {
|
||||
#expect(input.parsedFileName == input.expected)
|
||||
}
|
||||
|
||||
@Test(arguments: TestParseBuildDirectory.testCases)
|
||||
func parseBuildDirectory(input: TestParseBuildDirectory) {
|
||||
#expect(input.parsedBuildDirectory == input.expected)
|
||||
}
|
||||
}
|
||||
|
||||
struct TestPdfEngine: Sendable {
|
||||
let fileType: PandocClient.FileType
|
||||
let expectedEngine: String?
|
||||
let configuration: Configuration
|
||||
let defaults: Configuration.Generate
|
||||
|
||||
var engine: String? {
|
||||
fileType.parsePdfEngine(configuration.generate, defaults)
|
||||
}
|
||||
|
||||
static let testCases: [Self] = [
|
||||
.init(fileType: .html, expectedEngine: nil, configuration: .init(), defaults: .default),
|
||||
.init(fileType: .latex, expectedEngine: nil, configuration: .init(), defaults: .default),
|
||||
.init(fileType: .pdf(engine: "lualatex"), expectedEngine: "lualatex", configuration: .init(), defaults: .default),
|
||||
.init(fileType: .pdf(engine: nil), expectedEngine: "xelatex", configuration: .init(), defaults: .default),
|
||||
.init(fileType: .pdf(engine: nil), expectedEngine: "xelatex", configuration: .init(), defaults: .init()),
|
||||
.init(fileType: .pdf(engine: nil), expectedEngine: "xelatex", configuration: .init(generate: .default), defaults: .init())
|
||||
]
|
||||
}
|
||||
|
||||
struct TestParseFiles: Sendable {
|
||||
|
||||
let expectedFiles: [String]
|
||||
let configuration: Configuration
|
||||
let defaults: Configuration.Generate
|
||||
let runOptions: PandocClient.RunOptions?
|
||||
|
||||
init(
|
||||
expectedFiles: [String],
|
||||
configuration: Configuration = .init(),
|
||||
defaults: Configuration.Generate = .default,
|
||||
runOptions: PandocClient.RunOptions? = nil
|
||||
) {
|
||||
self.expectedFiles = expectedFiles
|
||||
self.configuration = configuration
|
||||
self.defaults = defaults
|
||||
self.runOptions = runOptions
|
||||
}
|
||||
|
||||
var parsedFiles: [String] {
|
||||
let runOptions = self.runOptions ?? PandocClient.RunOptions(
|
||||
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug),
|
||||
projectDirectory: nil,
|
||||
quiet: true,
|
||||
shouldBuild: false
|
||||
)
|
||||
|
||||
return runOptions.parseFiles(configuration.generate, defaults)
|
||||
}
|
||||
|
||||
static let testCases: [Self] = [
|
||||
.init(expectedFiles: ["Report.md", "Definitions.md"]),
|
||||
.init(expectedFiles: ["Report.md", "Definitions.md"], configuration: .init(generate: .default), defaults: .init()),
|
||||
.init(expectedFiles: [], defaults: .init()),
|
||||
.init(
|
||||
expectedFiles: ["custom.md"],
|
||||
configuration: .init(),
|
||||
defaults: .init(),
|
||||
runOptions: .init(
|
||||
files: ["custom.md"],
|
||||
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug),
|
||||
projectDirectory: nil,
|
||||
quiet: true,
|
||||
shouldBuild: false
|
||||
)
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
struct TestParseIncludeInHeaderFiles: Sendable {
|
||||
|
||||
let expectedHeaderFiles: [String]
|
||||
let configuration: Configuration
|
||||
let defaults: Configuration.Generate
|
||||
let runOptions: PandocClient.RunOptions?
|
||||
|
||||
init(
|
||||
expectedHeaderFiles: [String],
|
||||
configuration: Configuration = .init(),
|
||||
defaults: Configuration.Generate = .default,
|
||||
runOptions: PandocClient.RunOptions? = nil
|
||||
) {
|
||||
self.expectedHeaderFiles = expectedHeaderFiles
|
||||
self.configuration = configuration
|
||||
self.defaults = defaults
|
||||
self.runOptions = runOptions
|
||||
}
|
||||
|
||||
var parsedFiles: [String] {
|
||||
let runOptions = self.runOptions ?? PandocClient.RunOptions(
|
||||
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug)
|
||||
)
|
||||
|
||||
return runOptions.parseIncludeInHeader(configuration.generate, defaults)
|
||||
}
|
||||
|
||||
static let testCases: [Self] = [
|
||||
.init(expectedHeaderFiles: ["head.tex", "footer.tex"]),
|
||||
.init(expectedHeaderFiles: ["head.tex", "footer.tex"], configuration: .init(generate: .default), defaults: .init()),
|
||||
.init(expectedHeaderFiles: [], defaults: .init()),
|
||||
.init(
|
||||
expectedHeaderFiles: ["custom.tex"],
|
||||
configuration: .init(),
|
||||
defaults: .init(),
|
||||
runOptions: .init(
|
||||
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug),
|
||||
includeInHeader: ["custom.tex"]
|
||||
)
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
struct TestParseOutputFileName: Sendable {
|
||||
|
||||
let expected: String
|
||||
let configuration: Configuration
|
||||
let defaults: Configuration.Generate
|
||||
let runOptions: PandocClient.RunOptions?
|
||||
|
||||
init(
|
||||
expected: String,
|
||||
configuration: Configuration = .init(),
|
||||
defaults: Configuration.Generate = .default,
|
||||
runOptions: PandocClient.RunOptions? = nil
|
||||
) {
|
||||
self.expected = expected
|
||||
self.configuration = configuration
|
||||
self.defaults = defaults
|
||||
self.runOptions = runOptions
|
||||
}
|
||||
|
||||
var parsedFileName: String {
|
||||
let runOptions = self.runOptions ?? PandocClient.RunOptions(
|
||||
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug)
|
||||
)
|
||||
|
||||
return runOptions.parseOutputFileName(configuration.generate, defaults)
|
||||
}
|
||||
|
||||
static let testCases: [Self] = [
|
||||
.init(expected: "Report"),
|
||||
.init(expected: "Report", configuration: .init(generate: .default), defaults: .init()),
|
||||
.init(expected: "Report", defaults: .init()),
|
||||
.init(
|
||||
expected: "custom",
|
||||
configuration: .init(),
|
||||
defaults: .init(),
|
||||
runOptions: .init(
|
||||
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug),
|
||||
outputFileName: "custom"
|
||||
)
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
struct TestParseBuildDirectory: Sendable {
|
||||
|
||||
let expected: String
|
||||
let configuration: Configuration
|
||||
let defaults: Configuration.Generate
|
||||
let runOptions: PandocClient.RunOptions?
|
||||
|
||||
init(
|
||||
expected: String = ".build",
|
||||
configuration: Configuration = .init(),
|
||||
defaults: Configuration.Generate = .default,
|
||||
runOptions: PandocClient.RunOptions? = nil
|
||||
) {
|
||||
self.expected = expected
|
||||
self.configuration = configuration
|
||||
self.defaults = defaults
|
||||
self.runOptions = runOptions
|
||||
}
|
||||
|
||||
var parsedBuildDirectory: String {
|
||||
let runOptions = self.runOptions ?? PandocClient.RunOptions(
|
||||
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug)
|
||||
)
|
||||
|
||||
return runOptions.parseBuildDirectory(configuration.generate, defaults)
|
||||
}
|
||||
|
||||
static let testCases: [Self] = [
|
||||
.init(),
|
||||
.init(configuration: .init(generate: .default), defaults: .init()),
|
||||
.init(defaults: .init()),
|
||||
.init(
|
||||
expected: "custom",
|
||||
configuration: .init(),
|
||||
defaults: .init(),
|
||||
runOptions: .init(
|
||||
buildDirectory: "custom",
|
||||
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug)
|
||||
)
|
||||
)
|
||||
]
|
||||
}
|
||||
308
Tests/PlaybookClientTests/PlaybookClientTests.swift
Normal file
308
Tests/PlaybookClientTests/PlaybookClientTests.swift
Normal file
@@ -0,0 +1,308 @@
|
||||
import CodersClient
|
||||
@_spi(Internal) import CommandClient
|
||||
@_spi(Internal) import ConfigurationClient
|
||||
import Dependencies
|
||||
import FileClient
|
||||
import Foundation
|
||||
@_spi(Internal) import PlaybookClient
|
||||
import ShellClient
|
||||
import Testing
|
||||
import TestSupport
|
||||
|
||||
@Suite("PlaybookClientTests")
|
||||
struct PlaybookClientTests: TestCase {
|
||||
|
||||
static var sharedRunOptions: PlaybookClient.RunPlaybook.SharedRunOptions {
|
||||
.init(loggingOptions: loggingOptions)
|
||||
}
|
||||
|
||||
static let defaultPlaybookPath = "~/.local/share/hpa/playbook/main.yml"
|
||||
static let defaultInventoryPath = "~/.local/share/hpa/playbook/inventory.ini"
|
||||
static let mockVaultArg = Configuration.mock.vault.args![0]
|
||||
|
||||
@Test(.tags(.repository))
|
||||
func repositoryInstallation() async throws {
|
||||
try await withTestLogger(key: "repositoryInstallation") {
|
||||
$0.fileClient = .liveValue
|
||||
$0.asyncShellClient = .liveValue
|
||||
$0.commandClient = .liveValue
|
||||
} operation: {
|
||||
try await withTemporaryDirectory { tempDirectory in
|
||||
@Dependency(\.fileClient) var fileClient
|
||||
@Dependency(\.logger) var logger
|
||||
let pathUrl = tempDirectory.appending(path: "playbook")
|
||||
let playbookClient = PlaybookClient.liveValue
|
||||
|
||||
let configuration = Configuration(playbook: .init(directory: pathUrl.cleanFilePath))
|
||||
|
||||
try? FileManager.default.removeItem(at: pathUrl)
|
||||
try await playbookClient.repository.install(configuration)
|
||||
logger.debug("Done cloning playbook")
|
||||
let exists = try await fileClient.isDirectory(pathUrl)
|
||||
#expect(exists)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test(
|
||||
.tags(.repository),
|
||||
arguments: [
|
||||
(Configuration(), PlaybookClient.Constants.defaultInstallationPath),
|
||||
(Configuration(playbook: .init(directory: "playbook")), "playbook")
|
||||
]
|
||||
)
|
||||
func repositoryDirectory(configuration: Configuration, expected: String) async throws {
|
||||
let client = PlaybookClient.liveValue
|
||||
let result = try await client.repository.directory(configuration)
|
||||
#expect(result == expected)
|
||||
}
|
||||
|
||||
@Test(.tags(.run))
|
||||
func runBuildProject() async throws {
|
||||
let captured = CommandClient.CapturingClient()
|
||||
|
||||
try await withMockConfiguration(captured, key: "runBuildProject") {
|
||||
@Dependency(\.playbookClient) var playbookClient
|
||||
|
||||
try await playbookClient.run.buildProject(.init(projectDirectory: "/foo", shared: Self.sharedRunOptions))
|
||||
|
||||
let arguments = await captured.options!.arguments
|
||||
print(arguments)
|
||||
|
||||
#expect(arguments == [
|
||||
"ansible-playbook", Self.defaultPlaybookPath,
|
||||
"--inventory", Self.defaultInventoryPath,
|
||||
Self.mockVaultArg,
|
||||
"--tags", "build-project",
|
||||
"--extra-vars", "project_dir=/foo"
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@Test(
|
||||
.tags(.run),
|
||||
arguments: [
|
||||
(true, "\'{\"template\":{\"path\":\"\(Configuration.mock.template.directory!)\"}}\'"),
|
||||
(false, "\'{\"template\":{\"repo\":{\"url\":\"\(Configuration.mock.template.url!)\",\"version\":\"\(Configuration.mock.template.version!)\"}}}\'")
|
||||
]
|
||||
)
|
||||
func runCreateProject(useLocalTemplateDirectory: Bool, json: String) async throws {
|
||||
let captured = CommandClient.CapturingClient()
|
||||
|
||||
try await withMockConfiguration(captured, key: "runBuildProject") {
|
||||
@Dependency(\.logger) var logger
|
||||
@Dependency(\.playbookClient) var playbookClient
|
||||
|
||||
try await playbookClient.run.createProject(
|
||||
.init(
|
||||
projectDirectory: "/project",
|
||||
shared: Self.sharedRunOptions,
|
||||
useLocalTemplateDirectory: useLocalTemplateDirectory
|
||||
)
|
||||
)
|
||||
|
||||
let arguments = await captured.options!.arguments
|
||||
logger.debug("\(arguments)")
|
||||
|
||||
#expect(arguments == [
|
||||
"ansible-playbook", Self.defaultPlaybookPath,
|
||||
"--inventory", Self.defaultInventoryPath,
|
||||
Self.mockVaultArg,
|
||||
"--tags", "setup-project",
|
||||
"--extra-vars", "project_dir=/project",
|
||||
"--extra-vars", json
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: CreateJsonTestOption.testCases)
|
||||
func createJson(input: CreateJsonTestOption) {
|
||||
withTestLogger(key: "generateJson") {
|
||||
$0.coders.jsonEncoder = { jsonEncoder }
|
||||
$0.configurationClient = .mock(input.configuration)
|
||||
} operation: {
|
||||
@Dependency(\.coders) var coders
|
||||
|
||||
let jsonData = try? input.options.createJSONData(
|
||||
configuration: input.configuration,
|
||||
encoder: coders.jsonEncoder()
|
||||
)
|
||||
|
||||
switch input.expectation {
|
||||
case let .success(expected):
|
||||
let json = String(data: jsonData!, encoding: .utf8)!
|
||||
if json != expected {
|
||||
print("json:", json)
|
||||
print("expected:", expected)
|
||||
}
|
||||
#expect(json == expected)
|
||||
case .failure:
|
||||
#expect(jsonData == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func generateTemplate() async throws {
|
||||
try await withCapturingCommandClient("generateTemplate") {
|
||||
$0.configurationClient = .mock()
|
||||
$0.playbookClient = .liveValue
|
||||
} run: {
|
||||
@Dependency(\.playbookClient) var playbookClient
|
||||
|
||||
let output = try await playbookClient.run.generateTemplate(.init(
|
||||
shared: Self.sharedRunOptions,
|
||||
templateDirectory: "/template"
|
||||
))
|
||||
|
||||
#expect(output == "/template")
|
||||
|
||||
} assert: { output in
|
||||
|
||||
let expected = [
|
||||
"ansible-playbook", Self.defaultPlaybookPath,
|
||||
"--inventory", Self.defaultInventoryPath,
|
||||
Self.mockVaultArg,
|
||||
"--tags", "repo-template",
|
||||
"--extra-vars", "output_dir=/template"
|
||||
]
|
||||
|
||||
#expect(output.arguments == expected)
|
||||
}
|
||||
}
|
||||
|
||||
func withMockConfiguration(
|
||||
_ capturing: CommandClient.CapturingClient,
|
||||
configuration: Configuration = .mock,
|
||||
key: String,
|
||||
logLevel: Logger.Level = .trace,
|
||||
depednencies setupDependencies: @escaping (inout DependencyValues) -> Void = { _ in },
|
||||
operation: @Sendable @escaping () async throws -> Void
|
||||
) async rethrows {
|
||||
try await withDependencies {
|
||||
$0.configurationClient = .mock(configuration)
|
||||
$0.commandClient = .capturing(capturing)
|
||||
$0.playbookClient = .liveValue
|
||||
setupDependencies(&$0)
|
||||
} operation: {
|
||||
try await operation()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct CreateJsonTestOption: Sendable {
|
||||
let options: PlaybookClient.RunPlaybook.CreateOptions
|
||||
let configuration: Configuration
|
||||
let expectation: Result<String, TestError>
|
||||
|
||||
static let testCases: [Self] = [
|
||||
CreateJsonTestOption(
|
||||
options: .init(
|
||||
projectDirectory: "/project",
|
||||
shared: PlaybookClientTests.sharedRunOptions,
|
||||
template: .init(url: nil, version: nil, directory: nil),
|
||||
useLocalTemplateDirectory: true
|
||||
),
|
||||
configuration: .init(),
|
||||
expectation: .failing
|
||||
),
|
||||
CreateJsonTestOption(
|
||||
options: .init(
|
||||
projectDirectory: "/project",
|
||||
shared: PlaybookClientTests.sharedRunOptions,
|
||||
template: .init(url: nil, version: nil, directory: nil),
|
||||
useLocalTemplateDirectory: false
|
||||
),
|
||||
configuration: .init(),
|
||||
expectation: .failing
|
||||
),
|
||||
CreateJsonTestOption(
|
||||
options: .init(
|
||||
projectDirectory: "/project",
|
||||
shared: PlaybookClientTests.sharedRunOptions,
|
||||
template: .init(url: nil, version: nil, directory: "/template"),
|
||||
useLocalTemplateDirectory: true
|
||||
),
|
||||
configuration: .init(template: .init(directory: "/template")),
|
||||
expectation: .success("""
|
||||
{
|
||||
"template" : {
|
||||
"path" : "/template"
|
||||
}
|
||||
}
|
||||
""")
|
||||
),
|
||||
CreateJsonTestOption(
|
||||
options: .init(
|
||||
projectDirectory: "/project",
|
||||
shared: PlaybookClientTests.sharedRunOptions,
|
||||
template: .init(url: nil, version: nil, directory: "/template"),
|
||||
useLocalTemplateDirectory: true
|
||||
),
|
||||
configuration: .init(template: .init(directory: "/template")),
|
||||
expectation: .success("""
|
||||
{
|
||||
"template" : {
|
||||
"path" : "/template"
|
||||
}
|
||||
}
|
||||
""")
|
||||
),
|
||||
CreateJsonTestOption(
|
||||
options: .init(
|
||||
projectDirectory: "/project",
|
||||
shared: PlaybookClientTests.sharedRunOptions,
|
||||
template: .init(url: "https://git.example.com/template.git", version: "main", directory: nil),
|
||||
useLocalTemplateDirectory: false
|
||||
),
|
||||
configuration: .init(),
|
||||
expectation: .success("""
|
||||
{
|
||||
"template" : {
|
||||
"repo" : {
|
||||
"url" : "https://git.example.com/template.git",
|
||||
"version" : "main"
|
||||
}
|
||||
}
|
||||
}
|
||||
""")
|
||||
),
|
||||
CreateJsonTestOption(
|
||||
options: .init(
|
||||
projectDirectory: "/project",
|
||||
shared: PlaybookClientTests.sharedRunOptions,
|
||||
template: .init(url: "https://git.example.com/template.git", version: "v0.1.0", directory: nil),
|
||||
useLocalTemplateDirectory: false
|
||||
),
|
||||
configuration: .init(template: .init(url: "https://git.example.com/template.git", version: "v0.1.0")),
|
||||
expectation: .success("""
|
||||
{
|
||||
"template" : {
|
||||
"repo" : {
|
||||
"url" : "https://git.example.com/template.git",
|
||||
"version" : "v0.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
""")
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
extension Result where Failure == TestError {
|
||||
static var failing: Self { .failure(TestError()) }
|
||||
}
|
||||
|
||||
struct TestError: Error {}
|
||||
|
||||
extension Tag {
|
||||
@Tag static var repository: Self
|
||||
@Tag static var run: Self
|
||||
}
|
||||
|
||||
let jsonEncoder: JSONEncoder = {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes, .sortedKeys]
|
||||
return encoder
|
||||
}()
|
||||
129
Tests/VaultClientTests/VaultClientTests.swift
Normal file
129
Tests/VaultClientTests/VaultClientTests.swift
Normal file
@@ -0,0 +1,129 @@
|
||||
@_spi(Internal) import ConfigurationClient
|
||||
import FileClient
|
||||
import Foundation
|
||||
import Testing
|
||||
import TestSupport
|
||||
@_spi(Internal) import VaultClient
|
||||
|
||||
@Suite("VaultClientTests")
|
||||
struct VaultClientTests: TestCase {
|
||||
|
||||
@Test(
|
||||
arguments: TestOptions.testCases
|
||||
)
|
||||
func decrypt(input: TestOptions) async throws {
|
||||
try await withCapturingCommandClient("decrypt") {
|
||||
$0.configurationClient = .mock(input.configuration)
|
||||
$0.fileClient.findVaultFile = { _ in URL(filePath: "/vault.yml") }
|
||||
$0.vaultClient = .liveValue
|
||||
} run: {
|
||||
@Dependency(\.vaultClient) var vaultClient
|
||||
|
||||
let output = try await vaultClient.run.decrypt(.init(
|
||||
extraOptions: input.extraOptions,
|
||||
loggingOptions: Self.loggingOptions,
|
||||
outputFilePath: input.outputFilePath,
|
||||
vaultFilePath: input.vaultFilePath
|
||||
))
|
||||
|
||||
if let outputFilePath = input.outputFilePath {
|
||||
#expect(output == outputFilePath)
|
||||
} else if let vaultFilePath = input.vaultFilePath {
|
||||
#expect(output == vaultFilePath)
|
||||
} else {
|
||||
#expect(output == "/vault.yml")
|
||||
}
|
||||
} assert: { options in
|
||||
|
||||
#expect(options.arguments == input.expected(.decrypt))
|
||||
}
|
||||
}
|
||||
|
||||
@Test(
|
||||
arguments: TestOptions.testCases
|
||||
)
|
||||
func encrypt(input: TestOptions) async throws {
|
||||
try await withCapturingCommandClient("decrypt") {
|
||||
$0.configurationClient = .mock(input.configuration)
|
||||
$0.fileClient.findVaultFile = { _ in URL(filePath: "/vault.yml") }
|
||||
$0.vaultClient = .liveValue
|
||||
} run: {
|
||||
@Dependency(\.vaultClient) var vaultClient
|
||||
|
||||
let output = try await vaultClient.run.encrypt(.init(
|
||||
extraOptions: input.extraOptions,
|
||||
loggingOptions: Self.loggingOptions,
|
||||
outputFilePath: input.outputFilePath,
|
||||
vaultFilePath: input.vaultFilePath
|
||||
))
|
||||
|
||||
if let outputFilePath = input.outputFilePath {
|
||||
#expect(output == outputFilePath)
|
||||
} else if let vaultFilePath = input.vaultFilePath {
|
||||
#expect(output == vaultFilePath)
|
||||
} else {
|
||||
#expect(output == "/vault.yml")
|
||||
}
|
||||
|
||||
} assert: { options in
|
||||
#expect(options.arguments == input.expected(.encrypt))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct TestOptions: Sendable {
|
||||
let configuration: Configuration
|
||||
let extraOptions: [String]?
|
||||
let outputFilePath: String?
|
||||
let vaultFilePath: String?
|
||||
|
||||
init(
|
||||
configuration: Configuration = .init(),
|
||||
extraOptions: [String]? = nil,
|
||||
outputFilePath: String? = nil,
|
||||
vaultFilePath: String? = nil
|
||||
) {
|
||||
self.configuration = configuration
|
||||
self.extraOptions = extraOptions
|
||||
self.outputFilePath = outputFilePath
|
||||
self.vaultFilePath = vaultFilePath
|
||||
}
|
||||
|
||||
func expected(_ route: VaultClient.Route) -> [String] {
|
||||
var expected = [
|
||||
"ansible-vault", "\(route.verb)"
|
||||
]
|
||||
|
||||
if let outputFilePath {
|
||||
expected.append(contentsOf: ["--output", outputFilePath])
|
||||
}
|
||||
|
||||
if let extraOptions {
|
||||
expected.append(contentsOf: extraOptions)
|
||||
}
|
||||
|
||||
if let vaultArgs = configuration.vault.args {
|
||||
expected.append(contentsOf: vaultArgs)
|
||||
}
|
||||
|
||||
if route == .encrypt,
|
||||
let id = configuration.vault.encryptId
|
||||
{
|
||||
expected.append(contentsOf: ["--encrypt-vault-id", id])
|
||||
}
|
||||
|
||||
expected.append(vaultFilePath ?? "/vault.yml")
|
||||
|
||||
return expected
|
||||
}
|
||||
|
||||
static let testCases: [Self] = [
|
||||
TestOptions(vaultFilePath: "/vault.yml"),
|
||||
TestOptions(extraOptions: ["--verbose"]),
|
||||
TestOptions(configuration: .mock),
|
||||
TestOptions(outputFilePath: "/output.yml")
|
||||
]
|
||||
}
|
||||
|
||||
struct TestError: Error {}
|
||||
23
docker/Dockerfile
Executable file
23
docker/Dockerfile
Executable file
@@ -0,0 +1,23 @@
|
||||
# Used this to build the release version of the image.
|
||||
# Build the executable
|
||||
ARG SWIFT_IMAGE_VERSION="6.0.3"
|
||||
|
||||
FROM swift:${SWIFT_IMAGE_VERSION} AS build
|
||||
WORKDIR /build
|
||||
COPY ./Package.* ./
|
||||
RUN swift package resolve
|
||||
COPY . .
|
||||
RUN swift build -c release -Xswiftc -g
|
||||
|
||||
# Run image
|
||||
FROM swift:${SWIFT_IMAGE_VERSION}-slim
|
||||
|
||||
RUN export DEBIAN_FRONTEND=nointeractive DEBCONF_NOINTERACTIVE_SEEN=true && apt-get -q update && \
|
||||
apt-get -q install -y \
|
||||
ansible \
|
||||
pandoc \
|
||||
texlive \
|
||||
&& rm -r /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /build/.build/release/hpa /usr/local/bin
|
||||
CMD ["/bin/bash", "-xc", "/usr/local/bin/hpa"]
|
||||
10
docker/Dockerfile.test
Normal file
10
docker/Dockerfile.test
Normal file
@@ -0,0 +1,10 @@
|
||||
# Used to build a test image.
|
||||
ARG SWIFT_IMAGE_VERSION="6.0.3"
|
||||
FROM swift:${SWIFT_IMAGE_VERSION}
|
||||
|
||||
WORKDIR /app
|
||||
COPY ./Package.* ./
|
||||
RUN swift package resolve
|
||||
COPY . .
|
||||
RUN swift build
|
||||
CMD ["/bin/bash", "-xc", "swift", "test"]
|
||||
30
justfile
30
justfile
@@ -1,15 +1,45 @@
|
||||
docker_image_name := "swift-hpa"
|
||||
install_path := "~/.local/share/bin/hpa"
|
||||
completion_path := "~/.local/share/zsh/completions/_hpa"
|
||||
|
||||
build mode="debug":
|
||||
swift build -c {{mode}}
|
||||
|
||||
alias b := build
|
||||
|
||||
build-docker file="Dockerfile" tag="latest":
|
||||
@docker build \
|
||||
--file docker/{{file}} \
|
||||
--tag {{docker_image_name}}:{{tag}} .
|
||||
|
||||
build-docker-test: (build-docker "Dockerfile.test" "test")
|
||||
|
||||
test *ARGS:
|
||||
swift test {{ARGS}}
|
||||
|
||||
alias t := test
|
||||
|
||||
test-docker *ARGS: (build-docker-test)
|
||||
@docker run --rm \
|
||||
--network host \
|
||||
{{docker_image_name}}:test \
|
||||
swift test {{ARGS}}
|
||||
|
||||
run *ARGS:
|
||||
swift run hpa {{ARGS}}
|
||||
|
||||
alias r := run
|
||||
|
||||
clean:
|
||||
rm -rf .build
|
||||
|
||||
update-version:
|
||||
@swift package \
|
||||
--disable-sandbox \
|
||||
--allow-writing-to-package-directory \
|
||||
update-version \
|
||||
hpa
|
||||
|
||||
install: (build "release")
|
||||
@cp .build/release/hpa {{install_path}}
|
||||
@{{install_path}} --generate-completion-script zsh > {{completion_path}}
|
||||
|
||||
Reference in New Issue
Block a user