@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.
Files changed (103) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +1 -0
  3. package/drizzle/0031_module_config_source.sql +20 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +2 -2
  6. package/schemas/system_config.json +2 -1
  7. package/src/capabilities/public-web-publish.test.ts +61 -0
  8. package/src/cli/commands/firewall-interface-list.test.ts +156 -7
  9. package/src/cli/commands/firewall-interface-list.ts +73 -7
  10. package/src/cli/commands/machine-add.ts +12 -55
  11. package/src/cli/commands/module-config.test.ts +20 -1
  12. package/src/cli/commands/module-import.ts +1 -1
  13. package/src/cli/commands/module-update.test.ts +82 -0
  14. package/src/cli/commands/module-update.ts +14 -4
  15. package/src/cli/commands/monitor.ts +2 -10
  16. package/src/cli/commands/restore.ts +16 -6
  17. package/src/cli/generate-zsh-completion.ts +1 -1
  18. package/src/cli/index.ts +4 -3
  19. package/src/cli/restore-migration-failure.test.ts +159 -0
  20. package/src/db/client.ts +5 -0
  21. package/src/db/migrate.test.ts +61 -135
  22. package/src/db/migrate.ts +7 -2
  23. package/src/db/schema.ts +10 -0
  24. package/src/hooks/broker.test.ts +106 -2
  25. package/src/hooks/broker.ts +91 -1
  26. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  27. package/src/hooks/capability-loader.ts +16 -3
  28. package/src/hooks/define-hook.test.ts +4 -3
  29. package/src/hooks/executor.test.ts +19 -18
  30. package/src/hooks/executor.ts +88 -11
  31. package/src/hooks/hook-jail-toolchain-reach.test.ts +79 -29
  32. package/src/hooks/hook-jail-unreachability.test.ts +55 -29
  33. package/src/hooks/hook-protocol.ts +46 -1
  34. package/src/hooks/hook-runner.ts +36 -0
  35. package/src/hooks/hook-store-proxy.test.ts +109 -0
  36. package/src/hooks/hook-store-proxy.ts +85 -0
  37. package/src/hooks/hook-store.test.ts +162 -0
  38. package/src/hooks/hook-store.ts +290 -0
  39. package/src/hooks/hook-timeout.test.ts +3 -2
  40. package/src/hooks/hook-trespass.test.ts +94 -14
  41. package/src/hooks/jail.test.ts +1 -1
  42. package/src/hooks/jail.ts +194 -32
  43. package/src/hooks/mount-set.test.ts +296 -1
  44. package/src/hooks/mount-set.ts +216 -13
  45. package/src/hooks/run-named-hook.ts +2 -0
  46. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  47. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  48. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  49. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  50. package/src/hooks/unjailed-lint.test.ts +27 -8
  51. package/src/manifest/schema.ts +1 -0
  52. package/src/module/packaging/build.ts +70 -2
  53. package/src/module/web-root.ts +17 -1
  54. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  55. package/src/policy/module-script-scan.test.ts +42 -1
  56. package/src/policy/module-script-scan.ts +275 -5
  57. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  58. package/src/policy/no-swallowed-refusal.test.ts +265 -0
  59. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  60. package/src/registry/client.test.ts +149 -0
  61. package/src/registry/client.ts +203 -11
  62. package/src/services/alerting/coverage-source.test.ts +86 -0
  63. package/src/services/alerting/coverage-source.ts +11 -1
  64. package/src/services/alerting/format.test.ts +57 -0
  65. package/src/services/alerting/format.ts +24 -0
  66. package/src/services/alerting/run-monitor.ts +2 -2
  67. package/src/services/backup-create.ts +7 -7
  68. package/src/services/backup-envelope-roundtrip.test.ts +45 -2
  69. package/src/services/backup-restore.ts +8 -4
  70. package/src/services/bus-interview.ts +37 -14
  71. package/src/services/config-provenance.ts +4 -0
  72. package/src/services/control-plane-bootstrap.test.ts +297 -0
  73. package/src/services/control-plane-bootstrap.ts +223 -0
  74. package/src/services/control-plane-health.test.ts +66 -0
  75. package/src/services/control-plane-health.ts +67 -0
  76. package/src/services/deploy-preflight.ts +8 -2
  77. package/src/services/deploy-validation.test.ts +22 -0
  78. package/src/services/deploy-validation.ts +8 -0
  79. package/src/services/deployed-systems.ts +12 -0
  80. package/src/services/dns-discovery.test.ts +147 -0
  81. package/src/services/dns-discovery.ts +134 -0
  82. package/src/services/fleet-checks.ts +6 -2
  83. package/src/services/fleet-key.test.ts +66 -2
  84. package/src/services/fleet-key.ts +54 -0
  85. package/src/services/health-runner.ts +36 -3
  86. package/src/services/module-config.ts +20 -2
  87. package/src/services/module-deploy.dns-repoint.test.ts +187 -0
  88. package/src/services/module-deploy.ts +214 -1
  89. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  90. package/src/services/module-validator/git-hygiene.ts +83 -14
  91. package/src/services/remote-access.test.ts +88 -4
  92. package/src/services/remote-access.ts +52 -1
  93. package/src/services/restore-from-file.test.ts +20 -0
  94. package/src/services/restore-from-file.ts +21 -6
  95. package/src/services/static-content-converge.test.ts +140 -2
  96. package/src/services/static-content-converge.ts +55 -8
  97. package/src/services/system-config-schema-types.ts +1 -1
  98. package/src/services/system-config-validator.test.ts +36 -0
  99. package/src/services/system-config-validator.ts +11 -0
  100. package/src/services/trusted-sources.test.ts +30 -0
  101. package/src/services/trusted-sources.ts +47 -10
  102. package/src/templates/generator.ts +9 -2
  103. 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, { cwd: dir, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
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 the
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 AFTER
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
- // Excluding manifest.yml from the "src" pathspec is the whole point — we
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. 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.
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 { capabilities, moduleInstances, moduleSystems, modules } from '../db/schema';
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
- execSync(`tar -xf ${shellEscape(tarPath)} -C ${shellEscape(envelopeDir)}`);
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
- copyFileSync(stagedDb, livePath);
432
-
433
- // Stale WAL/SHM siblings would corrupt the restored DB.
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 });