@celilo/cli 1.6.0 → 1.7.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 -1
- package/CELILO_SUBSYSTEMS.md +2 -0
- package/MODULE_PRIMITIVES.md +6 -1
- package/package.json +3 -3
- package/src/capabilities/lookup.ts +39 -29
- package/src/capabilities/secret-ref.test.ts +24 -0
- package/src/capabilities/secret-validation.ts +50 -0
- package/src/capabilities/validation.test.ts +187 -2
- package/src/capabilities/validation.ts +53 -1
- package/src/cli/commands/alerts-sweep.ts +18 -0
- package/src/cli/commands/module-remove.ts +34 -2
- package/src/cli/commands/module-update.test.ts +149 -2
- package/src/cli/commands/module-update.ts +113 -25
- package/src/cli/commands/service-set-credentials.test.ts +108 -0
- package/src/cli/commands/service-set-credentials.ts +115 -0
- package/src/cli/commands/system-migrate.ts +6 -4
- package/src/cli/completion.ts +16 -1
- package/src/cli/index.ts +9 -0
- package/src/db/client.ts +10 -8
- package/src/db/migrate.test.ts +147 -0
- package/src/db/migrate.ts +69 -1
- package/src/hooks/capability-loader.test.ts +55 -0
- package/src/hooks/capability-loader.ts +16 -1
- package/src/module/import.ts +20 -5
- package/src/policy/module-business-baseline.ts +0 -11
- package/src/services/alerting/monitors.ts +54 -2
- package/src/services/alerting/sweep-runner.ts +38 -1
- package/src/services/consumer-cleanup.ts +5 -3
- package/src/services/container-service.test.ts +34 -0
- package/src/services/container-service.ts +44 -0
- package/src/services/deployed-systems.test.ts +101 -0
- package/src/services/deployed-systems.ts +43 -11
- package/src/services/dns-provider-backfill.ts +30 -0
- package/src/services/fleet-checks.test.ts +26 -0
- package/src/services/fleet-checks.ts +11 -1
- package/src/services/module-deploy.ts +88 -41
- package/src/services/provider-arrival.test.ts +241 -0
- package/src/services/provider-arrival.ts +213 -0
- package/src/templates/generator.test.ts +35 -0
- package/src/templates/generator.ts +29 -1
- package/src/variables/context.test.ts +63 -0
- package/src/variables/context.ts +10 -2
- package/src/variables/declarative-derivation.test.ts +47 -8
- package/src/variables/declarative-derivation.ts +6 -4
- package/src/services/public-web-republish.test.ts +0 -189
- package/src/services/public-web-republish.ts +0 -84
|
@@ -691,7 +691,7 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
691
691
|
expect(context.selfConfig.primary_domain).toBeUndefined();
|
|
692
692
|
});
|
|
693
693
|
|
|
694
|
-
test('
|
|
694
|
+
test('derives scalar numeric values from capability data', () => {
|
|
695
695
|
const manifest: ModuleManifest = {
|
|
696
696
|
celilo_contract: '1.0',
|
|
697
697
|
id: 'test-module',
|
|
@@ -703,10 +703,10 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
703
703
|
owns: [
|
|
704
704
|
{
|
|
705
705
|
name: 'port',
|
|
706
|
-
type: '
|
|
706
|
+
type: 'integer',
|
|
707
707
|
required: false,
|
|
708
|
-
source: '
|
|
709
|
-
derive_from: '$
|
|
708
|
+
source: 'capability',
|
|
709
|
+
derive_from: '$capability:dns_internal.dns.knot_port',
|
|
710
710
|
},
|
|
711
711
|
],
|
|
712
712
|
imports: [],
|
|
@@ -716,16 +716,55 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
716
716
|
const context: ResolutionContext = {
|
|
717
717
|
moduleId: 'test-module',
|
|
718
718
|
selfConfig: {},
|
|
719
|
-
systemConfig: {
|
|
719
|
+
systemConfig: {},
|
|
720
720
|
systemSecrets: {},
|
|
721
721
|
secrets: {},
|
|
722
|
-
capabilities: {
|
|
722
|
+
capabilities: {
|
|
723
|
+
dns_internal: { dns: { knot_port: 5353 } },
|
|
724
|
+
},
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
applyDeclarativeDerivations(manifest, context);
|
|
728
|
+
|
|
729
|
+
expect(context.selfConfig.port).toBe('5353');
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
test('continues to skip structured derivations', () => {
|
|
733
|
+
const manifest: ModuleManifest = {
|
|
734
|
+
celilo_contract: '1.0',
|
|
735
|
+
id: 'test-module',
|
|
736
|
+
name: 'Test Module',
|
|
737
|
+
version: '1.0.0',
|
|
738
|
+
requires: { capabilities: [] },
|
|
739
|
+
provides: { capabilities: [] },
|
|
740
|
+
variables: {
|
|
741
|
+
owns: [
|
|
742
|
+
{
|
|
743
|
+
name: 'domains',
|
|
744
|
+
type: 'array',
|
|
745
|
+
required: false,
|
|
746
|
+
source: 'capability',
|
|
747
|
+
derive_from: '$capability:dns_internal.dns.managed_domains',
|
|
748
|
+
},
|
|
749
|
+
],
|
|
750
|
+
imports: [],
|
|
751
|
+
},
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
const context: ResolutionContext = {
|
|
755
|
+
moduleId: 'test-module',
|
|
756
|
+
selfConfig: {},
|
|
757
|
+
systemConfig: {},
|
|
758
|
+
systemSecrets: {},
|
|
759
|
+
secrets: {},
|
|
760
|
+
capabilities: {
|
|
761
|
+
dns_internal: { dns: { managed_domains: ['home.arpa'] } },
|
|
762
|
+
},
|
|
723
763
|
};
|
|
724
764
|
|
|
725
765
|
applyDeclarativeDerivations(manifest, context);
|
|
726
766
|
|
|
727
|
-
|
|
728
|
-
expect(context.selfConfig.port).toBeUndefined();
|
|
767
|
+
expect(context.selfConfig.domains).toBeUndefined();
|
|
729
768
|
});
|
|
730
769
|
});
|
|
731
770
|
|
|
@@ -186,10 +186,12 @@ export function applyDeclarativeDerivations(
|
|
|
186
186
|
continue;
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
-
// Declarative derivation
|
|
190
|
-
//
|
|
191
|
-
// the config
|
|
192
|
-
|
|
189
|
+
// Declarative derivation resolves scalar template expressions. Numeric and
|
|
190
|
+
// boolean values flow through this resolution context as their string form
|
|
191
|
+
// and recover their declared type at the module-config / inventory boundary.
|
|
192
|
+
// Structured arrays and objects still belong to the typed config path: a
|
|
193
|
+
// template substitution would collapse them to a lossy string.
|
|
194
|
+
if (variable.type === 'array' || variable.type === 'object') {
|
|
193
195
|
continue;
|
|
194
196
|
}
|
|
195
197
|
|
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import type { DbClient } from '../db/client';
|
|
6
|
-
import { modules, webRoutes } from '../db/schema';
|
|
7
|
-
import type { ModuleManifest } from '../manifest/schema';
|
|
8
|
-
import { setupTestDatabase } from '../test-utils/setup-test-db';
|
|
9
|
-
import { providesPublicWeb, republishStaticWebConsumers } from './public-web-republish';
|
|
10
|
-
|
|
11
|
-
const caddyManifest = {
|
|
12
|
-
provides: { capabilities: [{ name: 'public_web', version: '3.0.0' }] },
|
|
13
|
-
} as unknown as ModuleManifest;
|
|
14
|
-
|
|
15
|
-
const plainManifest = { provides: { capabilities: [] } } as unknown as ModuleManifest;
|
|
16
|
-
|
|
17
|
-
describe('republishStaticWebConsumers', () => {
|
|
18
|
-
let dir: string;
|
|
19
|
-
let db: DbClient;
|
|
20
|
-
|
|
21
|
-
function seedModule(id: string) {
|
|
22
|
-
db.insert(modules)
|
|
23
|
-
.values({ id, name: id, version: '1.0.0', manifestData: {}, sourcePath: `/tmp/${id}` })
|
|
24
|
-
.run();
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function seedRoute(
|
|
28
|
-
moduleId: string,
|
|
29
|
-
slug: string,
|
|
30
|
-
path: string,
|
|
31
|
-
type: 'static' | 'reverse_proxy',
|
|
32
|
-
) {
|
|
33
|
-
db.insert(webRoutes)
|
|
34
|
-
.values({
|
|
35
|
-
slug,
|
|
36
|
-
moduleId,
|
|
37
|
-
type,
|
|
38
|
-
path,
|
|
39
|
-
hostname: 'iamtheinternet.org',
|
|
40
|
-
targetHost: type === 'reverse_proxy' ? '10.0.20.5' : null,
|
|
41
|
-
targetPort: type === 'reverse_proxy' ? 8080 : null,
|
|
42
|
-
})
|
|
43
|
-
.run();
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function recordingHook(failFor: string[] = []) {
|
|
47
|
-
const calls: string[] = [];
|
|
48
|
-
const runHook = (async (moduleId: string) => {
|
|
49
|
-
calls.push(moduleId);
|
|
50
|
-
return failFor.includes(moduleId)
|
|
51
|
-
? { success: false, outputs: {}, error: 'boom', duration: 1 }
|
|
52
|
-
: { success: true, outputs: {}, duration: 1 };
|
|
53
|
-
}) as unknown as Parameters<typeof republishStaticWebConsumers>[0]['runHook'];
|
|
54
|
-
return { runHook, calls };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
beforeEach(async () => {
|
|
58
|
-
dir = mkdtempSync(join(tmpdir(), 'pwr-'));
|
|
59
|
-
const dbPath = join(dir, 'celilo.db');
|
|
60
|
-
process.env.CELILO_DB_PATH = dbPath;
|
|
61
|
-
db = await setupTestDatabase(dbPath);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
afterEach(() => {
|
|
65
|
-
db.$client.close();
|
|
66
|
-
process.env.CELILO_DB_PATH = undefined;
|
|
67
|
-
try {
|
|
68
|
-
rmSync(dir, { recursive: true, force: true });
|
|
69
|
-
} catch {
|
|
70
|
-
/* ignore */
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test('providesPublicWeb reads the manifest', () => {
|
|
75
|
-
expect(providesPublicWeb(caddyManifest)).toBe(true);
|
|
76
|
-
expect(providesPublicWeb(plainManifest)).toBe(false);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
test('does not run for a module that is not a public_web provider', async () => {
|
|
80
|
-
seedModule('hello-foo');
|
|
81
|
-
seedRoute('hello-foo', 'foo', '/foo', 'static');
|
|
82
|
-
const { runHook, calls } = recordingHook();
|
|
83
|
-
|
|
84
|
-
const result = await republishStaticWebConsumers({
|
|
85
|
-
providerModuleId: 'namecheap',
|
|
86
|
-
manifest: plainManifest,
|
|
87
|
-
db,
|
|
88
|
-
runHook,
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
expect(result).toEqual({ ran: false, republished: [], failures: [] });
|
|
92
|
-
expect(calls).toEqual([]);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test('re-runs on_install for every static consumer', async () => {
|
|
96
|
-
seedModule('caddy');
|
|
97
|
-
seedModule('hello-foo');
|
|
98
|
-
seedModule('hello-bar');
|
|
99
|
-
seedModule('celilo-website');
|
|
100
|
-
seedRoute('hello-foo', 'foo', '/foo', 'static');
|
|
101
|
-
seedRoute('hello-bar', 'bar', '/bar', 'static');
|
|
102
|
-
seedRoute('celilo-website', 'celilo-website', '/', 'static');
|
|
103
|
-
const { runHook, calls } = recordingHook();
|
|
104
|
-
|
|
105
|
-
const result = await republishStaticWebConsumers({
|
|
106
|
-
providerModuleId: 'caddy',
|
|
107
|
-
manifest: caddyManifest,
|
|
108
|
-
db,
|
|
109
|
-
runHook,
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
expect(result.ran).toBe(true);
|
|
113
|
-
expect(calls.sort()).toEqual(['celilo-website', 'hello-bar', 'hello-foo']);
|
|
114
|
-
expect(result.republished.sort()).toEqual(['celilo-website', 'hello-bar', 'hello-foo']);
|
|
115
|
-
expect(result.failures).toEqual([]);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
test('skips reverse-proxy-only consumers (no assets on the provider host)', async () => {
|
|
119
|
-
seedModule('caddy');
|
|
120
|
-
seedModule('forgejo');
|
|
121
|
-
seedModule('hello-foo');
|
|
122
|
-
seedRoute('forgejo', 'git', '/git', 'reverse_proxy');
|
|
123
|
-
seedRoute('hello-foo', 'foo', '/foo', 'static');
|
|
124
|
-
const { runHook, calls } = recordingHook();
|
|
125
|
-
|
|
126
|
-
await republishStaticWebConsumers({
|
|
127
|
-
providerModuleId: 'caddy',
|
|
128
|
-
manifest: caddyManifest,
|
|
129
|
-
db,
|
|
130
|
-
runHook,
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
expect(calls).toEqual(['hello-foo']);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
test('never re-runs the provider itself', async () => {
|
|
137
|
-
seedModule('caddy');
|
|
138
|
-
// A provider that also owns a static route (a landing page of its own).
|
|
139
|
-
seedRoute('caddy', 'caddy', '/', 'static');
|
|
140
|
-
const { runHook, calls } = recordingHook();
|
|
141
|
-
|
|
142
|
-
const result = await republishStaticWebConsumers({
|
|
143
|
-
providerModuleId: 'caddy',
|
|
144
|
-
manifest: caddyManifest,
|
|
145
|
-
db,
|
|
146
|
-
runHook,
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
expect(calls).toEqual([]);
|
|
150
|
-
expect(result.republished).toEqual([]);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
test('de-duplicates a consumer that owns several static routes', async () => {
|
|
154
|
-
seedModule('caddy');
|
|
155
|
-
seedModule('hello-foo');
|
|
156
|
-
seedRoute('hello-foo', 'foo', '/foo', 'static');
|
|
157
|
-
seedRoute('hello-foo', 'foo-docs', '/foo/docs', 'static');
|
|
158
|
-
const { runHook, calls } = recordingHook();
|
|
159
|
-
|
|
160
|
-
await republishStaticWebConsumers({
|
|
161
|
-
providerModuleId: 'caddy',
|
|
162
|
-
manifest: caddyManifest,
|
|
163
|
-
db,
|
|
164
|
-
runHook,
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
expect(calls).toEqual(['hello-foo']);
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
test('one consumer failing does not stop the others', async () => {
|
|
171
|
-
seedModule('caddy');
|
|
172
|
-
seedModule('hello-foo');
|
|
173
|
-
seedModule('hello-bar');
|
|
174
|
-
seedRoute('hello-foo', 'foo', '/foo', 'static');
|
|
175
|
-
seedRoute('hello-bar', 'bar', '/bar', 'static');
|
|
176
|
-
const { runHook, calls } = recordingHook(['hello-foo']);
|
|
177
|
-
|
|
178
|
-
const result = await republishStaticWebConsumers({
|
|
179
|
-
providerModuleId: 'caddy',
|
|
180
|
-
manifest: caddyManifest,
|
|
181
|
-
db,
|
|
182
|
-
runHook,
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
expect(calls.sort()).toEqual(['hello-bar', 'hello-foo']);
|
|
186
|
-
expect(result.republished).toEqual(['hello-bar']);
|
|
187
|
-
expect(result.failures).toEqual([{ moduleId: 'hello-foo', error: 'boom' }]);
|
|
188
|
-
});
|
|
189
|
-
});
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Restore consumer content after a public_web provider redeploy.
|
|
3
|
-
*
|
|
4
|
-
* A provider redeploy destroys the provider's host and rebuilds it, taking
|
|
5
|
-
* `/srv/www` with it. The consumers' `web_routes` rows survive — they belong to
|
|
6
|
-
* the consumer modules, not the provider — so the rebuilt provider renders
|
|
7
|
-
* `root * /srv/www/<slug>` for directories that no longer exist and every
|
|
8
|
-
* static site 404s. Nothing re-uploaded the assets; an operator had to redeploy
|
|
9
|
-
* each consumer by hand and there was no signal telling them to.
|
|
10
|
-
*
|
|
11
|
-
* The fix is to do exactly what that operator did: after a provider deploy
|
|
12
|
-
* lands, re-run `on_install` for every module holding a STATIC route.
|
|
13
|
-
* `publishStaticSite` is idempotent (it upserts the route and re-tars the
|
|
14
|
-
* source dir), so the re-run re-registers the route and refills the fresh
|
|
15
|
-
* `/srv/www/<slug>`.
|
|
16
|
-
*
|
|
17
|
-
* Reverse-proxy consumers are skipped: they keep no state on the provider host,
|
|
18
|
-
* so re-running them buys nothing and only widens the blast radius.
|
|
19
|
-
*
|
|
20
|
-
* ponytail: re-runs every static consumer unconditionally rather than probing
|
|
21
|
-
* which /srv/www/<slug> directories are actually missing — O(static consumers)
|
|
22
|
-
* per provider deploy, currently a handful. If a large fleet makes provider
|
|
23
|
-
* deploys slow, probe the provider host first and re-run only the misses.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import { eq } from 'drizzle-orm';
|
|
27
|
-
import type { DbClient } from '../db/client';
|
|
28
|
-
import { webRoutes } from '../db/schema';
|
|
29
|
-
import { createConsoleLogger } from '../hooks/logger';
|
|
30
|
-
import { runNamedHook } from '../hooks/run-named-hook';
|
|
31
|
-
import type { ModuleManifest } from '../manifest/schema';
|
|
32
|
-
|
|
33
|
-
export interface RepublishFailure {
|
|
34
|
-
moduleId: string;
|
|
35
|
-
error: string;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface RepublishResult {
|
|
39
|
-
/** False when the deployed module isn't a public_web provider. */
|
|
40
|
-
ran: boolean;
|
|
41
|
-
/** Consumer modules whose on_install was re-run successfully. */
|
|
42
|
-
republished: string[];
|
|
43
|
-
failures: RepublishFailure[];
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function providesPublicWeb(manifest: ModuleManifest): boolean {
|
|
47
|
-
return (manifest.provides?.capabilities ?? []).some((c) => c.name === 'public_web');
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface RepublishDeps {
|
|
51
|
-
providerModuleId: string;
|
|
52
|
-
manifest: ModuleManifest;
|
|
53
|
-
db: DbClient;
|
|
54
|
-
/** Injectable for tests. */
|
|
55
|
-
runHook?: typeof runNamedHook;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export async function republishStaticWebConsumers(deps: RepublishDeps): Promise<RepublishResult> {
|
|
59
|
-
const { providerModuleId, manifest, db, runHook = runNamedHook } = deps;
|
|
60
|
-
|
|
61
|
-
if (!providesPublicWeb(manifest)) {
|
|
62
|
-
return { ran: false, republished: [], failures: [] };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const staticRoutes = db.select().from(webRoutes).where(eq(webRoutes.type, 'static')).all();
|
|
66
|
-
const consumerIds = [
|
|
67
|
-
...new Set(staticRoutes.map((r) => r.moduleId).filter((id) => id !== providerModuleId)),
|
|
68
|
-
];
|
|
69
|
-
|
|
70
|
-
const republished: string[] = [];
|
|
71
|
-
const failures: RepublishFailure[] = [];
|
|
72
|
-
|
|
73
|
-
for (const moduleId of consumerIds) {
|
|
74
|
-
const logger = createConsoleLogger(moduleId, 'on_install');
|
|
75
|
-
const result = await runHook(moduleId, 'on_install', db, logger, {});
|
|
76
|
-
if (result.success) {
|
|
77
|
-
republished.push(moduleId);
|
|
78
|
-
} else {
|
|
79
|
-
failures.push({ moduleId, error: result.error ?? 'unknown error' });
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return { ran: true, republished, failures };
|
|
84
|
-
}
|