feat: Adds playbook client
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ DerivedData/
|
|||||||
.nvim/*
|
.nvim/*
|
||||||
.swiftpm/*
|
.swiftpm/*
|
||||||
./hpa.toml
|
./hpa.toml
|
||||||
|
./Version.*
|
||||||
|
|||||||
@@ -18,6 +18,13 @@
|
|||||||
"identifier" : "CliClientTests",
|
"identifier" : "CliClientTests",
|
||||||
"name" : "CliClientTests"
|
"name" : "CliClientTests"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target" : {
|
||||||
|
"containerPath" : "container:",
|
||||||
|
"identifier" : "ConfigurationClientTests",
|
||||||
|
"name" : "ConfigurationClientTests"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 1
|
"version" : 1
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ let package = Package(
|
|||||||
.library(name: "CliClient", targets: ["CliClient"]),
|
.library(name: "CliClient", targets: ["CliClient"]),
|
||||||
.library(name: "CodersClient", targets: ["CodersClient"]),
|
.library(name: "CodersClient", targets: ["CodersClient"]),
|
||||||
.library(name: "ConfigurationClient", targets: ["ConfigurationClient"]),
|
.library(name: "ConfigurationClient", targets: ["ConfigurationClient"]),
|
||||||
.library(name: "FileClient", targets: ["FileClient"])
|
.library(name: "FileClient", targets: ["FileClient"]),
|
||||||
|
.library(name: "PlaybookClient", targets: ["PlaybookClient"])
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
|
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
|
||||||
@@ -41,13 +42,10 @@ let package = Package(
|
|||||||
dependencies: [
|
dependencies: [
|
||||||
"CodersClient",
|
"CodersClient",
|
||||||
"ConfigurationClient",
|
"ConfigurationClient",
|
||||||
|
"PlaybookClient",
|
||||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||||
.product(name: "ShellClient", package: "swift-shell-client")
|
.product(name: "ShellClient", package: "swift-shell-client")
|
||||||
],
|
|
||||||
resources: [
|
|
||||||
.copy("Resources/ansible-hpa-playbook"),
|
|
||||||
.copy("Resources/Brewfile")
|
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
@@ -98,6 +96,20 @@ let package = Package(
|
|||||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
.product(name: "DependenciesMacros", package: "swift-dependencies")
|
.product(name: "DependenciesMacros", package: "swift-dependencies")
|
||||||
]
|
]
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "PlaybookClient",
|
||||||
|
dependencies: [
|
||||||
|
"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"]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Dependencies
|
|||||||
import DependenciesMacros
|
import DependenciesMacros
|
||||||
import FileClient
|
import FileClient
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import PlaybookClient
|
||||||
import ShellClient
|
import ShellClient
|
||||||
|
|
||||||
public extension CliClient {
|
public extension CliClient {
|
||||||
@@ -28,13 +29,13 @@ public extension CliClient {
|
|||||||
shell: String? = nil,
|
shell: String? = nil,
|
||||||
extraArgs: [String]? = nil
|
extraArgs: [String]? = nil
|
||||||
) async throws {
|
) async throws {
|
||||||
guard let url = Bundle.module.url(forResource: "Brewfile", withExtension: nil) else {
|
@Dependency(\.playbookClient) var playbookClient
|
||||||
throw CliClientError.brewfileNotFound
|
@Dependency(\.configurationClient) var configurationClient
|
||||||
}
|
|
||||||
var arguments = [
|
var arguments = [
|
||||||
"brew", "bundle",
|
"brew", "install"
|
||||||
"--file", url.cleanFilePath
|
] + Constants.brewPackages
|
||||||
]
|
|
||||||
if let extraArgs {
|
if let extraArgs {
|
||||||
arguments.append(contentsOf: extraArgs)
|
arguments.append(contentsOf: extraArgs)
|
||||||
}
|
}
|
||||||
@@ -44,6 +45,9 @@ public extension CliClient {
|
|||||||
shell: shell.orDefault,
|
shell: shell.orDefault,
|
||||||
arguments
|
arguments
|
||||||
)
|
)
|
||||||
|
|
||||||
|
let configuration = try await configurationClient.findAndLoad()
|
||||||
|
try await playbookClient.installPlaybook(configuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runPlaybookCommand(
|
func runPlaybookCommand(
|
||||||
@@ -53,11 +57,12 @@ public extension CliClient {
|
|||||||
try await withLogger(loggingOptions) {
|
try await withLogger(loggingOptions) {
|
||||||
@Dependency(\.configurationClient) var configurationClient
|
@Dependency(\.configurationClient) var configurationClient
|
||||||
@Dependency(\.logger) var logger
|
@Dependency(\.logger) var logger
|
||||||
|
@Dependency(\.playbookClient) var playbookClient
|
||||||
|
|
||||||
let configuration = try await configurationClient.ensuredConfiguration(options.configuration)
|
let configuration = try await configurationClient.ensuredConfiguration(options.configuration)
|
||||||
logger.trace("Configuration: \(configuration)")
|
logger.trace("Configuration: \(configuration)")
|
||||||
|
|
||||||
let playbookDirectory = try configuration.ensuredPlaybookDirectory(options.playbookDirectory)
|
let playbookDirectory = try await playbookClient.playbookDirectory(configuration)
|
||||||
let playbookPath = "\(playbookDirectory)/\(Constants.playbookFileName)"
|
let playbookPath = "\(playbookDirectory)/\(Constants.playbookFileName)"
|
||||||
logger.trace("Playbook path: \(playbookPath)")
|
logger.trace("Playbook path: \(playbookPath)")
|
||||||
|
|
||||||
@@ -144,30 +149,30 @@ public extension ConfigurationClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@_spi(Internal)
|
// @_spi(Internal)
|
||||||
public extension Configuration {
|
// public extension Configuration {
|
||||||
|
|
||||||
func defaultPlaybookDirectory() throws -> String {
|
// func defaultPlaybookDirectory() throws -> String {
|
||||||
let playbookDirectory = Bundle.module.url(
|
// let playbookDirectory = Bundle.module.url(
|
||||||
forResource: Constants.playbookBundleDirectoryName,
|
// forResource: Constants.playbookBundleDirectoryName,
|
||||||
withExtension: nil
|
// withExtension: nil
|
||||||
)
|
// )
|
||||||
guard let playbookDirectory else {
|
// guard let playbookDirectory else {
|
||||||
throw CliClientError.playbookDirectoryNotFound
|
// throw CliClientError.playbookDirectoryNotFound
|
||||||
}
|
// }
|
||||||
return playbookDirectory.cleanFilePath
|
// return playbookDirectory.cleanFilePath
|
||||||
}
|
// }
|
||||||
|
|
||||||
func ensuredPlaybookDirectory(_ optionalDirectory: String?) throws -> String {
|
// func ensuredPlaybookDirectory(_ optionalDirectory: String?) throws -> String {
|
||||||
guard let directory = optionalDirectory else {
|
// guard let directory = optionalDirectory else {
|
||||||
guard let directory = playbook?.directory else {
|
// guard let directory = playbook?.directory else {
|
||||||
return try defaultPlaybookDirectory()
|
// return try defaultPlaybookDirectory()
|
||||||
}
|
// }
|
||||||
return directory
|
// return directory
|
||||||
}
|
// }
|
||||||
return directory
|
// return directory
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
@_spi(Internal)
|
@_spi(Internal)
|
||||||
public extension Optional where Wrapped == String {
|
public extension Optional where Wrapped == String {
|
||||||
|
|||||||
@@ -5,4 +5,7 @@ enum Constants {
|
|||||||
static let playbookFileName = "main.yml"
|
static let playbookFileName = "main.yml"
|
||||||
static let inventoryFileName = "inventory.ini"
|
static let inventoryFileName = "inventory.ini"
|
||||||
static let vaultCommand = "ansible-vault"
|
static let vaultCommand = "ansible-vault"
|
||||||
|
static let brewPackages = [
|
||||||
|
"ansible", "imagemagick", "pandoc", "texLive"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
brew "ansible"
|
|
||||||
brew "imagemagick"
|
|
||||||
brew "pandoc"
|
|
||||||
brew "texlive"
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# editorconfig.org
|
|
||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
end_of_line = lf
|
|
||||||
charset = utf-8
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
insert_final_newline = true
|
|
||||||
|
|
||||||
[*.md]
|
|
||||||
trim_trailing_whitespace = false
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
The MIT License (MIT)
|
|
||||||
|
|
||||||
Copyright (c) 2024 Michael Housh
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
||||||
this software and associated documentation files (the "Software"), to deal in
|
|
||||||
the Software without restriction, including without limitation the rights to
|
|
||||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
||||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
||||||
subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
||||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
||||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
||||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
||||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# ansible-hpa-playbook
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
[defaults]
|
|
||||||
inventory = ./inventory.ini
|
|
||||||
roles_path = ./roles
|
|
||||||
interpreter_python = auto_silent
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
|
|
||||||
[local]
|
|
||||||
127.0.0.1 ansible_connection=local
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
[private]
|
|
||||||
default:
|
|
||||||
just --list
|
|
||||||
|
|
||||||
# Run the playbook with the passed in arguments.
|
|
||||||
[group('plays')]
|
|
||||||
run *ARGS:
|
|
||||||
@ansible-playbook ./main.yml \
|
|
||||||
--inventory ./inventory.ini \
|
|
||||||
{{ARGS}}
|
|
||||||
|
|
||||||
# Run the repo-template option in the `dir` with the passed in arguements.
|
|
||||||
[group('plays')]
|
|
||||||
create-repo-template dir *ARGS:
|
|
||||||
@just run \
|
|
||||||
--tags repo-template \
|
|
||||||
--extra-vars output_dir={{dir}} \
|
|
||||||
{{ARGS}}
|
|
||||||
|
|
||||||
# Run the build-project option in the `dir` with the passed in arguements.
|
|
||||||
[group('plays')]
|
|
||||||
build-project dir *ARGS:
|
|
||||||
@just run \
|
|
||||||
--tags build-project \
|
|
||||||
--extra-vars project_dir={{dir}} \
|
|
||||||
{{ARGS}}
|
|
||||||
|
|
||||||
# Setup a new consult project from a template repo.
|
|
||||||
[group('plays')]
|
|
||||||
setup-project repo-url version project-dir *ARGS:
|
|
||||||
@ansible-playbook ./main.yml \
|
|
||||||
--inventory ./inventory.ini \
|
|
||||||
--tags setup-project \
|
|
||||||
--extra-vars "{'template': {'repo': {'url': '{{repo-url}}', 'version': '{{version}}' }}}" \
|
|
||||||
--extra-vars "project_dir={{project-dir}}" \
|
|
||||||
{{ARGS}}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
---
|
|
||||||
- name: HPA Playbook
|
|
||||||
hosts: all
|
|
||||||
roles:
|
|
||||||
- role: repo-template
|
|
||||||
tags:
|
|
||||||
- repo-template
|
|
||||||
- never # makes it so a tag must be supplied to run.
|
|
||||||
- role: build-project
|
|
||||||
tags:
|
|
||||||
- build-project
|
|
||||||
- never # makes it so a tag must be supplied to run.
|
|
||||||
- role: setup-project
|
|
||||||
tags:
|
|
||||||
- never # makes it so a tag must be supplied to run.
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
---
|
|
||||||
build_dir_name: ".build"
|
|
||||||
build_dir: "{{ project_dir }}/{{ build_dir_name }}"
|
|
||||||
|
|
||||||
project_dir: "{{ lookup('env', 'PWD') }}"
|
|
||||||
project_vars_dir: "{{ project_dir }}"
|
|
||||||
|
|
||||||
# template:
|
|
||||||
# path: "/path/to/template/dir
|
|
||||||
# vars: "repo_vars"
|
|
||||||
# repo: (optional if using a repo as a template)
|
|
||||||
template:
|
|
||||||
path: "/path/to/template/dir"
|
|
||||||
vars: "repo_vars"
|
|
||||||
|
|
||||||
# When using a repository as a template dir. In general, it's
|
|
||||||
# probably best to pin to a particular version of the repo template
|
|
||||||
# instead of a branch.
|
|
||||||
#
|
|
||||||
# repo:
|
|
||||||
# url: "https://example.com/repo.git"
|
|
||||||
# version: "main"
|
|
||||||
repo: {}
|
|
||||||
|
|
||||||
copy_on_build:
|
|
||||||
- "{{ template.path }}/head.tex"
|
|
||||||
- "{{ template.path }}/Definitions.md"
|
|
||||||
|
|
||||||
template_on_build:
|
|
||||||
- "{{ project_dir }}/Report.md"
|
|
||||||
- "{{ template.path }}/footer.tex"
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
---
|
|
||||||
- name: Starting build project.
|
|
||||||
ansible.builtin.debug:
|
|
||||||
msg: "Build dir: {{ build_dir }}"
|
|
||||||
|
|
||||||
- name: Load project vars.
|
|
||||||
ansible.builtin.include_vars:
|
|
||||||
dir: "{{ project_vars_dir }}"
|
|
||||||
ignore_unknown_extensions: true
|
|
||||||
|
|
||||||
- name: Ensure build directory exists.
|
|
||||||
ansible.builtin.file:
|
|
||||||
path: "{{ build_dir }}"
|
|
||||||
state: directory
|
|
||||||
|
|
||||||
- name: Parse template facts.
|
|
||||||
ansible.builtin.include_role:
|
|
||||||
name: "prepare-template-facts"
|
|
||||||
|
|
||||||
- name: Load repo vars.
|
|
||||||
ansible.builtin.include_role:
|
|
||||||
name: "load-template-vars"
|
|
||||||
|
|
||||||
- name: Copy build files.
|
|
||||||
ansible.builtin.copy:
|
|
||||||
src: "{{ item }}"
|
|
||||||
dest: "{{ build_dir }}/{{ item | basename }}"
|
|
||||||
mode: '0600'
|
|
||||||
with_items: "{{ copy_on_build }}"
|
|
||||||
|
|
||||||
- name: Template build files.
|
|
||||||
ansible.builtin.template:
|
|
||||||
src: "{{ item }}"
|
|
||||||
dest: "{{ build_dir }}/{{ item | basename }}"
|
|
||||||
mode: '0600'
|
|
||||||
with_items: "{{ template_on_build }}"
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
---
|
|
||||||
# Used internally to clone the template repo, if applicable and load it's
|
|
||||||
# variables.
|
|
||||||
#
|
|
||||||
# NOTE: This expects that you've called prepare-template-facts first.
|
|
||||||
|
|
||||||
- name: Check if template path exists.
|
|
||||||
ansible.builtin.stat:
|
|
||||||
path: "{{ template_dir }}"
|
|
||||||
register: template_dir_stat
|
|
||||||
tags:
|
|
||||||
- always
|
|
||||||
|
|
||||||
- name: Debug template variable.
|
|
||||||
ansible.builtin.debug:
|
|
||||||
var: template
|
|
||||||
tags:
|
|
||||||
- debug
|
|
||||||
- never
|
|
||||||
|
|
||||||
- name: Ensure repo.
|
|
||||||
ansible.builtin.git:
|
|
||||||
repo: "{{ template.repo.url }}"
|
|
||||||
dest: "{{ template_dir }}"
|
|
||||||
version: "{{ template.repo.version | default('main') }}"
|
|
||||||
when: template.repo.url is defined
|
|
||||||
tags:
|
|
||||||
- always
|
|
||||||
|
|
||||||
- name: Check for repo vars directory.
|
|
||||||
ansible.builtin.stat:
|
|
||||||
path: "{{ template_vars_path }}"
|
|
||||||
register: repo_vars
|
|
||||||
tags:
|
|
||||||
- always
|
|
||||||
|
|
||||||
- name: Load repo vars if available.
|
|
||||||
ansible.builtin.include_vars:
|
|
||||||
dir: "{{ template_vars_path }}"
|
|
||||||
when: repo_vars.stat.isdir is defined
|
|
||||||
tags:
|
|
||||||
- always
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
---
|
|
||||||
# This role is used internally to parse template variables, depending on
|
|
||||||
# what is supplied.
|
|
||||||
#
|
|
||||||
- name: Set default template path.
|
|
||||||
ansible.builtin.set_fact:
|
|
||||||
repo_template_path: "{{ build_dir }}/template"
|
|
||||||
tags:
|
|
||||||
- always
|
|
||||||
|
|
||||||
- name: Parse template path.
|
|
||||||
ansible.builtin.set_fact:
|
|
||||||
template_dir: "{{ template.path | default(repo_template_path) }}"
|
|
||||||
tags:
|
|
||||||
- always
|
|
||||||
|
|
||||||
- name: Parse template vars path.
|
|
||||||
ansible.builtin.set_fact:
|
|
||||||
template_vars_path: "{{ template_dir }}/{{ template.vars | default('repo_vars') }}"
|
|
||||||
tags:
|
|
||||||
- always
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
|
|
||||||
output_dir: "{{ lookup('env', 'PWD') }}"
|
|
||||||
use_vault: true
|
|
||||||
repo_vars_dir: "repo_vars"
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Definitions
|
|
||||||
|
|
||||||
<!-- NOTE: Keep these at the very bottom, if any new definitions are added then
|
|
||||||
they should also be added as links to the content section near the top. -->
|
|
||||||
|
|
||||||
### CFM50 {#cfm50-definition}
|
|
||||||
|
|
||||||
Is a unit of measurement that is taken when a blower door test is performed on the home. Stands for
|
|
||||||
$C$ubic $F$eet per $M$inute at 50 pascals.
|
|
||||||
|
|
||||||
### IAQ {#iaq-definition}
|
|
||||||
|
|
||||||
Stands for $I$ndoor $A$ir $Q$uality. We spend most of our time indoors, so having good indoor air
|
|
||||||
quality can help reduce illness and potentially improve the quality and longevity of life.
|
|
||||||
|
|
||||||
### LAIR {#lair-definition}
|
|
||||||
|
|
||||||
Stands for $L$eakage $A$ir $I$nfiltration $R$ate, which is a metric that compares the square footage
|
|
||||||
of the home and it's blower door number. This aids in determining if a home is controllable by
|
|
||||||
properly sized HVAC equipment or if [shell] improvements (air sealing and insulation) should be
|
|
||||||
considered.
|
|
||||||
|
|
||||||
### Load Caclulation {#load-calculation-definition}
|
|
||||||
|
|
||||||
A load calculation, also called a Manual-J, calculates the heating and cooling requirements for a
|
|
||||||
home. Each home has a unique heating and cooling load based on the direction it faces, the air
|
|
||||||
leakage, the location, the insulation values, the types and sizes of windows, among other factors.
|
|
||||||
|
|
||||||
### Shell {#shell-definition}
|
|
||||||
|
|
||||||
Shell is a term used to describe the enclosure of the house. It is often used when talking about air
|
|
||||||
leakage and insulation levels.
|
|
||||||
|
|
||||||
### SHR {#shr-definition}
|
|
||||||
|
|
||||||
Stands for $S$ensible $H$eat $R$atio. Sensible heat ratio is the ratio of work required by the air
|
|
||||||
conditioner to remove moisture, as well as maintain the sensible temperature. The higher the number
|
|
||||||
the better.
|
|
||||||
|
|
||||||
### TESP {#tesp-definition}
|
|
||||||
|
|
||||||
Stands for $T$otal $E$xternal $S$tatic $P$ressure, which is a metric used to determine how much
|
|
||||||
resistance the blower motor has to overcome for the airflow requirement of the system.
|
|
||||||
|
|
||||||
### WC {#wc-definition}
|
|
||||||
|
|
||||||
Stands for $W$ater $C$olumn, which is a unit of measurement for pressure.
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
---
|
|
||||||
title: "{{ document_title }}"
|
|
||||||
author: "{{ author_name }}"
|
|
||||||
date: \today{}
|
|
||||||
mainfont: Avenir Next
|
|
||||||
documentclass: article
|
|
||||||
fontsize: 12pt
|
|
||||||
# NOTE: The applegreen is a custom color defined in resources/head.tex
|
|
||||||
linkcolor: applegreen
|
|
||||||
urlcolor: applegreen
|
|
||||||
abstract: |
|
|
||||||
**Prepared For:**
|
|
||||||
|
|
||||||
{{ customer.name }}
|
|
||||||
|
|
||||||
{{ customer.address.street }}
|
|
||||||
{{ customer.address.city }}, {{ customer.address.state }} {{ customer.address.zip }}
|
|
||||||
|
|
||||||
```{=latex}
|
|
||||||
\begin{center}
|
|
||||||
```
|
|
||||||
<!-- below are just forced line breaks -->
|
|
||||||
\
|
|
||||||
\
|
|
||||||
\
|
|
||||||
|
|
||||||
[{ width=30% }]({{ company.website }})
|
|
||||||
|
|
||||||
```{=latex}
|
|
||||||
\end{center}
|
|
||||||
```
|
|
||||||
---
|
|
||||||
|
|
||||||
<!-- NOTE: Table of contents section only needs changed if new sections are
|
|
||||||
added.
|
|
||||||
-->
|
|
||||||
|
|
||||||
# Contents
|
|
||||||
|
|
||||||
1. [Introduction](#home-performance-report)
|
|
||||||
1. [Your Goals](#your-goals)
|
|
||||||
1. [Leakage](#how-leaky-is-your-house)
|
|
||||||
1. [Observations](#observations)
|
|
||||||
1. [HVAC](#hvac-observations)
|
|
||||||
1. [Home](#home-observations)
|
|
||||||
1. [Load Calculations](#load-calculations)
|
|
||||||
1. [Your Loads](#your-loads)
|
|
||||||
1. [Airflow Assessment](#airflow-assessment)
|
|
||||||
1. [Your Static Measurements](#your-static-measurements)
|
|
||||||
1. [Static Pressure Forecast](#static-measurements-forecast)
|
|
||||||
1. [Summary](#summary)
|
|
||||||
1. [Definitions](#definitions)
|
|
||||||
1. [CFM50]
|
|
||||||
1. [IAQ]
|
|
||||||
1. [LAIR]
|
|
||||||
1. [Load Calculation][load-calculation]
|
|
||||||
1. [Shell][shell]
|
|
||||||
1. [SHR]
|
|
||||||
1. [TESP]
|
|
||||||
1. [WC][wc]
|
|
||||||
|
|
||||||
# Home Performance Report
|
|
||||||
|
|
||||||
Thank you for having us to your home for a home performance assessment. I hope that it was
|
|
||||||
beneficial. We learned a lot about your home and your goals during the brief visit. So, let’s jump
|
|
||||||
in to some of the things discovered.
|
|
||||||
|
|
||||||
Below is a summary of your goals, house measurements, and our budget discussion as well as my
|
|
||||||
observations and some recommendations. As part of the service we ran load calculations that help
|
|
||||||
determine the right sized HVAC for your home as it stand and with some upgrades. Those are attached
|
|
||||||
as are bids for replacements.
|
|
||||||
|
|
||||||
Once you read the report you’ll have 3 options: do nothing, pick and choose upgrades, or do more
|
|
||||||
planning for more difficult goals or complex projects. These options are discussed at the end of the
|
|
||||||
report as well as our leanings for your home.
|
|
||||||
|
|
||||||
\goalsimage
|
|
||||||
|
|
||||||
## Your goals:
|
|
||||||
|
|
||||||
<!-- NOTE: This should probably be a table in most cases, with a rank.-->
|
|
||||||
|
|
||||||
<!--
|
|
||||||
| Goal | Rank |
|
|
||||||
| ------ | ---- |
|
|
||||||
| A goal | 8 |
|
|
||||||
| B goal | 5 |
|
|
||||||
-->
|
|
||||||
<!-- WARNING: Add goals here -->
|
|
||||||
|
|
||||||
1. Add air conditioning.
|
|
||||||
|
|
||||||
# How Leaky Is Your House
|
|
||||||
|
|
||||||
<!-- NOTE: Several of the variables used in this section are defined in vars.sh
|
|
||||||
Do not change them here, instead update them in the vars.sh file.
|
|
||||||
-->
|
|
||||||
|
|
||||||
One of the main objectives was to perform a blower door test and load calculations for the home. We
|
|
||||||
discovered that the blower door number was {{ home.cfm50 }} [CFM50] for the approximately
|
|
||||||
{{ home.square_feet }} \squarefoot ({{ home.lair }} [LAIR]). A leaky home, most often, is an
|
|
||||||
uncomfortable and uncontrollable home.
|
|
||||||
|
|
||||||
> _Air leakage tends to have one of the largest impacts on the load of a home._
|
|
||||||
>
|
|
||||||
> - _Around 1:1 homes begin to be more controllable / comfortable_
|
|
||||||
> - _your home is {{ home.cfm50 }}:{{ home.square_feet }} \squarefoot for a ratio of
|
|
||||||
> {{ home.lair }}_
|
|
||||||
> - _Leaky homes are very difficult to maintain comfort with HVAC alone._
|
|
||||||
|
|
||||||
# Observations
|
|
||||||
|
|
||||||
The below sections are observations about the current HVAC system and the home.
|
|
||||||
|
|
||||||
## HVAC Observations
|
|
||||||
|
|
||||||
<!-- WARNING: Add HVAC observations here. -->
|
|
||||||
|
|
||||||
1. Current furnace is sized appropriately based on the load of the home.
|
|
||||||
1. Current duct system is not sized adequately for the system.
|
|
||||||
1. Filter is too small for the system.
|
|
||||||
1. Return sizing is not adequate.
|
|
||||||
1. Supply sizing is marginal.
|
|
||||||
1. Current static pressure is already high.
|
|
||||||
1. These problems may become worse when AC is installed.
|
|
||||||
|
|
||||||
## Home Observations
|
|
||||||
|
|
||||||
<!-- WARNING: Add house observations here. -->
|
|
||||||
|
|
||||||
1. The house leakage is high for the size of the home.
|
|
||||||
1. May be hard to control comfort without [shell] improvements.
|
|
||||||
|
|
||||||
# Load Calculations
|
|
||||||
|
|
||||||
Several [load-calculations][load-calculation] were performed on your home to determine the proper
|
|
||||||
equipment sizing for this application. Below is a comparison of the
|
|
||||||
[load-calculations][load-calculation] with the current air leakage and several improved air leakage
|
|
||||||
targets.
|
|
||||||
|
|
||||||
## Your Loads
|
|
||||||
|
|
||||||
<!-- WARNING: Add the project loads here. -->
|
|
||||||
|
|
||||||
| [CFM50] | | Heating Total | Cooling Total | [SHR] | [LAIR] |
|
|
||||||
| -------------------- | ----------- | ------------- | ------------- | -------- | ------------------- |
|
|
||||||
| **{{ home.cfm50 }}** | **Current** | **55,102** | **20,726** | **0.79** | **{{ home.lair }}** |
|
|
||||||
| 2,000 | | 40,320 | 17,279 | 0.85 | 1.5:1 |
|
|
||||||
| 1,350 | | 35,885 | 16,245 | 0.88 | 1:1 |
|
|
||||||
|
|
||||||
> **Note:**
|
|
||||||
>
|
|
||||||
> 1. _The lower the heating and cooling total's the better._
|
|
||||||
> 1. _An undersized air conditioner is better than an oversized one._
|
|
||||||
> 1. _Supplemental dehumidification may be required for [SHR]'s below 0.83._
|
|
||||||
|
|
||||||
The above table shows the relationship between air leakage and the amount of heating and cooling
|
|
||||||
that is required for the home. A tight home is easier to control the comfort levels, offers superior
|
|
||||||
IAQ levels, and lower utility costs.
|
|
||||||
|
|
||||||
<!-- WARNING: Update this summary for the given project -->
|
|
||||||
|
|
||||||
The projected cooling size required for your home is around 2-Tons for the current leakage rate, or
|
|
||||||
1.5-Tons if [shell] improvements were made.
|
|
||||||
|
|
||||||
[Here is a link to your load calculation reports][loads-folder]
|
|
||||||
|
|
||||||
# Airflow Assessment
|
|
||||||
|
|
||||||
While on site, we also measured the total system airflow and static pressure of the system. Static
|
|
||||||
pressure is equivalent to the blood pressure of your system and gives us a better understanding of
|
|
||||||
the overall ability for the system to provide the proper amount of airflow, as well as how much it
|
|
||||||
may struggle to do so.
|
|
||||||
|
|
||||||
Static pressure is the amount of resistance that the blower has to work against in order to move air
|
|
||||||
through the system. Things that have an effect on the static pressure of the system include, air
|
|
||||||
filters (size and type), duct sizes, amount of ducts, length of ducts, duct fittings and
|
|
||||||
transitions, as well as internal system components. Each component of the system has a resistance
|
|
||||||
associated with it that the blower has to overcome, by taking some key measurements we are able to
|
|
||||||
determine the [TESP] of the system. While there are several static pressures in the system, when we
|
|
||||||
talk about static pressure we are generally referring to [TESP].
|
|
||||||
|
|
||||||
Static pressure ([TESP]) has a range of _low_, _acceptable_, or _high_. While these numbers are
|
|
||||||
specific to the actual equipment, most manufacturers follow similar standards. For the sake of
|
|
||||||
simplicity, 0.5" [wc] or under is an _acceptable_ target, 0.8" [wc] is generally the max acceptable
|
|
||||||
static pressure (although we like to stay well below this if possible), and above 0.8" [wc] is
|
|
||||||
considered _high_ and should be addressed. _Low_ is generally not common and is rarely problematic,
|
|
||||||
so it is not focused on much.
|
|
||||||
|
|
||||||
<!-- NOTE: Force a new page here, for images and sub-section heading to be on same page -->
|
|
||||||
|
|
||||||
\newpage
|
|
||||||
|
|
||||||
## Your Static Measurements
|
|
||||||
|
|
||||||
![True Flow Report][trueflow-image]{ height=50% }
|
|
||||||
|
|
||||||
<!-- WARNING: Summarize the static measurements here -->
|
|
||||||
|
|
||||||
The above image is a snapshot of the static pressures recorded for your system. This shows that the
|
|
||||||
static pressure of your system is very high (1.114" [wc]). The primary culprits for the high static
|
|
||||||
pressure are that the filter is undersized for the airflow required and the return duct sizing is
|
|
||||||
small.
|
|
||||||
|
|
||||||
These measurements were taken in the heating mode because your system does not currently have air
|
|
||||||
conditioning. Currently the heating airflow is on the low side for what is required for your system
|
|
||||||
(1200 CFM would be ideal). This should be adjusted if possible during the install to get better
|
|
||||||
performance and efficiency out of the system, given that some static pressures can be improved
|
|
||||||
during the project.
|
|
||||||
|
|
||||||
\
|
|
||||||
\
|
|
||||||
|
|
||||||
[Here is a link to the full airflow report.][trueflow-file]
|
|
||||||
|
|
||||||
<!-- NOTE: Force a new page here, for images and sub-section heading to be on same page -->
|
|
||||||
|
|
||||||
\newpage
|
|
||||||
|
|
||||||
## Static Measurements Forecast
|
|
||||||
|
|
||||||
![True Flow Forecast][trueflow-forecast-image]{ height=50% }
|
|
||||||
|
|
||||||
<!-- WARNING: Summarize the forecast results below. -->
|
|
||||||
|
|
||||||
The above image is a snapshot is of a forecast of the static pressures after adding air
|
|
||||||
conditioning. It should be noted that these measurements are based solely on the airflow required
|
|
||||||
for cooling mode, not for heating mode (in other words, heating mode is going to be higher because
|
|
||||||
the airflow requirement is higher).
|
|
||||||
|
|
||||||
This shows that with an upgraded filter we can get the static pressure below the 0.8" [wc] max
|
|
||||||
target while in cooling mode.
|
|
||||||
|
|
||||||
\
|
|
||||||
\
|
|
||||||
|
|
||||||
[Here is a link to the full forecast report.][trueflow-forecast-file]
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
|
|
||||||
The purpose of the home performance assessment is to help find the overlap between the house needs,
|
|
||||||
the goals, and the budget to see if there's a viable project.
|
|
||||||
|
|
||||||
<!-- WARNING: Add summary here -->
|
|
||||||
|
|
||||||
The house is pretty leaky overall. This is due to the age and construction style of the house. This
|
|
||||||
may lead to comfort problems or trouble maintaining comfort in all areas of the house.
|
|
||||||
|
|
||||||
Based on the [load calculations][load-calculation], the previously quoted systems are too large for
|
|
||||||
the current load, so we need to update the proposals to be for 2-Ton systems. This will help with
|
|
||||||
the fact that the static pressure of the system is already really high. The static pressure for
|
|
||||||
heating will likely still be above the 0.8" [wc] max threshold.
|
|
||||||
|
|
||||||
An upgraded air filter is going to be required to help alleviate the blower motor. Another return
|
|
||||||
may be required in the living space to further drop the static pressure, however this could likely
|
|
||||||
be done in the future if desired. I would estimate that adding another return would be in the
|
|
||||||
**$800-1,200** range.
|
|
||||||
|
|
||||||
While on site it was mentioned that you would like some of the ducts to be sealed that go to the
|
|
||||||
second floor. This is something that is not included in our general proposals. I would estimate this
|
|
||||||
to be an additional **$150-300** and will add options in the updated proposals.
|
|
||||||
|
|
||||||
Since the goal is to add air conditioning, then I would recommend going with a 2-Ton system. If the
|
|
||||||
system does not maintain then you could look into [shell] improvements and air sealing the home.
|
|
||||||
|
|
||||||
Regards,
|
|
||||||
|
|
||||||
\
|
|
||||||
\
|
|
||||||
\
|
|
||||||
|
|
||||||
[Here is a link to all the documents][document-folder]
|
|
||||||
|
|
||||||
<!-- prettier-ignore-start -->
|
|
||||||
|
|
||||||
<!-- NOTE: These are reference links for short form linking in the document.
|
|
||||||
This allows all the links to be changed / updated in one place.
|
|
||||||
However most do not need updated unless sections are added, besides the
|
|
||||||
document links, which are defined / setup in vars.sh.
|
|
||||||
-->
|
|
||||||
|
|
||||||
[CFM50]: #cfm50-definition "CFM50"
|
|
||||||
[IAQ]: #iaq-definition "IAQ"
|
|
||||||
[LAIR]: #lair-definition "LAIR"
|
|
||||||
[load-calculation]: #load-calculation-definition "load calculation"
|
|
||||||
[shell]: #shell-definition "shell"
|
|
||||||
[SHR]: #shr-definition "SHR"
|
|
||||||
[wc]: #wc-definition "wc"
|
|
||||||
[TESP]: #tesp-definition "TESP"
|
|
||||||
|
|
||||||
|
|
||||||
<!-- IMPORTANT: Take screen shots of the TrueFlow reports and add to the img
|
|
||||||
folder with names / path setup in vars.sh -->
|
|
||||||
|
|
||||||
<!-- default is img/trueflow.png -->
|
|
||||||
[trueflow-image]: "{{ links.images.trueflow }}"
|
|
||||||
|
|
||||||
<!-- default is img/forecast.png -->
|
|
||||||
[trueflow-forecast-image]: "{{ links.images.trueflow_forecast }}"
|
|
||||||
|
|
||||||
<!-- NOTE: These links are defined in vars.sh -->
|
|
||||||
|
|
||||||
[loads-folder]: "{{ links.documents.loads_folder }}"
|
|
||||||
[trueflow-file]: "{{ links.documents.trueflow_file }}"
|
|
||||||
[trueflow-forecast-file]: "{{ links.documents.trueflow_forecast_file }}"
|
|
||||||
[document-folder]: "{{ links.documents.document_folder }}"
|
|
||||||
<!-- prettier-ignore-end -->
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
% customize the footer
|
|
||||||
\usepackage{fancyhdr}
|
|
||||||
\pagestyle{fancy}
|
|
||||||
% clear all footers
|
|
||||||
\fancyfoot{}
|
|
||||||
% clear all headers
|
|
||||||
\fancyhead{}
|
|
||||||
|
|
||||||
% change font size on footer.
|
|
||||||
\newcommand{\changefont}{
|
|
||||||
\fontsize{8}{10}\selectfont
|
|
||||||
}
|
|
||||||
% NOTE: What is displayed in the footer of each page.
|
|
||||||
\fancyfoot[LE,LO]{
|
|
||||||
\href{ {{ company.website }} }
|
|
||||||
{\changefont\textbf{
|
|
||||||
{{ company.url_display_title }}
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
\fancyfoot[RE,RO]{\textbf{
|
|
||||||
\changefont{
|
|
||||||
Phone: \href{tel:{{ company.phone }} }{
|
|
||||||
\color{orange}{{ company.phone }}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
% https://jdhao.github.io/2019/05/30/markdown2pdf_pandoc/#the-top
|
|
||||||
% These were adapted from the above, which was very helpful in getting this
|
|
||||||
% functional.
|
|
||||||
|
|
||||||
% change style of quote, see also https://tex.stackexchange.com/a/436253/114857
|
|
||||||
\usepackage[most]{tcolorbox}
|
|
||||||
|
|
||||||
% change page margins
|
|
||||||
\usepackage[top=2cm, bottom=1.5cm, left=2cm, right=2cm]{geometry}
|
|
||||||
|
|
||||||
% change the line spacing
|
|
||||||
\usepackage{setspace}
|
|
||||||
\setstretch{1.25}
|
|
||||||
|
|
||||||
\usepackage[utf8]{inputenc}
|
|
||||||
|
|
||||||
% NOTE: This needs to stay above hyperref otherwise internal links break.
|
|
||||||
|
|
||||||
% start each section on new page and make section titles orange.
|
|
||||||
\usepackage{titlesec}
|
|
||||||
\titleformat{\section}
|
|
||||||
{\color{orange}\normalfont\Huge\bfseries}
|
|
||||||
\newcommand{\sectionbreak}{\clearpage}
|
|
||||||
|
|
||||||
% custom colors
|
|
||||||
\definecolor{applegreen}{rgb}{0.55,0.71,0.0}
|
|
||||||
|
|
||||||
% Remove figure from images
|
|
||||||
\usepackage[labelformat=empty]{caption}
|
|
||||||
|
|
||||||
\usepackage{fancyvrb,newverbs}
|
|
||||||
|
|
||||||
% see for different color codes https://rgbcolorcode.com/color/E6FFEA
|
|
||||||
\definecolor{linequote}{RGB}{224,215,188}
|
|
||||||
\definecolor{backquote}{RGB}{230,255,234} % background color of quotes
|
|
||||||
\definecolor{bordercolor}{RGB}{221,221,221}
|
|
||||||
|
|
||||||
% change left border: https://tex.stackexchange.com/a/475716/114857
|
|
||||||
% change left margin: https://tex.stackexchange.com/a/457936/114857
|
|
||||||
\newtcolorbox{myquote}[1][]{%
|
|
||||||
enhanced,
|
|
||||||
breakable,
|
|
||||||
size=minimal,
|
|
||||||
left=10pt,
|
|
||||||
top=5pt,
|
|
||||||
bottom=5pt,
|
|
||||||
frame hidden,
|
|
||||||
boxrule=0pt,
|
|
||||||
sharp corners=all,
|
|
||||||
colback=backquote,
|
|
||||||
borderline west={4pt}{0pt}{bordercolor},
|
|
||||||
#1
|
|
||||||
}
|
|
||||||
|
|
||||||
% redefine quote environment to use the myquote environment, see https://tex.stackexchange.com/a/337587/114857
|
|
||||||
\renewenvironment{quote}{\begin{myquote}}{\end{myquote}}
|
|
||||||
|
|
||||||
% remove the abstract title.
|
|
||||||
\usepackage{abstract}
|
|
||||||
\renewcommand{\abstractname}{}
|
|
||||||
\renewcommand{\absnamepos}{empty}
|
|
||||||
|
|
||||||
\def\squarefoot{$ft^{\text{2}}$ }
|
|
||||||
\def\goalsimage{
|
|
||||||
\begin{center}
|
|
||||||
|
|
||||||
\hfill\break
|
|
||||||
\hfill\break
|
|
||||||
|
|
||||||
\includegraphics[width=0.6\linewidth,height=\textheight,keepaspectratio]{img/goals.png}
|
|
||||||
|
|
||||||
\end{center}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
---
|
|
||||||
author_name: "Testy McTestface Jr"
|
|
||||||
document_title: "Home Performance Report"
|
|
||||||
company:
|
|
||||||
website: "https://example.com"
|
|
||||||
phone: "555-555-5555"
|
|
||||||
url_display_title: "www.Example.com"
|
|
||||||
|
|
||||||
links:
|
|
||||||
images:
|
|
||||||
logo: "img/logo.png"
|
|
||||||
trueflow: "img/trueflow.png"
|
|
||||||
trueflow_forecast: "img/forecast.png"
|
|
||||||
documents:
|
|
||||||
loads_folder: "https://example.com/path/to/loads/folder"
|
|
||||||
trueflow_file: "https://example.com/path/to/trueflow/file"
|
|
||||||
trueflow_forecast_file: "https://example.com/path/to/trueflow/forecast/file"
|
|
||||||
document_folder: "https://example.com/path/to/document/folder"
|
|
||||||
|
|
||||||
customer:
|
|
||||||
name: "Testy McTestface Sr"
|
|
||||||
address:
|
|
||||||
street: "1234 Seasme Street"
|
|
||||||
city: "No Mans Land"
|
|
||||||
state: "Foo"
|
|
||||||
zip: "55555"
|
|
||||||
|
|
||||||
home:
|
|
||||||
square_feet: "3,000"
|
|
||||||
cfm50: "3,000"
|
|
||||||
lair: "1:1"
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
on_setup:
|
|
||||||
- "Report.md"
|
|
||||||
- "vars.yml"
|
|
||||||
- "vault.yml" # optional if using vault.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
---
|
|
||||||
author_name: "{{ vault_author_name }}"
|
|
||||||
document_title: "Home Performance Report"
|
|
||||||
company: "{{ vault_company }}"
|
|
||||||
|
|
||||||
links:
|
|
||||||
images:
|
|
||||||
logo: "img/logo.png"
|
|
||||||
trueflow: "img/trueflow.png"
|
|
||||||
trueflow_forecast: "img/forecast.png"
|
|
||||||
documents: "{{ vault_document_links }}"
|
|
||||||
|
|
||||||
customer: "{{ vault_customer }}"
|
|
||||||
|
|
||||||
home:
|
|
||||||
square_feet: "3,000"
|
|
||||||
cfm50: "3,000"
|
|
||||||
lair: "1:1"
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
vault_author_name: "Testy McTestface Vault"
|
|
||||||
vault_company:
|
|
||||||
website: "https://vault.example.com"
|
|
||||||
phone: "555-555-5555"
|
|
||||||
url_display_title: "www.VaultExample.com"
|
|
||||||
|
|
||||||
vault_customer:
|
|
||||||
name: "Testy McTestface Sr Vault"
|
|
||||||
address:
|
|
||||||
street: "1234 Seasme Street"
|
|
||||||
city: "Vault"
|
|
||||||
state: "Foo"
|
|
||||||
zip: "55555"
|
|
||||||
|
|
||||||
vault_document_links:
|
|
||||||
loads_folder: "https://vault.example.com/path/to/loads/folder"
|
|
||||||
trueflow_file: "https://vault.example.com/path/to/trueflow/file"
|
|
||||||
trueflow_forecast_file: "https://vault.example.com/path/to/trueflow/forecast/file"
|
|
||||||
document_folder: "https://vault.example.com/path/to/document/folder"
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
---
|
|
||||||
- name: Starting repo template role.
|
|
||||||
ansible.builtin.debug:
|
|
||||||
msg: "Output directory: {{ output_dir }}"
|
|
||||||
|
|
||||||
- name: Ensure output directory exists.
|
|
||||||
ansible.builtin.file:
|
|
||||||
path: "{{ output_dir }}"
|
|
||||||
state: directory
|
|
||||||
|
|
||||||
- name: Ensure repo vars directory.
|
|
||||||
ansible.builtin.file:
|
|
||||||
path: "{{ output_dir }}/{{ repo_vars_dir }}"
|
|
||||||
state: directory
|
|
||||||
|
|
||||||
- name: Copy general files.
|
|
||||||
ansible.builtin.copy:
|
|
||||||
src: "files/{{ item.src | default(item) }}"
|
|
||||||
dest: "{{ output_dir }}/{{ item.dest | default(item) }}"
|
|
||||||
with_items:
|
|
||||||
- "Definitions.md"
|
|
||||||
- "Report.md"
|
|
||||||
- "head.tex"
|
|
||||||
- "footer.tex"
|
|
||||||
- src: "vars.repo.yml"
|
|
||||||
dest: "{{ repo_vars_dir }}/vars.yml"
|
|
||||||
|
|
||||||
- name: Copy basic vars files.
|
|
||||||
ansible.builtin.copy:
|
|
||||||
src: "files/vars.default.yml"
|
|
||||||
dest: "{{ output_dir }}/vars.yml"
|
|
||||||
when: not 'with-vault' in ansible_run_tags or use_vault | bool == False
|
|
||||||
|
|
||||||
- name: Copy vault and vars files.
|
|
||||||
ansible.builtin.copy:
|
|
||||||
src: "files/{{ item.src }}"
|
|
||||||
dest: "{{ output_dir }}/{{ item.dest }}"
|
|
||||||
with_items:
|
|
||||||
- src: "vars.vault.yml"
|
|
||||||
dest: "vars.yml"
|
|
||||||
- src: "vault.default.yml"
|
|
||||||
dest: "vault.yml"
|
|
||||||
when: "'with_vault' in ansible_run_tags or use_vault | bool == True"
|
|
||||||
tags:
|
|
||||||
- with-vault
|
|
||||||
- never
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
---
|
|
||||||
# template:
|
|
||||||
# path: "/path/to/local/template/dir
|
|
||||||
# vars: "repo_vars" (optional path inside template directory to find variables, defaults to 'repo_vars')
|
|
||||||
#
|
|
||||||
# OR
|
|
||||||
#
|
|
||||||
# template:
|
|
||||||
# repo:
|
|
||||||
# url: "https://example.com/template.git
|
|
||||||
# version: "1.0.0" or "branch" (tagging to a version is more ideal)
|
|
||||||
#
|
|
||||||
template:
|
|
||||||
|
|
||||||
# The preject directory to setup in.
|
|
||||||
project_dir: "{{ lookup('env', 'PWD') }}"
|
|
||||||
|
|
||||||
# This path get's setup / parsed based on the template variable,
|
|
||||||
# it will point to the directory of the template, which could be a
|
|
||||||
# local path on the system or inside of the project directory, depending
|
|
||||||
# on if the template is a repo or not.
|
|
||||||
#
|
|
||||||
# This is safe to use inside of the project or template specifications
|
|
||||||
# for paths to files that live in the template directory not the project
|
|
||||||
# directory.
|
|
||||||
#template_dir: ""
|
|
||||||
|
|
||||||
# Files or directories that are copied from the template directory to the project
|
|
||||||
# directory.
|
|
||||||
#
|
|
||||||
# These can be a simple item that is a path from the root of the template directory
|
|
||||||
# to a file, which will copy the file to the root of the project directory or
|
|
||||||
# in the form of:
|
|
||||||
#
|
|
||||||
# src: "path/in/template/dir"
|
|
||||||
# dest: "path/in/project/dir"
|
|
||||||
# mode: '0600' (optional mode of the file/dir to copy)
|
|
||||||
#
|
|
||||||
copy_on_setup: []
|
|
||||||
|
|
||||||
# Copies the entire contents of a directory to the root of the project directory.
|
|
||||||
#
|
|
||||||
# This is useful if you keep all the template files in a sub-directory of your project
|
|
||||||
# template, it will copy that entire directory over when setting up a new project.
|
|
||||||
#
|
|
||||||
# NOTE: If the project has been setup (indicated by a .setup file) this
|
|
||||||
# will be skipped so that it does not overwrite any changes to the
|
|
||||||
# project files. This ensures that a project is only setup once.
|
|
||||||
copy_directory_on_setup: []
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
---
|
|
||||||
# NOTE: This is just an internal task that checks if a file exists
|
|
||||||
# before copying, to prevent changes from being overwritten.
|
|
||||||
#
|
|
||||||
- name: "Check if {{ destination | basename }} exists already."
|
|
||||||
ansible.builtin.stat:
|
|
||||||
path: "{{ destination }}"
|
|
||||||
register: filestat
|
|
||||||
tags:
|
|
||||||
- always
|
|
||||||
|
|
||||||
- name: "Copy {{ destination | basename }} file."
|
|
||||||
ansible.builtin.copy:
|
|
||||||
src: "{{ source }}"
|
|
||||||
dest: "{{ destination }}"
|
|
||||||
mode: "{{ mode | default('0600') }}"
|
|
||||||
when: not filestat.stat.exists
|
|
||||||
tags:
|
|
||||||
- always
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
---
|
|
||||||
- name: Starting setup project.
|
|
||||||
ansible.builtin.debug:
|
|
||||||
msg: "Project dir: {{ project_dir }}"
|
|
||||||
tags:
|
|
||||||
- setup-project
|
|
||||||
- never
|
|
||||||
|
|
||||||
- name: Debug template vars pre parse.
|
|
||||||
ansible.builtin.debug:
|
|
||||||
var: template
|
|
||||||
tags:
|
|
||||||
- debug
|
|
||||||
- never
|
|
||||||
|
|
||||||
- name: Debug build dir.
|
|
||||||
ansible.builtin.debug:
|
|
||||||
var: build_dir
|
|
||||||
tags:
|
|
||||||
- debug
|
|
||||||
- never
|
|
||||||
|
|
||||||
- name: Parse template facts.
|
|
||||||
ansible.builtin.include_role:
|
|
||||||
name: "prepare-template-facts"
|
|
||||||
tags:
|
|
||||||
- setup-project
|
|
||||||
- never
|
|
||||||
|
|
||||||
|
|
||||||
- name: Debug template path post parse.
|
|
||||||
ansible.builtin.debug:
|
|
||||||
var: template_dir
|
|
||||||
tags:
|
|
||||||
- debug
|
|
||||||
- never
|
|
||||||
|
|
||||||
- name: Ensure output directory exists.
|
|
||||||
ansible.builtin.file:
|
|
||||||
path: "{{ project_dir }}"
|
|
||||||
state: directory
|
|
||||||
tags:
|
|
||||||
- setup-project
|
|
||||||
- never
|
|
||||||
|
|
||||||
- name: Load template vars.
|
|
||||||
ansible.builtin.include_role:
|
|
||||||
name: "load-template-vars"
|
|
||||||
tags:
|
|
||||||
- setup-project
|
|
||||||
- never
|
|
||||||
|
|
||||||
- name: Debug on_setup.
|
|
||||||
ansible.builtin.debug:
|
|
||||||
msg: "On setup vars: {{ copy_on_setup }}"
|
|
||||||
tags:
|
|
||||||
- debug
|
|
||||||
- never
|
|
||||||
|
|
||||||
- name: Debug copy directory contents.
|
|
||||||
ansible.builtin.debug:
|
|
||||||
var: copy_directory_on_setup
|
|
||||||
tags:
|
|
||||||
- debug
|
|
||||||
- never
|
|
||||||
|
|
||||||
- name: Check if project has been previously setup.
|
|
||||||
ansible.builtin.stat:
|
|
||||||
path: "{{ project_dir }}/.setup"
|
|
||||||
register: setup_file
|
|
||||||
tags:
|
|
||||||
- setup-project
|
|
||||||
- never
|
|
||||||
|
|
||||||
- name: Debug setup file stat.
|
|
||||||
ansible.builtin.debug:
|
|
||||||
var: setup_file.exists
|
|
||||||
tags:
|
|
||||||
- debug
|
|
||||||
- never
|
|
||||||
|
|
||||||
- name: Copy directory contents to project directory.
|
|
||||||
ansible.builtin.command: |
|
|
||||||
cp -r "{{ template_dir }}/{{ item.src | default(item) }}/." \
|
|
||||||
"{{ item.dest | default(project_dir) }}"
|
|
||||||
with_items: "{{ copy_directory_on_setup }}"
|
|
||||||
when: setup_file.stat.exists is false
|
|
||||||
register: copy_directory_stat
|
|
||||||
tags:
|
|
||||||
- setup-project
|
|
||||||
- never
|
|
||||||
|
|
||||||
- name: Debug copy directory stat.
|
|
||||||
ansible.builtin.debug:
|
|
||||||
var: copy_directory_stat
|
|
||||||
tags:
|
|
||||||
- debug
|
|
||||||
- never
|
|
||||||
|
|
||||||
- name: Copy project files.
|
|
||||||
ansible.builtin.include_tasks:
|
|
||||||
file: "copy_if_not_exists.yml"
|
|
||||||
vars:
|
|
||||||
source: "{{ template_dir }}/{{ item.src | default(item) }}"
|
|
||||||
destination: "{{ project_dir }}/{{ item.dest | default(item) }}"
|
|
||||||
mode: "{{ item.mode | default('0600') }}"
|
|
||||||
loop: "{{ copy_on_setup }}"
|
|
||||||
tags:
|
|
||||||
- setup-project
|
|
||||||
- never
|
|
||||||
|
|
||||||
- name: Create setup file.
|
|
||||||
ansible.builtin.template:
|
|
||||||
src: "templates/setup.txt"
|
|
||||||
dest: "{{ project_dir }}/.setup"
|
|
||||||
mode: '0600'
|
|
||||||
when: not setup_file.stat.exists
|
|
||||||
tags:
|
|
||||||
- setup-project
|
|
||||||
- never
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
This file is managed by the ansible-hpa-playbook. It is an indication
|
|
||||||
that the project using the template.
|
|
||||||
|
|
||||||
{{ template.repo.url | default(template_dir) }}
|
|
||||||
|
|
||||||
Has already called setup. This file should not be removed or subsequent calls
|
|
||||||
to setup may overwrite existing data.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
[defaults]
|
|
||||||
inventory = ../inventory.ini
|
|
||||||
roles_path = ../roles
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
test *ARGS:
|
|
||||||
@ansible-playbook ./test.yml {{ARGS}}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
---
|
|
||||||
- name: Test ansible-hpa-playbook
|
|
||||||
hosts: all
|
|
||||||
roles:
|
|
||||||
- role: setup-project
|
|
||||||
tags:
|
|
||||||
- setup-project
|
|
||||||
- never # force passing tags to run.
|
|
||||||
vars:
|
|
||||||
template_dir:
|
|
||||||
path: "/tmp/hpa-playbook-tmp"
|
|
||||||
vars: "repo_vars"
|
|
||||||
project_dir: "/tmp/hpa-setup-project-tmp"
|
|
||||||
|
|
||||||
- role: repo-template
|
|
||||||
tags:
|
|
||||||
- repo-template
|
|
||||||
- never # force passing tags to run.
|
|
||||||
- role: build-project
|
|
||||||
tags:
|
|
||||||
- build-project
|
|
||||||
- never # force passing tags to run.
|
|
||||||
vars:
|
|
||||||
template_dir:
|
|
||||||
path: "/tmp/hpa-playbook-tmp"
|
|
||||||
vars: "repo_vars"
|
|
||||||
project_dir: "/tmp/hpa-setup-project-tmp"
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
- name: Test complex var, set fact.
|
|
||||||
ansible.builtin.set_fact:
|
|
||||||
template:
|
|
||||||
path: "{{ template.path | default('.build') }}"
|
|
||||||
vars:
|
|
||||||
template:
|
|
||||||
repo:
|
|
||||||
url: "https://example.com"
|
|
||||||
version: "main"
|
|
||||||
tags:
|
|
||||||
- vars
|
|
||||||
- never
|
|
||||||
|
|
||||||
- name: Test complex var, set fact when path is specified.
|
|
||||||
ansible.builtin.set_fact:
|
|
||||||
template_path_specified: true
|
|
||||||
vars:
|
|
||||||
template:
|
|
||||||
repo:
|
|
||||||
url: "https://example.com"
|
|
||||||
version: "main"
|
|
||||||
when: template.path is defined
|
|
||||||
tags:
|
|
||||||
- vars
|
|
||||||
- never
|
|
||||||
|
|
||||||
- name: Test complex var was set.
|
|
||||||
ansible.builtin.debug:
|
|
||||||
msg: "Template path: {{ template.path }}, specified: {{ template_path_specified | default(false) }}"
|
|
||||||
tags:
|
|
||||||
- vars
|
|
||||||
- never
|
|
||||||
@@ -33,15 +33,19 @@ public struct Configuration: Codable, Equatable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct Playbook: Codable, Equatable, Sendable {
|
public struct Playbook: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
public let directory: String?
|
public let directory: String?
|
||||||
public let inventory: String?
|
public let inventory: String?
|
||||||
|
public let version: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
directory: String? = nil,
|
directory: String? = nil,
|
||||||
inventory: String? = nil
|
inventory: String? = nil,
|
||||||
|
version: String? = nil
|
||||||
) {
|
) {
|
||||||
self.directory = directory
|
self.directory = directory
|
||||||
self.inventory = inventory
|
self.inventory = inventory
|
||||||
|
self.version = version
|
||||||
}
|
}
|
||||||
|
|
||||||
public static var mock: Self { .init() }
|
public static var mock: Self { .init() }
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ useVaultArgs = true
|
|||||||
#[playbook]
|
#[playbook]
|
||||||
#directory = '/path/to/local/playbook-directory'
|
#directory = '/path/to/local/playbook-directory'
|
||||||
#inventory = '/path/to/local/inventory.ini'
|
#inventory = '/path/to/local/inventory.ini'
|
||||||
|
#version = 'main'
|
||||||
|
|
||||||
# These are to declare where your template files are either on your local system or
|
# These are to declare where your template files are either on your local system or
|
||||||
# a remote git repository.
|
# a remote git repository.
|
||||||
|
|||||||
9
Sources/PlaybookClient/Constants.swift
Normal file
9
Sources/PlaybookClient/Constants.swift
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// TODO: Use an actuall version tag for playbook repo.
|
||||||
|
public extension PlaybookClient {
|
||||||
|
@_spi(Internal)
|
||||||
|
enum Constants {
|
||||||
|
public static let defaultInstallationPath = "~/.local/share/hpa/playbook"
|
||||||
|
public static let playbookRepoUrl = "https://git.housh.dev/michael/ansible-hpa-playbook.git"
|
||||||
|
public static let playbookRepoVersion = "main"
|
||||||
|
}
|
||||||
|
}
|
||||||
72
Sources/PlaybookClient/PlaybookClient.swift
Normal file
72
Sources/PlaybookClient/PlaybookClient.swift
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import ConfigurationClient
|
||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import FileClient
|
||||||
|
import Foundation
|
||||||
|
import ShellClient
|
||||||
|
|
||||||
|
// TODO: Add update checks and pull for the playbook.
|
||||||
|
|
||||||
|
public extension DependencyValues {
|
||||||
|
var playbookClient: PlaybookClient {
|
||||||
|
get { self[PlaybookClient.self] }
|
||||||
|
set { self[PlaybookClient.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
public struct PlaybookClient: Sendable {
|
||||||
|
|
||||||
|
public var installPlaybook: @Sendable (Configuration) async throws -> Void
|
||||||
|
public var playbookDirectory: @Sendable (Configuration) async throws -> String
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PlaybookClient: DependencyKey {
|
||||||
|
public static let testValue: PlaybookClient = Self()
|
||||||
|
|
||||||
|
public static var liveValue: PlaybookClient {
|
||||||
|
.init {
|
||||||
|
try await install(config: $0.playbook)
|
||||||
|
} playbookDirectory: {
|
||||||
|
$0.playbook?.directory ?? Constants.defaultInstallationPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func install(config: Configuration.Playbook?) async throws {
|
||||||
|
@Dependency(\.fileClient) var fileClient
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
@Dependency(\.asyncShellClient) var shell
|
||||||
|
|
||||||
|
let path = config?.directory ?? PlaybookClient.Constants.defaultInstallationPath
|
||||||
|
let version = config?.version ?? PlaybookClient.Constants.playbookRepoVersion
|
||||||
|
|
||||||
|
let parentDirectory = URL(filePath: path)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
|
||||||
|
let exists = try await fileClient.isDirectory(parentDirectory)
|
||||||
|
if !exists {
|
||||||
|
try await fileClient.createDirectory(parentDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
let playbookExists = try await fileClient.isDirectory(URL(filePath: path))
|
||||||
|
|
||||||
|
if !playbookExists {
|
||||||
|
try await shell.foreground(.init([
|
||||||
|
"git", "clone",
|
||||||
|
"--branch", version,
|
||||||
|
PlaybookClient.Constants.playbookRepoUrl, path
|
||||||
|
]))
|
||||||
|
} else {
|
||||||
|
logger.debug("Playbook exists, ensuring it's up to date.")
|
||||||
|
try await shell.foreground(.init(
|
||||||
|
in: path,
|
||||||
|
["git", "pull", "--tags"]
|
||||||
|
))
|
||||||
|
try await shell.foreground(.init(
|
||||||
|
in: path,
|
||||||
|
["git", "checkout", version]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
@_exported import Dependencies
|
@_exported import Dependencies
|
||||||
|
import Foundation
|
||||||
|
import Logging
|
||||||
@_exported import ShellClient
|
@_exported import ShellClient
|
||||||
|
|
||||||
public protocol TestCase {}
|
public protocol TestCase {}
|
||||||
|
|
||||||
public extension TestCase {
|
public extension TestCase {
|
||||||
|
// static var logLevel: Logger.Level = {
|
||||||
|
// let levelString = ProcessInfo.processInfo.environment["LOG_LEVEL"] ?? "debug"
|
||||||
|
// return Logger.Level(rawValue: levelString) ?? .debug
|
||||||
|
// }()
|
||||||
|
|
||||||
func withTestLogger(
|
func withTestLogger(
|
||||||
key: String,
|
key: String,
|
||||||
logLevel: Logger.Level = .debug,
|
logLevel: Logger.Level = .debug,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ struct InstallDependenciesCommand: AsyncParsableCommand {
|
|||||||
@Argument(
|
@Argument(
|
||||||
help: "Extra arguments / options to pass to the homebrew command."
|
help: "Extra arguments / options to pass to the homebrew command."
|
||||||
)
|
)
|
||||||
var extraOptions: [String]
|
var extraOptions: [String] = []
|
||||||
|
|
||||||
mutating func run() async throws {
|
mutating func run() async throws {
|
||||||
@Dependency(\.cliClient) var cliClient
|
@Dependency(\.cliClient) var cliClient
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import ConfigurationClient
|
|||||||
import Dependencies
|
import Dependencies
|
||||||
import FileClient
|
import FileClient
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import PlaybookClient
|
||||||
import ShellClient
|
import ShellClient
|
||||||
import Testing
|
import Testing
|
||||||
import TestSupport
|
import TestSupport
|
||||||
@@ -104,19 +105,19 @@ struct CliClientTests: TestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
// @Test
|
||||||
func ensuredPlaybookDirectory() throws {
|
// func ensuredPlaybookDirectory() throws {
|
||||||
let configuration = Configuration.mock
|
// let configuration = Configuration.mock
|
||||||
let playbookDir = try configuration.ensuredPlaybookDirectory("playbook")
|
// let playbookDir = try configuration.ensuredPlaybookDirectory("playbook")
|
||||||
#expect(playbookDir == "playbook")
|
// #expect(playbookDir == "playbook")
|
||||||
|
//
|
||||||
do {
|
// do {
|
||||||
_ = try configuration.ensuredPlaybookDirectory(nil)
|
// _ = try configuration.ensuredPlaybookDirectory(nil)
|
||||||
#expect(Bool(false))
|
// #expect(Bool(false))
|
||||||
} catch {
|
// } catch {
|
||||||
#expect(Bool(true))
|
// #expect(Bool(true))
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func shellOrDefault() {
|
func shellOrDefault() {
|
||||||
@@ -295,6 +296,7 @@ struct CliClientTests: TestCase {
|
|||||||
try await withTestLogger(key: key, logLevel: logLevel) {
|
try await withTestLogger(key: key, logLevel: logLevel) {
|
||||||
$0.configurationClient = .mock(configuration)
|
$0.configurationClient = .mock(configuration)
|
||||||
$0.cliClient = .capturing(capturing)
|
$0.cliClient = .capturing(capturing)
|
||||||
|
$0.playbookClient = .liveValue
|
||||||
setupDependencies(&$0)
|
setupDependencies(&$0)
|
||||||
} operation: {
|
} operation: {
|
||||||
try await operation()
|
try await operation()
|
||||||
|
|||||||
42
Tests/PlaybookClientTests/PlaybookClientTests.swift
Normal file
42
Tests/PlaybookClientTests/PlaybookClientTests.swift
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import ConfigurationClient
|
||||||
|
import Dependencies
|
||||||
|
import FileClient
|
||||||
|
import Foundation
|
||||||
|
@_spi(Internal) import PlaybookClient
|
||||||
|
import ShellClient
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@Suite("PlaybookClientTests")
|
||||||
|
struct PlaybookClientTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func installation() async throws {
|
||||||
|
try await withDependencies {
|
||||||
|
$0.fileClient = .liveValue
|
||||||
|
$0.asyncShellClient = .liveValue
|
||||||
|
} operation: {
|
||||||
|
let tempDirectory = FileManager.default.temporaryDirectory
|
||||||
|
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.installPlaybook(configuration)
|
||||||
|
let exists = FileManager.default.fileExists(atPath: pathUrl.cleanFilePath)
|
||||||
|
#expect(exists)
|
||||||
|
|
||||||
|
try FileManager.default.removeItem(at: pathUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: [
|
||||||
|
(Configuration(), PlaybookClient.Constants.defaultInstallationPath),
|
||||||
|
(Configuration(playbook: .init(directory: "playbook")), "playbook")
|
||||||
|
])
|
||||||
|
func playbookDirectory(configuration: Configuration, expected: String) async throws {
|
||||||
|
let client = PlaybookClient.liveValue
|
||||||
|
let result = try await client.playbookDirectory(configuration)
|
||||||
|
#expect(result == expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user