@celilo/cli 0.1.4 → 0.1.6
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/drizzle/0004_caddy_hostname_list.sql +25 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +9 -2
- package/src/ansible/inventory.test.ts +9 -8
- package/src/ansible/inventory.ts +9 -7
- package/src/capabilities/public-web-helpers.test.ts +2 -2
- package/src/capabilities/public-web-publish.test.ts +45 -12
- package/src/capabilities/registration.test.ts +6 -6
- package/src/capabilities/well-known.test.ts +2 -2
- package/src/capabilities/well-known.ts +5 -5
- package/src/cli/cli.test.ts +2 -2
- package/src/cli/command-registry.ts +146 -3
- package/src/cli/command-tree-parser.test.ts +1 -1
- package/src/cli/command-tree-parser.ts +9 -8
- package/src/cli/commands/hook-run.ts +15 -66
- package/src/cli/commands/module-audit.ts +14 -44
- package/src/cli/commands/module-deploy.ts +4 -1
- package/src/cli/commands/module-import-registry.test.ts +115 -0
- package/src/cli/commands/module-import.ts +106 -22
- package/src/cli/commands/module-publish.test.ts +235 -0
- package/src/cli/commands/module-publish.ts +234 -0
- package/src/cli/commands/module-remove.ts +82 -2
- package/src/cli/commands/module-search.ts +57 -0
- package/src/cli/commands/module-secret-get.ts +59 -0
- package/src/cli/commands/module-show.ts +1 -1
- package/src/cli/commands/module-terraform-unlock.ts +57 -0
- package/src/cli/commands/module-verify.test.ts +59 -0
- package/src/cli/commands/module-verify.ts +53 -0
- package/src/cli/commands/status.ts +30 -20
- package/src/cli/commands/system-audit.test.ts +138 -0
- package/src/cli/commands/system-audit.ts +571 -0
- package/src/cli/commands/system-update.ts +391 -0
- package/src/cli/completion.ts +15 -1
- package/src/cli/fuel-gauge.ts +68 -3
- package/src/cli/generate-zsh-completion.ts +13 -3
- package/src/cli/index.ts +112 -5
- package/src/cli/parser.ts +11 -0
- package/src/cli/prompts.ts +36 -5
- package/src/cli/tui/audit-state.test.ts +246 -0
- package/src/cli/tui/audit-state.ts +525 -0
- package/src/cli/tui/audit-tui.test.tsx +135 -0
- package/src/cli/tui/audit-tui.tsx +624 -0
- package/src/cli/tui/celebration.tsx +29 -0
- package/src/cli/tui/clipboard.test.ts +94 -0
- package/src/cli/tui/clipboard.ts +101 -0
- package/src/cli/tui/icons.ts +22 -0
- package/src/cli/tui/keybar.tsx +65 -0
- package/src/cli/tui/keymap.test.ts +105 -0
- package/src/cli/tui/keymap.ts +70 -0
- package/src/cli/tui/modals/analyzing.tsx +75 -0
- package/src/cli/tui/modals/celebration.tsx +44 -0
- package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
- package/src/cli/tui/modals/remediate.tsx +44 -0
- package/src/cli/tui/modals.test.ts +137 -0
- package/src/cli/tui/mouse.test.ts +78 -0
- package/src/cli/tui/mouse.ts +114 -0
- package/src/cli/tui/panes/categories.tsx +62 -0
- package/src/cli/tui/panes/command-log.tsx +87 -0
- package/src/cli/tui/panes/detail.tsx +175 -0
- package/src/cli/tui/panes/findings.tsx +97 -0
- package/src/cli/tui/panes/summary.tsx +64 -0
- package/src/cli/tui/spawn.ts +130 -0
- package/src/cli/tui/theme.ts +42 -0
- package/src/cli/tui/wrap.test.ts +43 -0
- package/src/cli/tui/wrap.ts +45 -0
- package/src/cli/types.ts +5 -0
- package/src/db/client.ts +55 -2
- package/src/db/schema.test.ts +3 -3
- package/src/db/schema.ts +26 -17
- package/src/hooks/capability-loader.ts +135 -72
- package/src/hooks/define-hook.test.ts +11 -3
- package/src/hooks/executor.ts +22 -1
- package/src/hooks/load-hook-config.test.ts +165 -0
- package/src/hooks/load-hook-config.ts +60 -0
- package/src/hooks/logger.ts +42 -12
- package/src/hooks/run-named-hook.ts +128 -0
- package/src/hooks/types.ts +19 -0
- package/src/manifest/ensure-schema.test.ts +115 -0
- package/src/manifest/schema.ts +76 -0
- package/src/manifest/template-validator.test.ts +1 -1
- package/src/manifest/template-validator.ts +1 -1
- package/src/manifest/validate.test.ts +1 -1
- package/src/module/import.ts +20 -12
- package/src/module/packaging/build.ts +121 -25
- package/src/module/packaging/release-metadata.test.ts +103 -0
- package/src/module/packaging/release-metadata.ts +145 -0
- package/src/registry/client.test.ts +228 -0
- package/src/registry/client.ts +157 -0
- package/src/services/audit/backups.test.ts +233 -0
- package/src/services/audit/backups.ts +128 -0
- package/src/services/audit/capability-abi.test.ts +153 -0
- package/src/services/audit/capability-abi.ts +204 -0
- package/src/services/audit/cli-version.test.ts +60 -0
- package/src/services/audit/cli-version.ts +87 -0
- package/src/services/audit/health.test.ts +84 -0
- package/src/services/audit/health.ts +43 -0
- package/src/services/audit/index.test.ts +99 -0
- package/src/services/audit/index.ts +118 -0
- package/src/services/audit/machines-reachable.test.ts +87 -0
- package/src/services/audit/machines-reachable.ts +87 -0
- package/src/services/audit/module-configs.test.ts +131 -0
- package/src/services/audit/module-configs.ts +80 -0
- package/src/services/audit/module-versions.test.ts +99 -0
- package/src/services/audit/module-versions.ts +154 -0
- package/src/services/audit/schema.test.ts +68 -0
- package/src/services/audit/schema.ts +115 -0
- package/src/services/audit/secrets-decryptable.test.ts +82 -0
- package/src/services/audit/secrets-decryptable.ts +97 -0
- package/src/services/audit/services-credentials.test.ts +54 -0
- package/src/services/audit/services-credentials.ts +64 -0
- package/src/services/audit/services-reachable.test.ts +60 -0
- package/src/services/audit/services-reachable.ts +64 -0
- package/src/services/audit/terraform-plan.test.ts +127 -0
- package/src/services/audit/terraform-plan.ts +153 -0
- package/src/services/audit/types.test.ts +36 -0
- package/src/services/audit/types.ts +90 -0
- package/src/services/audit/unconfigured-modules.test.ts +48 -0
- package/src/services/audit/unconfigured-modules.ts +71 -0
- package/src/services/audit/undeployed-modules.test.ts +66 -0
- package/src/services/audit/undeployed-modules.ts +72 -0
- package/src/services/build-stream.ts +122 -122
- package/src/services/config-interview.ts +407 -2
- package/src/services/deploy-ansible.ts +73 -7
- package/src/services/deploy-planner.ts +5 -5
- package/src/services/deploy-preflight.ts +45 -4
- package/src/services/deploy-terraform.ts +31 -24
- package/src/services/deploy-validation.ts +167 -23
- package/src/services/dns-auto-register.ts +4 -4
- package/src/services/ensure-interview.test.ts +245 -0
- package/src/services/health-runner.ts +110 -38
- package/src/services/infrastructure-variable-resolver.test.ts +1 -1
- package/src/services/infrastructure-variable-resolver.ts +3 -3
- package/src/services/module-build.ts +11 -13
- package/src/services/module-deploy.ts +372 -61
- package/src/services/proxmox-state-recovery.ts +6 -6
- package/src/services/ssh-key-manager.test.ts +1 -1
- package/src/services/ssh-key-manager.ts +3 -2
- package/src/services/terraform-env.ts +62 -0
- package/src/services/update/dep-graph.test.ts +214 -0
- package/src/services/update/dep-graph.ts +215 -0
- package/src/services/update/orchestrator.test.ts +463 -0
- package/src/services/update/orchestrator.ts +359 -0
- package/src/services/update/progress.ts +49 -0
- package/src/services/update/self-update.test.ts +68 -0
- package/src/services/update/self-update.ts +57 -0
- package/src/services/update/types.ts +94 -0
- package/src/templates/generator.test.ts +3 -3
- package/src/templates/generator.ts +43 -2
- package/src/test-utils/completion-harness.test.ts +1 -1
- package/src/test-utils/completion-harness.ts +4 -4
- package/src/variables/capability-self-ref.test.ts +203 -0
- package/src/variables/context.test.ts +31 -31
- package/src/variables/context.ts +65 -17
- package/src/variables/declarative-derivation.test.ts +306 -0
- package/src/variables/declarative-derivation.ts +4 -2
- package/src/variables/parser.test.ts +64 -9
- package/src/variables/parser.ts +47 -6
- package/src/variables/resolver.test.ts +14 -14
- package/src/variables/resolver.ts +27 -9
- package/src/variables/types.ts +1 -1
- package/tsconfig.json +1 -0
|
@@ -8,8 +8,9 @@ import * as p from '@clack/prompts';
|
|
|
8
8
|
import { and, eq } from 'drizzle-orm';
|
|
9
9
|
import { log, promptPassword, promptText } from '../cli/prompts';
|
|
10
10
|
import type { DbClient } from '../db/client';
|
|
11
|
-
import { moduleConfigs, secrets } from '../db/schema';
|
|
12
|
-
import {
|
|
11
|
+
import { moduleConfigs, modules, secrets } from '../db/schema';
|
|
12
|
+
import type { Ensure } from '../manifest/schema';
|
|
13
|
+
import { decryptSecret, encryptSecret } from '../secrets/encryption';
|
|
13
14
|
import { deriveSecret, generateSecret } from '../secrets/generators';
|
|
14
15
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
15
16
|
import type { Machine } from '../types/infrastructure';
|
|
@@ -81,6 +82,80 @@ function resolveMachineZoneIp(machine: Machine, zone: string): string | null {
|
|
|
81
82
|
return iface?.ipAddress ?? null;
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Auto-derive $machine: variables from a machine without prompting.
|
|
87
|
+
* Called in both interactive and non-interactive deployments so machine-
|
|
88
|
+
* catalogued data (zones, zone IPs, etc.) is always applied automatically.
|
|
89
|
+
*
|
|
90
|
+
* @returns InterviewResult with the keys that were successfully derived
|
|
91
|
+
*/
|
|
92
|
+
export async function autoDeriveMachineConfig(
|
|
93
|
+
moduleId: string,
|
|
94
|
+
missingVariables: MissingVariable[],
|
|
95
|
+
db: DbClient,
|
|
96
|
+
machine: Machine,
|
|
97
|
+
): Promise<InterviewResult> {
|
|
98
|
+
const configured: string[] = [];
|
|
99
|
+
|
|
100
|
+
for (const variable of missingVariables) {
|
|
101
|
+
if (!variable.derive_from?.startsWith('$machine:')) continue;
|
|
102
|
+
|
|
103
|
+
const derived = resolveMachineDerivation(variable.derive_from, machine);
|
|
104
|
+
if (derived === null) continue;
|
|
105
|
+
|
|
106
|
+
log.info(`✓ ${variable.name} = ${derived} (auto-derived from ${machine.hostname})`);
|
|
107
|
+
|
|
108
|
+
await db
|
|
109
|
+
.insert(moduleConfigs)
|
|
110
|
+
.values({
|
|
111
|
+
moduleId,
|
|
112
|
+
key: variable.name,
|
|
113
|
+
value: derived,
|
|
114
|
+
valueJson: null,
|
|
115
|
+
createdAt: new Date(),
|
|
116
|
+
updatedAt: new Date(),
|
|
117
|
+
})
|
|
118
|
+
.onConflictDoUpdate({
|
|
119
|
+
target: [moduleConfigs.moduleId, moduleConfigs.key],
|
|
120
|
+
set: { value: derived, updatedAt: new Date() },
|
|
121
|
+
});
|
|
122
|
+
configured.push(variable.name);
|
|
123
|
+
|
|
124
|
+
// Handle per_selection follow-ups (e.g., zone_ip_* from zone list)
|
|
125
|
+
if (variable.options && variable.per_selection) {
|
|
126
|
+
for (const selectedVal of derived.split(',')) {
|
|
127
|
+
const followUpKey = variable.per_selection.key_pattern.replace('{value}', selectedVal);
|
|
128
|
+
let followUpValue: string | null = null;
|
|
129
|
+
|
|
130
|
+
if (variable.per_selection.derive_from === '$machine:zone_ip') {
|
|
131
|
+
followUpValue = resolveMachineZoneIp(machine, selectedVal);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (followUpValue !== null) {
|
|
135
|
+
log.info(`✓ ${followUpKey} = ${followUpValue} (auto-derived from ${machine.hostname})`);
|
|
136
|
+
await db
|
|
137
|
+
.insert(moduleConfigs)
|
|
138
|
+
.values({
|
|
139
|
+
moduleId,
|
|
140
|
+
key: followUpKey,
|
|
141
|
+
value: followUpValue,
|
|
142
|
+
valueJson: null,
|
|
143
|
+
createdAt: new Date(),
|
|
144
|
+
updatedAt: new Date(),
|
|
145
|
+
})
|
|
146
|
+
.onConflictDoUpdate({
|
|
147
|
+
target: [moduleConfigs.moduleId, moduleConfigs.key],
|
|
148
|
+
set: { value: followUpValue, updatedAt: new Date() },
|
|
149
|
+
});
|
|
150
|
+
configured.push(followUpKey);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { success: true, configured };
|
|
157
|
+
}
|
|
158
|
+
|
|
84
159
|
/**
|
|
85
160
|
* Interview user for missing required configuration
|
|
86
161
|
*
|
|
@@ -692,3 +767,333 @@ export async function interviewForMissingSecrets(
|
|
|
692
767
|
configured,
|
|
693
768
|
};
|
|
694
769
|
}
|
|
770
|
+
|
|
771
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
772
|
+
// Cross-module "ensure" interview
|
|
773
|
+
//
|
|
774
|
+
// When a hook's capability call detects that a value isn't covered by a
|
|
775
|
+
// provider module's config, the framework runs this interview against
|
|
776
|
+
// the provider's `ensures` block to extend its config in place. See
|
|
777
|
+
// `apps/celilo/designs/CROSS_MODULE_CONFIG_INTERVIEW.md`.
|
|
778
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
779
|
+
|
|
780
|
+
interface EnsureTargetParts {
|
|
781
|
+
/** Always one of "config" | "secret" — derived from `target:` prefix. */
|
|
782
|
+
scope: 'config' | 'secret';
|
|
783
|
+
/** Variable / secret name on the provider module. */
|
|
784
|
+
name: string;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function parseEnsureTarget(target: string): EnsureTargetParts {
|
|
788
|
+
const [scope, name] = target.split('.', 2);
|
|
789
|
+
if ((scope !== 'config' && scope !== 'secret') || !name) {
|
|
790
|
+
throw new Error(
|
|
791
|
+
`Invalid ensure target: "${target}" (expected "config.<name>" or "secret.<name>")`,
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
return { scope, name };
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function renderEnsureTemplate(template: string, value: string): string {
|
|
798
|
+
return template.replace(/\{\{\s*value\s*\}\}/g, value);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async function readModuleConfigKey(moduleId: string, key: string, db: DbClient): Promise<unknown> {
|
|
802
|
+
const row = db
|
|
803
|
+
.select()
|
|
804
|
+
.from(moduleConfigs)
|
|
805
|
+
.where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, key)))
|
|
806
|
+
.get();
|
|
807
|
+
if (!row) return undefined;
|
|
808
|
+
if (row.valueJson) {
|
|
809
|
+
try {
|
|
810
|
+
return JSON.parse(row.valueJson);
|
|
811
|
+
} catch {
|
|
812
|
+
return row.value;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return row.value;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
async function writeModuleConfigKey(
|
|
819
|
+
moduleId: string,
|
|
820
|
+
key: string,
|
|
821
|
+
value: unknown,
|
|
822
|
+
db: DbClient,
|
|
823
|
+
): Promise<void> {
|
|
824
|
+
const valueJson = JSON.stringify(value);
|
|
825
|
+
const stringValue = typeof value === 'string' ? value : valueJson;
|
|
826
|
+
await db
|
|
827
|
+
.insert(moduleConfigs)
|
|
828
|
+
.values({
|
|
829
|
+
moduleId,
|
|
830
|
+
key,
|
|
831
|
+
value: stringValue,
|
|
832
|
+
valueJson,
|
|
833
|
+
createdAt: new Date(),
|
|
834
|
+
updatedAt: new Date(),
|
|
835
|
+
})
|
|
836
|
+
.onConflictDoUpdate({
|
|
837
|
+
target: [moduleConfigs.moduleId, moduleConfigs.key],
|
|
838
|
+
set: { value: stringValue, valueJson, updatedAt: new Date() },
|
|
839
|
+
})
|
|
840
|
+
.run();
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
async function readModuleSecretKey(
|
|
844
|
+
moduleId: string,
|
|
845
|
+
name: string,
|
|
846
|
+
db: DbClient,
|
|
847
|
+
masterKey: Buffer,
|
|
848
|
+
): Promise<string | undefined> {
|
|
849
|
+
const row = db
|
|
850
|
+
.select()
|
|
851
|
+
.from(secrets)
|
|
852
|
+
.where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, name)))
|
|
853
|
+
.get();
|
|
854
|
+
if (!row) return undefined;
|
|
855
|
+
return decryptSecret(
|
|
856
|
+
{ encryptedValue: row.encryptedValue, iv: row.iv, authTag: row.authTag },
|
|
857
|
+
masterKey,
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
async function writeModuleSecretKey(
|
|
862
|
+
moduleId: string,
|
|
863
|
+
name: string,
|
|
864
|
+
plaintext: string,
|
|
865
|
+
db: DbClient,
|
|
866
|
+
masterKey: Buffer,
|
|
867
|
+
): Promise<void> {
|
|
868
|
+
const encrypted = encryptSecret(plaintext, masterKey);
|
|
869
|
+
// No unique index on (module_id, name) for secrets; manual upsert.
|
|
870
|
+
const existing = db
|
|
871
|
+
.select()
|
|
872
|
+
.from(secrets)
|
|
873
|
+
.where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, name)))
|
|
874
|
+
.get();
|
|
875
|
+
if (existing) {
|
|
876
|
+
await db
|
|
877
|
+
.update(secrets)
|
|
878
|
+
.set({
|
|
879
|
+
encryptedValue: encrypted.encryptedValue,
|
|
880
|
+
iv: encrypted.iv,
|
|
881
|
+
authTag: encrypted.authTag,
|
|
882
|
+
updatedAt: new Date(),
|
|
883
|
+
})
|
|
884
|
+
.where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, name)))
|
|
885
|
+
.run();
|
|
886
|
+
} else {
|
|
887
|
+
await db
|
|
888
|
+
.insert(secrets)
|
|
889
|
+
.values({
|
|
890
|
+
moduleId,
|
|
891
|
+
name,
|
|
892
|
+
encryptedValue: encrypted.encryptedValue,
|
|
893
|
+
iv: encrypted.iv,
|
|
894
|
+
authTag: encrypted.authTag,
|
|
895
|
+
})
|
|
896
|
+
.run();
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
export interface EnsureInterviewOptions {
|
|
901
|
+
/**
|
|
902
|
+
* In non-interactive mode the framework prints the recipe and aborts
|
|
903
|
+
* before reaching this function — but tests / scripted callers still
|
|
904
|
+
* use this flag to apply changes without prompting for any text input.
|
|
905
|
+
*/
|
|
906
|
+
noInteractive?: boolean;
|
|
907
|
+
/** Override prompts for testing — return string answers in order. */
|
|
908
|
+
promptOverride?: (prompt: string, hint?: string) => Promise<string>;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
export interface EnsureInterviewResult {
|
|
912
|
+
success: boolean;
|
|
913
|
+
/** True when every input was already satisfied (idempotent retry). */
|
|
914
|
+
alreadyApplied?: boolean;
|
|
915
|
+
error?: string;
|
|
916
|
+
/** Human-readable lines describing what changed (for the deploy log). */
|
|
917
|
+
applied: string[];
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Render the CLI recipe shown in `--no-interactive` mode. Generated
|
|
922
|
+
* from the manifest's `ensures` block so it stays correct as modules
|
|
923
|
+
* evolve.
|
|
924
|
+
*/
|
|
925
|
+
export function renderEnsureRecipe(
|
|
926
|
+
providerModuleId: string,
|
|
927
|
+
ensure: Ensure,
|
|
928
|
+
value: string,
|
|
929
|
+
): string {
|
|
930
|
+
const lines: string[] = [
|
|
931
|
+
`Provider module "${providerModuleId}" doesn't yet ensure "${ensure.id}" for "${value}".`,
|
|
932
|
+
'Resolve before re-running:',
|
|
933
|
+
'',
|
|
934
|
+
];
|
|
935
|
+
for (const input of ensure.inputs) {
|
|
936
|
+
const { scope, name } = parseEnsureTarget(input.target);
|
|
937
|
+
if (input.kind === 'append_to_array') {
|
|
938
|
+
lines.push(
|
|
939
|
+
` # append "${value}" to ${scope}.${name}:`,
|
|
940
|
+
` celilo module config get ${providerModuleId} ${name} # read existing array`,
|
|
941
|
+
` celilo module config set ${providerModuleId} ${name} '<existing + "${value}">'`,
|
|
942
|
+
);
|
|
943
|
+
} else {
|
|
944
|
+
const renderedKey = renderEnsureTemplate(input.key, value);
|
|
945
|
+
const renderedPrompt = renderEnsureTemplate(input.prompt, value);
|
|
946
|
+
const cmd = scope === 'secret' ? 'secret' : 'config';
|
|
947
|
+
lines.push(
|
|
948
|
+
` # ${renderedPrompt}:`,
|
|
949
|
+
` celilo module ${cmd} set ${providerModuleId} ${name} '<JSON object with "${renderedKey}":"<value>">'`,
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
lines.push('');
|
|
953
|
+
}
|
|
954
|
+
if (ensure.post === 'redeploy_self') {
|
|
955
|
+
lines.push(` celilo module deploy ${providerModuleId}`);
|
|
956
|
+
}
|
|
957
|
+
return lines.join('\n');
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* Apply one `ensures` block to the provider module's config + secrets.
|
|
962
|
+
*
|
|
963
|
+
* - `append_to_array`: idempotent — re-running with an already-present
|
|
964
|
+
* value is a no-op.
|
|
965
|
+
* - `set_in_object`: prompts only when the key isn't already set on the
|
|
966
|
+
* target object. Re-running with the same value is a no-op.
|
|
967
|
+
*
|
|
968
|
+
* Returns success even when every input was already satisfied — the
|
|
969
|
+
* caller (module-deploy) decides whether to retry the hook regardless.
|
|
970
|
+
*/
|
|
971
|
+
export async function interviewForEnsureInputs(
|
|
972
|
+
providerModuleId: string,
|
|
973
|
+
ensure: Ensure,
|
|
974
|
+
value: string,
|
|
975
|
+
db: DbClient,
|
|
976
|
+
options: EnsureInterviewOptions = {},
|
|
977
|
+
): Promise<EnsureInterviewResult> {
|
|
978
|
+
const applied: string[] = [];
|
|
979
|
+
let allNoop = true;
|
|
980
|
+
|
|
981
|
+
// No confirm prompt: running `module deploy <consumer>` is itself the
|
|
982
|
+
// user's consent for whatever cross-module config its hooks imply. A
|
|
983
|
+
// confirm here would be redundant — the user just typed the deploy
|
|
984
|
+
// command. Prompts that *gather information* (e.g. a DDNS password)
|
|
985
|
+
// still appear, because the framework genuinely doesn't know the
|
|
986
|
+
// value. See apps/celilo/designs/CADDY_HOSTNAME_LIST.md, Decision 6.
|
|
987
|
+
|
|
988
|
+
const masterKey = await getOrCreateMasterKey();
|
|
989
|
+
|
|
990
|
+
for (const input of ensure.inputs) {
|
|
991
|
+
const { scope, name } = parseEnsureTarget(input.target);
|
|
992
|
+
|
|
993
|
+
if (input.kind === 'append_to_array') {
|
|
994
|
+
if (scope !== 'config') {
|
|
995
|
+
return {
|
|
996
|
+
success: false,
|
|
997
|
+
applied,
|
|
998
|
+
error: `append_to_array only supports config targets (got ${input.target})`,
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
const current = await readModuleConfigKey(providerModuleId, name, db);
|
|
1002
|
+
const arr = Array.isArray(current) ? [...current] : [];
|
|
1003
|
+
if (arr.includes(value)) {
|
|
1004
|
+
applied.push(`${input.target} already contains "${value}" — skipped`);
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
arr.push(value);
|
|
1008
|
+
await writeModuleConfigKey(providerModuleId, name, arr, db);
|
|
1009
|
+
applied.push(`${input.target} ← appended "${value}"`);
|
|
1010
|
+
allNoop = false;
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// input.kind === 'set_in_object'
|
|
1015
|
+
const objectKey = renderEnsureTemplate(input.key, value);
|
|
1016
|
+
const promptMsg = renderEnsureTemplate(input.prompt, value);
|
|
1017
|
+
|
|
1018
|
+
if (scope === 'config') {
|
|
1019
|
+
const current = await readModuleConfigKey(providerModuleId, name, db);
|
|
1020
|
+
const obj =
|
|
1021
|
+
current && typeof current === 'object' && !Array.isArray(current)
|
|
1022
|
+
? { ...(current as Record<string, unknown>) }
|
|
1023
|
+
: {};
|
|
1024
|
+
if (objectKey in obj) {
|
|
1025
|
+
applied.push(`${input.target}["${objectKey}"] already set — skipped`);
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
const promptFn =
|
|
1029
|
+
options.promptOverride ??
|
|
1030
|
+
(async () => promptText({ message: promptMsg, placeholder: input.hint }));
|
|
1031
|
+
const userValue = await promptFn(promptMsg, input.hint);
|
|
1032
|
+
obj[objectKey] = userValue;
|
|
1033
|
+
await writeModuleConfigKey(providerModuleId, name, obj, db);
|
|
1034
|
+
applied.push(`${input.target}["${objectKey}"] set`);
|
|
1035
|
+
allNoop = false;
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Secret object: stored as a JSON-encoded string secret. Round-trip
|
|
1040
|
+
// through parse/serialize so other keys are preserved.
|
|
1041
|
+
const currentRaw = await readModuleSecretKey(providerModuleId, name, db, masterKey);
|
|
1042
|
+
let obj: Record<string, unknown> = {};
|
|
1043
|
+
if (currentRaw) {
|
|
1044
|
+
try {
|
|
1045
|
+
const parsed = JSON.parse(currentRaw);
|
|
1046
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
1047
|
+
obj = parsed as Record<string, unknown>;
|
|
1048
|
+
}
|
|
1049
|
+
} catch {
|
|
1050
|
+
// Existing secret isn't a JSON object — overwrite (the schema
|
|
1051
|
+
// declares this secret as an object, so any other shape is stale).
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
if (objectKey in obj) {
|
|
1055
|
+
applied.push(`${input.target}["${objectKey}"] already set — skipped`);
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
const promptFn =
|
|
1059
|
+
options.promptOverride ??
|
|
1060
|
+
// promptPassword doesn't take a placeholder, so fold the hint
|
|
1061
|
+
// into the message — the user only sees the message before typing.
|
|
1062
|
+
(async () =>
|
|
1063
|
+
promptPassword({
|
|
1064
|
+
message: input.hint ? `${promptMsg}\n ${input.hint}` : promptMsg,
|
|
1065
|
+
}));
|
|
1066
|
+
const userValue = await promptFn(promptMsg, input.hint);
|
|
1067
|
+
obj[objectKey] = userValue;
|
|
1068
|
+
await writeModuleSecretKey(providerModuleId, name, JSON.stringify(obj), db, masterKey);
|
|
1069
|
+
applied.push(`${input.target}["${objectKey}"] set`);
|
|
1070
|
+
allNoop = false;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
return { success: true, alreadyApplied: allNoop, applied };
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* Look up an `ensures` block on a provider module's manifest by id.
|
|
1078
|
+
* Returns null when the module doesn't exist or doesn't declare a
|
|
1079
|
+
* matching ensure — callers should treat that as a hard failure since
|
|
1080
|
+
* the consumer's capability call asked for something the provider
|
|
1081
|
+
* doesn't know how to satisfy.
|
|
1082
|
+
*/
|
|
1083
|
+
export function findEnsureOnProvider(
|
|
1084
|
+
providerModuleId: string,
|
|
1085
|
+
ensureId: string,
|
|
1086
|
+
db: DbClient,
|
|
1087
|
+
): Ensure | null {
|
|
1088
|
+
const row = db.select().from(modules).where(eq(modules.id, providerModuleId)).get();
|
|
1089
|
+
if (!row?.manifestData) return null;
|
|
1090
|
+
const manifest = row.manifestData as {
|
|
1091
|
+
provides?: { capabilities?: Array<{ ensures?: Ensure[] }> };
|
|
1092
|
+
};
|
|
1093
|
+
for (const cap of manifest.provides?.capabilities ?? []) {
|
|
1094
|
+
for (const ensure of cap.ensures ?? []) {
|
|
1095
|
+
if (ensure.id === ensureId) return ensure;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
@@ -18,6 +18,69 @@ export interface AnsibleResult {
|
|
|
18
18
|
error?: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Parse raw Ansible output lines into concise human-readable status.
|
|
23
|
+
* Returns null for lines that should be suppressed (decorative separators, etc.)
|
|
24
|
+
*/
|
|
25
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally stripping ANSI escape codes
|
|
26
|
+
const ANSI_ESCAPE = /\x1b\[[0-9;]*m/g;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Emit structured progress markers to stdout for each Ansible play/task.
|
|
30
|
+
* Uses console.log (same channel as log.info) so the e2e runner sees them
|
|
31
|
+
* in real-time. The runner matches [ansible:play] / [ansible:task] patterns.
|
|
32
|
+
*/
|
|
33
|
+
function emitAnsibleProgress(rawChunk: string): void {
|
|
34
|
+
// In non-TTY mode (e.g. inside a Docker exec) write to stdout, which is
|
|
35
|
+
// line-buffered by stdbuf. stderr may arrive batched or out-of-order through
|
|
36
|
+
// docker exec -T, so stdout gives the e2e runner reliable real-time delivery.
|
|
37
|
+
const out = process.stdout.isTTY ? process.stderr : process.stdout;
|
|
38
|
+
for (const line of rawChunk.split('\n')) {
|
|
39
|
+
const stripped = line.replace(ANSI_ESCAPE, '').trim();
|
|
40
|
+
if (/^PLAY \[/.test(stripped)) {
|
|
41
|
+
const name = stripped.replace(/^PLAY \[/, '').replace(/\].*$/, '');
|
|
42
|
+
out.write(`[ansible:play] ${name}\n`);
|
|
43
|
+
} else if (/^TASK \[/.test(stripped)) {
|
|
44
|
+
const name = stripped.replace(/^TASK \[/, '').replace(/\].*$/, '');
|
|
45
|
+
out.write(`[ansible:task] ${name}\n`);
|
|
46
|
+
} else if (/^RUNNING HANDLER \[/.test(stripped)) {
|
|
47
|
+
const name = stripped.replace(/^RUNNING HANDLER \[/, '').replace(/\].*$/, '');
|
|
48
|
+
out.write(`[ansible:handler] ${name}\n`);
|
|
49
|
+
} else if (/^\w[\w.-]+ *:/.test(stripped) && stripped.includes('ok=')) {
|
|
50
|
+
out.write(`[ansible:recap] ${stripped}\n`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseAnsibleLine(line: string): string | null {
|
|
56
|
+
const stripped = line.replace(ANSI_ESCAPE, '').trim();
|
|
57
|
+
|
|
58
|
+
if (/^PLAY \[/.test(stripped)) {
|
|
59
|
+
const name = stripped.replace(/^PLAY \[/, '').replace(/\].*$/, '');
|
|
60
|
+
return `▶ ${name}`;
|
|
61
|
+
}
|
|
62
|
+
if (/^TASK \[/.test(stripped)) {
|
|
63
|
+
const name = stripped.replace(/^TASK \[/, '').replace(/\].*$/, '');
|
|
64
|
+
return ` • ${name}`;
|
|
65
|
+
}
|
|
66
|
+
if (/^RUNNING HANDLER \[/.test(stripped)) {
|
|
67
|
+
const name = stripped.replace(/^RUNNING HANDLER \[/, '').replace(/\].*$/, '');
|
|
68
|
+
return ` ↺ handler: ${name}`;
|
|
69
|
+
}
|
|
70
|
+
if (/^ok:/.test(stripped)) return ' ok';
|
|
71
|
+
if (/^changed:/.test(stripped)) return ' changed';
|
|
72
|
+
if (/^skipping:/.test(stripped)) return ' skipped';
|
|
73
|
+
if (/^failed:/.test(stripped)) return ' FAILED';
|
|
74
|
+
if (/^fatal:/.test(stripped)) return ` FATAL: ${stripped.slice(7)}`;
|
|
75
|
+
if (/^PLAY RECAP/.test(stripped)) return ' recap:';
|
|
76
|
+
if (/^\w[\w.-]+ *:/.test(stripped) && stripped.includes('ok=')) return ` ${stripped}`;
|
|
77
|
+
|
|
78
|
+
// Suppress decorative separator lines (all * or = chars)
|
|
79
|
+
if (/^[*=\s]+$/.test(stripped)) return null;
|
|
80
|
+
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
21
84
|
/**
|
|
22
85
|
* Execute Ansible playbook with streaming progress
|
|
23
86
|
* Execution function - performs software deployment
|
|
@@ -33,12 +96,8 @@ export async function executeAnsible(
|
|
|
33
96
|
const inventoryPath = join(ansibleDir, 'inventory', 'hosts.ini');
|
|
34
97
|
const playbookPath = join(ansibleDir, 'playbook.yml');
|
|
35
98
|
|
|
36
|
-
// Get Ansible Vault password
|
|
37
99
|
const vaultPassword = await getVaultPassword();
|
|
38
100
|
|
|
39
|
-
// Write vault password to temp file (same approach as ansible/secrets.ts)
|
|
40
|
-
// Using /dev/stdin doesn't work reliably — Ansible tries to execute it as a
|
|
41
|
-
// script and hits permission errors on some platforms.
|
|
42
101
|
const tempDir = await mkdtemp(join(tmpdir(), 'celilo-vault-'));
|
|
43
102
|
const passwordPath = join(tempDir, 'vault-pass');
|
|
44
103
|
await writeFile(passwordPath, vaultPassword, { mode: 0o600 });
|
|
@@ -47,8 +106,6 @@ export async function executeAnsible(
|
|
|
47
106
|
log.info('Deploying software...');
|
|
48
107
|
|
|
49
108
|
try {
|
|
50
|
-
// Execute ansible-playbook with streaming progress
|
|
51
|
-
// Note: Paths escaped for shell execution (macOS paths contain spaces)
|
|
52
109
|
const result = await executeBuildWithProgress({
|
|
53
110
|
command: 'ansible-playbook',
|
|
54
111
|
args: [
|
|
@@ -59,11 +116,20 @@ export async function executeAnsible(
|
|
|
59
116
|
shellEscape(playbookPath),
|
|
60
117
|
],
|
|
61
118
|
cwd: ansibleDir,
|
|
119
|
+
// PYTHONUNBUFFERED=1: forces Python (Ansible) to flush stdout immediately
|
|
120
|
+
// instead of buffering when writing to a pipe — makes progress stream in
|
|
121
|
+
// real-time rather than appearing in one burst at the end.
|
|
122
|
+
// ANSIBLE_SSH_PIPELINING: enables SSH pipelining for performance without ControlMaster
|
|
123
|
+
env: {
|
|
124
|
+
PYTHONUNBUFFERED: '1',
|
|
125
|
+
ANSIBLE_SSH_PIPELINING: 'True',
|
|
126
|
+
},
|
|
62
127
|
title: 'Deploying software',
|
|
63
128
|
noInteractive: options?.noInteractive,
|
|
129
|
+
filterOutput: parseAnsibleLine,
|
|
130
|
+
onOutput: emitAnsibleProgress,
|
|
64
131
|
});
|
|
65
132
|
|
|
66
|
-
// Persist full output to log file for debugging
|
|
67
133
|
const timestamp = new Date().toISOString();
|
|
68
134
|
const logHeader = `\n--- Ansible deploy ${timestamp} ---\n`;
|
|
69
135
|
await appendFile(logPath, logHeader + result.output, 'utf-8');
|
|
@@ -126,15 +126,15 @@ export async function extractTargetHost(
|
|
|
126
126
|
const hostname = configMap.get('hostname') || moduleId;
|
|
127
127
|
|
|
128
128
|
// Extract IP - support infrastructure-derived variables
|
|
129
|
-
// Priority:
|
|
129
|
+
// Priority: target_ip > ip.primary > vps_ip (backward compatibility)
|
|
130
130
|
let ip = '';
|
|
131
|
-
const
|
|
131
|
+
const targetIp = configMap.get('target_ip');
|
|
132
132
|
const ipPrimary = configMap.get('ip.primary');
|
|
133
133
|
const vpsIp = configMap.get('vps_ip');
|
|
134
134
|
|
|
135
|
-
if (
|
|
136
|
-
//
|
|
137
|
-
ip =
|
|
135
|
+
if (targetIp) {
|
|
136
|
+
// Target IP format can be "10.0.10.10/24" - extract just IP
|
|
137
|
+
ip = targetIp.split('/')[0];
|
|
138
138
|
} else if (ipPrimary) {
|
|
139
139
|
// Infrastructure-derived IP (already plain format)
|
|
140
140
|
ip = ipPrimary;
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* - Modify any state
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import { compareConsumerToProvider } from '@celilo/capabilities';
|
|
24
25
|
import { eq } from 'drizzle-orm';
|
|
25
26
|
import type { DbClient } from '../db/client';
|
|
26
27
|
import { getDb } from '../db/client';
|
|
@@ -39,6 +40,7 @@ export interface PreflightError {
|
|
|
39
40
|
category:
|
|
40
41
|
| 'missing-config'
|
|
41
42
|
| 'missing-capability'
|
|
43
|
+
| 'capability-version-mismatch'
|
|
42
44
|
| 'unresolved-template'
|
|
43
45
|
| 'no-infrastructure'
|
|
44
46
|
| 'missing-secret';
|
|
@@ -84,21 +86,58 @@ export async function runPreflight(
|
|
|
84
86
|
|
|
85
87
|
const manifest = module.manifestData as ModuleManifest;
|
|
86
88
|
|
|
87
|
-
// 2. Required capabilities have providers
|
|
89
|
+
// 2. Required capabilities have providers, and at least one
|
|
90
|
+
// provider's version is compatible. Multi-provider scenarios
|
|
91
|
+
// (e.g., zone-scoped dns_registrar with separate internal +
|
|
92
|
+
// external providers) pass when ANY installed provider matches
|
|
93
|
+
// the consumer's required major — that's what the runtime
|
|
94
|
+
// zone-aware lookup actually picks. The audit catches the same
|
|
95
|
+
// drift after deploy; this surface refuses up front so the
|
|
96
|
+
// operator gets an actionable error instead of a silent
|
|
97
|
+
// template-generation against an ABI the provider can't fulfil.
|
|
88
98
|
if (manifest.requires?.capabilities) {
|
|
89
99
|
for (const cap of manifest.requires.capabilities) {
|
|
90
|
-
const
|
|
100
|
+
const providers = db
|
|
91
101
|
.select()
|
|
92
102
|
.from(capabilities)
|
|
93
103
|
.where(eq(capabilities.capabilityName, cap.name))
|
|
94
|
-
.
|
|
95
|
-
if (
|
|
104
|
+
.all();
|
|
105
|
+
if (providers.length === 0) {
|
|
96
106
|
errors.push({
|
|
97
107
|
category: 'missing-capability',
|
|
98
108
|
message: `Required capability '${cap.name}' has no provider installed`,
|
|
99
109
|
suggestion: `Deploy a module that provides '${cap.name}' first`,
|
|
100
110
|
});
|
|
111
|
+
continue;
|
|
101
112
|
}
|
|
113
|
+
let exampleMismatch: {
|
|
114
|
+
provider: (typeof providers)[number];
|
|
115
|
+
reason: string;
|
|
116
|
+
} | null = null;
|
|
117
|
+
let anyCompatible = false;
|
|
118
|
+
for (const p of providers) {
|
|
119
|
+
const result = compareConsumerToProvider(cap.version, p.version);
|
|
120
|
+
if (result.compatible) {
|
|
121
|
+
anyCompatible = true;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
if (!exampleMismatch) {
|
|
125
|
+
exampleMismatch = { provider: p, reason: result.reason };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (anyCompatible || !exampleMismatch) continue;
|
|
129
|
+
|
|
130
|
+
const { provider: example, reason } = exampleMismatch;
|
|
131
|
+
errors.push({
|
|
132
|
+
category: 'capability-version-mismatch',
|
|
133
|
+
message:
|
|
134
|
+
`Module '${moduleId}' requires ${cap.name}@${cap.version} but ` +
|
|
135
|
+
`provider '${example.moduleId}' offers ${cap.name}@${example.version}`,
|
|
136
|
+
suggestion:
|
|
137
|
+
reason === 'caller_minor_too_old'
|
|
138
|
+
? `Upgrade provider '${example.moduleId}' to a version that provides ${cap.name}@${cap.version} or newer`
|
|
139
|
+
: `The interface major version differs. Update '${moduleId}' to require ${cap.name}@${example.version} (matching the provider's major), or rebuild '${example.moduleId}' against ${cap.name}@${cap.version}.`,
|
|
140
|
+
});
|
|
102
141
|
}
|
|
103
142
|
}
|
|
104
143
|
|
|
@@ -264,6 +303,8 @@ function errorIcon(category: PreflightError['category']): string {
|
|
|
264
303
|
return '✗';
|
|
265
304
|
case 'missing-capability':
|
|
266
305
|
return '◯';
|
|
306
|
+
case 'capability-version-mismatch':
|
|
307
|
+
return '⚠';
|
|
267
308
|
case 'unresolved-template':
|
|
268
309
|
return '⟲';
|
|
269
310
|
case 'no-infrastructure':
|