@celilo/cli 1.14.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +2 -2
- package/CELILO_SUBSYSTEMS.md +26 -3
- 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-publish.test.ts +18 -0
- package/src/cli/commands/alerts-sweep.ts +3 -0
- package/src/cli/commands/monitor.ts +15 -2
- package/src/cli/commands/system-doctor.test.ts +121 -1
- package/src/cli/commands/system-doctor.ts +151 -1
- package/src/cli/completion.ts +9 -2
- package/src/cli/index.ts +1 -1
- package/src/console/control-plane-boundary.test.ts +82 -4
- package/src/db/schema.ts +0 -1
- package/src/hooks/capability-loader.ts +15 -2
- package/src/hooks/executor.ts +116 -17
- package/src/hooks/hook-jail-toolchain-reach.test.ts +273 -0
- package/src/hooks/hook-jail-unreachability.test.ts +77 -26
- 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 +74 -12
- package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
- package/src/hooks/jail.test.ts +92 -0
- package/src/hooks/jail.ts +304 -32
- package/src/hooks/mount-set.test.ts +116 -7
- package/src/hooks/mount-set.ts +189 -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 +254 -0
- package/src/hooks/unjailed-lint.ts +395 -0
- package/src/policy/module-business-baseline.ts +13 -1
- package/src/policy/module-script-scan.ts +60 -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/coverage-source.test.ts +86 -0
- package/src/services/alerting/coverage-source.ts +11 -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/backup-create.ts +3 -0
- package/src/services/backup-restore.ts +2 -0
- package/src/services/control-plane-bootstrap.test.ts +177 -0
- package/src/services/control-plane-bootstrap.ts +176 -0
- package/src/services/control-plane-health.test.ts +66 -0
- package/src/services/control-plane-health.ts +67 -0
- package/src/services/deploy-ansible.ts +9 -1
- package/src/services/deployed-systems.ts +12 -0
- package/src/services/dns-discovery.test.ts +93 -0
- package/src/services/dns-discovery.ts +92 -0
- package/src/services/fleet-checks.ts +6 -2
- package/src/services/health-runner.ts +36 -3
- package/src/services/module-build.test.ts +1 -64
- package/src/services/module-build.ts +10 -86
- package/src/services/module-deploy.ts +71 -0
- package/src/services/remote-access.test.ts +223 -0
- package/src/services/remote-access.ts +149 -0
- package/src/services/restore-from-file.ts +6 -1
- package/src/services/static-content-converge.test.ts +338 -0
- package/src/services/static-content-converge.ts +299 -0
|
@@ -0,0 +1,149 @@
|
|
|
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, machines, moduleConfigs, 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
|
+
if (managesPoolMachine(moduleId, target.ipv4_address, db)) {
|
|
64
|
+
return { allowed: true };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { allowed: false, message: refusalMessage(moduleId, target.ipv4_address, db) };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A machine in celilo's pool that this module's own config names.
|
|
74
|
+
*
|
|
75
|
+
* ## The case this exists for
|
|
76
|
+
*
|
|
77
|
+
* A module that MANAGES a pre-existing box rather than being deployed onto one
|
|
78
|
+
* — `iptables` configuring the firewall is the archetype — provisions no system
|
|
79
|
+
* and therefore never owns one. `module_systems` records nothing for a
|
|
80
|
+
* config-only module at any point in its lifecycle, so the owned-systems branch
|
|
81
|
+
* above cannot ever pass for it. Without this, such a module has no sanctioned
|
|
82
|
+
* path to the one box it exists to configure, and no amount of waiting fixes it
|
|
83
|
+
* (celilo#1225).
|
|
84
|
+
*
|
|
85
|
+
* ## Why this is not a module granting itself reach
|
|
86
|
+
*
|
|
87
|
+
* Both halves are the OPERATOR's acts, and a module can supply neither:
|
|
88
|
+
*
|
|
89
|
+
* - The address is in the machine pool. `celilo machine add <ip> --ssh-key…`
|
|
90
|
+
* is the operator saying celilo may use its credential on that box. The
|
|
91
|
+
* fleet key's authority is exactly the set of boxes celilo was given, and
|
|
92
|
+
* this is that set — the same reasoning as the non-root carve-out above.
|
|
93
|
+
* - The module's own config names that address. `firewall_ip` is
|
|
94
|
+
* `source: user, required: true`; an operator typed it.
|
|
95
|
+
*
|
|
96
|
+
* A module declares which config KEYS exist, never their values, so it cannot
|
|
97
|
+
* point itself at a box the operator did not both admit to the pool and assign
|
|
98
|
+
* to it. Two independent operator decisions have to agree.
|
|
99
|
+
*
|
|
100
|
+
* ## What it deliberately does not do
|
|
101
|
+
*
|
|
102
|
+
* It does not allow every pool machine. That would let any module reach any box
|
|
103
|
+
* celilo knows, which is far wider than the rule this file states. The
|
|
104
|
+
* intersection is the point: pool membership alone is not authorization, and
|
|
105
|
+
* config alone is not either.
|
|
106
|
+
*/
|
|
107
|
+
function managesPoolMachine(moduleId: string, ipv4Address: string, db: DbClient): boolean {
|
|
108
|
+
const inPool = db.select().from(machines).where(eq(machines.ipAddress, ipv4Address)).get();
|
|
109
|
+
if (!inPool) return false;
|
|
110
|
+
|
|
111
|
+
return db
|
|
112
|
+
.select({ value: moduleConfigs.value })
|
|
113
|
+
.from(moduleConfigs)
|
|
114
|
+
.where(eq(moduleConfigs.moduleId, moduleId))
|
|
115
|
+
.all()
|
|
116
|
+
.some((row) => row.value === ipv4Address);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Name the module, the target, and the capability route (task 5.2). The owner
|
|
121
|
+
* lookup is best-effort colour: the refusal stands whether or not celilo can
|
|
122
|
+
* say whose system it is.
|
|
123
|
+
*/
|
|
124
|
+
function refusalMessage(moduleId: string, ipv4Address: string, db: DbClient): string {
|
|
125
|
+
const preamble = `Module '${moduleId}' may not reach ${ipv4Address}: it is not a system this module (or one of its instances) provisioned.`;
|
|
126
|
+
const guidance =
|
|
127
|
+
'A hook reaches its own systems via ctx.systems and everything else through a capability (hook-process-boundary, design D12).';
|
|
128
|
+
|
|
129
|
+
const owner = db
|
|
130
|
+
.select()
|
|
131
|
+
.from(moduleSystems)
|
|
132
|
+
.where(eq(moduleSystems.ipv4Address, ipv4Address))
|
|
133
|
+
.get();
|
|
134
|
+
if (!owner) return `${preamble} ${guidance}`;
|
|
135
|
+
|
|
136
|
+
const provided = db
|
|
137
|
+
.select({ capabilityName: capabilities.capabilityName })
|
|
138
|
+
.from(capabilities)
|
|
139
|
+
.where(eq(capabilities.moduleId, owner.moduleId))
|
|
140
|
+
.all()
|
|
141
|
+
.map((row) => row.capabilityName);
|
|
142
|
+
|
|
143
|
+
const route =
|
|
144
|
+
provided.length > 0
|
|
145
|
+
? `Reach it through a capability it provides ('${provided.join("', '")}').`
|
|
146
|
+
: "Reach it through a capability, never by SSH from another module's hook.";
|
|
147
|
+
|
|
148
|
+
return `${preamble} That system ('${owner.hostname}') belongs to module '${owner.moduleId}'. ${route}`;
|
|
149
|
+
}
|
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
refuseIfInFlight,
|
|
55
55
|
startOperation,
|
|
56
56
|
} from './module-operations';
|
|
57
|
+
import { remoteAccessPolicy } from './remote-access';
|
|
57
58
|
|
|
58
59
|
export interface RestoreFromFileResult {
|
|
59
60
|
success: boolean;
|
|
@@ -216,7 +217,11 @@ export async function restoreFromArtifactFile(
|
|
|
216
217
|
{},
|
|
217
218
|
{},
|
|
218
219
|
logger,
|
|
219
|
-
{
|
|
220
|
+
{
|
|
221
|
+
debug: false,
|
|
222
|
+
systems: getModuleSystems(mod.id, db),
|
|
223
|
+
remoteAccess: remoteAccessPolicy(mod.id, db),
|
|
224
|
+
},
|
|
220
225
|
);
|
|
221
226
|
if (!hookResult.success) {
|
|
222
227
|
const errMsg = hookResult.error ?? 'on_restore hook failed';
|
|
@@ -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
|
+
});
|