@celilo/cli 2.0.0 → 2.2.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 +1 -0
- package/drizzle/0031_module_config_source.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/schemas/system_config.json +2 -1
- package/src/capabilities/public-web-publish.test.ts +61 -0
- package/src/cli/commands/firewall-interface-list.test.ts +156 -7
- package/src/cli/commands/firewall-interface-list.ts +73 -7
- package/src/cli/commands/machine-add.ts +12 -55
- package/src/cli/commands/module-config.test.ts +20 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-update.test.ts +82 -0
- package/src/cli/commands/module-update.ts +14 -4
- package/src/cli/commands/monitor.ts +2 -10
- package/src/cli/commands/restore.ts +16 -6
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +4 -3
- package/src/cli/restore-migration-failure.test.ts +159 -0
- package/src/db/client.ts +5 -0
- package/src/db/migrate.test.ts +61 -135
- package/src/db/migrate.ts +7 -2
- package/src/db/schema.ts +10 -0
- package/src/hooks/broker.test.ts +106 -2
- package/src/hooks/broker.ts +91 -1
- package/src/hooks/capability-loader-firewall.test.ts +37 -0
- package/src/hooks/capability-loader.ts +16 -3
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +19 -18
- package/src/hooks/executor.ts +88 -11
- package/src/hooks/hook-jail-toolchain-reach.test.ts +79 -29
- package/src/hooks/hook-jail-unreachability.test.ts +55 -29
- package/src/hooks/hook-protocol.ts +46 -1
- package/src/hooks/hook-runner.ts +36 -0
- package/src/hooks/hook-store-proxy.test.ts +109 -0
- package/src/hooks/hook-store-proxy.ts +85 -0
- package/src/hooks/hook-store.test.ts +162 -0
- package/src/hooks/hook-store.ts +290 -0
- package/src/hooks/hook-timeout.test.ts +3 -2
- package/src/hooks/hook-trespass.test.ts +94 -14
- package/src/hooks/jail.test.ts +1 -1
- package/src/hooks/jail.ts +194 -32
- package/src/hooks/mount-set.test.ts +296 -1
- package/src/hooks/mount-set.ts +216 -13
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
- package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
- package/src/hooks/test-fixtures/store-backed.ts +47 -0
- package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
- package/src/hooks/unjailed-lint.test.ts +27 -8
- package/src/manifest/schema.ts +1 -0
- package/src/module/packaging/build.ts +70 -2
- package/src/module/web-root.ts +17 -1
- package/src/policy/fixture-capability-coverage.test.ts +322 -0
- package/src/policy/module-script-scan.test.ts +42 -1
- package/src/policy/module-script-scan.ts +275 -5
- package/src/policy/no-hand-built-ssh.test.ts +34 -1
- package/src/policy/no-swallowed-refusal.test.ts +265 -0
- package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
- package/src/registry/client.test.ts +149 -0
- package/src/registry/client.ts +203 -11
- package/src/services/alerting/coverage-source.test.ts +86 -0
- package/src/services/alerting/coverage-source.ts +11 -1
- package/src/services/alerting/format.test.ts +57 -0
- package/src/services/alerting/format.ts +24 -0
- package/src/services/alerting/run-monitor.ts +2 -2
- package/src/services/backup-create.ts +7 -7
- package/src/services/backup-envelope-roundtrip.test.ts +45 -2
- package/src/services/backup-restore.ts +8 -4
- package/src/services/bus-interview.ts +37 -14
- package/src/services/config-provenance.ts +4 -0
- package/src/services/control-plane-bootstrap.test.ts +297 -0
- package/src/services/control-plane-bootstrap.ts +223 -0
- package/src/services/control-plane-health.test.ts +66 -0
- package/src/services/control-plane-health.ts +67 -0
- package/src/services/deploy-preflight.ts +8 -2
- package/src/services/deploy-validation.test.ts +22 -0
- package/src/services/deploy-validation.ts +8 -0
- package/src/services/deployed-systems.ts +12 -0
- package/src/services/dns-discovery.test.ts +147 -0
- package/src/services/dns-discovery.ts +134 -0
- package/src/services/fleet-checks.ts +6 -2
- package/src/services/fleet-key.test.ts +66 -2
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +36 -3
- package/src/services/module-config.ts +20 -2
- package/src/services/module-deploy.dns-repoint.test.ts +187 -0
- package/src/services/module-deploy.ts +214 -1
- package/src/services/module-validator/git-hygiene.test.ts +122 -3
- package/src/services/module-validator/git-hygiene.ts +83 -14
- package/src/services/remote-access.test.ts +88 -4
- package/src/services/remote-access.ts +52 -1
- package/src/services/restore-from-file.test.ts +20 -0
- package/src/services/restore-from-file.ts +21 -6
- package/src/services/static-content-converge.test.ts +140 -2
- package/src/services/static-content-converge.ts +55 -8
- package/src/services/system-config-schema-types.ts +1 -1
- package/src/services/system-config-validator.test.ts +36 -0
- package/src/services/system-config-validator.ts +11 -0
- package/src/services/trusted-sources.test.ts +30 -0
- package/src/services/trusted-sources.ts +47 -10
- package/src/templates/generator.ts +9 -2
- package/src/variables/context.ts +16 -5
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
import { execSync } from 'node:child_process';
|
|
3
|
-
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
|
-
import { join } from 'node:path';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { classifyModulePath } from '../../module/packaging/package-rules';
|
|
6
7
|
import { checkGitHygiene, checkModuleStale } from './git-hygiene';
|
|
7
8
|
|
|
8
9
|
interface TempRepo {
|
|
@@ -18,8 +19,19 @@ interface TempRepo {
|
|
|
18
19
|
*/
|
|
19
20
|
function makeTempRepo(): TempRepo {
|
|
20
21
|
const dir = mkdtempSync(join(tmpdir(), 'celilo-git-hygiene-'));
|
|
22
|
+
// Bound every git child. A git call that hangs used to run until bun's
|
|
23
|
+
// per-test timeout killed it, and bun's dangling-process kill left the
|
|
24
|
+
// WHOLE run parked (reproduced for celilo#1282; the park itself is
|
|
25
|
+
// celilo#1246). Erroring the test instead keeps the failure visible and
|
|
26
|
+
// the run exitable. 30s per command stays under the 60s per-test ceiling
|
|
27
|
+
// this suite runs with.
|
|
21
28
|
const exec = (cmd: string): string =>
|
|
22
|
-
execSync(cmd, {
|
|
29
|
+
execSync(cmd, {
|
|
30
|
+
cwd: dir,
|
|
31
|
+
encoding: 'utf-8',
|
|
32
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
33
|
+
timeout: 30_000,
|
|
34
|
+
});
|
|
23
35
|
exec('git init -q');
|
|
24
36
|
exec('git config user.email "test@example.com"');
|
|
25
37
|
exec('git config user.name "Test"');
|
|
@@ -88,6 +100,113 @@ describe('checkModuleStale', () => {
|
|
|
88
100
|
});
|
|
89
101
|
});
|
|
90
102
|
|
|
103
|
+
describe('staleness reach vs the packager (celilo#1270)', () => {
|
|
104
|
+
// Paths planted to span the classifications classifyModulePath produces.
|
|
105
|
+
// The expected staleness outcome is NOT hand-written per path: each test
|
|
106
|
+
// asks classifyModulePath what the packager does with the path and holds
|
|
107
|
+
// the staleness gate to the same answer.
|
|
108
|
+
const planted = [
|
|
109
|
+
'e2e/split-horizon-views.test.ts',
|
|
110
|
+
'e2e/fixtures/cases.json',
|
|
111
|
+
'scripts/install.sh',
|
|
112
|
+
'scripts/tsconfig.json',
|
|
113
|
+
'ansible/playbook.yml',
|
|
114
|
+
'terraform/main.tf',
|
|
115
|
+
'schema/tables.sql',
|
|
116
|
+
'hooks/run.test.ts',
|
|
117
|
+
'generated/report.json',
|
|
118
|
+
'state/last-run.json',
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
/** Repo with only manifest.yml committed; returns `commit(rel)` helper. */
|
|
122
|
+
function repoWithManifest() {
|
|
123
|
+
const repo = makeTempRepo();
|
|
124
|
+
writeFileSync(join(repo.dir, 'manifest.yml'), 'id: x\nversion: 1.0.0\n');
|
|
125
|
+
repo.exec('git add manifest.yml && git commit -q -m manifest');
|
|
126
|
+
const commit = (rel: string) => {
|
|
127
|
+
const abs = join(repo.dir, rel);
|
|
128
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
129
|
+
writeFileSync(abs, 'planted\n');
|
|
130
|
+
repo.exec(`git add ${rel} && git commit -q -m ${JSON.stringify(rel)}`);
|
|
131
|
+
};
|
|
132
|
+
return { repo, commit };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
test(
|
|
136
|
+
'a commit touching only e2e/ does not mark the manifest stale',
|
|
137
|
+
() => {
|
|
138
|
+
const { repo, commit } = repoWithManifest();
|
|
139
|
+
try {
|
|
140
|
+
commit('e2e/split-horizon-views.test.ts');
|
|
141
|
+
expect(checkModuleStale(repo.dir)).toBeNull();
|
|
142
|
+
} finally {
|
|
143
|
+
repo.cleanup();
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
{ timeout: 30_000 },
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
test(
|
|
150
|
+
'a commit touching scripts/, ansible/, terraform/ or schema/ still fires',
|
|
151
|
+
() => {
|
|
152
|
+
for (const rel of [
|
|
153
|
+
'scripts/install.sh',
|
|
154
|
+
'ansible/playbook.yml',
|
|
155
|
+
'terraform/main.tf',
|
|
156
|
+
'schema/tables.sql',
|
|
157
|
+
]) {
|
|
158
|
+
const { repo, commit } = repoWithManifest();
|
|
159
|
+
try {
|
|
160
|
+
commit(rel);
|
|
161
|
+
expect(classifyModulePath(rel)).toBe('package');
|
|
162
|
+
expect(checkModuleStale(repo.dir)).not.toBeNull();
|
|
163
|
+
} finally {
|
|
164
|
+
repo.cleanup();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
{ timeout: 30_000 },
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
test(
|
|
172
|
+
'reach parity: the gate fires exactly for paths the packager ships',
|
|
173
|
+
async () => {
|
|
174
|
+
for (const rel of planted) {
|
|
175
|
+
const { repo, commit } = repoWithManifest();
|
|
176
|
+
try {
|
|
177
|
+
commit(rel);
|
|
178
|
+
const stale = checkModuleStale(repo.dir);
|
|
179
|
+
if (classifyModulePath(rel) === 'package') {
|
|
180
|
+
expect(stale).not.toBeNull();
|
|
181
|
+
} else {
|
|
182
|
+
expect(stale).toBeNull();
|
|
183
|
+
}
|
|
184
|
+
} finally {
|
|
185
|
+
repo.cleanup();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
{ timeout: 30_000 },
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
test(
|
|
193
|
+
'non-shipped commits between manifest and a shipped change stay quiet until shipped bytes move',
|
|
194
|
+
() => {
|
|
195
|
+
const { repo, commit } = repoWithManifest();
|
|
196
|
+
try {
|
|
197
|
+
commit('e2e/split-horizon-views.test.ts');
|
|
198
|
+
commit('scripts/tsconfig.json');
|
|
199
|
+
expect(checkModuleStale(repo.dir)).toBeNull();
|
|
200
|
+
commit('scripts/install.sh');
|
|
201
|
+
expect(checkModuleStale(repo.dir)).not.toBeNull();
|
|
202
|
+
} finally {
|
|
203
|
+
repo.cleanup();
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
{ timeout: 30_000 },
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
91
210
|
describe('checkGitHygiene', () => {
|
|
92
211
|
test('all ok on a fresh, clean repo with manifest as latest commit', () => {
|
|
93
212
|
const repo = makeTempRepo();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { classifyModulePath } from '../../module/packaging/package-rules';
|
|
3
4
|
import { collectGitInfo, makeRealGitRunner } from '../../module/packaging/release-metadata';
|
|
4
5
|
import type { Check } from './types';
|
|
5
6
|
|
|
@@ -19,10 +20,14 @@ export interface StalenessIssue {
|
|
|
19
20
|
* anywhere should still talk to lunacycle's git history).
|
|
20
21
|
*/
|
|
21
22
|
function lastCommitTouching(cwd: string, pathspec: string[]): string | null {
|
|
23
|
+
// timeout: a hung git degrades to the "can't determine — skip" path (status
|
|
24
|
+
// null) instead of hanging `module check` forever. 30s is enormous against
|
|
25
|
+
// the milliseconds a bounded `git log -1` takes on any real repo.
|
|
22
26
|
const r = spawnSync('git', ['log', '-1', '--format=%H', '--', ...pathspec], {
|
|
23
27
|
cwd,
|
|
24
28
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
25
29
|
encoding: 'utf-8',
|
|
30
|
+
timeout: 30_000,
|
|
26
31
|
});
|
|
27
32
|
if (r.status !== 0) return null;
|
|
28
33
|
const sha = r.stdout.trim();
|
|
@@ -33,17 +38,90 @@ function isAncestor(cwd: string, maybeAncestor: string, descendant: string): boo
|
|
|
33
38
|
const r = spawnSync('git', ['merge-base', '--is-ancestor', maybeAncestor, descendant], {
|
|
34
39
|
cwd,
|
|
35
40
|
stdio: 'ignore',
|
|
41
|
+
timeout: 30_000,
|
|
36
42
|
});
|
|
37
43
|
return r.status === 0;
|
|
38
44
|
}
|
|
39
45
|
|
|
46
|
+
/**
|
|
47
|
+
* One commit from `git log --name-only`: its SHA and the repo-root-relative
|
|
48
|
+
* paths it changed. A merge commit carries an empty file list (git log omits
|
|
49
|
+
* diffs for merges), so it never qualifies as a src commit — which is right,
|
|
50
|
+
* a merge changes no authored byte of its own.
|
|
51
|
+
*/
|
|
52
|
+
interface CommitWithPaths {
|
|
53
|
+
sha: string;
|
|
54
|
+
paths: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The commits touching `pathspec`, newest first, with the repo-root-relative
|
|
59
|
+
* paths each one changed. `git log --name-only` prints repo-root-relative
|
|
60
|
+
* paths even when cwd is a subdirectory (measured: it does not honor cwd for
|
|
61
|
+
* output), so the caller strips the prefix itself via `show-prefix`.
|
|
62
|
+
*/
|
|
63
|
+
function listCommitsTouching(cwd: string, pathspec: string): CommitWithPaths[] {
|
|
64
|
+
const r = spawnSync(
|
|
65
|
+
'git',
|
|
66
|
+
['-c', 'core.quotePath=false', 'log', '--format=%H', '--name-only', '--', pathspec],
|
|
67
|
+
{ cwd, stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' },
|
|
68
|
+
);
|
|
69
|
+
if (r.status !== 0) return [];
|
|
70
|
+
const commits: CommitWithPaths[] = [];
|
|
71
|
+
let current: CommitWithPaths | null = null;
|
|
72
|
+
for (const line of (r.stdout ?? '').split('\n')) {
|
|
73
|
+
const trimmed = line.trim();
|
|
74
|
+
if (!trimmed) continue;
|
|
75
|
+
if (/^[0-9a-f]{40}$/.test(trimmed)) {
|
|
76
|
+
current = { sha: trimmed, paths: [] };
|
|
77
|
+
commits.push(current);
|
|
78
|
+
} else if (current) {
|
|
79
|
+
current.paths.push(trimmed);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return commits;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* SHA of the last commit that touched SHIPPED module source, or null when no
|
|
87
|
+
* commit qualifies (no history, or only non-shipped paths ever committed).
|
|
88
|
+
*
|
|
89
|
+
* The staleness scan's reach is the PACKAGER's rule, not a second list beside
|
|
90
|
+
* it (celilo#1270). A path counts as source exactly when `classifyModulePath`
|
|
91
|
+
* calls it `package` — the same answer that decides whether the path lands in
|
|
92
|
+
* the .netapp. So a commit touching only `e2e/**`, `*.test.ts`, or
|
|
93
|
+
* `tsconfig.json` changes no installed byte and must not read as drift.
|
|
94
|
+
*
|
|
95
|
+
* Excluding manifest.yml from the "src" scan is the gate's own semantics, not
|
|
96
|
+
* packaging: we want to know if anything else shipped past it. node_modules,
|
|
97
|
+
* dist, and other build outputs are gitignored and so never appear here.
|
|
98
|
+
*/
|
|
99
|
+
function lastShippedSourceCommit(moduleDir: string): string | null {
|
|
100
|
+
const prefixR = spawnSync('git', ['rev-parse', '--show-prefix'], {
|
|
101
|
+
cwd: moduleDir,
|
|
102
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
103
|
+
encoding: 'utf-8',
|
|
104
|
+
});
|
|
105
|
+
if (prefixR.status !== 0) return null;
|
|
106
|
+
const repoPrefix = (prefixR.stdout ?? '').trim();
|
|
107
|
+
for (const commit of listCommitsTouching(moduleDir, moduleDir)) {
|
|
108
|
+
for (const p of commit.paths) {
|
|
109
|
+
if (repoPrefix && !p.startsWith(repoPrefix)) continue;
|
|
110
|
+
const rel = repoPrefix ? p.slice(repoPrefix.length) : p;
|
|
111
|
+
if (rel === 'manifest.yml') continue;
|
|
112
|
+
if (classifyModulePath(rel) === 'package') return commit.sha;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
40
118
|
/**
|
|
41
119
|
* Detect "I edited module src but forgot to bump (or touch) manifest.yml."
|
|
42
120
|
*
|
|
43
|
-
* Returns null when the manifest is the most recently-touched file in
|
|
44
|
-
* dir (or when neither side has any commit history — e.g. brand-new module
|
|
45
|
-
* not yet committed). Returns a StalenessIssue when src has commits
|
|
46
|
-
* the last manifest.yml change — the operator must bump the manifest
|
|
121
|
+
* Returns null when the manifest is the most recently-touched shipped file in
|
|
122
|
+
* the dir (or when neither side has any commit history — e.g. brand-new module
|
|
123
|
+
* not yet committed). Returns a StalenessIssue when shipped src has commits
|
|
124
|
+
* AFTER the last manifest.yml change — the operator must bump the manifest
|
|
47
125
|
* (semver change → reset +N to 1) or just touch it (release-only change →
|
|
48
126
|
* auto-bump +N), then re-publish.
|
|
49
127
|
*
|
|
@@ -54,16 +132,7 @@ function isAncestor(cwd: string, maybeAncestor: string, descendant: string): boo
|
|
|
54
132
|
export function checkModuleStale(moduleDir: string): StalenessIssue | null {
|
|
55
133
|
const manifestPath = join(moduleDir, 'manifest.yml');
|
|
56
134
|
const lastManifest = lastCommitTouching(moduleDir, [manifestPath]);
|
|
57
|
-
|
|
58
|
-
// want to know if anything ELSE in the dir moved past it. node_modules
|
|
59
|
-
// and common build outputs are gitignored already, but be explicit
|
|
60
|
-
// defensively.
|
|
61
|
-
const lastSrc = lastCommitTouching(moduleDir, [
|
|
62
|
-
moduleDir,
|
|
63
|
-
`:(exclude)${manifestPath}`,
|
|
64
|
-
`:(exclude)${moduleDir}/node_modules`,
|
|
65
|
-
`:(exclude)${moduleDir}/dist`,
|
|
66
|
-
]);
|
|
135
|
+
const lastSrc = lastShippedSourceCommit(moduleDir);
|
|
67
136
|
if (!lastManifest || !lastSrc) return null;
|
|
68
137
|
if (lastSrc === lastManifest) return null;
|
|
69
138
|
if (!isAncestor(moduleDir, lastManifest, lastSrc)) return null;
|
|
@@ -7,16 +7,27 @@
|
|
|
7
7
|
* 2. an explicit non-root user is allowed — the fleet key's authority is
|
|
8
8
|
* root on fleet systems, and a non-root account trusts it only where an
|
|
9
9
|
* operator installed it (the cPanel bootstrap);
|
|
10
|
-
* 3.
|
|
11
|
-
*
|
|
12
|
-
* the
|
|
10
|
+
* 3. the target is a system the module or one of its instances provisioned;
|
|
11
|
+
* 4. or it is a machine in celilo's pool that this module's own config names
|
|
12
|
+
* — the case a config-only manager module needs, since it provisions
|
|
13
|
+
* nothing and so can never satisfy rule 3 (celilo#1225);
|
|
14
|
+
*
|
|
15
|
+
* and otherwise a refusal names the module, the target, and the capability
|
|
16
|
+
* route.
|
|
13
17
|
*/
|
|
14
18
|
|
|
15
19
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
16
20
|
import { existsSync } from 'node:fs';
|
|
17
21
|
import { rm } from 'node:fs/promises';
|
|
18
22
|
import { type DbClient, createDbClient } from '../db/client';
|
|
19
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
capabilities,
|
|
25
|
+
machines,
|
|
26
|
+
moduleConfigs,
|
|
27
|
+
moduleInstances,
|
|
28
|
+
moduleSystems,
|
|
29
|
+
modules,
|
|
30
|
+
} from '../db/schema';
|
|
20
31
|
import { remoteAccessPolicy } from './remote-access';
|
|
21
32
|
|
|
22
33
|
const TEST_DB_PATH = './test-remote-access.db';
|
|
@@ -48,6 +59,26 @@ function seedSystem(db: DbClient, moduleId: string, ipv4Address: string, hostnam
|
|
|
48
59
|
.run();
|
|
49
60
|
}
|
|
50
61
|
|
|
62
|
+
/** A box the operator admitted to celilo's pool. */
|
|
63
|
+
function seedPoolMachine(db: DbClient, ipAddress: string, hostname: string): void {
|
|
64
|
+
db.insert(machines)
|
|
65
|
+
.values({
|
|
66
|
+
id: `machine-${hostname}`,
|
|
67
|
+
hostname,
|
|
68
|
+
zone: 'internal',
|
|
69
|
+
ipAddress,
|
|
70
|
+
sshUser: 'root',
|
|
71
|
+
sshKeyEncrypted: 'x',
|
|
72
|
+
hardware: { cpu_cores: 1, memory_mb: 512, disk_gb: 8 },
|
|
73
|
+
})
|
|
74
|
+
.run();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A config value the operator set on a module. */
|
|
78
|
+
function seedConfig(db: DbClient, moduleId: string, key: string, value: string): void {
|
|
79
|
+
db.insert(moduleConfigs).values({ moduleId, key, value }).run();
|
|
80
|
+
}
|
|
81
|
+
|
|
51
82
|
describe('remoteAccessPolicy', () => {
|
|
52
83
|
let db: DbClient;
|
|
53
84
|
|
|
@@ -136,4 +167,57 @@ describe('remoteAccessPolicy', () => {
|
|
|
136
167
|
);
|
|
137
168
|
expect(verdict.allowed).toBe(false);
|
|
138
169
|
});
|
|
170
|
+
|
|
171
|
+
describe('a config-only module managing a pool machine (celilo#1225)', () => {
|
|
172
|
+
// `iptables` is the archetype: it configures a pre-existing firewall and is
|
|
173
|
+
// deployed onto nothing, so `module_systems` holds no row for it EVER. Rule
|
|
174
|
+
// 3 is unsatisfiable for it by construction, not by timing.
|
|
175
|
+
const FIREWALL = '10.226.1.254';
|
|
176
|
+
|
|
177
|
+
beforeEach(() => {
|
|
178
|
+
seedModule(db, 'iptables');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("allows a pool machine this module's config names", () => {
|
|
182
|
+
seedPoolMachine(db, FIREWALL, 'fw-main');
|
|
183
|
+
seedConfig(db, 'iptables', 'firewall_ip', FIREWALL);
|
|
184
|
+
|
|
185
|
+
expect(
|
|
186
|
+
remoteAccessPolicy('iptables', db).checkTarget({ ipv4_address: FIREWALL }, false),
|
|
187
|
+
).toEqual({ allowed: true });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('pool membership ALONE is not authorization', () => {
|
|
191
|
+
// Otherwise any module could reach every box celilo knows about, which is
|
|
192
|
+
// far wider than the rule this policy states.
|
|
193
|
+
seedPoolMachine(db, FIREWALL, 'fw-main');
|
|
194
|
+
|
|
195
|
+
const result = remoteAccessPolicy('iptables', db).checkTarget(
|
|
196
|
+
{ ipv4_address: FIREWALL },
|
|
197
|
+
false,
|
|
198
|
+
);
|
|
199
|
+
expect(result.allowed).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('config ALONE is not authorization', () => {
|
|
203
|
+
// A module names its own config keys. If naming an address were enough, a
|
|
204
|
+
// module could point itself at any box in the world.
|
|
205
|
+
seedConfig(db, 'iptables', 'firewall_ip', FIREWALL);
|
|
206
|
+
|
|
207
|
+
const result = remoteAccessPolicy('iptables', db).checkTarget(
|
|
208
|
+
{ ipv4_address: FIREWALL },
|
|
209
|
+
false,
|
|
210
|
+
);
|
|
211
|
+
expect(result.allowed).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("one module's config does not unlock another module's box", () => {
|
|
215
|
+
seedPoolMachine(db, FIREWALL, 'fw-main');
|
|
216
|
+
seedConfig(db, 'iptables', 'firewall_ip', FIREWALL);
|
|
217
|
+
|
|
218
|
+
// caddy has its own systems and no config naming the firewall.
|
|
219
|
+
const result = remoteAccessPolicy('caddy', db).checkTarget({ ipv4_address: FIREWALL }, false);
|
|
220
|
+
expect(result.allowed).toBe(false);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
139
223
|
});
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
|
|
41
41
|
import { eq } from 'drizzle-orm';
|
|
42
42
|
import type { DbClient } from '../db/client';
|
|
43
|
-
import { capabilities, moduleSystems } from '../db/schema';
|
|
43
|
+
import { capabilities, machines, moduleConfigs, moduleSystems } from '../db/schema';
|
|
44
44
|
import type { RemoteAccessPolicy } from '../hooks/remote-broker';
|
|
45
45
|
import { getModuleSystems } from './deployed-systems';
|
|
46
46
|
import { ownedSystemModuleIds } from './module-instances';
|
|
@@ -60,11 +60,62 @@ export function remoteAccessPolicy(moduleId: string, db: DbClient): RemoteAccess
|
|
|
60
60
|
return { allowed: true };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
if (managesPoolMachine(moduleId, target.ipv4_address, db)) {
|
|
64
|
+
return { allowed: true };
|
|
65
|
+
}
|
|
66
|
+
|
|
63
67
|
return { allowed: false, message: refusalMessage(moduleId, target.ipv4_address, db) };
|
|
64
68
|
},
|
|
65
69
|
};
|
|
66
70
|
}
|
|
67
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
|
+
|
|
68
119
|
/**
|
|
69
120
|
* Name the module, the target, and the capability route (task 5.2). The owner
|
|
70
121
|
* lookup is best-effort colour: the refusal stands whether or not celilo can
|
|
@@ -86,6 +86,26 @@ describe('applyStagedSystemFiles', () => {
|
|
|
86
86
|
expect(existsSync(`${livePath}-shm`)).toBe(false);
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
// celilo#1293 live repro 2026-09-06: the restore process's own pre-swap
|
|
90
|
+
// connection can outlive closeDb() at the fd level (measured: bun:sqlite's
|
|
91
|
+
// close() returned while the fds stayed open), and an IN-PLACE copy
|
|
92
|
+
// overwrites the inode that connection holds locks on — the swapped-in DB
|
|
93
|
+
// then inherits locks it must not have, and the next opener's DELETE→WAL
|
|
94
|
+
// conversion fails with "database is locked". The swap must therefore
|
|
95
|
+
// replace the INODE (rename), so any lingering lock lands on the unlinked
|
|
96
|
+
// old inode and is irrelevant to the new file.
|
|
97
|
+
it('replaces the live DB inode, not just its content', () => {
|
|
98
|
+
writeFileSync(livePath, 'old-db');
|
|
99
|
+
writeFileSync(join(stagingDir, 'celilo.db'), 'new-db');
|
|
100
|
+
const inodeBefore = statSync(livePath).ino;
|
|
101
|
+
|
|
102
|
+
applyStagedSystemFiles(stagingDir);
|
|
103
|
+
|
|
104
|
+
expect(readFileSync(livePath, 'utf-8')).toBe('new-db');
|
|
105
|
+
expect(statSync(livePath).ino).not.toBe(inodeBefore);
|
|
106
|
+
expect(existsSync(`${livePath}.restore-swap`)).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
89
109
|
it('handles partial staging (only master.key, no DB)', () => {
|
|
90
110
|
writeFileSync(join(stagingDir, 'master.key'), 'just-the-key');
|
|
91
111
|
const result = applyStagedSystemFiles(stagingDir);
|
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { Database } from 'bun:sqlite';
|
|
20
|
-
import { execSync } from 'node:child_process';
|
|
21
20
|
import {
|
|
22
21
|
chmodSync,
|
|
23
22
|
closeSync,
|
|
@@ -29,20 +28,22 @@ import {
|
|
|
29
28
|
readFileSync,
|
|
30
29
|
readSync,
|
|
31
30
|
readdirSync,
|
|
31
|
+
renameSync,
|
|
32
32
|
rmSync,
|
|
33
33
|
} from 'node:fs';
|
|
34
34
|
import { tmpdir } from 'node:os';
|
|
35
35
|
import { join } from 'node:path';
|
|
36
36
|
import { eq } from 'drizzle-orm';
|
|
37
|
+
import { extract as tarExtract } from 'tar';
|
|
37
38
|
import { getDbPath, getMasterKeyPath, getModuleStoragePath } from '../config/paths';
|
|
38
39
|
import { closeDb, getDb } from '../db/client';
|
|
39
40
|
import { runMigrations } from '../db/migrate';
|
|
40
41
|
import { modules } from '../db/schema';
|
|
41
42
|
import { invokeHook } from '../hooks/executor';
|
|
43
|
+
import { createHookStores } from '../hooks/hook-store';
|
|
42
44
|
import { createConsoleLogger } from '../hooks/logger';
|
|
43
45
|
import type { ModuleManifest } from '../manifest/schema';
|
|
44
46
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
45
|
-
import { shellEscape } from '../utils/shell';
|
|
46
47
|
import { decryptFileToFile } from './backup-cipher';
|
|
47
48
|
import { assertCompatibleSchema, parseManifest } from './backup-manifest';
|
|
48
49
|
import { applyCrossModuleWriteRoot, moduleHasCrossModuleRead } from './cross-module-read';
|
|
@@ -128,7 +129,7 @@ export async function restoreFromArtifactFile(
|
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
// Extract the envelope tar.
|
|
131
|
-
|
|
132
|
+
await tarExtract({ file: tarPath, cwd: envelopeDir });
|
|
132
133
|
|
|
133
134
|
// 2. Read + validate the envelope manifest.
|
|
134
135
|
const manifestPath = join(envelopeDir, 'manifest.json');
|
|
@@ -221,6 +222,7 @@ export async function restoreFromArtifactFile(
|
|
|
221
222
|
debug: false,
|
|
222
223
|
systems: getModuleSystems(mod.id, db),
|
|
223
224
|
remoteAccess: remoteAccessPolicy(mod.id, db),
|
|
225
|
+
hookStores: () => createHookStores(db, mod.id),
|
|
224
226
|
},
|
|
225
227
|
);
|
|
226
228
|
if (!hookResult.success) {
|
|
@@ -428,9 +430,22 @@ export function applyStagedSystemFiles(systemStagingDir: string): StagedSystemAp
|
|
|
428
430
|
closeDb();
|
|
429
431
|
const livePath = getDbPath();
|
|
430
432
|
mkdirSync(join(livePath, '..'), { recursive: true });
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
//
|
|
433
|
+
// Swap by RENAME, not by in-place copy. copyFileSync overwrites the live
|
|
434
|
+
// inode in place, so any connection whose fd close was deferred (measured
|
|
435
|
+
// 2026-09-06, celilo#1293 live repro: bun:sqlite's close() returns before
|
|
436
|
+
// the fds are released) keeps its WAL shared locks on the NEW content, and
|
|
437
|
+
// the next opener's DELETE→WAL conversion in ensureWalMode then fails with
|
|
438
|
+
// "database is locked" for its whole retry budget. A rename gives the new
|
|
439
|
+
// file a fresh inode nobody has ever held. It also makes the swap atomic:
|
|
440
|
+
// a crash mid-copy left a truncated live DB; a crash mid-rename leaves the
|
|
441
|
+
// old one.
|
|
442
|
+
const stagedSwapPath = `${livePath}.restore-swap`;
|
|
443
|
+
copyFileSync(stagedDb, stagedSwapPath);
|
|
444
|
+
renameSync(stagedSwapPath, livePath);
|
|
445
|
+
|
|
446
|
+
// Stale WAL/SHM siblings belong to the replaced inode — remove them before
|
|
447
|
+
// any new connection opens this path, or SQLite would try to recover
|
|
448
|
+
// through a foreign WAL.
|
|
434
449
|
for (const suffix of ['-wal', '-shm']) {
|
|
435
450
|
try {
|
|
436
451
|
rmSync(`${livePath}${suffix}`, { force: true });
|