@celilo/cli 0.8.2 → 0.9.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/AGENTS.md +10 -18
- package/CELILO_CORE_MODULES.md +61 -0
- package/CELILO_SUBSYSTEMS.md +83 -0
- package/README.md +1539 -48
- package/drizzle/0012_module_systems_sizing.sql +3 -0
- package/drizzle/0013_dns_view_overrides.sql +1 -0
- package/drizzle/meta/_journal.json +15 -1
- package/package.json +5 -10
- package/src/capabilities/well-known.test.ts +12 -66
- package/src/capabilities/well-known.ts +11 -12
- package/src/cli/command-registry.ts +65 -1
- package/src/cli/commands/module-upgrade.test.ts +29 -0
- package/src/cli/commands/module-upgrade.ts +57 -24
- package/src/cli/commands/proxmox-instance-list.test.ts +77 -0
- package/src/cli/commands/proxmox-instance-list.ts +140 -0
- package/src/cli/commands/proxmox-instance-resize.ts +235 -0
- package/src/cli/commands/proxmox-node-list.ts +1 -34
- package/src/cli/commands/proxmox-resize-guards.test.ts +55 -0
- package/src/cli/commands/proxmox-resize-guards.ts +102 -0
- package/src/cli/commands/proxmox-service.ts +38 -0
- package/src/cli/completion.ts +11 -37
- package/src/cli/index.ts +15 -0
- package/src/cli/validators.test.ts +1 -206
- package/src/cli/validators.ts +0 -168
- package/src/db/schema.ts +21 -1
- package/src/hooks/capability-loader.ts +22 -0
- package/src/manifest/template-validator.test.ts +31 -1
- package/src/manifest/template-validator.ts +9 -0
- package/src/services/aspect-approvals.test.ts +52 -0
- package/src/services/aspect-approvals.ts +41 -8
- package/src/services/deployed-systems.test.ts +73 -1
- package/src/services/deployed-systems.ts +72 -0
- package/src/services/dns-internal-records.test.ts +76 -3
- package/src/services/dns-internal-records.ts +52 -3
- package/src/services/dns-provider-backfill.ts +15 -3
- package/src/services/fleet-checks.test.ts +18 -16
- package/src/services/machine-detector.ts +34 -12
- package/src/services/programmatic-responder.aspect.test.ts +157 -0
- package/src/services/programmatic-responder.ts +51 -0
- package/src/templates/generator.ts +49 -1
- package/src/utils/shell.test.ts +1 -163
- package/src/utils/shell.ts +0 -100
- package/src/validation/schemas.ts +0 -5
- package/src/variables/context.ts +36 -7
- package/CLI_USAGE.md +0 -433
- package/src/config/env.ts +0 -41
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression: the programmatic responder (`celilo events respond`) must handle
|
|
3
|
+
* `aspect.required.*` so a HEADLESS deploy of a module with a base_module_aspect
|
|
4
|
+
* can be approved without a TTY.
|
|
5
|
+
*
|
|
6
|
+
* Before the fix it watched only config/secret/ensure/interview — never aspect —
|
|
7
|
+
* so a headless deploy whose aspect consent wasn't pre-recorded emitted
|
|
8
|
+
* `aspect.required.<m>.<role>` and hung forever (busInterview uses timeoutMs:0;
|
|
9
|
+
* no responder ever replied). This reproduced the ISS-0156 cutover hang and is
|
|
10
|
+
* the core of #262.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
14
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { defineEvents, openBus } from '@celilo/event-bus';
|
|
18
|
+
import { closeDb, getDb } from '../db/client';
|
|
19
|
+
import { runMigrations } from '../db/migrate';
|
|
20
|
+
import { type AspectRequiredPayload, EVENT_TYPES } from './bus-interview';
|
|
21
|
+
import { startProgrammaticResponder } from './programmatic-responder';
|
|
22
|
+
|
|
23
|
+
const NO_SCHEMAS = defineEvents({});
|
|
24
|
+
|
|
25
|
+
const ASPECT_PAYLOAD: AspectRequiredPayload = {
|
|
26
|
+
module: 'technitium',
|
|
27
|
+
role: 'dns-client-config',
|
|
28
|
+
zones: ['dmz', 'app', 'secure', 'internal'],
|
|
29
|
+
triggers: ['on_install'],
|
|
30
|
+
trigger: 'on_install',
|
|
31
|
+
reason: 'no_approval',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Emit the aspect-consent query and return the responder's decision, or null. */
|
|
35
|
+
async function askAspectConsent(
|
|
36
|
+
busPath: string,
|
|
37
|
+
module: string,
|
|
38
|
+
role: string,
|
|
39
|
+
payload: AspectRequiredPayload,
|
|
40
|
+
): Promise<boolean | null> {
|
|
41
|
+
const bus = openBus({ dbPath: busPath, events: NO_SCHEMAS });
|
|
42
|
+
try {
|
|
43
|
+
const replies = await bus.query(
|
|
44
|
+
EVENT_TYPES.aspectRequired(module, role) as never,
|
|
45
|
+
payload as never,
|
|
46
|
+
{
|
|
47
|
+
timeoutMs: 3000,
|
|
48
|
+
pollIntervalMs: 100,
|
|
49
|
+
expect: 'first',
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
if (replies.length === 0) return null;
|
|
53
|
+
return (replies[0].payload as { consented?: boolean }).consented ?? null;
|
|
54
|
+
} finally {
|
|
55
|
+
bus.close();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('programmatic responder — aspect.required consent (#262)', () => {
|
|
60
|
+
let dir: string;
|
|
61
|
+
let busPath: string;
|
|
62
|
+
let db: ReturnType<typeof getDb>;
|
|
63
|
+
|
|
64
|
+
beforeEach(async () => {
|
|
65
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-resp-aspect-'));
|
|
66
|
+
process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
|
|
67
|
+
busPath = join(dir, 'bus.db');
|
|
68
|
+
await runMigrations(process.env.CELILO_DB_PATH);
|
|
69
|
+
db = getDb();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
closeDb();
|
|
74
|
+
rmSync(dir, { recursive: true, force: true });
|
|
75
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('approves (consented=true) when the policy approves the module', async () => {
|
|
79
|
+
const handle = startProgrammaticResponder({
|
|
80
|
+
busDbPath: busPath,
|
|
81
|
+
db,
|
|
82
|
+
onMissing: 'skip',
|
|
83
|
+
values: { aspects: { technitium: true } },
|
|
84
|
+
});
|
|
85
|
+
try {
|
|
86
|
+
const decision = await askAspectConsent(
|
|
87
|
+
busPath,
|
|
88
|
+
'technitium',
|
|
89
|
+
'dns-client-config',
|
|
90
|
+
ASPECT_PAYLOAD,
|
|
91
|
+
);
|
|
92
|
+
expect(decision).toBe(true);
|
|
93
|
+
} finally {
|
|
94
|
+
handle.close();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("approves via the '*' wildcard policy", async () => {
|
|
99
|
+
const handle = startProgrammaticResponder({
|
|
100
|
+
busDbPath: busPath,
|
|
101
|
+
db,
|
|
102
|
+
onMissing: 'skip',
|
|
103
|
+
values: { aspects: { '*': true } },
|
|
104
|
+
});
|
|
105
|
+
try {
|
|
106
|
+
const decision = await askAspectConsent(
|
|
107
|
+
busPath,
|
|
108
|
+
'technitium',
|
|
109
|
+
'dns-client-config',
|
|
110
|
+
ASPECT_PAYLOAD,
|
|
111
|
+
);
|
|
112
|
+
expect(decision).toBe(true);
|
|
113
|
+
} finally {
|
|
114
|
+
handle.close();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('refuses (consented=false) when the policy denies the module', async () => {
|
|
119
|
+
const handle = startProgrammaticResponder({
|
|
120
|
+
busDbPath: busPath,
|
|
121
|
+
db,
|
|
122
|
+
onMissing: 'skip',
|
|
123
|
+
values: { aspects: { technitium: false } },
|
|
124
|
+
});
|
|
125
|
+
try {
|
|
126
|
+
const decision = await askAspectConsent(
|
|
127
|
+
busPath,
|
|
128
|
+
'technitium',
|
|
129
|
+
'dns-client-config',
|
|
130
|
+
ASPECT_PAYLOAD,
|
|
131
|
+
);
|
|
132
|
+
expect(decision).toBe(false);
|
|
133
|
+
} finally {
|
|
134
|
+
handle.close();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('does not reply when no aspect policy is provided (onMissing: skip)', async () => {
|
|
139
|
+
const handle = startProgrammaticResponder({
|
|
140
|
+
busDbPath: busPath,
|
|
141
|
+
db,
|
|
142
|
+
onMissing: 'skip',
|
|
143
|
+
values: {},
|
|
144
|
+
});
|
|
145
|
+
try {
|
|
146
|
+
const decision = await askAspectConsent(
|
|
147
|
+
busPath,
|
|
148
|
+
'technitium',
|
|
149
|
+
'dns-client-config',
|
|
150
|
+
ASPECT_PAYLOAD,
|
|
151
|
+
);
|
|
152
|
+
expect(decision).toBeNull();
|
|
153
|
+
} finally {
|
|
154
|
+
handle.close();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -21,6 +21,7 @@ import type { DbClient } from '../db/client';
|
|
|
21
21
|
import { generateSecret } from '../secrets/generators';
|
|
22
22
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
23
23
|
import type {
|
|
24
|
+
AspectRequiredPayload,
|
|
24
25
|
ConfigRequiredPayload,
|
|
25
26
|
EnsureRequiredPayload,
|
|
26
27
|
InterviewRequiredPayload,
|
|
@@ -66,6 +67,17 @@ export interface ResponderValues {
|
|
|
66
67
|
* (string for text/select, string[] for multiselect, boolean for confirm).
|
|
67
68
|
*/
|
|
68
69
|
interview?: Record<string, unknown>;
|
|
70
|
+
/**
|
|
71
|
+
* Aspect-consent decisions for a module's `base_module_aspect`
|
|
72
|
+
* (ISS-0027 / #262). When a HEADLESS deploy emits
|
|
73
|
+
* `aspect.required.<module>.<role>`, the responder replies
|
|
74
|
+
* `{ consented }` so the fan-out is approved/denied without a TTY —
|
|
75
|
+
* the gap that hung the ISS-0156 cutover. Lookup precedence:
|
|
76
|
+
* `<module>.<role>`, then `<module>`, then the `'*'` wildcard.
|
|
77
|
+
* Absent → the responder skips (onMissing), exactly like an unmapped
|
|
78
|
+
* config value — it never silently approves an un-policied aspect.
|
|
79
|
+
*/
|
|
80
|
+
aspects?: Record<string, boolean>;
|
|
69
81
|
}
|
|
70
82
|
|
|
71
83
|
export interface ProgrammaticResponderOptions {
|
|
@@ -117,6 +129,7 @@ export interface ProgrammaticResponderHandle {
|
|
|
117
129
|
seenSecretPayloads(): SecretRequiredPayload[];
|
|
118
130
|
seenEnsurePayloads(): EnsureRequiredPayload[];
|
|
119
131
|
seenInterviewPayloads(): InterviewRequiredPayload[];
|
|
132
|
+
seenAspectPayloads(): AspectRequiredPayload[];
|
|
120
133
|
/** Stop watching. Caller still owns the db client. */
|
|
121
134
|
close(): void;
|
|
122
135
|
}
|
|
@@ -136,6 +149,7 @@ export function startProgrammaticResponder(
|
|
|
136
149
|
const seenSecret: SecretRequiredPayload[] = [];
|
|
137
150
|
const seenEnsure: EnsureRequiredPayload[] = [];
|
|
138
151
|
const seenInterview: InterviewRequiredPayload[] = [];
|
|
152
|
+
const seenAspect: AspectRequiredPayload[] = [];
|
|
139
153
|
let lastActivityAt = Date.now();
|
|
140
154
|
|
|
141
155
|
const me = opts.emittedBy ?? 'programmatic';
|
|
@@ -308,6 +322,41 @@ export function startProgrammaticResponder(
|
|
|
308
322
|
answered.push({ type: event.type, key: lookupKey });
|
|
309
323
|
});
|
|
310
324
|
|
|
325
|
+
// Aspect consent (ISS-0027 / #262): a headless deploy about to fan out a
|
|
326
|
+
// module's base_module_aspect emits `aspect.required.<module>.<role>` and
|
|
327
|
+
// waits (busInterview, timeoutMs:0). Without this watch the responder never
|
|
328
|
+
// replied → the deploy hung forever (the ISS-0156 cutover failure). We reply
|
|
329
|
+
// per the `aspects` policy; an un-policied aspect is skipped, never approved.
|
|
330
|
+
const aspectWatch = bus.watch('aspect.required.*.*', async (event) => {
|
|
331
|
+
if (event.replyFor !== null) return;
|
|
332
|
+
lastActivityAt = Date.now();
|
|
333
|
+
|
|
334
|
+
const payload = event.payload as AspectRequiredPayload;
|
|
335
|
+
if (!payload || typeof payload.module !== 'string' || typeof payload.role !== 'string') {
|
|
336
|
+
missed.push({ type: event.type, key: '?', reason: 'malformed payload' });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
seenAspect.push(payload);
|
|
340
|
+
|
|
341
|
+
// Precedence: exact "<module>.<role>", then "<module>", then "*" wildcard.
|
|
342
|
+
const lookupKey = `${payload.module}.${payload.role}`;
|
|
343
|
+
const decision =
|
|
344
|
+
opts.values.aspects?.[lookupKey] ??
|
|
345
|
+
opts.values.aspects?.[payload.module] ??
|
|
346
|
+
opts.values.aspects?.['*'];
|
|
347
|
+
if (decision === undefined) {
|
|
348
|
+
handleMissing(event.type, lookupKey, `no aspect decision for "${lookupKey}"`);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
bus.emitRaw(
|
|
353
|
+
`${event.type}.reply`,
|
|
354
|
+
{ consented: decision },
|
|
355
|
+
{ replyFor: event.id, emittedBy: me },
|
|
356
|
+
);
|
|
357
|
+
answered.push({ type: event.type, key: lookupKey });
|
|
358
|
+
});
|
|
359
|
+
|
|
311
360
|
// Liveness probe: a non-interactive caller (e.g. `module generate`
|
|
312
361
|
// with no TTY) emits `responder.probe` to detect whether any
|
|
313
362
|
// responder is listening before calling busInterview (which waits
|
|
@@ -331,11 +380,13 @@ export function startProgrammaticResponder(
|
|
|
331
380
|
seenSecretPayloads: () => [...seenSecret],
|
|
332
381
|
seenEnsurePayloads: () => [...seenEnsure],
|
|
333
382
|
seenInterviewPayloads: () => [...seenInterview],
|
|
383
|
+
seenAspectPayloads: () => [...seenAspect],
|
|
334
384
|
close: () => {
|
|
335
385
|
configWatch.close();
|
|
336
386
|
secretWatch.close();
|
|
337
387
|
ensureWatch.close();
|
|
338
388
|
interviewWatch.close();
|
|
389
|
+
aspectWatch.close();
|
|
339
390
|
probeWatch.close();
|
|
340
391
|
bus.close();
|
|
341
392
|
},
|
|
@@ -27,7 +27,11 @@ import {
|
|
|
27
27
|
findBrokenCapabilityDerivations,
|
|
28
28
|
} from '../services/fleet-checks';
|
|
29
29
|
import { selectInfrastructure } from '../services/infrastructure-selector';
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
deleteModuleConfig,
|
|
32
|
+
getModuleConfigValue,
|
|
33
|
+
upsertModuleConfig,
|
|
34
|
+
} from '../services/module-config';
|
|
31
35
|
import type { InfrastructureSelection } from '../types/infrastructure';
|
|
32
36
|
import { convertSecretsToJinja } from '../variables/ansible-resolver';
|
|
33
37
|
import { buildResolutionContext } from '../variables/context';
|
|
@@ -730,6 +734,50 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
730
734
|
}
|
|
731
735
|
}
|
|
732
736
|
|
|
737
|
+
// Dedicated DNS-ingress IP (ISS-0156). A dns_internal provider now deploys into
|
|
738
|
+
// a PROTECTED zone (dmz) so it can see protected-zone query sources for
|
|
739
|
+
// split-horizon views (v2/INTERNAL_DNS_ZONE_VIEWS.md). `internal` devices have
|
|
740
|
+
// no route into the 10-net, so they reach the resolver through a firewall DNAT
|
|
741
|
+
// on a dedicated `internal`-subnet address. A module opts in by declaring a
|
|
742
|
+
// `dns_ingress_ip` infrastructure variable; we allocate a free IP from the
|
|
743
|
+
// `internal` subnet via IPAM and RESERVE it (so it's never re-handed-out),
|
|
744
|
+
// idempotently (reuse the stored value on re-generate). The resolver's
|
|
745
|
+
// on_install passes it to firewall.exposeService({ ingressIp }).
|
|
746
|
+
const wantsDnsIngress = manifest.variables?.owns?.some(
|
|
747
|
+
(v) => v.name === 'dns_ingress_ip' && v.source === 'infrastructure',
|
|
748
|
+
);
|
|
749
|
+
if (wantsDnsIngress) {
|
|
750
|
+
const existing = getModuleConfigValue(moduleId, 'dns_ingress_ip', db)?.value;
|
|
751
|
+
if (typeof existing === 'string' && existing.length > 0) {
|
|
752
|
+
log.success(`Using existing DNS-ingress IP ${existing} for ${moduleId}`);
|
|
753
|
+
} else {
|
|
754
|
+
const subnetRow = db.$client
|
|
755
|
+
.prepare('SELECT value FROM system_config WHERE key = ?')
|
|
756
|
+
.get('network.internal.subnet') as { value: string } | undefined;
|
|
757
|
+
if (!subnetRow?.value) {
|
|
758
|
+
return {
|
|
759
|
+
success: false,
|
|
760
|
+
error:
|
|
761
|
+
'network.internal.subnet is not configured — required to allocate the ' +
|
|
762
|
+
'dns_internal DNS-ingress IP (ISS-0156). Ensure the internal network is set up first.',
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
const { allocateIPFromSubnet, reserveIP } = await import('../ipam/allocator');
|
|
766
|
+
const { stripCIDR } = await import('../ipam/subnet-parser');
|
|
767
|
+
try {
|
|
768
|
+
const ip = stripCIDR(await allocateIPFromSubnet(subnetRow.value, 'internal', db));
|
|
769
|
+
await reserveIP(ip, 'internal', `dns-ingress:${moduleId}`, null, db);
|
|
770
|
+
upsertModuleConfig(db, moduleId, 'dns_ingress_ip', ip);
|
|
771
|
+
log.success(`Allocated DNS-ingress IP ${ip} (internal subnet) for ${moduleId}`);
|
|
772
|
+
} catch (error) {
|
|
773
|
+
return {
|
|
774
|
+
success: false,
|
|
775
|
+
error: `DNS-ingress IP allocation failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
733
781
|
// Infrastructure Properties Resolution (Proxmox provider config)
|
|
734
782
|
// For Proxmox services, extract provider config and store as temporary values
|
|
735
783
|
// This happens during generation so templates can access target_node, lxc_template, etc.
|
package/src/utils/shell.test.ts
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
3
|
-
needsEscaping,
|
|
4
|
-
safeShellEscape,
|
|
5
|
-
shellEscape,
|
|
6
|
-
shellEscapeArray,
|
|
7
|
-
validatePath,
|
|
8
|
-
} from './shell';
|
|
2
|
+
import { shellEscape } from './shell';
|
|
9
3
|
|
|
10
4
|
describe('shellEscape', () => {
|
|
11
5
|
describe('simple paths', () => {
|
|
@@ -176,162 +170,6 @@ describe('shellEscape', () => {
|
|
|
176
170
|
});
|
|
177
171
|
});
|
|
178
172
|
|
|
179
|
-
describe('shellEscapeArray', () => {
|
|
180
|
-
test('escapes array of simple paths', () => {
|
|
181
|
-
const paths = ['/tmp/test1', '/tmp/test2', '/tmp/test3'];
|
|
182
|
-
expect(shellEscapeArray(paths)).toEqual(["'/tmp/test1'", "'/tmp/test2'", "'/tmp/test3'"]);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
test('escapes array of paths with spaces', () => {
|
|
186
|
-
const paths = ['/tmp/test one', '/tmp/test two'];
|
|
187
|
-
expect(shellEscapeArray(paths)).toEqual(["'/tmp/test one'", "'/tmp/test two'"]);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test('escapes empty array', () => {
|
|
191
|
-
expect(shellEscapeArray([])).toEqual([]);
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
test('escapes array with mixed path types', () => {
|
|
195
|
-
const paths = ['/tmp/simple', '/tmp/with spaces', "/tmp/with'quote", '/tmp/$special'];
|
|
196
|
-
expect(shellEscapeArray(paths)).toEqual([
|
|
197
|
-
"'/tmp/simple'",
|
|
198
|
-
"'/tmp/with spaces'",
|
|
199
|
-
"'/tmp/with'\\''quote'",
|
|
200
|
-
"'/tmp/$special'",
|
|
201
|
-
]);
|
|
202
|
-
});
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
describe('needsEscaping', () => {
|
|
206
|
-
test('returns false for simple path', () => {
|
|
207
|
-
expect(needsEscaping('/tmp/test')).toBe(false);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
test('returns false for path with only alphanumeric and slashes', () => {
|
|
211
|
-
expect(needsEscaping('/usr/local/bin/test123')).toBe(false);
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
test('returns false for path with hyphens and underscores', () => {
|
|
215
|
-
expect(needsEscaping('/tmp/test-module_v1')).toBe(false);
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
test('returns false for path with dots', () => {
|
|
219
|
-
expect(needsEscaping('./relative/path.txt')).toBe(false);
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
test('returns true for path with space', () => {
|
|
223
|
-
expect(needsEscaping('/tmp/test module')).toBe(true);
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
test('returns true for path with single quote', () => {
|
|
227
|
-
expect(needsEscaping("/tmp/Bob's Files")).toBe(true);
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
test('returns true for path with double quote', () => {
|
|
231
|
-
expect(needsEscaping('/tmp/"test"')).toBe(true);
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
test('returns true for path with dollar sign', () => {
|
|
235
|
-
expect(needsEscaping('/tmp/$VAR')).toBe(true);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
test('returns true for path with backtick', () => {
|
|
239
|
-
expect(needsEscaping('/tmp/`cmd`')).toBe(true);
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
test('returns true for path with special shell characters', () => {
|
|
243
|
-
const specialChars = [
|
|
244
|
-
'!',
|
|
245
|
-
'&',
|
|
246
|
-
'|',
|
|
247
|
-
';',
|
|
248
|
-
'<',
|
|
249
|
-
'>',
|
|
250
|
-
'(',
|
|
251
|
-
')',
|
|
252
|
-
'[',
|
|
253
|
-
']',
|
|
254
|
-
'{',
|
|
255
|
-
'}',
|
|
256
|
-
'*',
|
|
257
|
-
'?',
|
|
258
|
-
'~',
|
|
259
|
-
'#',
|
|
260
|
-
'\\',
|
|
261
|
-
];
|
|
262
|
-
for (const char of specialChars) {
|
|
263
|
-
expect(needsEscaping(`/tmp/test${char}`)).toBe(true);
|
|
264
|
-
}
|
|
265
|
-
});
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
describe('validatePath', () => {
|
|
269
|
-
test('accepts valid simple path', () => {
|
|
270
|
-
expect(() => validatePath('/tmp/test')).not.toThrow();
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
test('accepts path with spaces', () => {
|
|
274
|
-
expect(() => validatePath('/tmp/test module')).not.toThrow();
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
test('accepts path with special characters', () => {
|
|
278
|
-
expect(() => validatePath('/tmp/$VAR/test')).not.toThrow();
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
test('accepts relative path', () => {
|
|
282
|
-
expect(() => validatePath('./modules/homebridge')).not.toThrow();
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
test('accepts path traversal (..)', () => {
|
|
286
|
-
expect(() => validatePath('../../etc/passwd')).not.toThrow();
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
test('throws on empty string', () => {
|
|
290
|
-
expect(() => validatePath('')).toThrow('Path cannot be empty');
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
test('throws on whitespace-only string', () => {
|
|
294
|
-
expect(() => validatePath(' ')).toThrow('Path cannot be empty');
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
test('throws on null byte', () => {
|
|
298
|
-
expect(() => validatePath('/tmp/test\0file')).toThrow('Path cannot contain null bytes');
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
test('throws on extremely long path', () => {
|
|
302
|
-
const longPath = `/tmp/${'a'.repeat(5000)}`;
|
|
303
|
-
expect(() => validatePath(longPath)).toThrow('Path exceeds maximum length');
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
test('accepts path at maximum length', () => {
|
|
307
|
-
const maxPath = `/tmp/${'a'.repeat(4090)}`; // Total ~4096
|
|
308
|
-
expect(() => validatePath(maxPath)).not.toThrow();
|
|
309
|
-
});
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
describe('safeShellEscape', () => {
|
|
313
|
-
test('validates and escapes valid path', () => {
|
|
314
|
-
expect(safeShellEscape('/tmp/test')).toBe("'/tmp/test'");
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
test('validates and escapes path with spaces', () => {
|
|
318
|
-
expect(safeShellEscape('/tmp/test module')).toBe("'/tmp/test module'");
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
test('throws on empty path', () => {
|
|
322
|
-
expect(() => safeShellEscape('')).toThrow('Path cannot be empty');
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
test('throws on null byte', () => {
|
|
326
|
-
expect(() => safeShellEscape('/tmp/test\0')).toThrow('Path cannot contain null bytes');
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
test('throws on extremely long path', () => {
|
|
330
|
-
const longPath = `/tmp/${'a'.repeat(5000)}`;
|
|
331
|
-
expect(() => safeShellEscape(longPath)).toThrow('Path exceeds maximum length');
|
|
332
|
-
});
|
|
333
|
-
});
|
|
334
|
-
|
|
335
173
|
describe('usage examples', () => {
|
|
336
174
|
test('example: cd command with spaces', () => {
|
|
337
175
|
const modulePath = '/Users/user/Library/Application Support/celilo';
|
package/src/utils/shell.ts
CHANGED
|
@@ -57,103 +57,3 @@ export function shellEscape(path: string): string {
|
|
|
57
57
|
|
|
58
58
|
return `'${escaped}'`;
|
|
59
59
|
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Escapes an array of paths for shell usage.
|
|
63
|
-
*
|
|
64
|
-
* @param paths - Array of paths to escape
|
|
65
|
-
* @returns Array of shell-escaped strings
|
|
66
|
-
*
|
|
67
|
-
* @example
|
|
68
|
-
* ```typescript
|
|
69
|
-
* const paths = ['/tmp/test', '/Users/user/My Files'];
|
|
70
|
-
* const escaped = shellEscapeArray(paths);
|
|
71
|
-
* // Returns: ["'/tmp/test'", "'/Users/user/My Files'"]
|
|
72
|
-
*
|
|
73
|
-
* // Use in command
|
|
74
|
-
* execSync(`cp ${escaped.join(' ')} /dest/`);
|
|
75
|
-
* ```
|
|
76
|
-
*/
|
|
77
|
-
export function shellEscapeArray(paths: string[]): string[] {
|
|
78
|
-
return paths.map((p) => shellEscape(p));
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Checks if a path contains characters that require escaping.
|
|
83
|
-
*
|
|
84
|
-
* This is primarily for logging/debugging - you should ALWAYS escape paths
|
|
85
|
-
* regardless of this check for security and reliability.
|
|
86
|
-
*
|
|
87
|
-
* @param path - Path to check
|
|
88
|
-
* @returns True if path contains special characters
|
|
89
|
-
*
|
|
90
|
-
* @example
|
|
91
|
-
* ```typescript
|
|
92
|
-
* needsEscaping('/tmp/test') // false
|
|
93
|
-
* needsEscaping('/tmp/test module') // true (space)
|
|
94
|
-
* needsEscaping('/tmp/Bob\'s Files') // true (quote)
|
|
95
|
-
* needsEscaping('/tmp/test$var') // true (special char)
|
|
96
|
-
* ```
|
|
97
|
-
*/
|
|
98
|
-
export function needsEscaping(path: string): boolean {
|
|
99
|
-
// Characters that require escaping in shell:
|
|
100
|
-
// - Spaces
|
|
101
|
-
// - Quotes (single and double)
|
|
102
|
-
// - Shell special characters: $ ` ! & | ; < > ( ) [ ] { } * ? ~ #
|
|
103
|
-
// - Backslash
|
|
104
|
-
const specialChars = /[ '"$`!&|;<>()[\]{}*?~#\\]/;
|
|
105
|
-
|
|
106
|
-
return specialChars.test(path);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Validates a path before escaping (throws on clearly invalid inputs).
|
|
111
|
-
*
|
|
112
|
-
* Note: This does NOT validate that the path exists or is accessible,
|
|
113
|
-
* only that it's not obviously malicious or invalid.
|
|
114
|
-
*
|
|
115
|
-
* @param path - Path to validate
|
|
116
|
-
* @throws {Error} If path is clearly invalid or suspicious
|
|
117
|
-
*
|
|
118
|
-
* @example
|
|
119
|
-
* ```typescript
|
|
120
|
-
* validatePath('/tmp/test') // OK
|
|
121
|
-
* validatePath('') // throws: empty path
|
|
122
|
-
* validatePath('../../../etc/passwd') // OK (relative paths allowed)
|
|
123
|
-
* ```
|
|
124
|
-
*/
|
|
125
|
-
export function validatePath(path: string): void {
|
|
126
|
-
if (!path || path.trim().length === 0) {
|
|
127
|
-
throw new Error('Path cannot be empty or whitespace-only');
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Check for null bytes (security risk)
|
|
131
|
-
if (path.includes('\0')) {
|
|
132
|
-
throw new Error('Path cannot contain null bytes');
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Check for extremely long paths (likely an error)
|
|
136
|
-
if (path.length > 4096) {
|
|
137
|
-
throw new Error('Path exceeds maximum length (4096 characters)');
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Safe shell escape with validation.
|
|
143
|
-
*
|
|
144
|
-
* Convenience function that validates then escapes a path.
|
|
145
|
-
*
|
|
146
|
-
* @param path - Path to validate and escape
|
|
147
|
-
* @returns Shell-escaped string
|
|
148
|
-
* @throws {Error} If path is invalid
|
|
149
|
-
*
|
|
150
|
-
* @example
|
|
151
|
-
* ```typescript
|
|
152
|
-
* safeShellEscape('/tmp/My Files') // Returns: '/tmp/My Files'
|
|
153
|
-
* safeShellEscape('') // Throws: Path cannot be empty
|
|
154
|
-
* ```
|
|
155
|
-
*/
|
|
156
|
-
export function safeShellEscape(path: string): string {
|
|
157
|
-
validatePath(path);
|
|
158
|
-
return shellEscape(path);
|
|
159
|
-
}
|
|
@@ -126,11 +126,6 @@ export const CLIServerResponseSchema = z.object({
|
|
|
126
126
|
|
|
127
127
|
export type CLIServerResponse = z.infer<typeof CLIServerResponseSchema>;
|
|
128
128
|
|
|
129
|
-
/**
|
|
130
|
-
* Array of strings (for inventory groups, etc.)
|
|
131
|
-
*/
|
|
132
|
-
export const StringArraySchema = z.array(z.string());
|
|
133
|
-
|
|
134
129
|
/**
|
|
135
130
|
* Helper: Parse JSON with Zod validation
|
|
136
131
|
* Wraps JSON.parse() with schema validation and user-friendly error messages
|
package/src/variables/context.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
machines,
|
|
9
9
|
moduleConfigs,
|
|
10
10
|
moduleInfrastructure,
|
|
11
|
+
moduleSystems,
|
|
11
12
|
modules,
|
|
12
13
|
secrets,
|
|
13
14
|
systemConfig,
|
|
@@ -264,20 +265,48 @@ export async function buildResolutionContext(
|
|
|
264
265
|
const systemResources = getSingularSystemSpec(manifest);
|
|
265
266
|
|
|
266
267
|
if (systemResources) {
|
|
267
|
-
//
|
|
268
|
+
// The DEPLOYED size is the SYSTEM's canonical state (ISS-0150), seeded from
|
|
269
|
+
// requires.system at first provision and thereafter owned by
|
|
270
|
+
// `celilo proxmox … resize`. So sizing flows: module_systems → these config
|
|
271
|
+
// vars → `$self:{cores,memory,disk}` in the instance Terraform.
|
|
272
|
+
//
|
|
273
|
+
// Precedence: the recorded system size WINS and overwrites the cached
|
|
274
|
+
// config (a resize must propagate on the next generate); only when this
|
|
275
|
+
// module has no recorded system size yet (the very first provision, before
|
|
276
|
+
// recordDeployedSystemForModule runs below) do we fall back to
|
|
277
|
+
// requires.system — and seed-when-unset, matching the prior behavior so the
|
|
278
|
+
// first-deploy / golden output is unchanged. `requires.system` stays the
|
|
279
|
+
// minimum floor, never the canonical size. (CLAUDE.md / ISS-0150.)
|
|
280
|
+
const sizedRow = db
|
|
281
|
+
.select({
|
|
282
|
+
cpu: moduleSystems.cpu,
|
|
283
|
+
memory: moduleSystems.memory,
|
|
284
|
+
disk: moduleSystems.disk,
|
|
285
|
+
})
|
|
286
|
+
.from(moduleSystems)
|
|
287
|
+
.where(eq(moduleSystems.moduleId, moduleId))
|
|
288
|
+
.all()
|
|
289
|
+
.find((r) => r.cpu != null || r.memory != null || r.disk != null);
|
|
290
|
+
|
|
268
291
|
const resourceMappings: Array<{
|
|
269
292
|
manifestKey: keyof typeof systemResources;
|
|
270
293
|
configKey: string;
|
|
294
|
+
systemValue: number | null | undefined;
|
|
271
295
|
}> = [
|
|
272
|
-
{ manifestKey: 'cpu', configKey: 'cores' }, //
|
|
273
|
-
{ manifestKey: 'memory', configKey: 'memory' },
|
|
274
|
-
{ manifestKey: 'disk', configKey: 'disk' },
|
|
275
|
-
{ manifestKey: 'storage', configKey: 'storage' },
|
|
296
|
+
{ manifestKey: 'cpu', configKey: 'cores', systemValue: sizedRow?.cpu }, // requires.system.cpu → cores
|
|
297
|
+
{ manifestKey: 'memory', configKey: 'memory', systemValue: sizedRow?.memory },
|
|
298
|
+
{ manifestKey: 'disk', configKey: 'disk', systemValue: sizedRow?.disk },
|
|
299
|
+
{ manifestKey: 'storage', configKey: 'storage', systemValue: undefined }, // pool name, not sizing
|
|
276
300
|
];
|
|
277
301
|
|
|
278
|
-
for (const { manifestKey, configKey } of resourceMappings) {
|
|
302
|
+
for (const { manifestKey, configKey, systemValue } of resourceMappings) {
|
|
303
|
+
if (systemValue != null) {
|
|
304
|
+
// Canonical system size — always wins so a resize propagates.
|
|
305
|
+
upsertModuleConfig(db, moduleId, configKey, systemValue);
|
|
306
|
+
selfConfig[configKey] = String(systemValue);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
279
309
|
const value = systemResources[manifestKey];
|
|
280
|
-
|
|
281
310
|
// Manifest fields are typed (cpu: number, storage: string, etc.).
|
|
282
311
|
// Pass them through unstringified so valueJson preserves the
|
|
283
312
|
// shape — see comment in the variable-defaults block above.
|