@celilo/cli 1.13.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +31 -5
- package/README.md +0 -2
- package/drizzle/0030_drop_module_builds_environment.sql +8 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/capabilities/public-web-helpers.test.ts +12 -6
- package/src/capabilities/public-web-publish.test.ts +42 -13
- package/src/capabilities/validation.test.ts +31 -0
- package/src/cli/commands/alerts-sweep.ts +3 -0
- package/src/cli/commands/console-get-chain.test.ts +96 -0
- package/src/cli/commands/console.ts +13 -5
- package/src/cli/commands/monitor.ts +15 -2
- package/src/cli/commands/notify-config.test.ts +79 -0
- package/src/cli/commands/notify-config.ts +13 -2
- package/src/cli/commands/system-doctor.test.ts +121 -1
- package/src/cli/commands/system-doctor.ts +151 -1
- package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
- package/src/cli/completion.ts +10 -2
- package/src/cli/index.ts +7 -1
- package/src/console/closure.test.ts +76 -0
- package/src/console/closure.ts +87 -1
- package/src/console/control-plane-boundary.test.ts +82 -4
- package/src/console/projection.test.ts +63 -1
- package/src/console/projection.ts +39 -2
- package/src/db/schema.ts +0 -1
- package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
- package/src/hooks/capability-loader.ts +81 -10
- package/src/hooks/executor.ts +110 -17
- package/src/hooks/hook-jail-toolchain-reach.test.ts +224 -0
- package/src/hooks/hook-jail-unreachability.test.ts +28 -2
- package/src/hooks/hook-protocol.ts +44 -0
- package/src/hooks/hook-runner-entry.ts +23 -0
- package/src/hooks/hook-runner.ts +10 -0
- package/src/hooks/hook-trespass.test.ts +9 -3
- package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
- package/src/hooks/jail.test.ts +92 -0
- package/src/hooks/jail.ts +128 -11
- package/src/hooks/mount-set.test.ts +28 -6
- package/src/hooks/mount-set.ts +34 -20
- package/src/hooks/remote-broker.test.ts +350 -0
- package/src/hooks/remote-broker.ts +404 -0
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
- package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
- package/src/hooks/unjailed-lint.test.ts +251 -0
- package/src/hooks/unjailed-lint.ts +395 -0
- package/src/manifest/contracts/v1.ts +22 -1
- package/src/manifest/validate.ts +25 -4
- package/src/module/web-root.ts +35 -0
- package/src/policy/module-business-baseline.ts +27 -3
- package/src/policy/module-script-scan.test.ts +22 -0
- package/src/policy/module-script-scan.ts +92 -1
- package/src/policy/no-hand-built-ssh.test.ts +39 -1
- package/src/policy/no-module-business-in-core.test.ts +1 -1
- package/src/services/alerting/hook-jail.test.ts +66 -0
- package/src/services/alerting/hook-jail.ts +70 -0
- package/src/services/alerting/run-monitor.test.ts +62 -0
- package/src/services/alerting/run-monitor.ts +12 -0
- package/src/services/alerting/sweep-runner.test.ts +1 -0
- package/src/services/api-principal-enrolment.test.ts +73 -0
- package/src/services/api-principal-enrolment.ts +55 -0
- package/src/services/backup-create.ts +36 -7
- package/src/services/backup-restore.ts +2 -0
- package/src/services/celilo-mgmt-hooks.test.ts +38 -79
- package/src/services/deploy-ansible.ts +9 -1
- package/src/services/fleet-key.test.ts +47 -0
- package/src/services/fleet-key.ts +75 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/module-build.test.ts +1 -64
- package/src/services/module-build.ts +10 -86
- package/src/services/module-deploy.ts +20 -0
- package/src/services/remote-access.test.ts +139 -0
- package/src/services/remote-access.ts +98 -0
- package/src/services/restore-from-file.ts +12 -6
- package/src/services/static-content-converge.test.ts +338 -0
- package/src/services/static-content-converge.ts +299 -0
- package/src/services/system-state-stage.test.ts +165 -0
- package/src/services/system-state-stage.ts +196 -0
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
runProviderBackfill,
|
|
47
47
|
} from './provider-arrival';
|
|
48
48
|
import { checkProxmoxReachable, formatProxmoxUnreachableError } from './proxmox-preflight';
|
|
49
|
+
import { remoteAccessPolicy } from './remote-access';
|
|
49
50
|
import { LOCAL_MACHINE_IP, deleteTemporarySshKey, writeTemporarySshKey } from './ssh-key-manager';
|
|
50
51
|
import { buildTerraformEnvForService } from './terraform-env';
|
|
51
52
|
|
|
@@ -720,6 +721,7 @@ async function deployModuleImpl(
|
|
|
720
721
|
capabilities: capabilityFunctions,
|
|
721
722
|
requiredCapabilities: manifest.requires.capabilities.map((c) => c.name),
|
|
722
723
|
systems: getModuleSystems(moduleId, db),
|
|
724
|
+
remoteAccess: remoteAccessPolicy(moduleId, db),
|
|
723
725
|
},
|
|
724
726
|
);
|
|
725
727
|
|
|
@@ -858,6 +860,7 @@ async function deployModuleImpl(
|
|
|
858
860
|
capabilities: capFns,
|
|
859
861
|
requiredCapabilities: (manifest.requires?.capabilities ?? []).map((c) => c.name),
|
|
860
862
|
systems: getModuleSystems(moduleId, db),
|
|
863
|
+
remoteAccess: remoteAccessPolicy(moduleId, db),
|
|
861
864
|
},
|
|
862
865
|
};
|
|
863
866
|
},
|
|
@@ -1159,6 +1162,7 @@ async function deployModuleImpl(
|
|
|
1159
1162
|
capabilities: capFunctions,
|
|
1160
1163
|
requiredCapabilities: providerManifest.requires.capabilities.map((c) => c.name),
|
|
1161
1164
|
systems: getModuleSystems(capRecord.moduleId, db),
|
|
1165
|
+
remoteAccess: remoteAccessPolicy(capRecord.moduleId, db),
|
|
1162
1166
|
},
|
|
1163
1167
|
);
|
|
1164
1168
|
|
|
@@ -1308,6 +1312,21 @@ async function deployModuleImpl(
|
|
|
1308
1312
|
}
|
|
1309
1313
|
}
|
|
1310
1314
|
|
|
1315
|
+
// The provider's own deploy runs the static-content converge alongside
|
|
1316
|
+
// everything else (design D10, task 4.5): the release set is written into
|
|
1317
|
+
// the generated inventory, the playbook's static_content-tagged tasks
|
|
1318
|
+
// converge /srv/www, and a rebuilt host recovers with no consumer
|
|
1319
|
+
// involvement. Same plan + writer the publish-time converge uses — one
|
|
1320
|
+
// code path, two callers.
|
|
1321
|
+
if (manifest.provides?.capabilities?.some((cap) => cap.name === 'public_web')) {
|
|
1322
|
+
const { writeStaticContentVars } = await import('./static-content-converge');
|
|
1323
|
+
const staticVars = await writeStaticContentVars(db, moduleId, generatedPath);
|
|
1324
|
+
if (!staticVars.success) {
|
|
1325
|
+
return { success: false, error: staticVars.error, phases };
|
|
1326
|
+
}
|
|
1327
|
+
log.success('Static-content release set written to the inventory');
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1311
1330
|
try {
|
|
1312
1331
|
const ansibleResult = await executeAnsible(generatedPath, {
|
|
1313
1332
|
noInteractive: !process.stdout.isTTY,
|
|
@@ -1412,6 +1431,7 @@ async function deployModuleImpl(
|
|
|
1412
1431
|
capabilities: capFns,
|
|
1413
1432
|
requiredCapabilities: manifest.requires.capabilities.map((c) => c.name),
|
|
1414
1433
|
systems: getModuleSystems(moduleId, db),
|
|
1434
|
+
remoteAccess: remoteAccessPolicy(moduleId, db),
|
|
1415
1435
|
},
|
|
1416
1436
|
};
|
|
1417
1437
|
},
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D12's reachability policy (hook-process-boundary stage 3, tasks 5.1b/5.2).
|
|
3
|
+
*
|
|
4
|
+
* The rules under test, in the order they short-circuit:
|
|
5
|
+
* 1. a request on the module's OWN credential is allowed — the credential is
|
|
6
|
+
* the scope;
|
|
7
|
+
* 2. an explicit non-root user is allowed — the fleet key's authority is
|
|
8
|
+
* root on fleet systems, and a non-root account trusts it only where an
|
|
9
|
+
* operator installed it (the cPanel bootstrap);
|
|
10
|
+
* 3. otherwise the target must be a system the module or one of its
|
|
11
|
+
* instances provisioned, and a refusal names the module, the target, and
|
|
12
|
+
* the capability route.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
16
|
+
import { existsSync } from 'node:fs';
|
|
17
|
+
import { rm } from 'node:fs/promises';
|
|
18
|
+
import { type DbClient, createDbClient } from '../db/client';
|
|
19
|
+
import { capabilities, moduleInstances, moduleSystems, modules } from '../db/schema';
|
|
20
|
+
import { remoteAccessPolicy } from './remote-access';
|
|
21
|
+
|
|
22
|
+
const TEST_DB_PATH = './test-remote-access.db';
|
|
23
|
+
|
|
24
|
+
function seedModule(db: DbClient, id: string): void {
|
|
25
|
+
db.insert(modules)
|
|
26
|
+
.values({
|
|
27
|
+
id,
|
|
28
|
+
name: id,
|
|
29
|
+
version: '1.0.0',
|
|
30
|
+
manifestData: {},
|
|
31
|
+
sourcePath: `/tmp/${id}`,
|
|
32
|
+
state: 'VERIFIED',
|
|
33
|
+
})
|
|
34
|
+
.run();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function seedSystem(db: DbClient, moduleId: string, ipv4Address: string, hostname: string): void {
|
|
38
|
+
db.insert(moduleSystems)
|
|
39
|
+
.values({
|
|
40
|
+
moduleId,
|
|
41
|
+
name: 'main',
|
|
42
|
+
hostname,
|
|
43
|
+
ipv4Address,
|
|
44
|
+
zone: 'dmz',
|
|
45
|
+
infraType: 'machine',
|
|
46
|
+
updatedAt: new Date(),
|
|
47
|
+
})
|
|
48
|
+
.run();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('remoteAccessPolicy', () => {
|
|
52
|
+
let db: DbClient;
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
db = createDbClient({ path: TEST_DB_PATH });
|
|
56
|
+
seedModule(db, 'caddy');
|
|
57
|
+
seedSystem(db, 'caddy', '10.0.10.10', 'www');
|
|
58
|
+
seedModule(db, 'homebridge');
|
|
59
|
+
seedSystem(db, 'homebridge', '10.0.30.7', 'homebridge');
|
|
60
|
+
db.insert(capabilities)
|
|
61
|
+
.values({ moduleId: 'caddy', capabilityName: 'public_web', version: '1.0.0', data: {} })
|
|
62
|
+
.run();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(async () => {
|
|
66
|
+
db.$client.close();
|
|
67
|
+
for (const suffix of ['', '-shm', '-wal']) {
|
|
68
|
+
const p = `${TEST_DB_PATH}${suffix}`;
|
|
69
|
+
if (existsSync(p)) await rm(p);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("allows the module's own system", () => {
|
|
74
|
+
const policy = remoteAccessPolicy('caddy', db);
|
|
75
|
+
expect(policy.checkTarget({ ipv4_address: '10.0.10.10' }, false)).toEqual({ allowed: true });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("allows an instance's system, one level deep", () => {
|
|
79
|
+
seedModule(db, 'forgejo-runner-abc123def456');
|
|
80
|
+
db.insert(moduleInstances)
|
|
81
|
+
.values({
|
|
82
|
+
moduleId: 'forgejo-runner-abc123def456',
|
|
83
|
+
parentId: 'homebridge',
|
|
84
|
+
submodule: 'runner',
|
|
85
|
+
instanceKey: 'lab',
|
|
86
|
+
state: 'ready',
|
|
87
|
+
})
|
|
88
|
+
.run();
|
|
89
|
+
seedSystem(db, 'forgejo-runner-abc123def456', '10.0.30.9', 'runner-lab');
|
|
90
|
+
|
|
91
|
+
const policy = remoteAccessPolicy('homebridge', db);
|
|
92
|
+
expect(policy.checkTarget({ ipv4_address: '10.0.30.9' }, false)).toEqual({ allowed: true });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("refuses another module's system, naming module, target and the capability route", () => {
|
|
96
|
+
const verdict = remoteAccessPolicy('homebridge', db).checkTarget(
|
|
97
|
+
{ ipv4_address: '10.0.10.10' },
|
|
98
|
+
false,
|
|
99
|
+
);
|
|
100
|
+
expect(verdict.allowed).toBe(false);
|
|
101
|
+
if (verdict.allowed) throw new Error('unreachable');
|
|
102
|
+
expect(verdict.message).toContain("Module 'homebridge'");
|
|
103
|
+
expect(verdict.message).toContain('10.0.10.10');
|
|
104
|
+
expect(verdict.message).toContain("belongs to module 'caddy'");
|
|
105
|
+
expect(verdict.message).toContain('public_web');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('refuses an address no deployed system has, with the generic guidance', () => {
|
|
109
|
+
const verdict = remoteAccessPolicy('homebridge', db).checkTarget(
|
|
110
|
+
{ ipv4_address: '203.0.113.50' },
|
|
111
|
+
false,
|
|
112
|
+
);
|
|
113
|
+
expect(verdict.allowed).toBe(false);
|
|
114
|
+
if (verdict.allowed) throw new Error('unreachable');
|
|
115
|
+
expect(verdict.message).toContain("Module 'homebridge'");
|
|
116
|
+
expect(verdict.message).toContain('203.0.113.50');
|
|
117
|
+
expect(verdict.message).toContain('capability');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('allows any target when the request carries its own credential', () => {
|
|
121
|
+
const policy = remoteAccessPolicy('homebridge', db);
|
|
122
|
+
expect(policy.checkTarget({ ipv4_address: '10.0.10.10' }, true)).toEqual({ allowed: true });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('allows an explicit non-root user (the off-fleet account case)', () => {
|
|
126
|
+
const policy = remoteAccessPolicy('generic-cpanel-hosting-provider', db);
|
|
127
|
+
expect(
|
|
128
|
+
policy.checkTarget({ ipv4_address: '198.51.100.7', user: 'peba-hosting' }, false),
|
|
129
|
+
).toEqual({ allowed: true });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("an explicit 'root' user gets no such pass", () => {
|
|
133
|
+
const verdict = remoteAccessPolicy('homebridge', db).checkTarget(
|
|
134
|
+
{ ipv4_address: '10.0.10.10', user: 'root' },
|
|
135
|
+
false,
|
|
136
|
+
);
|
|
137
|
+
expect(verdict.allowed).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D12's reachability policy: which remote targets a module may reach with the
|
|
3
|
+
* FLEET credential (openspec/changes/hook-process-boundary, stage 3).
|
|
4
|
+
*
|
|
5
|
+
* The rule: a module may reach the systems it provisioned — its own and its
|
|
6
|
+
* instances' (`ownedSystemModuleIds`, one indexed query because ownership is
|
|
7
|
+
* one level deep) — and reaches everything else through capabilities. The
|
|
8
|
+
* mount set is derived from the hook context; the reachable set is derived
|
|
9
|
+
* from the same identity.
|
|
10
|
+
*
|
|
11
|
+
* Two carve-outs, both following from "scoped by the CREDENTIAL":
|
|
12
|
+
*
|
|
13
|
+
* - A request carrying the module's OWN credential (an identity key, or
|
|
14
|
+
* `installAuthorizedKey`'s password) is not riding the fleet key at all.
|
|
15
|
+
* The credential is the scope: it works exactly where its holder was
|
|
16
|
+
* given access, which celilo neither grants nor needs to police.
|
|
17
|
+
* - An explicit NON-ROOT user. The fleet key's authority is root on the
|
|
18
|
+
* systems celilo provisions — that is the only place celilo installs it.
|
|
19
|
+
* A non-root account trusts it only where an operator deliberately put it
|
|
20
|
+
* there (the cPanel bootstrap installs it into an unprivileged hosting
|
|
21
|
+
* account), and refusing those would break every off-fleet provider while
|
|
22
|
+
* protecting nothing the key actually unlocks. The residual — an operator
|
|
23
|
+
* who installed the fleet key as root on an OFF-fleet box — is refused
|
|
24
|
+
* until that target carries its own identity, and design D12 records it.
|
|
25
|
+
*
|
|
26
|
+
* **Attribution (D12, task 5.1b).** A policy is built for the module that
|
|
27
|
+
* PERFORMS the operation, never the one that asked for it. For a hook that is
|
|
28
|
+
* the hook's module — the nine `invokeHook` call sites construct it with the
|
|
29
|
+
* id they are invoking. A capability provider's internal transport belongs to
|
|
30
|
+
* the provider's module; providers run in celilo's own process today (design
|
|
31
|
+
* D10), so their transport does not cross the remote-ops broker, and the one
|
|
32
|
+
* hand-built-ssh provider site (`public_web`'s upload) is being replaced by
|
|
33
|
+
* an Ansible converge under `openspec/changes/capability-owned-tables`
|
|
34
|
+
* (celilo#1014). If provider transport is ever brokered, it takes a policy
|
|
35
|
+
* built with the PROVIDER's id from this same function — neither "the calling
|
|
36
|
+
* module's systems" nor an exemption.
|
|
37
|
+
*
|
|
38
|
+
* Policy function (Rule 10.1) — decides; the remote-ops broker enforces.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { eq } from 'drizzle-orm';
|
|
42
|
+
import type { DbClient } from '../db/client';
|
|
43
|
+
import { capabilities, moduleSystems } from '../db/schema';
|
|
44
|
+
import type { RemoteAccessPolicy } from '../hooks/remote-broker';
|
|
45
|
+
import { getModuleSystems } from './deployed-systems';
|
|
46
|
+
import { ownedSystemModuleIds } from './module-instances';
|
|
47
|
+
|
|
48
|
+
/** Build the policy one hook run's remote operations are checked against. */
|
|
49
|
+
export function remoteAccessPolicy(moduleId: string, db: DbClient): RemoteAccessPolicy {
|
|
50
|
+
return {
|
|
51
|
+
moduleId,
|
|
52
|
+
checkTarget(target, hasOwnCredential) {
|
|
53
|
+
if (hasOwnCredential) return { allowed: true };
|
|
54
|
+
if (target.user !== undefined && target.user !== 'root') return { allowed: true };
|
|
55
|
+
|
|
56
|
+
// Queried per call rather than snapshotted at invoke time, so a system
|
|
57
|
+
// recorded mid-run (a provider arrival) is visible to the next check.
|
|
58
|
+
const owned = ownedSystemModuleIds(moduleId, db).flatMap((id) => getModuleSystems(id, db));
|
|
59
|
+
if (owned.some((system) => system.ipv4_address === target.ipv4_address)) {
|
|
60
|
+
return { allowed: true };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { allowed: false, message: refusalMessage(moduleId, target.ipv4_address, db) };
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Name the module, the target, and the capability route (task 5.2). The owner
|
|
70
|
+
* lookup is best-effort colour: the refusal stands whether or not celilo can
|
|
71
|
+
* say whose system it is.
|
|
72
|
+
*/
|
|
73
|
+
function refusalMessage(moduleId: string, ipv4Address: string, db: DbClient): string {
|
|
74
|
+
const preamble = `Module '${moduleId}' may not reach ${ipv4Address}: it is not a system this module (or one of its instances) provisioned.`;
|
|
75
|
+
const guidance =
|
|
76
|
+
'A hook reaches its own systems via ctx.systems and everything else through a capability (hook-process-boundary, design D12).';
|
|
77
|
+
|
|
78
|
+
const owner = db
|
|
79
|
+
.select()
|
|
80
|
+
.from(moduleSystems)
|
|
81
|
+
.where(eq(moduleSystems.ipv4Address, ipv4Address))
|
|
82
|
+
.get();
|
|
83
|
+
if (!owner) return `${preamble} ${guidance}`;
|
|
84
|
+
|
|
85
|
+
const provided = db
|
|
86
|
+
.select({ capabilityName: capabilities.capabilityName })
|
|
87
|
+
.from(capabilities)
|
|
88
|
+
.where(eq(capabilities.moduleId, owner.moduleId))
|
|
89
|
+
.all()
|
|
90
|
+
.map((row) => row.capabilityName);
|
|
91
|
+
|
|
92
|
+
const route =
|
|
93
|
+
provided.length > 0
|
|
94
|
+
? `Reach it through a capability it provides ('${provided.join("', '")}').`
|
|
95
|
+
: "Reach it through a capability, never by SSH from another module's hook.";
|
|
96
|
+
|
|
97
|
+
return `${preamble} That system ('${owner.hostname}') belongs to module '${owner.moduleId}'. ${route}`;
|
|
98
|
+
}
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
rmSync,
|
|
33
33
|
} from 'node:fs';
|
|
34
34
|
import { tmpdir } from 'node:os';
|
|
35
|
-
import {
|
|
35
|
+
import { join } from 'node:path';
|
|
36
36
|
import { eq } from 'drizzle-orm';
|
|
37
37
|
import { getDbPath, getMasterKeyPath, getModuleStoragePath } from '../config/paths';
|
|
38
38
|
import { closeDb, getDb } from '../db/client';
|
|
@@ -47,12 +47,14 @@ import { decryptFileToFile } from './backup-cipher';
|
|
|
47
47
|
import { assertCompatibleSchema, parseManifest } from './backup-manifest';
|
|
48
48
|
import { applyCrossModuleWriteRoot, moduleHasCrossModuleRead } from './cross-module-read';
|
|
49
49
|
import { getModuleSystems } from './deployed-systems';
|
|
50
|
+
import { getFleetSshDir } from './fleet-key';
|
|
50
51
|
import {
|
|
51
52
|
completeOperation,
|
|
52
53
|
failOperation,
|
|
53
54
|
refuseIfInFlight,
|
|
54
55
|
startOperation,
|
|
55
56
|
} from './module-operations';
|
|
57
|
+
import { remoteAccessPolicy } from './remote-access';
|
|
56
58
|
|
|
57
59
|
export interface RestoreFromFileResult {
|
|
58
60
|
success: boolean;
|
|
@@ -215,7 +217,11 @@ export async function restoreFromArtifactFile(
|
|
|
215
217
|
{},
|
|
216
218
|
{},
|
|
217
219
|
logger,
|
|
218
|
-
{
|
|
220
|
+
{
|
|
221
|
+
debug: false,
|
|
222
|
+
systems: getModuleSystems(mod.id, db),
|
|
223
|
+
remoteAccess: remoteAccessPolicy(mod.id, db),
|
|
224
|
+
},
|
|
219
225
|
);
|
|
220
226
|
if (!hookResult.success) {
|
|
221
227
|
const errMsg = hookResult.error ?? 'on_restore hook failed';
|
|
@@ -376,10 +382,10 @@ export function applyStagedSystemFiles(systemStagingDir: string): StagedSystemAp
|
|
|
376
382
|
// managed machines; the DB only carries the public half, so without the
|
|
377
383
|
// private half on disk the restored box can't authenticate to the fleet.
|
|
378
384
|
if (existsSync(stagedSsh)) {
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
const liveSshDir =
|
|
385
|
+
// One helper, shared with the minting side, so restore and mint cannot
|
|
386
|
+
// drift to different directories. See getFleetSshDir for why it follows
|
|
387
|
+
// the DB rather than getDataDir().
|
|
388
|
+
const liveSshDir = getFleetSshDir();
|
|
383
389
|
mkdirSync(liveSshDir, { recursive: true, mode: 0o700 });
|
|
384
390
|
chmodSync(liveSshDir, 0o700);
|
|
385
391
|
for (const entry of readdirSync(stagedSsh)) {
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { eq } from 'drizzle-orm';
|
|
6
|
+
import type { DbClient } from '../db/client';
|
|
7
|
+
import { moduleConfigs, modules, webRoutes } from '../db/schema';
|
|
8
|
+
import { setupTestDatabaseAt } from '../test-utils/database';
|
|
9
|
+
import {
|
|
10
|
+
buildStaticContentPlan,
|
|
11
|
+
planStaticContent,
|
|
12
|
+
resolveStaticContentRetention,
|
|
13
|
+
staticContentVarsPath,
|
|
14
|
+
staticContentVarsYaml,
|
|
15
|
+
writeStaticContentVars,
|
|
16
|
+
} from './static-content-converge';
|
|
17
|
+
|
|
18
|
+
const HASH1 = 'a'.repeat(64);
|
|
19
|
+
const HASH2 = 'b'.repeat(64);
|
|
20
|
+
|
|
21
|
+
describe('planStaticContent — the declared release set (D10 task 4.1)', () => {
|
|
22
|
+
let dir: string;
|
|
23
|
+
let db: DbClient;
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
dir = mkdtempSync(join(tmpdir(), 'static-converge-'));
|
|
27
|
+
const dbPath = join(dir, 'celilo.db');
|
|
28
|
+
process.env.CELILO_DB_PATH = dbPath;
|
|
29
|
+
db = await setupTestDatabaseAt(dbPath);
|
|
30
|
+
|
|
31
|
+
// Two installed modules with real web roots on disk.
|
|
32
|
+
writeSite('lunacycle', '<html>luna</html>');
|
|
33
|
+
writeSite('hello-foo', '<html>foo</html>');
|
|
34
|
+
db.insert(modules)
|
|
35
|
+
.values({
|
|
36
|
+
id: 'lunacycle',
|
|
37
|
+
name: 'Lunacycle',
|
|
38
|
+
version: '1.0.0',
|
|
39
|
+
manifestData: {},
|
|
40
|
+
sourcePath: join(dir, 'modules', 'lunacycle'),
|
|
41
|
+
})
|
|
42
|
+
.onConflictDoNothing()
|
|
43
|
+
.run();
|
|
44
|
+
db.insert(modules)
|
|
45
|
+
.values({
|
|
46
|
+
id: 'hello-foo',
|
|
47
|
+
name: 'Hello Foo',
|
|
48
|
+
version: '0.1.2',
|
|
49
|
+
manifestData: {},
|
|
50
|
+
sourcePath: join(dir, 'modules', 'hello-foo'),
|
|
51
|
+
})
|
|
52
|
+
.onConflictDoNothing()
|
|
53
|
+
.run();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
db.$client.close();
|
|
58
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
59
|
+
try {
|
|
60
|
+
rmSync(dir, { recursive: true, force: true });
|
|
61
|
+
} catch {
|
|
62
|
+
/* ignore */
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
function writeSite(moduleId: string, html: string): string {
|
|
67
|
+
const webRoot = join(dir, 'modules', moduleId, 'site', 'dist');
|
|
68
|
+
mkdirSync(webRoot, { recursive: true });
|
|
69
|
+
writeFileSync(join(webRoot, 'index.html'), html, 'utf-8');
|
|
70
|
+
return webRoot;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function insertRoute(route: {
|
|
74
|
+
slug: string;
|
|
75
|
+
moduleId: string;
|
|
76
|
+
hostname: string;
|
|
77
|
+
path?: string;
|
|
78
|
+
type?: 'static' | 'reverse_proxy';
|
|
79
|
+
contentHash?: string | null;
|
|
80
|
+
}) {
|
|
81
|
+
db.insert(webRoutes)
|
|
82
|
+
.values({
|
|
83
|
+
slug: route.slug,
|
|
84
|
+
moduleId: route.moduleId,
|
|
85
|
+
type: route.type ?? 'static',
|
|
86
|
+
path: route.path ?? '/',
|
|
87
|
+
hostname: route.hostname,
|
|
88
|
+
websocket: false,
|
|
89
|
+
contentHash: route.contentHash ?? HASH1,
|
|
90
|
+
})
|
|
91
|
+
.run();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
it('groups by slug across hostnames and derives the source dir', () => {
|
|
95
|
+
insertRoute({ slug: 'lunacycle', moduleId: 'lunacycle', hostname: 'lunacycle.net' });
|
|
96
|
+
insertRoute({
|
|
97
|
+
slug: 'lunacycle',
|
|
98
|
+
moduleId: 'lunacycle',
|
|
99
|
+
hostname: 'www.example.com',
|
|
100
|
+
path: '/x',
|
|
101
|
+
});
|
|
102
|
+
insertRoute({
|
|
103
|
+
slug: 'hello-foo',
|
|
104
|
+
moduleId: 'hello-foo',
|
|
105
|
+
hostname: 'www.example.com',
|
|
106
|
+
path: '/foo',
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const releases = planStaticContent(db);
|
|
110
|
+
|
|
111
|
+
// Reverse-proxy rows and rows without a hash are not releases.
|
|
112
|
+
expect(releases).toHaveLength(2);
|
|
113
|
+
const luna = releases.find((r) => r.slug === 'lunacycle');
|
|
114
|
+
expect(luna?.hostnames).toEqual(['lunacycle.net', 'www.example.com']);
|
|
115
|
+
expect(luna?.sourceDir).toBe(join(dir, 'modules', 'lunacycle', 'site', 'dist'));
|
|
116
|
+
expect(luna?.contentHash).toBe(HASH1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('a slug claimed by two modules is a contradiction, not a merge', () => {
|
|
120
|
+
insertRoute({ slug: 'about', moduleId: 'lunacycle', hostname: 'a.example.com' });
|
|
121
|
+
insertRoute({ slug: 'about', moduleId: 'hello-foo', hostname: 'b.example.com' });
|
|
122
|
+
|
|
123
|
+
expect(() => planStaticContent(db)).toThrow(/claimed by 2 modules/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('rows sharing a slug must share a hash', () => {
|
|
127
|
+
insertRoute({ slug: 'lunacycle', moduleId: 'lunacycle', hostname: 'lunacycle.net' });
|
|
128
|
+
insertRoute({
|
|
129
|
+
slug: 'lunacycle',
|
|
130
|
+
moduleId: 'lunacycle',
|
|
131
|
+
hostname: 'www.example.com',
|
|
132
|
+
path: '/x',
|
|
133
|
+
contentHash: HASH2,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(() => planStaticContent(db)).toThrow(/content hashes/);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('a missing web root fails loudly instead of converging an empty release', () => {
|
|
140
|
+
insertRoute({ slug: 'lunacycle', moduleId: 'lunacycle', hostname: 'lunacycle.net' });
|
|
141
|
+
rmSync(join(dir, 'modules', 'lunacycle', 'site', 'dist'), { recursive: true, force: true });
|
|
142
|
+
|
|
143
|
+
expect(() => planStaticContent(db)).toThrow(/no built site/);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('resolveStaticContentRetention — operator config with a default', () => {
|
|
148
|
+
let dir: string;
|
|
149
|
+
let db: DbClient;
|
|
150
|
+
|
|
151
|
+
beforeEach(async () => {
|
|
152
|
+
dir = mkdtempSync(join(tmpdir(), 'static-retention-'));
|
|
153
|
+
process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
|
|
154
|
+
db = await setupTestDatabaseAt(join(dir, 'celilo.db'));
|
|
155
|
+
db.insert(modules)
|
|
156
|
+
.values({
|
|
157
|
+
id: 'caddy',
|
|
158
|
+
name: 'Caddy',
|
|
159
|
+
version: '2.3.8',
|
|
160
|
+
manifestData: {
|
|
161
|
+
variables: { owns: [{ name: 'static_release_retention', default: 5 }] },
|
|
162
|
+
},
|
|
163
|
+
sourcePath: join(dir, 'modules', 'caddy'),
|
|
164
|
+
})
|
|
165
|
+
.onConflictDoNothing()
|
|
166
|
+
.run();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
afterEach(() => {
|
|
170
|
+
db.$client.close();
|
|
171
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
172
|
+
try {
|
|
173
|
+
rmSync(dir, { recursive: true, force: true });
|
|
174
|
+
} catch {
|
|
175
|
+
/* ignore */
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('operator config wins over the manifest default', () => {
|
|
180
|
+
db.insert(moduleConfigs)
|
|
181
|
+
.values({ moduleId: 'caddy', key: 'static_release_retention', value: '2', valueJson: '2' })
|
|
182
|
+
.run();
|
|
183
|
+
expect(resolveStaticContentRetention(db, 'caddy')).toBe(2);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('falls back to the manifest default, then the constant', () => {
|
|
187
|
+
expect(resolveStaticContentRetention(db, 'caddy')).toBe(5);
|
|
188
|
+
|
|
189
|
+
db.update(modules).set({ manifestData: {} }).where(eq(modules.id, 'caddy')).run();
|
|
190
|
+
expect(resolveStaticContentRetention(db, 'caddy')).toBe(5);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('rejects a retention that cannot keep the current release', () => {
|
|
194
|
+
db.insert(moduleConfigs)
|
|
195
|
+
.values({ moduleId: 'caddy', key: 'static_release_retention', value: '0', valueJson: '0' })
|
|
196
|
+
.run();
|
|
197
|
+
expect(() => resolveStaticContentRetention(db, 'caddy')).toThrow(/positive integer/);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('staticContentVarsYaml — deterministic desired state', () => {
|
|
202
|
+
it('carries slug, content_hash, hostnames and the derived source dir, keyed by slug', () => {
|
|
203
|
+
const yaml = staticContentVarsYaml({
|
|
204
|
+
retention: 3,
|
|
205
|
+
releases: [
|
|
206
|
+
{
|
|
207
|
+
slug: 'hello-foo',
|
|
208
|
+
contentHash: HASH2,
|
|
209
|
+
hostnames: ['www.example.com'],
|
|
210
|
+
sourceDir: '/data/modules/hello-foo/site/dist',
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
slug: 'lunacycle',
|
|
214
|
+
contentHash: HASH1,
|
|
215
|
+
hostnames: ['lunacycle.net', 'www.example.com'],
|
|
216
|
+
sourceDir: '/data/modules/lunacycle/site/dist',
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(yaml).toContain('static_release_retention: 3');
|
|
222
|
+
expect(yaml).toContain('slug: lunacycle');
|
|
223
|
+
expect(yaml).toContain(`content_hash: ${HASH1}`);
|
|
224
|
+
expect(yaml).toContain('source_dir: /data/modules/lunacycle/site/dist');
|
|
225
|
+
// Deterministic: same input, same bytes — the idempotence of the WRITE
|
|
226
|
+
// depends on it.
|
|
227
|
+
expect(yaml).toBe(
|
|
228
|
+
staticContentVarsYaml({
|
|
229
|
+
retention: 3,
|
|
230
|
+
releases: [
|
|
231
|
+
{
|
|
232
|
+
slug: 'hello-foo',
|
|
233
|
+
contentHash: HASH2,
|
|
234
|
+
hostnames: ['www.example.com'],
|
|
235
|
+
sourceDir: '/data/modules/hello-foo/site/dist',
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
slug: 'lunacycle',
|
|
239
|
+
contentHash: HASH1,
|
|
240
|
+
hostnames: ['lunacycle.net', 'www.example.com'],
|
|
241
|
+
sourceDir: '/data/modules/lunacycle/site/dist',
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
}),
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('writeStaticContentVars — lands in the generated inventory', () => {
|
|
250
|
+
let dir: string;
|
|
251
|
+
let db: DbClient;
|
|
252
|
+
|
|
253
|
+
beforeEach(async () => {
|
|
254
|
+
dir = mkdtempSync(join(tmpdir(), 'static-vars-'));
|
|
255
|
+
process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
|
|
256
|
+
db = await setupTestDatabaseAt(join(dir, 'celilo.db'));
|
|
257
|
+
mkdirSync(join(dir, 'modules', 'caddy', 'site', 'dist'), { recursive: true });
|
|
258
|
+
writeFileSync(
|
|
259
|
+
join(dir, 'modules', 'caddy', 'site', 'dist', 'index.html'),
|
|
260
|
+
'<html>caddy</html>',
|
|
261
|
+
'utf-8',
|
|
262
|
+
);
|
|
263
|
+
db.insert(modules)
|
|
264
|
+
.values({
|
|
265
|
+
id: 'caddy',
|
|
266
|
+
name: 'Caddy',
|
|
267
|
+
version: '2.3.8',
|
|
268
|
+
manifestData: {},
|
|
269
|
+
sourcePath: join(dir, 'modules', 'caddy'),
|
|
270
|
+
})
|
|
271
|
+
.onConflictDoNothing()
|
|
272
|
+
.run();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
afterEach(() => {
|
|
276
|
+
db.$client.close();
|
|
277
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
278
|
+
try {
|
|
279
|
+
rmSync(dir, { recursive: true, force: true });
|
|
280
|
+
} catch {
|
|
281
|
+
/* ignore */
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('writes the vars file and never writes into a module tree (task 4.7)', async () => {
|
|
286
|
+
db.insert(webRoutes)
|
|
287
|
+
.values({
|
|
288
|
+
slug: 'caddy',
|
|
289
|
+
moduleId: 'caddy',
|
|
290
|
+
type: 'static',
|
|
291
|
+
path: '/',
|
|
292
|
+
hostname: 'www.example.com',
|
|
293
|
+
websocket: false,
|
|
294
|
+
contentHash: HASH1,
|
|
295
|
+
})
|
|
296
|
+
.run();
|
|
297
|
+
|
|
298
|
+
const moduleTreeBefore = readFileSync(
|
|
299
|
+
join(dir, 'modules', 'caddy', 'site', 'dist', 'index.html'),
|
|
300
|
+
'utf-8',
|
|
301
|
+
);
|
|
302
|
+
const result = await writeStaticContentVars(
|
|
303
|
+
db,
|
|
304
|
+
'caddy',
|
|
305
|
+
join(dir, 'modules', 'caddy', 'generated'),
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
expect(result.success).toBe(true);
|
|
309
|
+
const varsPath = staticContentVarsPath(join(dir, 'modules', 'caddy', 'generated'));
|
|
310
|
+
expect(existsSync(varsPath)).toBe(true);
|
|
311
|
+
expect(readFileSync(varsPath, 'utf-8')).toContain('slug: caddy');
|
|
312
|
+
|
|
313
|
+
// Task 4.7: the converge path is read-only over the module tree. The
|
|
314
|
+
// clientConfig write into it is celilo#1018 and belongs to that thread —
|
|
315
|
+
// THIS path must not add a second writer.
|
|
316
|
+
expect(readFileSync(join(dir, 'modules', 'caddy', 'site', 'dist', 'index.html'), 'utf-8')).toBe(
|
|
317
|
+
moduleTreeBefore,
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('buildStaticContentPlan carries the resolved retention alongside the releases', async () => {
|
|
322
|
+
db.insert(webRoutes)
|
|
323
|
+
.values({
|
|
324
|
+
slug: 'caddy',
|
|
325
|
+
moduleId: 'caddy',
|
|
326
|
+
type: 'static',
|
|
327
|
+
path: '/',
|
|
328
|
+
hostname: 'www.example.com',
|
|
329
|
+
websocket: false,
|
|
330
|
+
contentHash: HASH1,
|
|
331
|
+
})
|
|
332
|
+
.run();
|
|
333
|
+
|
|
334
|
+
const plan = buildStaticContentPlan(db, 'caddy');
|
|
335
|
+
expect(plan.retention).toBe(5);
|
|
336
|
+
expect(plan.releases).toHaveLength(1);
|
|
337
|
+
});
|
|
338
|
+
});
|