@celilo/cli 0.1.9 → 0.2.1
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/package.json +3 -3
- package/src/capabilities/well-known.test.ts +1 -1
- package/src/capabilities/well-known.ts +12 -4
- package/src/cli/commands/module-upgrade.ts +75 -24
- package/src/cli/prompts.ts +1 -1
- package/src/hooks/define-hook.test.ts +12 -0
- package/src/hooks/executor.ts +9 -0
- package/src/hooks/logger.ts +38 -0
- package/src/hooks/types.ts +12 -1
- package/src/manifest/validate.test.ts +68 -0
- package/src/manifest/validate.ts +9 -3
- package/src/services/config-interview.ts +9 -9
- package/src/services/deploy-ansible.ts +1 -1
- package/src/services/health-runner.ts +1 -1
- package/src/services/module-deploy.ts +11 -22
- package/src/templates/generator.ts +3 -6
- package/src/variables/declarative-derivation.test.ts +93 -8
- package/src/variables/declarative-derivation.ts +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@aws-sdk/client-s3": "^3.1024.0",
|
|
49
|
-
"@celilo/capabilities": "^0.1.
|
|
50
|
-
"@celilo/cli-display": "0.1.4",
|
|
49
|
+
"@celilo/capabilities": "^0.1.9",
|
|
50
|
+
"@celilo/cli-display": "^0.1.4",
|
|
51
51
|
"@clack/prompts": "^1.1.0",
|
|
52
52
|
"ajv": "^8.18.0",
|
|
53
53
|
"drizzle-orm": "^0.36.4",
|
|
@@ -58,8 +58,16 @@ export const WELL_KNOWN_CAPABILITIES: Record<string, WellKnownCapability> = {
|
|
|
58
58
|
required_zone: 'dmz',
|
|
59
59
|
zone_enforced: false,
|
|
60
60
|
data_schema: {
|
|
61
|
+
// The capability contract no longer carries a `primary_domain`
|
|
62
|
+
// convenience field. Consumers that want a default index into
|
|
63
|
+
// `domains[]` themselves; consumers that need to commit to a
|
|
64
|
+
// specific domain (lunacycle, authentik, knot-unbound-internal,
|
|
65
|
+
// technitium, etc.) declare an explicit user-set `domain`
|
|
66
|
+
// variable in their own manifest. The implicit primary-domain
|
|
67
|
+
// hand-off too easily gave a module the household's first
|
|
68
|
+
// registered domain without anyone deciding to.
|
|
61
69
|
provider: 'namecheap',
|
|
62
|
-
|
|
70
|
+
domains: '$self:domains',
|
|
63
71
|
supports: ['dynamic_dns_a_record'],
|
|
64
72
|
},
|
|
65
73
|
},
|
|
@@ -91,9 +99,9 @@ export const WELL_KNOWN_CAPABILITIES: Record<string, WellKnownCapability> = {
|
|
|
91
99
|
* Note: `oidc.issuer_url` is no longer derived here. Per the firm rule in
|
|
92
100
|
* design/TECHNICAL_DESIGN_MANIFEST_V2.md D9, well-known capability data
|
|
93
101
|
* templates must not cross-reference other capabilities. The IDP-providing
|
|
94
|
-
* module
|
|
95
|
-
*
|
|
96
|
-
*
|
|
102
|
+
* module declares an explicit user-set `domain` field in its own manifest,
|
|
103
|
+
* derives `auth_url` from `$self:domain`, and exposes it through
|
|
104
|
+
* `provides.capabilities[].data`.
|
|
97
105
|
*/
|
|
98
106
|
auth: {
|
|
99
107
|
canonical_hostname: 'auth',
|
|
@@ -22,6 +22,16 @@ import { cleanupTempDir, extractPackage } from '../../module/packaging/extract';
|
|
|
22
22
|
import { log } from '../prompts';
|
|
23
23
|
import type { CommandResult } from '../types';
|
|
24
24
|
|
|
25
|
+
type UpgradeOutcome =
|
|
26
|
+
| { status: 'success'; moduleId: string }
|
|
27
|
+
| { status: 'failed'; moduleId: string; error: string }
|
|
28
|
+
// `skipped` means the path expanded from a glob but isn't an
|
|
29
|
+
// upgradable target — either no manifest at all (probably a non-
|
|
30
|
+
// module sibling like `modules/archive/`) or a real module that
|
|
31
|
+
// isn't installed in this celilo. Treated as a soft pass so
|
|
32
|
+
// `celilo module update modules/*` does what users expect.
|
|
33
|
+
| { status: 'skipped'; moduleId: string; reason: string };
|
|
34
|
+
|
|
25
35
|
/**
|
|
26
36
|
* Upgrade a single module from a source path
|
|
27
37
|
*/
|
|
@@ -29,11 +39,15 @@ async function upgradeOne(
|
|
|
29
39
|
sourcePath: string,
|
|
30
40
|
db: ReturnType<typeof getDb>,
|
|
31
41
|
flags: Record<string, string | boolean> = {},
|
|
32
|
-
): Promise<
|
|
42
|
+
): Promise<UpgradeOutcome> {
|
|
33
43
|
const originalCwd = process.env.CELILO_ORIGINAL_CWD || process.cwd();
|
|
34
44
|
const importPath = resolve(originalCwd, sourcePath);
|
|
35
45
|
if (!existsSync(importPath)) {
|
|
36
|
-
return {
|
|
46
|
+
return {
|
|
47
|
+
status: 'failed',
|
|
48
|
+
moduleId: sourcePath,
|
|
49
|
+
error: `Source path not found: ${importPath}`,
|
|
50
|
+
};
|
|
37
51
|
}
|
|
38
52
|
|
|
39
53
|
// Handle .netapp packages: extract to temp dir
|
|
@@ -44,8 +58,8 @@ async function upgradeOne(
|
|
|
44
58
|
const extractResult = await extractPackage(importPath);
|
|
45
59
|
if (!extractResult.success || !extractResult.tempDir) {
|
|
46
60
|
return {
|
|
61
|
+
status: 'failed',
|
|
47
62
|
moduleId: sourcePath,
|
|
48
|
-
success: false,
|
|
49
63
|
error: extractResult.error || 'Failed to extract package',
|
|
50
64
|
};
|
|
51
65
|
}
|
|
@@ -59,8 +73,8 @@ async function upgradeOne(
|
|
|
59
73
|
if (!verifyResult.success) {
|
|
60
74
|
await cleanupTempDir(tempDir);
|
|
61
75
|
return {
|
|
76
|
+
status: 'failed',
|
|
62
77
|
moduleId: sourcePath,
|
|
63
|
-
success: false,
|
|
64
78
|
error: verifyResult.error || 'Package verification failed',
|
|
65
79
|
};
|
|
66
80
|
}
|
|
@@ -72,10 +86,13 @@ async function upgradeOne(
|
|
|
72
86
|
const manifestPath = join(actualPath, 'manifest.yml');
|
|
73
87
|
if (!existsSync(manifestPath)) {
|
|
74
88
|
if (tempDir) await cleanupTempDir(tempDir);
|
|
89
|
+
// No manifest means the path isn't a module directory at all —
|
|
90
|
+
// a likely outcome of `module update modules/*` matching a
|
|
91
|
+
// non-module sibling. Skip silently rather than fail the batch.
|
|
75
92
|
return {
|
|
93
|
+
status: 'skipped',
|
|
76
94
|
moduleId: sourcePath,
|
|
77
|
-
|
|
78
|
-
error: `No manifest.yml found at ${actualPath}`,
|
|
95
|
+
reason: 'not a module directory (no manifest.yml)',
|
|
79
96
|
};
|
|
80
97
|
}
|
|
81
98
|
|
|
@@ -87,17 +104,22 @@ async function upgradeOne(
|
|
|
87
104
|
} catch (err) {
|
|
88
105
|
if (tempDir) await cleanupTempDir(tempDir);
|
|
89
106
|
const msg = err instanceof Error ? err.message : String(err);
|
|
90
|
-
return {
|
|
107
|
+
return { status: 'failed', moduleId: sourcePath, error: `Invalid manifest: ${msg}` };
|
|
91
108
|
}
|
|
92
109
|
|
|
93
110
|
const moduleId = newManifest.id;
|
|
94
111
|
|
|
95
112
|
const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
96
113
|
if (!module) {
|
|
114
|
+
if (tempDir) await cleanupTempDir(tempDir);
|
|
115
|
+
// Module isn't installed in this celilo. Don't fail the batch —
|
|
116
|
+
// `module update modules/*` should keep going for everything
|
|
117
|
+
// that IS installed. Caller surfaces the skip count so the user
|
|
118
|
+
// sees what was passed over.
|
|
97
119
|
return {
|
|
120
|
+
status: 'skipped',
|
|
98
121
|
moduleId,
|
|
99
|
-
|
|
100
|
-
error: `Module '${moduleId}' is not installed. Use 'celilo module import ${sourcePath}' first.`,
|
|
122
|
+
reason: `not installed (run 'celilo module import ${sourcePath}' to add)`,
|
|
101
123
|
};
|
|
102
124
|
}
|
|
103
125
|
|
|
@@ -141,7 +163,7 @@ async function upgradeOne(
|
|
|
141
163
|
}
|
|
142
164
|
|
|
143
165
|
log.success(`Upgraded ${moduleId} (v${oldManifest.version} → v${newManifest.version})`);
|
|
144
|
-
return {
|
|
166
|
+
return { status: 'success', moduleId };
|
|
145
167
|
}
|
|
146
168
|
|
|
147
169
|
/**
|
|
@@ -162,31 +184,60 @@ export async function handleModuleUpgrade(
|
|
|
162
184
|
}
|
|
163
185
|
|
|
164
186
|
const db = getDb();
|
|
165
|
-
const results:
|
|
187
|
+
const results: UpgradeOutcome[] = [];
|
|
166
188
|
|
|
167
189
|
for (const path of args) {
|
|
168
190
|
const result = await upgradeOne(path, db, flags);
|
|
169
191
|
results.push(result);
|
|
170
192
|
}
|
|
171
193
|
|
|
172
|
-
const succeeded = results.filter(
|
|
173
|
-
|
|
194
|
+
const succeeded = results.filter(
|
|
195
|
+
(r): r is Extract<UpgradeOutcome, { status: 'success' }> => r.status === 'success',
|
|
196
|
+
);
|
|
197
|
+
const failed = results.filter(
|
|
198
|
+
(r): r is Extract<UpgradeOutcome, { status: 'failed' }> => r.status === 'failed',
|
|
199
|
+
);
|
|
200
|
+
const skipped = results.filter(
|
|
201
|
+
(r): r is Extract<UpgradeOutcome, { status: 'skipped' }> => r.status === 'skipped',
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// Skips that fall under a wildcard expansion (e.g. modules/* picking
|
|
205
|
+
// up `modules/archive/`) shouldn't even be mentioned — they're not
|
|
206
|
+
// signal. Skips for "module not installed" ARE signal because the
|
|
207
|
+
// user explicitly named the path; surface those.
|
|
208
|
+
const meaningfulSkips = skipped.filter((r) => !r.reason.startsWith('not a module directory'));
|
|
174
209
|
|
|
175
210
|
if (failed.length > 0) {
|
|
176
211
|
const errors = failed.map((r) => ` ${r.moduleId}: ${r.error}`).join('\n');
|
|
212
|
+
const parts: string[] = [];
|
|
177
213
|
if (succeeded.length > 0) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
214
|
+
parts.push(
|
|
215
|
+
`Upgraded ${succeeded.length} module(s): ${succeeded.map((r) => r.moduleId).join(', ')}`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (meaningfulSkips.length > 0) {
|
|
219
|
+
const skipLines = meaningfulSkips.map((r) => ` ${r.moduleId}: ${r.reason}`).join('\n');
|
|
220
|
+
parts.push(`Skipped ${meaningfulSkips.length}:\n${skipLines}`);
|
|
183
221
|
}
|
|
184
|
-
|
|
222
|
+
parts.push(`Failed ${failed.length}:\n${errors}`);
|
|
223
|
+
return { success: false, error: parts.join('\n\n') };
|
|
185
224
|
}
|
|
186
225
|
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
226
|
+
const lines: string[] = [];
|
|
227
|
+
if (succeeded.length > 0) {
|
|
228
|
+
lines.push(
|
|
229
|
+
`Updated ${succeeded.length} module(s): ${succeeded.map((r) => r.moduleId).join(', ')}`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (meaningfulSkips.length > 0) {
|
|
233
|
+
const skipLines = meaningfulSkips.map((r) => ` ${r.moduleId}: ${r.reason}`).join('\n');
|
|
234
|
+
lines.push(`Skipped ${meaningfulSkips.length}:\n${skipLines}`);
|
|
235
|
+
}
|
|
236
|
+
if (lines.length === 0) {
|
|
237
|
+
// Every arg was a non-module sibling — odd but not a failure.
|
|
238
|
+
lines.push(
|
|
239
|
+
`No modules to update (${results.length} path(s) skipped — none had a manifest.yml)`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
return { success: true, message: lines.join('\n\n') };
|
|
192
243
|
}
|
package/src/cli/prompts.ts
CHANGED
|
@@ -122,7 +122,7 @@ export function showNote(message: string, title?: string): void {
|
|
|
122
122
|
export const log = {
|
|
123
123
|
success: (message: string) => {
|
|
124
124
|
const d = getActiveDisplay();
|
|
125
|
-
if (d) return d.subEvent(`\x1b[32m
|
|
125
|
+
if (d) return d.subEvent(`\x1b[32m✔\x1b[0m ${message}`);
|
|
126
126
|
p.log.success(message);
|
|
127
127
|
},
|
|
128
128
|
error: (message: string) => {
|
|
@@ -91,6 +91,9 @@ const fakeIdp: IdpCapability = {
|
|
|
91
91
|
async create_user() {
|
|
92
92
|
return { user_id: 1, created: true };
|
|
93
93
|
},
|
|
94
|
+
async create_token() {
|
|
95
|
+
return { token: 'fake-token', created: true };
|
|
96
|
+
},
|
|
94
97
|
};
|
|
95
98
|
|
|
96
99
|
describe('defineHook', () => {
|
|
@@ -253,6 +256,9 @@ describe('defineCapabilityFunction', () => {
|
|
|
253
256
|
async create_user() {
|
|
254
257
|
return { user_id: 1, created: true };
|
|
255
258
|
},
|
|
259
|
+
async create_token() {
|
|
260
|
+
return { token: 'fake-token', created: true };
|
|
261
|
+
},
|
|
256
262
|
}),
|
|
257
263
|
});
|
|
258
264
|
|
|
@@ -313,6 +319,9 @@ describe('defineCapabilityFunction', () => {
|
|
|
313
319
|
async create_user(_request: CreateUserRequest): Promise<CreateUserResult> {
|
|
314
320
|
return { user_id: 1, created: true };
|
|
315
321
|
},
|
|
322
|
+
async create_token(): Promise<{ token: string; created: boolean }> {
|
|
323
|
+
return { token: 'tok', created: true };
|
|
324
|
+
},
|
|
316
325
|
};
|
|
317
326
|
},
|
|
318
327
|
});
|
|
@@ -443,6 +452,9 @@ void defineCapabilityFunction({
|
|
|
443
452
|
async create_user() {
|
|
444
453
|
return { user_id: 1, created: true };
|
|
445
454
|
},
|
|
455
|
+
async create_token() {
|
|
456
|
+
return { token: 'x', created: true };
|
|
457
|
+
},
|
|
446
458
|
}),
|
|
447
459
|
});
|
|
448
460
|
|
package/src/hooks/executor.ts
CHANGED
|
@@ -7,6 +7,15 @@
|
|
|
7
7
|
* - Output validation
|
|
8
8
|
* - Structured logging
|
|
9
9
|
*
|
|
10
|
+
* **Execution model:** hook scripts run *in-process* on the celilo CLI host.
|
|
11
|
+
* `runScript` dynamically `import()`s the hook module and awaits its default
|
|
12
|
+
* export. They do NOT execute on the target machine. A hook that needs to
|
|
13
|
+
* touch the target initiates SSH outbound itself (see e.g.
|
|
14
|
+
* modules/lunacycle/celilo/scripts/health-check.ts's `ssh()` helper). This
|
|
15
|
+
* means hook scripts can freely use npm packages, make HTTP calls, read
|
|
16
|
+
* local files, etc. — and conversely, anything they depend on (chromium,
|
|
17
|
+
* system binaries, credentials) must be available on the celilo CLI host.
|
|
18
|
+
*
|
|
10
19
|
* Execution function (Rule 10.1) - performs side effects (script execution)
|
|
11
20
|
*/
|
|
12
21
|
|
package/src/hooks/logger.ts
CHANGED
|
@@ -66,6 +66,44 @@ export function createGaugeLogger(
|
|
|
66
66
|
warn: (message: string) => emit('warn', message),
|
|
67
67
|
error: (message: string) => emit('error', message),
|
|
68
68
|
success: (message: string) => emit('success', message),
|
|
69
|
+
beginStep: (name: string) => {
|
|
70
|
+
const display = getActiveDisplay();
|
|
71
|
+
if (display) {
|
|
72
|
+
// pushStep nests under the FuelGauge step that wraps the hook,
|
|
73
|
+
// so inner log calls (logger.info within the capability impl)
|
|
74
|
+
// are sub-events of this nested step.
|
|
75
|
+
display.pushStep(`→ ${name}`, `✓ ${name}`);
|
|
76
|
+
} else {
|
|
77
|
+
// Without a display, fall back to a plain info-style marker so
|
|
78
|
+
// the line still appears in raw log output.
|
|
79
|
+
gauge.addOutput(`${prefix} → ${name}`);
|
|
80
|
+
if (nonInteractive) {
|
|
81
|
+
process.stdout.write(`${prefix} → ${name}\n`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
endStep: (_name: string) => {
|
|
86
|
+
const display = getActiveDisplay();
|
|
87
|
+
if (display) {
|
|
88
|
+
display.doneStep();
|
|
89
|
+
} else {
|
|
90
|
+
gauge.addOutput(`${prefix} ✓ ${_name}`);
|
|
91
|
+
if (nonInteractive) {
|
|
92
|
+
process.stdout.write(`${prefix} ✓ ${_name}\n`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
failStep: (name: string, error: string) => {
|
|
97
|
+
const display = getActiveDisplay();
|
|
98
|
+
if (display) {
|
|
99
|
+
display.failStep(`${name}: ${error}`);
|
|
100
|
+
} else {
|
|
101
|
+
gauge.addOutput(`${prefix} ✗ ${name}: ${error}`);
|
|
102
|
+
if (nonInteractive) {
|
|
103
|
+
process.stdout.write(`${prefix} ✗ ${name}: ${error}\n`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
},
|
|
69
107
|
};
|
|
70
108
|
}
|
|
71
109
|
|
package/src/hooks/types.ts
CHANGED
|
@@ -24,13 +24,24 @@ export interface HookDefinition {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* Logger interface provided to hook scripts
|
|
27
|
+
* Logger interface provided to hook scripts.
|
|
28
|
+
*
|
|
29
|
+
* Mirrors `HookLogger` in `@celilo/capabilities/types` — kept in sync so
|
|
30
|
+
* concrete loggers we build here (createGaugeLogger, createConsoleLogger,
|
|
31
|
+
* createCapturingLogger) satisfy both the in-process consumers and the
|
|
32
|
+
* cross-package contract used by `wrapWithLogging`.
|
|
28
33
|
*/
|
|
29
34
|
export interface HookLogger {
|
|
30
35
|
info(message: string): void;
|
|
31
36
|
warn(message: string): void;
|
|
32
37
|
error(message: string): void;
|
|
33
38
|
success(message: string): void;
|
|
39
|
+
/** Begin a logical span. Subsequent log calls nest under it visually. */
|
|
40
|
+
beginStep?(name: string): void;
|
|
41
|
+
/** End the current span (success). */
|
|
42
|
+
endStep?(name: string): void;
|
|
43
|
+
/** End the current span (failure) with an error message. */
|
|
44
|
+
failStep?(name: string, error: string): void;
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
/**
|
|
@@ -993,6 +993,74 @@ describe('validateDeriveFromSources', () => {
|
|
|
993
993
|
const result = validateDeriveFromSources(manifest);
|
|
994
994
|
expect(result).toBeNull();
|
|
995
995
|
});
|
|
996
|
+
|
|
997
|
+
test('should accept $self: references regardless of source', () => {
|
|
998
|
+
// $self: points at another variable in this same manifest, so it
|
|
999
|
+
// doesn't introduce the cross-context mismatch the validator exists
|
|
1000
|
+
// to catch (e.g. authentik's auth_url derives from $self:domain).
|
|
1001
|
+
const manifest = {
|
|
1002
|
+
celilo_contract: '1.0' as const,
|
|
1003
|
+
id: 'authentik',
|
|
1004
|
+
name: 'Authentik',
|
|
1005
|
+
version: '1.0.0',
|
|
1006
|
+
requires: { capabilities: [] },
|
|
1007
|
+
provides: { capabilities: [] },
|
|
1008
|
+
variables: {
|
|
1009
|
+
owns: [
|
|
1010
|
+
{
|
|
1011
|
+
name: 'domain',
|
|
1012
|
+
type: 'string' as const,
|
|
1013
|
+
required: true,
|
|
1014
|
+
source: 'user' as const,
|
|
1015
|
+
},
|
|
1016
|
+
{
|
|
1017
|
+
name: 'auth_url',
|
|
1018
|
+
type: 'string' as const,
|
|
1019
|
+
required: false,
|
|
1020
|
+
source: 'capability' as const,
|
|
1021
|
+
derive_from: 'https://auth.$self:domain',
|
|
1022
|
+
},
|
|
1023
|
+
],
|
|
1024
|
+
imports: [],
|
|
1025
|
+
},
|
|
1026
|
+
};
|
|
1027
|
+
|
|
1028
|
+
const result = validateDeriveFromSources(manifest);
|
|
1029
|
+
expect(result).toBeNull();
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
test('should still reject mixed $self: and $system: when source is capability', () => {
|
|
1033
|
+
// Self-refs are exempt, but other foreign sources still trigger the
|
|
1034
|
+
// mismatch error. This guards against accidentally green-lighting
|
|
1035
|
+
// "$self:foo and $system:bar" combos.
|
|
1036
|
+
const manifest = {
|
|
1037
|
+
celilo_contract: '1.0' as const,
|
|
1038
|
+
id: 'test',
|
|
1039
|
+
name: 'Test',
|
|
1040
|
+
version: '1.0.0',
|
|
1041
|
+
requires: { capabilities: [] },
|
|
1042
|
+
provides: { capabilities: [] },
|
|
1043
|
+
variables: {
|
|
1044
|
+
owns: [
|
|
1045
|
+
{
|
|
1046
|
+
name: 'mixed',
|
|
1047
|
+
type: 'string' as const,
|
|
1048
|
+
required: false,
|
|
1049
|
+
source: 'capability' as const,
|
|
1050
|
+
derive_from: '$self:domain/$system:primary_domain',
|
|
1051
|
+
},
|
|
1052
|
+
],
|
|
1053
|
+
imports: [],
|
|
1054
|
+
},
|
|
1055
|
+
};
|
|
1056
|
+
|
|
1057
|
+
const result = validateDeriveFromSources(manifest);
|
|
1058
|
+
expect(result).not.toBeNull();
|
|
1059
|
+
if (result) {
|
|
1060
|
+
expect(result.errors[0]?.message).toContain('$system:');
|
|
1061
|
+
expect(result.errors[0]?.message).not.toContain('$self:');
|
|
1062
|
+
}
|
|
1063
|
+
});
|
|
996
1064
|
});
|
|
997
1065
|
|
|
998
1066
|
describe('validateHookContract', () => {
|
package/src/manifest/validate.ts
CHANGED
|
@@ -285,9 +285,13 @@ export function validateZoneRequirements(manifest: ModuleManifest): ValidationEr
|
|
|
285
285
|
*
|
|
286
286
|
* Rules:
|
|
287
287
|
* - `source: capability` → `derive_from` must only reference `$capability:`
|
|
288
|
-
* tokens (and may also include `{var}` placeholders).
|
|
288
|
+
* tokens (and may also include `{var}` and `$self:` placeholders).
|
|
289
289
|
* - `source: system` → `derive_from` must only reference `$system:` tokens
|
|
290
|
-
* (and `{var}` placeholders).
|
|
290
|
+
* (and `{var}` and `$self:` placeholders).
|
|
291
|
+
* - `$self:` references are allowed under *any* source. They point at
|
|
292
|
+
* another variable owned by the same manifest, so they don't introduce
|
|
293
|
+
* a cross-context mismatch — that's the bug class this validator was
|
|
294
|
+
* built to catch.
|
|
291
295
|
* - Other sources (`user`, `infrastructure`, `terraform`) — `derive_from` is
|
|
292
296
|
* optional and unconstrained; we don't check the prefix.
|
|
293
297
|
*/
|
|
@@ -306,7 +310,9 @@ export function validateDeriveFromSources(manifest: ModuleManifest): ValidationE
|
|
|
306
310
|
if (!expected) continue;
|
|
307
311
|
|
|
308
312
|
const tokens = [...variable.derive_from.matchAll(tokenPattern)].map((m) => m[1]);
|
|
309
|
-
|
|
313
|
+
// `self` is always allowed: it references another variable in this
|
|
314
|
+
// same manifest, which is the same context as the declared source.
|
|
315
|
+
const offending = tokens.filter((t) => t !== expected && t !== 'self');
|
|
310
316
|
if (offending.length > 0) {
|
|
311
317
|
const unique = Array.from(new Set(offending)).join(', ');
|
|
312
318
|
errors.push({
|
|
@@ -103,7 +103,7 @@ export async function autoDeriveMachineConfig(
|
|
|
103
103
|
const derived = resolveMachineDerivation(variable.derive_from, machine);
|
|
104
104
|
if (derived === null) continue;
|
|
105
105
|
|
|
106
|
-
log.
|
|
106
|
+
log.success(`${variable.name} = ${derived} (auto-derived from ${machine.hostname})`);
|
|
107
107
|
|
|
108
108
|
await db
|
|
109
109
|
.insert(moduleConfigs)
|
|
@@ -132,7 +132,7 @@ export async function autoDeriveMachineConfig(
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
if (followUpValue !== null) {
|
|
135
|
-
log.
|
|
135
|
+
log.success(`${followUpKey} = ${followUpValue} (auto-derived from ${machine.hostname})`);
|
|
136
136
|
await db
|
|
137
137
|
.insert(moduleConfigs)
|
|
138
138
|
.values({
|
|
@@ -534,7 +534,7 @@ export async function interviewForMissingSecrets(
|
|
|
534
534
|
): Promise<InterviewResult> {
|
|
535
535
|
const configured: string[] = [];
|
|
536
536
|
|
|
537
|
-
log.
|
|
537
|
+
log.message(`Module '${moduleId}' requires secrets. Configuring:`);
|
|
538
538
|
|
|
539
539
|
const masterKey = await getOrCreateMasterKey();
|
|
540
540
|
|
|
@@ -635,7 +635,7 @@ export async function interviewForMissingSecrets(
|
|
|
635
635
|
deriveMethod: metadata.deriveMethod,
|
|
636
636
|
});
|
|
637
637
|
|
|
638
|
-
log.
|
|
638
|
+
log.message(`Derived ${variable.name} from ${metadata.deriveFrom}`);
|
|
639
639
|
} else if (source === 'generated') {
|
|
640
640
|
// Auto-generate without prompting
|
|
641
641
|
// Manifest generate field takes priority over schema metadata
|
|
@@ -644,7 +644,7 @@ export async function interviewForMissingSecrets(
|
|
|
644
644
|
|
|
645
645
|
value = generateSecret({ format, length });
|
|
646
646
|
|
|
647
|
-
log.
|
|
647
|
+
log.message(`Auto-generated ${format} secret: ${variable.name}`);
|
|
648
648
|
} else if (source === 'user_provided') {
|
|
649
649
|
// Always prompt, required
|
|
650
650
|
// Check if we're in interactive mode
|
|
@@ -667,7 +667,7 @@ export async function interviewForMissingSecrets(
|
|
|
667
667
|
},
|
|
668
668
|
});
|
|
669
669
|
|
|
670
|
-
log.
|
|
670
|
+
log.success(`Saved ${variable.name}`);
|
|
671
671
|
} else if (source === 'user_password') {
|
|
672
672
|
// Password the user must remember — prompt twice to confirm
|
|
673
673
|
if (!process.stdin.isTTY) {
|
|
@@ -706,7 +706,7 @@ export async function interviewForMissingSecrets(
|
|
|
706
706
|
};
|
|
707
707
|
}
|
|
708
708
|
|
|
709
|
-
log.
|
|
709
|
+
log.success(`Saved ${variable.name}`);
|
|
710
710
|
} else if (source === 'generated_optional') {
|
|
711
711
|
// Prompt with auto-generate option
|
|
712
712
|
const message = variable.description
|
|
@@ -725,11 +725,11 @@ export async function interviewForMissingSecrets(
|
|
|
725
725
|
|
|
726
726
|
value = generateSecret({ format, length });
|
|
727
727
|
|
|
728
|
-
log.
|
|
728
|
+
log.message(`Auto-generated ${format} secret: ${variable.name}`);
|
|
729
729
|
} else {
|
|
730
730
|
// Use user-provided value
|
|
731
731
|
value = userValue;
|
|
732
|
-
log.
|
|
732
|
+
log.success(`Saved ${variable.name}`);
|
|
733
733
|
}
|
|
734
734
|
} else {
|
|
735
735
|
return {
|
|
@@ -152,7 +152,7 @@ export async function executeAnsible(
|
|
|
152
152
|
await writeFile(passwordPath, vaultPassword, { mode: 0o600 });
|
|
153
153
|
|
|
154
154
|
const logPath = join(generatedPath, 'deploy.log');
|
|
155
|
-
log.
|
|
155
|
+
log.success('Configuring host (ansible-playbook)');
|
|
156
156
|
|
|
157
157
|
try {
|
|
158
158
|
const result = await executeBuildWithProgress({
|
|
@@ -117,7 +117,7 @@ export async function runModuleHealthCheck(
|
|
|
117
117
|
{ debug: false, capabilities: capabilityFunctions, requiredCapabilities },
|
|
118
118
|
);
|
|
119
119
|
} else {
|
|
120
|
-
const gauge = new FuelGauge(
|
|
120
|
+
const gauge = new FuelGauge('Testing app', {
|
|
121
121
|
skipAnimation: options.noInteractive,
|
|
122
122
|
});
|
|
123
123
|
gauge.start();
|
|
@@ -295,7 +295,6 @@ export async function deployModule(
|
|
|
295
295
|
}
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
-
log.info(`Deploying module: ${moduleId}`);
|
|
299
298
|
const validation = await validateAndPrepareDeployment(moduleId, db);
|
|
300
299
|
phases.validation = validation.success;
|
|
301
300
|
phases.autoGenerated = validation.autoGenerated;
|
|
@@ -469,11 +468,9 @@ export async function deployModule(
|
|
|
469
468
|
}
|
|
470
469
|
}
|
|
471
470
|
|
|
472
|
-
log.success(
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
log.message(' Module built');
|
|
476
|
-
}
|
|
471
|
+
log.success(
|
|
472
|
+
validation.autoBuilt ? 'Templates generated and module built' : 'Templates generated',
|
|
473
|
+
);
|
|
477
474
|
|
|
478
475
|
// Run validate_config hook if defined (e.g., credential validation via Playwright)
|
|
479
476
|
if (manifest.hooks?.validate_config) {
|
|
@@ -613,7 +610,7 @@ export async function deployModule(
|
|
|
613
610
|
// Run on_install hook for config-only modules (e.g. publishing static files to caddy)
|
|
614
611
|
if (manifest.hooks?.on_install) {
|
|
615
612
|
const onInstallDef = manifest.hooks.on_install;
|
|
616
|
-
log.
|
|
613
|
+
log.success('Running on_install hook');
|
|
617
614
|
|
|
618
615
|
const { moduleConfigs: pcTable, secrets: secretsTable } = await import('../db/schema');
|
|
619
616
|
const installConfigs = db
|
|
@@ -695,7 +692,6 @@ export async function deployModule(
|
|
|
695
692
|
// Run health checks for config-only modules too
|
|
696
693
|
if (manifest.hooks?.health_check) {
|
|
697
694
|
const { runModuleHealthCheck } = await import('./health-runner');
|
|
698
|
-
log.info('Running health checks...');
|
|
699
695
|
const healthResult = await runModuleHealthCheck(moduleId, db, {
|
|
700
696
|
debug: options.debug,
|
|
701
697
|
noInteractive: options.noInteractive,
|
|
@@ -710,7 +706,6 @@ export async function deployModule(
|
|
|
710
706
|
.join('\n');
|
|
711
707
|
return { success: false, phases, error: `Health checks failed:\n${failedChecks}` };
|
|
712
708
|
}
|
|
713
|
-
log.success('Health checks passed → VERIFIED');
|
|
714
709
|
}
|
|
715
710
|
|
|
716
711
|
return {
|
|
@@ -855,7 +850,7 @@ export async function deployModule(
|
|
|
855
850
|
|
|
856
851
|
if (!containerCreatedHook) continue;
|
|
857
852
|
|
|
858
|
-
log.
|
|
853
|
+
log.message(
|
|
859
854
|
`Running container_created hook on ${capRecord.moduleId} (provides ${requiredCap.name})`,
|
|
860
855
|
);
|
|
861
856
|
|
|
@@ -988,7 +983,6 @@ export async function deployModule(
|
|
|
988
983
|
let machineId: string | undefined;
|
|
989
984
|
if (plan.infrastructure?.type === 'machine' && plan.infrastructure.machineId) {
|
|
990
985
|
machineId = plan.infrastructure.machineId;
|
|
991
|
-
log.info(`Writing temporary SSH key for machine: ${machineId}`);
|
|
992
986
|
await writeTemporarySshKey(machineId);
|
|
993
987
|
}
|
|
994
988
|
|
|
@@ -1012,15 +1006,16 @@ export async function deployModule(
|
|
|
1012
1006
|
plan.infrastructure?.type === 'container_service' &&
|
|
1013
1007
|
plan.infrastructure.serviceId
|
|
1014
1008
|
) {
|
|
1015
|
-
// TODO:
|
|
1016
|
-
//
|
|
1017
|
-
|
|
1009
|
+
// TODO: extract Terraform outputs and persist them on
|
|
1010
|
+
// module_infrastructure.containerMetadata. Until that lands,
|
|
1011
|
+
// the deploy still succeeds — we just don't track which
|
|
1012
|
+
// container ID/IP got assigned per module in the DB.
|
|
1018
1013
|
}
|
|
1019
1014
|
|
|
1020
1015
|
// Run on_install hook (post-deploy actions like port forwarding, DNS registration)
|
|
1021
1016
|
if (manifest.hooks?.on_install) {
|
|
1022
1017
|
const onInstallDef = manifest.hooks.on_install;
|
|
1023
|
-
log.
|
|
1018
|
+
log.success('Running on_install hook');
|
|
1024
1019
|
|
|
1025
1020
|
const { moduleConfigs: pcTable, secrets: secretsTable } = await import('../db/schema');
|
|
1026
1021
|
const installConfigs = db
|
|
@@ -1120,7 +1115,6 @@ export async function deployModule(
|
|
|
1120
1115
|
// Run health checks after successful deployment
|
|
1121
1116
|
if (manifest.hooks?.health_check) {
|
|
1122
1117
|
const { runModuleHealthCheck } = await import('./health-runner');
|
|
1123
|
-
log.info('Running post-deploy health checks...');
|
|
1124
1118
|
const healthResult = await runModuleHealthCheck(moduleId, db, {
|
|
1125
1119
|
debug: options.debug,
|
|
1126
1120
|
noInteractive: options.noInteractive,
|
|
@@ -1144,10 +1138,6 @@ export async function deployModule(
|
|
|
1144
1138
|
error: `Health checks failed:\n${failedChecks}`,
|
|
1145
1139
|
};
|
|
1146
1140
|
}
|
|
1147
|
-
const summary = healthResult.checks
|
|
1148
|
-
.map((c) => ` ${c.status === 'pass' ? '✓' : '⚠'} ${c.name}`)
|
|
1149
|
-
.join('\n');
|
|
1150
|
-
log.success(`Health checks passed → VERIFIED\n${summary}`);
|
|
1151
1141
|
}
|
|
1152
1142
|
|
|
1153
1143
|
// Auto-register module hostname in internal DNS (if available)
|
|
@@ -1167,7 +1157,7 @@ export async function deployModule(
|
|
|
1167
1157
|
);
|
|
1168
1158
|
}
|
|
1169
1159
|
|
|
1170
|
-
|
|
1160
|
+
log.success(`Module '${moduleId}' deployed successfully`);
|
|
1171
1161
|
return {
|
|
1172
1162
|
success: true,
|
|
1173
1163
|
phases,
|
|
@@ -1175,7 +1165,6 @@ export async function deployModule(
|
|
|
1175
1165
|
} finally {
|
|
1176
1166
|
// Clean up temporary SSH key
|
|
1177
1167
|
if (machineId) {
|
|
1178
|
-
log.info(`Cleaning up temporary SSH key for machine: ${machineId}`);
|
|
1179
1168
|
deleteTemporarySshKey(machineId);
|
|
1180
1169
|
}
|
|
1181
1170
|
}
|
|
@@ -491,9 +491,6 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
491
491
|
}
|
|
492
492
|
}
|
|
493
493
|
} else {
|
|
494
|
-
// Module doesn't require infrastructure provisioning (e.g., VPS-based modules)
|
|
495
|
-
// Skip infrastructure selection
|
|
496
|
-
log.info('Skipping infrastructure selection (no requires.machine specified)');
|
|
497
494
|
infrastructureSelection = undefined;
|
|
498
495
|
}
|
|
499
496
|
|
|
@@ -525,12 +522,12 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
525
522
|
if (!allocation) {
|
|
526
523
|
// First generation - allocate VMID and IP
|
|
527
524
|
allocation = await allocateForModule(moduleId, zone, db, db.$client);
|
|
528
|
-
log.
|
|
525
|
+
log.success(
|
|
529
526
|
`Allocated VMID ${allocation.vmid} and IP ${allocation.containerIp} for ${moduleId}`,
|
|
530
527
|
);
|
|
531
528
|
} else {
|
|
532
529
|
// Reuse existing allocation
|
|
533
|
-
log.
|
|
530
|
+
log.success(
|
|
534
531
|
`Using existing allocation: VMID ${allocation.vmid}, IP ${allocation.containerIp}`,
|
|
535
532
|
);
|
|
536
533
|
}
|
|
@@ -615,7 +612,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
615
612
|
.run();
|
|
616
613
|
}
|
|
617
614
|
|
|
618
|
-
log.
|
|
615
|
+
log.success(
|
|
619
616
|
`Infrastructure properties resolved from service: target_node=${providerConfig.default_target_node}`,
|
|
620
617
|
);
|
|
621
618
|
}
|
|
@@ -940,11 +940,13 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
940
940
|
});
|
|
941
941
|
});
|
|
942
942
|
|
|
943
|
-
describe('$self:
|
|
944
|
-
// $self:
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
|
|
943
|
+
describe('$self: in derive_from', () => {
|
|
944
|
+
// $self: is the canonical reference syntax in capability data blocks
|
|
945
|
+
// (e.g. authentik's `data.auth_url: $self:auth_url`), so it must
|
|
946
|
+
// resolve in derive_from too — otherwise capability-sourced variables
|
|
947
|
+
// that derive from another self variable can't be persisted.
|
|
948
|
+
// Equivalent to the {var} syntax; reads from selfConfig.
|
|
949
|
+
test('resolves $self: against selfConfig like {var}', () => {
|
|
948
950
|
const manifest: ModuleManifest = {
|
|
949
951
|
celilo_contract: '1.0',
|
|
950
952
|
id: 'test-module',
|
|
@@ -959,7 +961,7 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
959
961
|
type: 'string',
|
|
960
962
|
required: false,
|
|
961
963
|
source: 'user',
|
|
962
|
-
derive_from: '$self:hostname',
|
|
964
|
+
derive_from: '$self:hostname',
|
|
963
965
|
},
|
|
964
966
|
],
|
|
965
967
|
imports: [],
|
|
@@ -977,8 +979,91 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
977
979
|
|
|
978
980
|
applyDeclarativeDerivations(manifest, context);
|
|
979
981
|
|
|
980
|
-
|
|
981
|
-
|
|
982
|
+
expect(context.selfConfig.hostname_copy).toBe('my-host');
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
test('resolves $self: inside string interpolation (authentik auth_url shape)', () => {
|
|
986
|
+
// Mirrors the authentik manifest pattern that motivated the fix:
|
|
987
|
+
// auth_url derives from "https://auth.$self:domain" where domain
|
|
988
|
+
// is another self-owned variable set by the user.
|
|
989
|
+
const manifest: ModuleManifest = {
|
|
990
|
+
celilo_contract: '1.0',
|
|
991
|
+
id: 'authentik',
|
|
992
|
+
name: 'Authentik',
|
|
993
|
+
version: '1.0.0',
|
|
994
|
+
requires: { capabilities: [] },
|
|
995
|
+
provides: { capabilities: [] },
|
|
996
|
+
variables: {
|
|
997
|
+
owns: [
|
|
998
|
+
{
|
|
999
|
+
name: 'domain',
|
|
1000
|
+
type: 'string',
|
|
1001
|
+
required: true,
|
|
1002
|
+
source: 'user',
|
|
1003
|
+
},
|
|
1004
|
+
{
|
|
1005
|
+
name: 'auth_url',
|
|
1006
|
+
type: 'string',
|
|
1007
|
+
required: false,
|
|
1008
|
+
source: 'capability',
|
|
1009
|
+
derive_from: 'https://auth.$self:domain',
|
|
1010
|
+
},
|
|
1011
|
+
],
|
|
1012
|
+
imports: [],
|
|
1013
|
+
},
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
const context: ResolutionContext = {
|
|
1017
|
+
moduleId: 'authentik',
|
|
1018
|
+
selfConfig: { domain: 'iamtheinternet.org' },
|
|
1019
|
+
systemConfig: {},
|
|
1020
|
+
systemSecrets: {},
|
|
1021
|
+
secrets: {},
|
|
1022
|
+
capabilities: {},
|
|
1023
|
+
};
|
|
1024
|
+
|
|
1025
|
+
applyDeclarativeDerivations(manifest, context);
|
|
1026
|
+
|
|
1027
|
+
expect(context.selfConfig.auth_url).toBe('https://auth.iamtheinternet.org');
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
test('throws when $self: references an undeclared variable', () => {
|
|
1031
|
+
const manifest: ModuleManifest = {
|
|
1032
|
+
celilo_contract: '1.0',
|
|
1033
|
+
id: 'test',
|
|
1034
|
+
name: 'Test',
|
|
1035
|
+
version: '1.0.0',
|
|
1036
|
+
requires: { capabilities: [] },
|
|
1037
|
+
provides: { capabilities: [] },
|
|
1038
|
+
variables: {
|
|
1039
|
+
owns: [
|
|
1040
|
+
{
|
|
1041
|
+
name: 'derived',
|
|
1042
|
+
type: 'string',
|
|
1043
|
+
required: false,
|
|
1044
|
+
source: 'user',
|
|
1045
|
+
derive_from: '$self:nonexistent',
|
|
1046
|
+
},
|
|
1047
|
+
],
|
|
1048
|
+
imports: [],
|
|
1049
|
+
},
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
const context: ResolutionContext = {
|
|
1053
|
+
moduleId: 'test',
|
|
1054
|
+
selfConfig: {},
|
|
1055
|
+
systemConfig: {},
|
|
1056
|
+
systemSecrets: {},
|
|
1057
|
+
secrets: {},
|
|
1058
|
+
capabilities: {},
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
// Optional variable + missing dep → derivation fails silently per
|
|
1062
|
+
// applyDeclarativeDerivations' optional-variable rule. The error
|
|
1063
|
+
// is thrown from substituteVariables but caught by the optional
|
|
1064
|
+
// branch in applyDeclarativeDerivations, leaving selfConfig clean.
|
|
1065
|
+
applyDeclarativeDerivations(manifest, context);
|
|
1066
|
+
expect(context.selfConfig.derived).toBeUndefined();
|
|
982
1067
|
});
|
|
983
1068
|
});
|
|
984
1069
|
|
|
@@ -65,6 +65,21 @@ function substituteVariables(
|
|
|
65
65
|
return value;
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
// Replace $self:key patterns (both $self:key and ${self:key} forms).
|
|
69
|
+
// Same lookup target as the {var} syntax below — both read selfConfig —
|
|
70
|
+
// but $self: is the form that appears in user-authored manifests and
|
|
71
|
+
// capability data blocks, so it must resolve here too. Without this,
|
|
72
|
+
// a `derive_from: "https://auth.$self:domain"` stays literally
|
|
73
|
+
// unresolved and the defensive guard in applyDeclarativeDerivations
|
|
74
|
+
// refuses to store it, breaking downstream capability consumers.
|
|
75
|
+
result = result.replace(/\$\{?self:([a-zA-Z0-9_]+)\}?/g, (_match, key) => {
|
|
76
|
+
const value = context.selfConfig[key];
|
|
77
|
+
if (value === undefined) {
|
|
78
|
+
throw new Error(`Missing self variable: ${key} (required by variable '${variableName}')`);
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
});
|
|
82
|
+
|
|
68
83
|
// Replace {variable_name} patterns
|
|
69
84
|
result = result.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, varName) => {
|
|
70
85
|
const value = context.selfConfig[varName];
|