@celilo/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1566 -0
- package/bin/celilo +16 -0
- package/drizzle/0000_complex_puma.sql +179 -0
- package/drizzle/0001_dizzy_wolfpack.sql +2 -0
- package/drizzle/0002_web_routes.sql +16 -0
- package/drizzle/0003_backup_storage.sql +32 -0
- package/drizzle/meta/0000_snapshot.json +1151 -0
- package/drizzle/meta/0001_snapshot.json +1167 -0
- package/drizzle/meta/0002_snapshot.json +1257 -0
- package/drizzle/meta/_journal.json +27 -0
- package/package.json +64 -0
- package/schemas/system_config.json +106 -0
- package/src/__integration__/container-services-cli.integration.test.ts +246 -0
- package/src/ansible/dependencies.test.ts +309 -0
- package/src/ansible/dependencies.ts +896 -0
- package/src/ansible/inventory.test.ts +463 -0
- package/src/ansible/inventory.ts +445 -0
- package/src/ansible/secrets.ts +222 -0
- package/src/ansible/validation.test.ts +92 -0
- package/src/ansible/validation.ts +272 -0
- package/src/api-clients/digitalocean.ts +94 -0
- package/src/api-clients/proxmox.ts +655 -0
- package/src/capabilities/logging-wrapper.test.ts +217 -0
- package/src/capabilities/lookup.test.ts +149 -0
- package/src/capabilities/lookup.ts +89 -0
- package/src/capabilities/public-web-helpers.test.ts +198 -0
- package/src/capabilities/public-web-publish.test.ts +458 -0
- package/src/capabilities/registration.test.ts +395 -0
- package/src/capabilities/registration.ts +200 -0
- package/src/capabilities/route-validation.test.ts +121 -0
- package/src/capabilities/route-validation.ts +96 -0
- package/src/capabilities/secret-ref.test.ts +313 -0
- package/src/capabilities/secret-validation.ts +157 -0
- package/src/capabilities/secrets.test.ts +750 -0
- package/src/capabilities/secrets.ts +244 -0
- package/src/capabilities/validation.test.ts +613 -0
- package/src/capabilities/validation.ts +160 -0
- package/src/capabilities/well-known.test.ts +238 -0
- package/src/capabilities/well-known.ts +222 -0
- package/src/cli/cli.test.ts +654 -0
- package/src/cli/command-registry.ts +742 -0
- package/src/cli/command-tree-parser.test.ts +180 -0
- package/src/cli/command-tree-parser.ts +193 -0
- package/src/cli/commands/backup-create.ts +137 -0
- package/src/cli/commands/backup-delete.ts +74 -0
- package/src/cli/commands/backup-import.ts +97 -0
- package/src/cli/commands/backup-list.ts +132 -0
- package/src/cli/commands/backup-name.ts +73 -0
- package/src/cli/commands/backup-prune.ts +98 -0
- package/src/cli/commands/backup-restore.ts +122 -0
- package/src/cli/commands/capability-info.ts +121 -0
- package/src/cli/commands/capability-list.ts +47 -0
- package/src/cli/commands/completion.ts +87 -0
- package/src/cli/commands/hook-run.ts +176 -0
- package/src/cli/commands/ipam.ts +607 -0
- package/src/cli/commands/machine-add.ts +235 -0
- package/src/cli/commands/machine-earmark.ts +82 -0
- package/src/cli/commands/machine-list.ts +77 -0
- package/src/cli/commands/machine-remove.ts +90 -0
- package/src/cli/commands/machine-status.ts +131 -0
- package/src/cli/commands/module-audit.ts +51 -0
- package/src/cli/commands/module-build.ts +60 -0
- package/src/cli/commands/module-config.ts +170 -0
- package/src/cli/commands/module-deploy.ts +71 -0
- package/src/cli/commands/module-generate.ts +236 -0
- package/src/cli/commands/module-health.ts +108 -0
- package/src/cli/commands/module-import.ts +80 -0
- package/src/cli/commands/module-list.ts +43 -0
- package/src/cli/commands/module-logs.ts +73 -0
- package/src/cli/commands/module-remove.ts +162 -0
- package/src/cli/commands/module-show.ts +208 -0
- package/src/cli/commands/module-status.ts +131 -0
- package/src/cli/commands/module-types.ts +189 -0
- package/src/cli/commands/module-upgrade.ts +192 -0
- package/src/cli/commands/package.ts +68 -0
- package/src/cli/commands/secret-list.ts +99 -0
- package/src/cli/commands/secret-set.ts +134 -0
- package/src/cli/commands/service-add-digitalocean.ts +133 -0
- package/src/cli/commands/service-add-proxmox.ts +342 -0
- package/src/cli/commands/service-config-get.ts +83 -0
- package/src/cli/commands/service-config-set.ts +145 -0
- package/src/cli/commands/service-list.ts +74 -0
- package/src/cli/commands/service-reconfigure.ts +230 -0
- package/src/cli/commands/service-remove.ts +103 -0
- package/src/cli/commands/service-verify.ts +240 -0
- package/src/cli/commands/status.ts +216 -0
- package/src/cli/commands/storage-add-local.ts +106 -0
- package/src/cli/commands/storage-add-s3.ts +114 -0
- package/src/cli/commands/storage-list.ts +72 -0
- package/src/cli/commands/storage-remove.ts +54 -0
- package/src/cli/commands/storage-set-default.ts +44 -0
- package/src/cli/commands/storage-verify.ts +54 -0
- package/src/cli/commands/system-config.ts +168 -0
- package/src/cli/commands/system-init.ts +314 -0
- package/src/cli/commands/system-secret-get.ts +98 -0
- package/src/cli/commands/system-secret-set.ts +76 -0
- package/src/cli/commands/system-vault-password.ts +34 -0
- package/src/cli/completion.test.ts +37 -0
- package/src/cli/completion.ts +482 -0
- package/src/cli/fuel-gauge.test.ts +208 -0
- package/src/cli/fuel-gauge.ts +405 -0
- package/src/cli/generate-zsh-completion.test.ts +95 -0
- package/src/cli/generate-zsh-completion.ts +497 -0
- package/src/cli/index.ts +1583 -0
- package/src/cli/interactive-config.test.ts +201 -0
- package/src/cli/interactive-config.ts +62 -0
- package/src/cli/parser.test.ts +227 -0
- package/src/cli/parser.ts +244 -0
- package/src/cli/prompts.test.ts +33 -0
- package/src/cli/prompts.ts +121 -0
- package/src/cli/types.ts +38 -0
- package/src/cli/validators.test.ts +235 -0
- package/src/cli/validators.ts +188 -0
- package/src/config/env.ts +41 -0
- package/src/config/paths.test.ts +172 -0
- package/src/config/paths.ts +108 -0
- package/src/db/client.ts +190 -0
- package/src/db/migrate.ts +30 -0
- package/src/db/schema.test.ts +221 -0
- package/src/db/schema.ts +434 -0
- package/src/hooks/capability-loader-firewall.test.ts +246 -0
- package/src/hooks/capability-loader.test.ts +100 -0
- package/src/hooks/capability-loader.ts +520 -0
- package/src/hooks/define-hook.test.ts +488 -0
- package/src/hooks/executor.test.ts +462 -0
- package/src/hooks/executor.ts +469 -0
- package/src/hooks/logger.test.ts +54 -0
- package/src/hooks/logger.ts +95 -0
- package/src/hooks/test-fixtures/failing-hook.ts +13 -0
- package/src/hooks/test-fixtures/no-default-hook.ts +6 -0
- package/src/hooks/test-fixtures/success-hook.ts +20 -0
- package/src/hooks/test-fixtures/unbranded-hook.ts +11 -0
- package/src/hooks/test-fixtures/void-hook.ts +13 -0
- package/src/hooks/types.ts +89 -0
- package/src/infrastructure/property-extractor.test.ts +194 -0
- package/src/infrastructure/property-extractor.ts +151 -0
- package/src/ipam/allocator.test.ts +442 -0
- package/src/ipam/allocator.ts +369 -0
- package/src/ipam/auto-allocator.test.ts +247 -0
- package/src/ipam/auto-allocator.ts +270 -0
- package/src/ipam/subnet-parser.test.ts +107 -0
- package/src/ipam/subnet-parser.ts +136 -0
- package/src/manifest/contracts/index.ts +61 -0
- package/src/manifest/contracts/v1.ts +118 -0
- package/src/manifest/json-schema-roundtrip.test.ts +99 -0
- package/src/manifest/schema.ts +367 -0
- package/src/manifest/template-validator.test.ts +231 -0
- package/src/manifest/template-validator.ts +322 -0
- package/src/manifest/validate.test.ts +1180 -0
- package/src/manifest/validate.ts +415 -0
- package/src/module/import.test.ts +355 -0
- package/src/module/import.ts +676 -0
- package/src/module/packaging/audit.ts +169 -0
- package/src/module/packaging/build.ts +228 -0
- package/src/module/packaging/checksum.ts +41 -0
- package/src/module/packaging/extract.ts +234 -0
- package/src/module/packaging/signature.ts +47 -0
- package/src/secrets/encryption.test.ts +284 -0
- package/src/secrets/encryption.ts +162 -0
- package/src/secrets/generators.test.ts +112 -0
- package/src/secrets/generators.ts +127 -0
- package/src/secrets/master-key.test.ts +159 -0
- package/src/secrets/master-key.ts +114 -0
- package/src/secrets/storage.test.ts +115 -0
- package/src/secrets/storage.ts +106 -0
- package/src/secrets/vault.test.ts +35 -0
- package/src/secrets/vault.ts +42 -0
- package/src/services/backup-create.ts +532 -0
- package/src/services/backup-metadata.ts +198 -0
- package/src/services/backup-restore.ts +229 -0
- package/src/services/backup-retention.ts +84 -0
- package/src/services/backup-storage.ts +281 -0
- package/src/services/build-stream.test.ts +122 -0
- package/src/services/build-stream.ts +201 -0
- package/src/services/config-interview.ts +694 -0
- package/src/services/container-service.test.ts +298 -0
- package/src/services/container-service.ts +401 -0
- package/src/services/cross-module-data-manager.test.ts +405 -0
- package/src/services/cross-module-data-manager.ts +412 -0
- package/src/services/deploy-ansible.ts +88 -0
- package/src/services/deploy-planner.ts +153 -0
- package/src/services/deploy-preflight.ts +274 -0
- package/src/services/deploy-ssh.ts +131 -0
- package/src/services/deploy-terraform.test.ts +55 -0
- package/src/services/deploy-terraform.ts +445 -0
- package/src/services/deploy-validation.ts +311 -0
- package/src/services/dns-auto-register.ts +211 -0
- package/src/services/health-runner.ts +184 -0
- package/src/services/infrastructure-selector.test.ts +485 -0
- package/src/services/infrastructure-selector.ts +245 -0
- package/src/services/infrastructure-variable-resolver.test.ts +751 -0
- package/src/services/infrastructure-variable-resolver.ts +234 -0
- package/src/services/machine-detector.ts +328 -0
- package/src/services/machine-pool.test.ts +405 -0
- package/src/services/machine-pool.ts +316 -0
- package/src/services/manifest-validation.ts +120 -0
- package/src/services/module-build.test.ts +290 -0
- package/src/services/module-build.ts +431 -0
- package/src/services/module-config.test.ts +237 -0
- package/src/services/module-config.ts +298 -0
- package/src/services/module-deploy.ts +862 -0
- package/src/services/module-types-drift.test.ts +73 -0
- package/src/services/module-types-generator.test.ts +288 -0
- package/src/services/module-types-generator.ts +189 -0
- package/src/services/proxmox-state-recovery.ts +140 -0
- package/src/services/schema-validation.ts +155 -0
- package/src/services/secret-schema-loader.test.ts +311 -0
- package/src/services/secret-schema-loader.ts +239 -0
- package/src/services/ssh-key-manager.test.ts +283 -0
- package/src/services/ssh-key-manager.ts +193 -0
- package/src/services/storage-providers/local.ts +105 -0
- package/src/services/storage-providers/s3.ts +182 -0
- package/src/services/storage-providers/types.ts +24 -0
- package/src/services/system-config-schema-types.ts +25 -0
- package/src/services/system-config-validator.test.ts +160 -0
- package/src/services/system-config-validator.ts +74 -0
- package/src/services/system-init.test.ts +153 -0
- package/src/services/system-init.ts +253 -0
- package/src/services/terraform-safety.ts +174 -0
- package/src/services/zone-detector.test.ts +110 -0
- package/src/services/zone-detector.ts +102 -0
- package/src/services/zone-policy.test.ts +97 -0
- package/src/services/zone-policy.ts +126 -0
- package/src/templates/generator.test.ts +645 -0
- package/src/templates/generator.ts +1119 -0
- package/src/templates/types.ts +62 -0
- package/src/test-utils/INTERACTIVE_PROMPTS.md +167 -0
- package/src/test-utils/cli-context-interactive.test.ts +152 -0
- package/src/test-utils/cli-context-server.test.ts +66 -0
- package/src/test-utils/cli-context.test.ts +273 -0
- package/src/test-utils/cli-context.ts +677 -0
- package/src/test-utils/cli-result.test.ts +282 -0
- package/src/test-utils/cli-result.ts +241 -0
- package/src/test-utils/cli.ts +55 -0
- package/src/test-utils/completion-harness.test.ts +126 -0
- package/src/test-utils/completion-harness.ts +82 -0
- package/src/test-utils/database.test.ts +182 -0
- package/src/test-utils/database.ts +126 -0
- package/src/test-utils/filesystem.test.ts +208 -0
- package/src/test-utils/filesystem.ts +142 -0
- package/src/test-utils/fixtures.test.ts +123 -0
- package/src/test-utils/fixtures.ts +160 -0
- package/src/test-utils/golden-diff.ts +197 -0
- package/src/test-utils/index.ts +77 -0
- package/src/test-utils/integration.ts +81 -0
- package/src/test-utils/module-fixtures.ts +468 -0
- package/src/test-utils/modules.test.ts +144 -0
- package/src/test-utils/modules.ts +183 -0
- package/src/test-utils/setup-test-db.ts +90 -0
- package/src/test-utils/value-extractor.test.ts +231 -0
- package/src/test-utils/value-extractor.ts +228 -0
- package/src/types/infrastructure.ts +157 -0
- package/src/utils/shell.test.ts +365 -0
- package/src/utils/shell.ts +159 -0
- package/src/validation/schemas.ts +166 -0
- package/src/variables/ansible-resolver.test.ts +142 -0
- package/src/variables/ansible-resolver.ts +69 -0
- package/src/variables/capability-self-ref.test.ts +220 -0
- package/src/variables/context.test.ts +1265 -0
- package/src/variables/context.ts +624 -0
- package/src/variables/declarative-derivation.test.ts +743 -0
- package/src/variables/declarative-derivation.ts +200 -0
- package/src/variables/parser.test.ts +231 -0
- package/src/variables/parser.ts +76 -0
- package/src/variables/resolver.test.ts +458 -0
- package/src/variables/resolver.ts +282 -0
- package/src/variables/types.ts +59 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { eq } from 'drizzle-orm';
|
|
4
|
+
import { stringify as stringifyYaml } from 'yaml';
|
|
5
|
+
import type { DbClient } from '../db/client';
|
|
6
|
+
import { machines, moduleConfigs, systemConfig } from '../db/schema';
|
|
7
|
+
import { getTempKeyPath } from '../services/ssh-key-manager';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Inventory generation result
|
|
11
|
+
*/
|
|
12
|
+
export interface InventoryResult {
|
|
13
|
+
success: boolean;
|
|
14
|
+
error?: string;
|
|
15
|
+
details?: unknown;
|
|
16
|
+
files?: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Inventory host definition
|
|
21
|
+
*/
|
|
22
|
+
export interface InventoryHost {
|
|
23
|
+
hostname: string;
|
|
24
|
+
ansibleHost: string;
|
|
25
|
+
ansibleUser: string;
|
|
26
|
+
groups: string[];
|
|
27
|
+
ansibleSshPrivateKeyFile?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Generate hosts.ini file in INI format
|
|
32
|
+
*
|
|
33
|
+
* Presentation function (Rule 10.1) - formats inventory structure
|
|
34
|
+
*
|
|
35
|
+
* @param hosts - Array of inventory host definitions
|
|
36
|
+
* @returns INI format string
|
|
37
|
+
*/
|
|
38
|
+
export function generateHostsIni(hosts: InventoryHost[]): string {
|
|
39
|
+
const lines: string[] = [];
|
|
40
|
+
|
|
41
|
+
// Group hosts by their groups
|
|
42
|
+
const groupMap = new Map<string, InventoryHost[]>();
|
|
43
|
+
|
|
44
|
+
for (const host of hosts) {
|
|
45
|
+
for (const group of host.groups) {
|
|
46
|
+
if (!groupMap.has(group)) {
|
|
47
|
+
groupMap.set(group, []);
|
|
48
|
+
}
|
|
49
|
+
groupMap.get(group)?.push(host);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Generate INI sections for each group
|
|
54
|
+
for (const [group, groupHosts] of groupMap) {
|
|
55
|
+
lines.push(`[${group}]`);
|
|
56
|
+
for (const host of groupHosts) {
|
|
57
|
+
let hostLine = `${host.hostname} ansible_host=${host.ansibleHost} ansible_user=${host.ansibleUser}`;
|
|
58
|
+
if (host.ansibleSshPrivateKeyFile) {
|
|
59
|
+
hostLine += ` ansible_ssh_private_key_file=${host.ansibleSshPrivateKeyFile}`;
|
|
60
|
+
}
|
|
61
|
+
lines.push(hostLine);
|
|
62
|
+
}
|
|
63
|
+
lines.push(''); // Blank line between groups
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Add [all:vars] section with common variables
|
|
67
|
+
lines.push('[all:vars]');
|
|
68
|
+
lines.push('ansible_python_interpreter=/usr/bin/python3');
|
|
69
|
+
lines.push('ansible_ssh_common_args=-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null');
|
|
70
|
+
lines.push('');
|
|
71
|
+
|
|
72
|
+
return lines.join('\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Generate host_vars YAML file
|
|
77
|
+
*
|
|
78
|
+
* Presentation function - formats host variables as YAML
|
|
79
|
+
*
|
|
80
|
+
* @param vars - Host variable object (supports primitives, arrays, nested objects)
|
|
81
|
+
* @param hostname - Optional hostname for comment
|
|
82
|
+
* @returns YAML format string
|
|
83
|
+
*/
|
|
84
|
+
export function generateHostVarsYaml(vars: Record<string, unknown>, hostname?: string): string {
|
|
85
|
+
const yamlContent = stringifyYaml(vars, {
|
|
86
|
+
indent: 2,
|
|
87
|
+
lineWidth: 0, // Don't wrap long lines
|
|
88
|
+
sortMapEntries: true, // Deterministic output for testing
|
|
89
|
+
});
|
|
90
|
+
const comment = hostname ? `# Host-specific variables for ${hostname}` : '';
|
|
91
|
+
return `---\n${comment ? `${comment}\n` : ''}${yamlContent}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Generate group_vars/all.yml with system configuration
|
|
96
|
+
*
|
|
97
|
+
* Presentation function - formats system config as YAML
|
|
98
|
+
*
|
|
99
|
+
* @param systemVars - System configuration object
|
|
100
|
+
* @returns YAML format string
|
|
101
|
+
*/
|
|
102
|
+
export function generateGroupVarsYaml(systemVars: Record<string, unknown>): string {
|
|
103
|
+
const yamlContent = stringifyYaml(systemVars, {
|
|
104
|
+
indent: 2,
|
|
105
|
+
lineWidth: 0,
|
|
106
|
+
sortMapEntries: true,
|
|
107
|
+
});
|
|
108
|
+
return `---\n# System-wide variables available to all hosts\n${yamlContent}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Parse module config value into appropriate type
|
|
113
|
+
*
|
|
114
|
+
* Policy function - normalizes config values
|
|
115
|
+
*
|
|
116
|
+
* @param value - Raw config value string
|
|
117
|
+
* @returns Parsed value (string, number, boolean, or JSON-parsed object/array)
|
|
118
|
+
*/
|
|
119
|
+
export function parseConfigValue(value: string): unknown {
|
|
120
|
+
// Try to parse as JSON (handles arrays, objects, numbers, booleans)
|
|
121
|
+
try {
|
|
122
|
+
return JSON.parse(value);
|
|
123
|
+
} catch {
|
|
124
|
+
// Not JSON, return as string
|
|
125
|
+
return value;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Sort object keys alphabetically (recursively for nested objects)
|
|
131
|
+
*
|
|
132
|
+
* Policy function - ensures deterministic output
|
|
133
|
+
*
|
|
134
|
+
* @param obj - Object to sort
|
|
135
|
+
* @returns New object with sorted keys
|
|
136
|
+
*/
|
|
137
|
+
function sortObjectKeys(obj: Record<string, unknown>): Record<string, unknown> {
|
|
138
|
+
const sorted: Record<string, unknown> = {};
|
|
139
|
+
const keys = Object.keys(obj).sort();
|
|
140
|
+
|
|
141
|
+
for (const key of keys) {
|
|
142
|
+
const value = obj[key];
|
|
143
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
144
|
+
sorted[key] = sortObjectKeys(value as Record<string, unknown>);
|
|
145
|
+
} else {
|
|
146
|
+
sorted[key] = value;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return sorted;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Build host variables from module configuration
|
|
155
|
+
*
|
|
156
|
+
* Planning function (Rule 10.1) - transforms DB config into host vars structure
|
|
157
|
+
*
|
|
158
|
+
* @param moduleId - Module identifier
|
|
159
|
+
* @param db - Database connection
|
|
160
|
+
* @returns Host variables object with sorted keys
|
|
161
|
+
*/
|
|
162
|
+
export function buildHostVars(moduleId: string, db: DbClient): Record<string, unknown> {
|
|
163
|
+
// Get all module config, ordered by key for deterministic output
|
|
164
|
+
const configs = db.select().from(moduleConfigs).where(eq(moduleConfigs.moduleId, moduleId)).all();
|
|
165
|
+
|
|
166
|
+
const vars: Record<string, unknown> = {};
|
|
167
|
+
|
|
168
|
+
for (const config of configs) {
|
|
169
|
+
// Skip inventory-specific keys (handled separately)
|
|
170
|
+
if (config.key.startsWith('inventory.')) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Parse value from correct column
|
|
175
|
+
let parsedValue: unknown;
|
|
176
|
+
if (config.valueJson) {
|
|
177
|
+
// Complex type stored in valueJson
|
|
178
|
+
try {
|
|
179
|
+
parsedValue = JSON.parse(config.valueJson);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`Failed to parse config value for ${config.key}: ${error instanceof Error ? error.message : 'Invalid JSON'}`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
// Primitive type stored in value
|
|
187
|
+
parsedValue = parseConfigValue(config.value);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Store with underscore naming
|
|
191
|
+
const key = config.key.replace(/\./g, '_');
|
|
192
|
+
vars[key] = parsedValue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Sort keys alphabetically for deterministic YAML output
|
|
196
|
+
return sortObjectKeys(vars);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Build system variables from system configuration
|
|
201
|
+
*
|
|
202
|
+
* Planning function - transforms DB system config into group vars
|
|
203
|
+
*
|
|
204
|
+
* @param db - Database connection
|
|
205
|
+
* @returns System variables object with sorted keys
|
|
206
|
+
*/
|
|
207
|
+
export function buildSystemVars(db: DbClient): Record<string, unknown> {
|
|
208
|
+
const configs = db.select().from(systemConfig).all();
|
|
209
|
+
|
|
210
|
+
const vars: Record<string, unknown> = {};
|
|
211
|
+
|
|
212
|
+
for (const config of configs) {
|
|
213
|
+
// Convert dot notation to underscore for Ansible variables
|
|
214
|
+
const key = config.key.replace(/\./g, '_');
|
|
215
|
+
vars[key] = parseConfigValue(config.value);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Sort keys alphabetically for deterministic YAML output
|
|
219
|
+
return sortObjectKeys(vars);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Extract inventory host definition from module config
|
|
224
|
+
*
|
|
225
|
+
* Planning function - extracts inventory metadata from config (auto-derived)
|
|
226
|
+
*
|
|
227
|
+
* Uses auto-derived inventory variables from context resolution:
|
|
228
|
+
* - inventory.hostname (from hostname)
|
|
229
|
+
* - inventory.ansible_host (from container_ip or vps_ip)
|
|
230
|
+
* - inventory.ansible_user (defaults to "root")
|
|
231
|
+
* - inventory.groups (defaults to module ID)
|
|
232
|
+
*
|
|
233
|
+
* @param moduleId - Module identifier
|
|
234
|
+
* @param db - Database connection
|
|
235
|
+
* @returns Inventory host definition or null if not configured
|
|
236
|
+
*/
|
|
237
|
+
export function extractInventoryHost(moduleId: string, db: DbClient): InventoryHost | null {
|
|
238
|
+
// Get all module config
|
|
239
|
+
const configs = db.select().from(moduleConfigs).where(eq(moduleConfigs.moduleId, moduleId)).all();
|
|
240
|
+
|
|
241
|
+
const moduleConfig: Record<string, string> = {};
|
|
242
|
+
for (const config of configs) {
|
|
243
|
+
// Parse value from correct column (same logic as buildHostVars)
|
|
244
|
+
if (config.valueJson) {
|
|
245
|
+
// Complex type stored in valueJson
|
|
246
|
+
moduleConfig[config.key] = config.valueJson;
|
|
247
|
+
} else {
|
|
248
|
+
// Primitive type stored in value
|
|
249
|
+
moduleConfig[config.key] = config.value;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Auto-derive inventory variables (same logic as context.ts)
|
|
254
|
+
const derived: Record<string, string> = {};
|
|
255
|
+
|
|
256
|
+
// Auto-derive hostname from hostname variable
|
|
257
|
+
if (moduleConfig.hostname) {
|
|
258
|
+
derived['inventory.hostname'] = moduleConfig.hostname;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Auto-derive ansible_host from infrastructure variables
|
|
262
|
+
// Priority: container_ip > ip.primary > vps_ip (for backward compatibility)
|
|
263
|
+
if (moduleConfig.container_ip) {
|
|
264
|
+
const slashIndex = moduleConfig.container_ip.indexOf('/');
|
|
265
|
+
derived['inventory.ansible_host'] =
|
|
266
|
+
slashIndex === -1
|
|
267
|
+
? moduleConfig.container_ip
|
|
268
|
+
: moduleConfig.container_ip.slice(0, slashIndex);
|
|
269
|
+
} else if (moduleConfig['ip.primary']) {
|
|
270
|
+
derived['inventory.ansible_host'] = moduleConfig['ip.primary'];
|
|
271
|
+
} else if (moduleConfig.vps_ip) {
|
|
272
|
+
derived['inventory.ansible_host'] = moduleConfig.vps_ip;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Auto-derive ansible_user (default: root)
|
|
276
|
+
derived['inventory.ansible_user'] = moduleConfig['inventory.ansible_user'] || 'root';
|
|
277
|
+
|
|
278
|
+
// Auto-derive groups from module ID
|
|
279
|
+
derived['inventory.groups'] = moduleConfig['inventory.groups'] || moduleId;
|
|
280
|
+
|
|
281
|
+
// Validate required fields
|
|
282
|
+
const hostname = derived['inventory.hostname'];
|
|
283
|
+
const ansibleHost = derived['inventory.ansible_host'];
|
|
284
|
+
const ansibleUser = derived['inventory.ansible_user'];
|
|
285
|
+
|
|
286
|
+
if (!hostname || !ansibleHost || !ansibleUser) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Parse groups (could be array already or comma-separated string)
|
|
291
|
+
let groups: string[] = [];
|
|
292
|
+
const groupsValue = derived['inventory.groups'];
|
|
293
|
+
if (groupsValue) {
|
|
294
|
+
try {
|
|
295
|
+
// Try parsing as JSON
|
|
296
|
+
groups = JSON.parse(groupsValue);
|
|
297
|
+
} catch {
|
|
298
|
+
// Not JSON, treat as single group
|
|
299
|
+
groups = [groupsValue];
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
hostname,
|
|
305
|
+
ansibleHost,
|
|
306
|
+
ansibleUser,
|
|
307
|
+
groups,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Infrastructure selection info (passed from generator)
|
|
313
|
+
*/
|
|
314
|
+
export interface InfrastructureInfo {
|
|
315
|
+
type: 'machine' | 'container_service';
|
|
316
|
+
machineId?: string;
|
|
317
|
+
serviceId?: string;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Generate Ansible inventory structure for a module
|
|
322
|
+
*
|
|
323
|
+
* Execution function (Rule 10.1) - performs file I/O
|
|
324
|
+
*
|
|
325
|
+
* Enhanced to support both container services and machine pool
|
|
326
|
+
* - Container services: use placeholder IP (will be updated after Terraform)
|
|
327
|
+
* - Machines: use machine IP, SSH user, and SSH key path from database
|
|
328
|
+
*
|
|
329
|
+
* @param moduleId - Module identifier
|
|
330
|
+
* @param outputPath - Base output path (e.g., /tmp/celilo/modules/homebridge/generated)
|
|
331
|
+
* @param db - Database connection
|
|
332
|
+
* @param infrastructure - Optional infrastructure selection info
|
|
333
|
+
* @returns Generation result with list of created files
|
|
334
|
+
*/
|
|
335
|
+
export async function generateInventory(
|
|
336
|
+
moduleId: string,
|
|
337
|
+
outputPath: string,
|
|
338
|
+
db: DbClient,
|
|
339
|
+
infrastructure?: InfrastructureInfo,
|
|
340
|
+
): Promise<InventoryResult> {
|
|
341
|
+
try {
|
|
342
|
+
const inventoryPath = join(outputPath, 'ansible/inventory');
|
|
343
|
+
await mkdir(inventoryPath, { recursive: true });
|
|
344
|
+
|
|
345
|
+
const createdFiles: string[] = [];
|
|
346
|
+
|
|
347
|
+
let host: InventoryHost | null = null;
|
|
348
|
+
|
|
349
|
+
if (infrastructure?.type === 'machine' && infrastructure.machineId) {
|
|
350
|
+
// Machine infrastructure: load machine from database
|
|
351
|
+
const machine = db
|
|
352
|
+
.select()
|
|
353
|
+
.from(machines)
|
|
354
|
+
.where(eq(machines.id, infrastructure.machineId))
|
|
355
|
+
.get();
|
|
356
|
+
|
|
357
|
+
if (!machine) {
|
|
358
|
+
return {
|
|
359
|
+
success: false,
|
|
360
|
+
error: `Machine not found: ${infrastructure.machineId}`,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Get hostname from module config (if available) or use machine hostname
|
|
365
|
+
const configs = db
|
|
366
|
+
.select()
|
|
367
|
+
.from(moduleConfigs)
|
|
368
|
+
.where(eq(moduleConfigs.moduleId, moduleId))
|
|
369
|
+
.all();
|
|
370
|
+
const moduleHostname = configs.find(
|
|
371
|
+
(c: typeof moduleConfigs.$inferSelect) => c.key === 'hostname',
|
|
372
|
+
)?.value;
|
|
373
|
+
|
|
374
|
+
// Build host definition from machine
|
|
375
|
+
host = {
|
|
376
|
+
hostname: moduleHostname || machine.hostname,
|
|
377
|
+
ansibleHost: machine.ipAddress,
|
|
378
|
+
ansibleUser: machine.sshUser,
|
|
379
|
+
groups: [moduleId], // Use module ID as group
|
|
380
|
+
ansibleSshPrivateKeyFile: getTempKeyPath(machine.id),
|
|
381
|
+
};
|
|
382
|
+
} else {
|
|
383
|
+
// Container service or no infrastructure: use module config
|
|
384
|
+
host = extractInventoryHost(moduleId, db);
|
|
385
|
+
if (!host) {
|
|
386
|
+
// No inventory configured - this is not an error, just skip
|
|
387
|
+
return {
|
|
388
|
+
success: true,
|
|
389
|
+
files: [],
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Generate hosts.ini
|
|
395
|
+
const hostsIni = generateHostsIni([host]);
|
|
396
|
+
await writeFile(join(inventoryPath, 'hosts.ini'), hostsIni, 'utf-8');
|
|
397
|
+
createdFiles.push('ansible/inventory/hosts.ini');
|
|
398
|
+
|
|
399
|
+
// Generate host_vars/<hostname>.yml
|
|
400
|
+
const hostVars = buildHostVars(moduleId, db);
|
|
401
|
+
|
|
402
|
+
// Inject machine architecture if deploying to a machine
|
|
403
|
+
if (infrastructure?.type === 'machine' && infrastructure.machineId) {
|
|
404
|
+
const machine = db
|
|
405
|
+
.select()
|
|
406
|
+
.from(machines)
|
|
407
|
+
.where(eq(machines.id, infrastructure.machineId))
|
|
408
|
+
.get();
|
|
409
|
+
if (machine?.hardware?.arch) {
|
|
410
|
+
hostVars.target_arch = machine.hardware.arch;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (Object.keys(hostVars).length > 0) {
|
|
415
|
+
const hostVarsPath = join(inventoryPath, 'host_vars');
|
|
416
|
+
await mkdir(hostVarsPath, { recursive: true });
|
|
417
|
+
|
|
418
|
+
const hostVarsYaml = generateHostVarsYaml(hostVars, host.hostname);
|
|
419
|
+
await writeFile(join(hostVarsPath, `${host.hostname}.yml`), hostVarsYaml, 'utf-8');
|
|
420
|
+
createdFiles.push(`ansible/inventory/host_vars/${host.hostname}.yml`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Generate group_vars/all.yml with system config
|
|
424
|
+
const systemVars = buildSystemVars(db);
|
|
425
|
+
if (Object.keys(systemVars).length > 0) {
|
|
426
|
+
const groupVarsPath = join(inventoryPath, 'group_vars');
|
|
427
|
+
await mkdir(groupVarsPath, { recursive: true });
|
|
428
|
+
|
|
429
|
+
const groupVarsYaml = generateGroupVarsYaml(systemVars);
|
|
430
|
+
await writeFile(join(groupVarsPath, 'all.yml'), groupVarsYaml, 'utf-8');
|
|
431
|
+
createdFiles.push('ansible/inventory/group_vars/all.yml');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
success: true,
|
|
436
|
+
files: createdFiles,
|
|
437
|
+
};
|
|
438
|
+
} catch (error) {
|
|
439
|
+
return {
|
|
440
|
+
success: false,
|
|
441
|
+
error: 'Failed to generate inventory',
|
|
442
|
+
details: error,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { eq } from 'drizzle-orm';
|
|
5
|
+
import { secrets } from '../db/schema';
|
|
6
|
+
import { decryptSecret } from '../secrets/encryption';
|
|
7
|
+
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
8
|
+
import { getVaultPassword } from '../secrets/vault';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Ansible secrets generation result
|
|
12
|
+
*/
|
|
13
|
+
export interface AnsibleSecretsResult {
|
|
14
|
+
success: boolean;
|
|
15
|
+
secretsYaml?: string;
|
|
16
|
+
encryptedPath?: string;
|
|
17
|
+
error?: string;
|
|
18
|
+
details?: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generate Ansible secrets YAML content
|
|
23
|
+
*
|
|
24
|
+
* Policy function (Rule 10.1) - pure formatting, no I/O
|
|
25
|
+
*
|
|
26
|
+
* @param secrets - Map of secret names to decrypted values
|
|
27
|
+
* @returns YAML content as string
|
|
28
|
+
*/
|
|
29
|
+
export function formatSecretsYaml(secrets: Record<string, string>): string {
|
|
30
|
+
const lines = ['---', '# Ansible Vault encrypted secrets', ''];
|
|
31
|
+
|
|
32
|
+
// Sort keys for deterministic output
|
|
33
|
+
const sortedKeys = Object.keys(secrets).sort();
|
|
34
|
+
|
|
35
|
+
for (const key of sortedKeys) {
|
|
36
|
+
const value = secrets[key];
|
|
37
|
+
// Escape YAML special characters
|
|
38
|
+
const escaped = value.includes("'") ? `"${value.replace(/"/g, '\\"')}"` : `'${value}'`;
|
|
39
|
+
lines.push(`${key}: ${escaped}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return `${lines.join('\n')}\n`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Decrypt all secrets for a module
|
|
47
|
+
*
|
|
48
|
+
* Execution function - performs database queries and decryption
|
|
49
|
+
*
|
|
50
|
+
* @param moduleId - Module ID
|
|
51
|
+
* @param db - Database client
|
|
52
|
+
* @returns Map of secret names to decrypted values
|
|
53
|
+
*/
|
|
54
|
+
export async function decryptModuleSecrets(
|
|
55
|
+
moduleId: string,
|
|
56
|
+
db: ReturnType<typeof import('../db/client').getDb>,
|
|
57
|
+
): Promise<Record<string, string>> {
|
|
58
|
+
// Get master key for decryption
|
|
59
|
+
const masterKey = await getOrCreateMasterKey();
|
|
60
|
+
|
|
61
|
+
// Fetch encrypted secrets from database
|
|
62
|
+
const secretRows = db.select().from(secrets).where(eq(secrets.moduleId, moduleId)).all();
|
|
63
|
+
|
|
64
|
+
const decryptedSecrets: Record<string, string> = {};
|
|
65
|
+
|
|
66
|
+
for (const row of secretRows) {
|
|
67
|
+
try {
|
|
68
|
+
const decrypted = decryptSecret(
|
|
69
|
+
{
|
|
70
|
+
encryptedValue: row.encryptedValue,
|
|
71
|
+
iv: row.iv,
|
|
72
|
+
authTag: row.authTag,
|
|
73
|
+
},
|
|
74
|
+
masterKey,
|
|
75
|
+
);
|
|
76
|
+
decryptedSecrets[row.name] = decrypted;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw new Error(`Failed to decrypt secret '${row.name}' for module '${moduleId}': ${error}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return decryptedSecrets;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Encrypt YAML content using ansible-vault
|
|
87
|
+
*
|
|
88
|
+
* Execution function - spawns external process
|
|
89
|
+
*
|
|
90
|
+
* Uses temporary files to avoid stdin/stdout complexity
|
|
91
|
+
*
|
|
92
|
+
* @param yamlContent - Plaintext YAML content
|
|
93
|
+
* @param vaultPassword - Vault password
|
|
94
|
+
* @returns Encrypted content or error
|
|
95
|
+
*/
|
|
96
|
+
export async function encryptWithAnsibleVault(
|
|
97
|
+
yamlContent: string,
|
|
98
|
+
vaultPassword: string,
|
|
99
|
+
): Promise<{ success: true; encrypted: string } | { success: false; error: string }> {
|
|
100
|
+
// Check if ansible-vault is available
|
|
101
|
+
const checkResult = Bun.spawnSync(['which', 'ansible-vault'], {
|
|
102
|
+
stdout: 'pipe',
|
|
103
|
+
stderr: 'pipe',
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (checkResult.exitCode !== 0) {
|
|
107
|
+
return {
|
|
108
|
+
success: false,
|
|
109
|
+
error:
|
|
110
|
+
'ansible-vault command not found. Please install Ansible: https://docs.ansible.com/ansible/latest/installation_guide/intro_installation.html',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Create temporary directory for vault operations
|
|
115
|
+
const tempDir = await mkdtemp(join(tmpdir(), 'celilo-vault-'));
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
// Write plaintext YAML to temp file
|
|
119
|
+
const plaintextPath = join(tempDir, 'plaintext.yml');
|
|
120
|
+
await writeFile(plaintextPath, yamlContent, 'utf-8');
|
|
121
|
+
|
|
122
|
+
// Write vault password to temp file
|
|
123
|
+
const passwordPath = join(tempDir, 'vault-pass');
|
|
124
|
+
await writeFile(passwordPath, vaultPassword, 'utf-8');
|
|
125
|
+
|
|
126
|
+
// Encrypt using ansible-vault
|
|
127
|
+
const encryptResult = Bun.spawnSync(
|
|
128
|
+
['ansible-vault', 'encrypt', '--vault-password-file', passwordPath, plaintextPath],
|
|
129
|
+
{
|
|
130
|
+
stdout: 'pipe',
|
|
131
|
+
stderr: 'pipe',
|
|
132
|
+
cwd: tempDir,
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
if (encryptResult.exitCode !== 0) {
|
|
137
|
+
const stderr = encryptResult.stderr
|
|
138
|
+
? new TextDecoder().decode(encryptResult.stderr)
|
|
139
|
+
: 'Unknown error';
|
|
140
|
+
return {
|
|
141
|
+
success: false,
|
|
142
|
+
error: `ansible-vault encryption failed: ${stderr}`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Read encrypted content
|
|
147
|
+
const encrypted = await readFile(plaintextPath, 'utf-8');
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
success: true,
|
|
151
|
+
encrypted,
|
|
152
|
+
};
|
|
153
|
+
} finally {
|
|
154
|
+
// Clean up temp directory
|
|
155
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Generate and encrypt Ansible secrets file
|
|
161
|
+
*
|
|
162
|
+
* Orchestration function - coordinates all steps
|
|
163
|
+
*
|
|
164
|
+
* @param moduleId - Module ID
|
|
165
|
+
* @param outputPath - Path to write encrypted secrets.yml
|
|
166
|
+
* @param db - Database client
|
|
167
|
+
* @returns Generation result
|
|
168
|
+
*/
|
|
169
|
+
export async function generateAnsibleSecrets(
|
|
170
|
+
moduleId: string,
|
|
171
|
+
outputPath: string,
|
|
172
|
+
db: ReturnType<typeof import('../db/client').getDb>,
|
|
173
|
+
): Promise<AnsibleSecretsResult> {
|
|
174
|
+
try {
|
|
175
|
+
// Step 1: Decrypt secrets from database
|
|
176
|
+
const decryptedSecrets = await decryptModuleSecrets(moduleId, db);
|
|
177
|
+
|
|
178
|
+
if (Object.keys(decryptedSecrets).length === 0) {
|
|
179
|
+
// No secrets to encrypt - skip file generation
|
|
180
|
+
return {
|
|
181
|
+
success: true,
|
|
182
|
+
secretsYaml: '',
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Step 2: Format as YAML
|
|
187
|
+
const yamlContent = formatSecretsYaml(decryptedSecrets);
|
|
188
|
+
|
|
189
|
+
// Step 3: Get vault password
|
|
190
|
+
const vaultPassword = await getVaultPassword();
|
|
191
|
+
|
|
192
|
+
// Step 4: Encrypt with ansible-vault
|
|
193
|
+
const encryptResult = await encryptWithAnsibleVault(yamlContent, vaultPassword);
|
|
194
|
+
|
|
195
|
+
if (!encryptResult.success) {
|
|
196
|
+
return {
|
|
197
|
+
success: false,
|
|
198
|
+
error: encryptResult.error,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Step 5: Write encrypted file
|
|
203
|
+
// Ensure directory exists
|
|
204
|
+
const outputDir = dirname(outputPath);
|
|
205
|
+
await mkdir(outputDir, { recursive: true });
|
|
206
|
+
await writeFile(outputPath, encryptResult.encrypted, 'utf-8');
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
success: true,
|
|
210
|
+
secretsYaml: yamlContent,
|
|
211
|
+
encryptedPath: outputPath,
|
|
212
|
+
};
|
|
213
|
+
} catch (error) {
|
|
214
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
215
|
+
const errorStack = error instanceof Error ? error.stack : undefined;
|
|
216
|
+
return {
|
|
217
|
+
success: false,
|
|
218
|
+
error: `Failed to generate Ansible secrets: ${errorMessage}`,
|
|
219
|
+
details: errorStack,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
}
|