@celilo/cli 2.3.0 → 3.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 +1 -1
- package/package.json +3 -3
- package/src/api/serve.ts +13 -1
- package/src/cli/commands/module-health.test.ts +35 -0
- package/src/cli/commands/module-health.ts +11 -3
- package/src/cli/commands/module-publish.test.ts +22 -0
- package/src/cli/commands/module-publish.ts +14 -12
- package/src/cli/commands/module-update.ts +69 -17
- package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
- package/src/cli/commands/module-upgrade.ts +43 -3
- package/src/cli/commands/publish/helpers.ts +4 -3
- package/src/cli/commands/publish/index.ts +13 -1
- package/src/cli/commands/publish/plan.test.ts +64 -0
- package/src/cli/commands/publish/plan.ts +52 -19
- package/src/cli/commands/publish/types.ts +16 -3
- package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
- package/src/cli/commands/subscribers-install-daemon.ts +107 -0
- package/src/cli/commands/subscribers-serve.test.ts +22 -0
- package/src/cli/commands/subscribers-serve.ts +22 -4
- package/src/cli/commands/system-audit.ts +14 -5
- package/src/cli/commands/system-update.ts +11 -6
- package/src/cli/completion.ts +10 -1
- package/src/cli/fuel-gauge.ts +12 -4
- package/src/cli/index.ts +12 -0
- package/src/cli/json-output.test.ts +81 -0
- package/src/cli/types.ts +9 -0
- package/src/hooks/broker.test.ts +9 -3
- package/src/hooks/capability-loader.ts +49 -1
- package/src/hooks/executor.test.ts +5 -2
- package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
- package/src/hooks/hook-jail-unreachability.test.ts +10 -2
- package/src/hooks/hook-trespass.test.ts +13 -4
- package/src/hooks/run-named-hook.ts +19 -16
- package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
- package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
- package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
- package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
- package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
- package/src/hooks/test-fixtures/silent-hook.ts +0 -1
- package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
- package/src/hooks/test-fixtures/success-hook.ts +4 -4
- package/src/manifest/contracts/v1.ts +19 -14
- package/src/manifest/json-schema-roundtrip.test.ts +1 -1
- package/src/manifest/schema.ts +66 -16
- package/src/manifest/validate.test.ts +47 -0
- package/src/policy/capability-shape-baseline.ts +69 -21
- package/src/policy/capability-shape-drift.test.ts +53 -1
- package/src/policy/capability-shape.test.ts +105 -0
- package/src/policy/capability-shape.ts +283 -2
- package/src/policy/module-business-baseline.ts +17 -0
- package/src/registry/client.test.ts +65 -0
- package/src/registry/client.ts +7 -7
- package/src/secrets/storage.test.ts +69 -3
- package/src/secrets/storage.ts +71 -1
- package/src/services/audit/health.test.ts +58 -0
- package/src/services/audit/health.ts +15 -3
- package/src/services/audit/index.test.ts +1 -1
- package/src/services/audit/interface-classification.test.ts +16 -5
- package/src/services/audit/interface-classification.ts +25 -2
- package/src/services/audit/module-versions.ts +5 -1
- package/src/services/audit/public-dns.test.ts +20 -0
- package/src/services/audit/public-dns.ts +7 -2
- package/src/services/audit/recurrence-gate.test.ts +225 -0
- package/src/services/audit/trusted-sources.test.ts +17 -0
- package/src/services/audit/trusted-sources.ts +21 -0
- package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +102 -150
- package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
- package/src/services/build-bus/hook-dispatch.test.ts +86 -116
- package/src/services/build-bus/hook-dispatch.ts +99 -121
- package/src/services/build-bus/hook-dispatcher.ts +106 -28
- package/src/services/build-bus/receiver-daemon.test.ts +189 -0
- package/src/services/build-bus/receiver-daemon.ts +355 -0
- package/src/services/build-bus/self-update.ts +156 -0
- package/src/services/capability-compat.test.ts +90 -0
- package/src/services/capability-compat.ts +128 -0
- package/src/services/deploy-terraform.ts +38 -1
- package/src/services/events-daemon.test.ts +57 -0
- package/src/services/events-daemon.ts +76 -0
- package/src/services/firewall-reach.ts +21 -8
- package/src/services/fleet-checks.test.ts +37 -1
- package/src/services/fleet-checks.ts +31 -1
- package/src/services/health-runner.ts +43 -5
- package/src/services/module-deploy-prune.test.ts +89 -0
- package/src/services/module-deploy.ts +72 -125
- package/src/services/module-types-drift.test.ts +1 -1
- package/src/services/module-validator/capability-versions.test.ts +13 -2
- package/src/services/provider-converge.test.ts +209 -0
- package/src/services/provider-converge.ts +241 -0
- package/src/services/static-content-converge.test.ts +39 -0
- package/src/services/static-content-converge.ts +26 -5
- package/src/services/terraform-safety.test.ts +83 -0
- package/src/services/terraform-safety.ts +53 -0
- package/src/services/update/orchestrator.test.ts +1 -1
- package/tsconfig.json +2 -13
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, 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 { parse as parseYaml } from 'yaml';
|
|
7
|
+
import type { DbClient } from '../db/client';
|
|
8
|
+
import { modules } from '../db/schema';
|
|
9
|
+
import { setupTestDatabaseAt } from '../test-utils/database';
|
|
10
|
+
import { resetTestDbPath } from '../test-utils/db-path';
|
|
11
|
+
import {
|
|
12
|
+
type ProviderArtifacts,
|
|
13
|
+
convergeProviderConfig,
|
|
14
|
+
providerConfigVarsPath,
|
|
15
|
+
providerConfigVarsYaml,
|
|
16
|
+
recordUnresolvedConsumers,
|
|
17
|
+
} from './provider-converge';
|
|
18
|
+
|
|
19
|
+
const HASH = 'a'.repeat(64);
|
|
20
|
+
|
|
21
|
+
function artifacts(overrides: Partial<ProviderArtifacts> = {}): ProviderArtifacts {
|
|
22
|
+
return {
|
|
23
|
+
files: [{ path: '/etc/caddy/Caddyfile', content: 'peterbanka.org {\n\ttls internal\n}\n' }],
|
|
24
|
+
sites: [
|
|
25
|
+
{
|
|
26
|
+
slug: 'peterbanka-org',
|
|
27
|
+
moduleId: 'peterbanka-org',
|
|
28
|
+
contentHash: HASH,
|
|
29
|
+
hostnames: ['peterbanka.org'],
|
|
30
|
+
sourceDir: '/var/celilo/modules/peterbanka-org/site/dist',
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
unresolved: [],
|
|
34
|
+
retention: 5,
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('providerConfigVarsYaml — the provider renders, celilo carries', () => {
|
|
40
|
+
it('carries each rendered file so the role can place it verbatim', () => {
|
|
41
|
+
const vars = parseYaml(providerConfigVarsYaml(artifacts()));
|
|
42
|
+
|
|
43
|
+
expect(vars.provider_config_files).toEqual([
|
|
44
|
+
{ path: '/etc/caddy/Caddyfile', content: 'peterbanka.org {\n\ttls internal\n}\n' },
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('carries the sites under the variable the static-content role already reads', () => {
|
|
49
|
+
const vars = parseYaml(providerConfigVarsYaml(artifacts()));
|
|
50
|
+
|
|
51
|
+
expect(vars.static_release_retention).toBe(5);
|
|
52
|
+
expect(vars.static_releases).toEqual([
|
|
53
|
+
{
|
|
54
|
+
slug: 'peterbanka-org',
|
|
55
|
+
content_hash: HASH,
|
|
56
|
+
hostnames: ['peterbanka.org'],
|
|
57
|
+
source_dir: '/var/celilo/modules/peterbanka-org/site/dist',
|
|
58
|
+
},
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('omits overlay_dir when there is none, so the role guard reads a clean variable', () => {
|
|
63
|
+
const withOverlay = artifacts({
|
|
64
|
+
sites: [
|
|
65
|
+
{ ...artifacts().sites[0], overlayDir: '/var/celilo/modules/peterbanka-org/state/site' },
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(parseYaml(providerConfigVarsYaml(artifacts())).static_releases[0].overlay_dir).toBe(
|
|
70
|
+
undefined,
|
|
71
|
+
);
|
|
72
|
+
expect(parseYaml(providerConfigVarsYaml(withOverlay)).static_releases[0].overlay_dir).toBe(
|
|
73
|
+
'/var/celilo/modules/peterbanka-org/state/site',
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("one consumer the provider could not render does not remove another's site", () => {
|
|
78
|
+
const partial = artifacts({
|
|
79
|
+
unresolved: [
|
|
80
|
+
{ moduleId: 'byoi', reason: 'no built site at /var/celilo/modules/byoi/site/dist' },
|
|
81
|
+
],
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const vars = parseYaml(providerConfigVarsYaml(partial));
|
|
85
|
+
|
|
86
|
+
expect(vars.static_releases.map((r: { slug: string }) => r.slug)).toEqual(['peterbanka-org']);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('is deterministic, so an unchanged desired state writes an unchanged file', () => {
|
|
90
|
+
expect(providerConfigVarsYaml(artifacts())).toBe(providerConfigVarsYaml(artifacts()));
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('providerConfigVarsPath — lands in the generated inventory', () => {
|
|
95
|
+
it('writes into the auto-loaded group_vars directory', () => {
|
|
96
|
+
expect(providerConfigVarsPath('/tmp/generated')).toBe(
|
|
97
|
+
'/tmp/generated/ansible/inventory/group_vars/all/provider_config.yml',
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('recordUnresolvedConsumers — the failure belongs to the consumer', () => {
|
|
103
|
+
let dir: string;
|
|
104
|
+
let db: DbClient;
|
|
105
|
+
|
|
106
|
+
beforeEach(async () => {
|
|
107
|
+
dir = mkdtempSync(join(tmpdir(), 'provider-converge-'));
|
|
108
|
+
const dbPath = join(dir, 'celilo.db');
|
|
109
|
+
process.env.CELILO_DB_PATH = dbPath;
|
|
110
|
+
db = await setupTestDatabaseAt(dbPath);
|
|
111
|
+
|
|
112
|
+
for (const id of ['caddy', 'byoi', 'peterbanka-org']) {
|
|
113
|
+
db.insert(modules)
|
|
114
|
+
.values({
|
|
115
|
+
id,
|
|
116
|
+
name: id,
|
|
117
|
+
version: '1.0.0',
|
|
118
|
+
manifestData: {},
|
|
119
|
+
sourcePath: join(dir, 'modules', id),
|
|
120
|
+
state: 'VERIFIED',
|
|
121
|
+
})
|
|
122
|
+
.onConflictDoNothing()
|
|
123
|
+
.run();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
afterEach(() => {
|
|
128
|
+
db.$client.close();
|
|
129
|
+
resetTestDbPath();
|
|
130
|
+
try {
|
|
131
|
+
rmSync(dir, { recursive: true, force: true });
|
|
132
|
+
} catch {
|
|
133
|
+
/* ignore */
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
function stateOf(id: string) {
|
|
138
|
+
return db.select().from(modules).where(eq(modules.id, id)).get();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
it('records the consumer in ERROR with the reason it could not be rendered', () => {
|
|
142
|
+
recordUnresolvedConsumers(db, 'caddy', [
|
|
143
|
+
{ moduleId: 'byoi', reason: 'no built site at /var/celilo/modules/byoi/site/dist' },
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
const byoi = stateOf('byoi');
|
|
147
|
+
expect(byoi?.state).toBe('ERROR');
|
|
148
|
+
expect(byoi?.errorMessage).toContain('no built site');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('leaves the provider and every other consumer alone', () => {
|
|
152
|
+
recordUnresolvedConsumers(db, 'caddy', [{ moduleId: 'byoi', reason: 'no built site' }]);
|
|
153
|
+
|
|
154
|
+
expect(stateOf('caddy')?.state).toBe('VERIFIED');
|
|
155
|
+
expect(stateOf('peterbanka-org')?.state).toBe('VERIFIED');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('returns the modules it recorded, so the converge can report them', () => {
|
|
159
|
+
const recorded = recordUnresolvedConsumers(db, 'caddy', [
|
|
160
|
+
{ moduleId: 'byoi', reason: 'no built site' },
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
expect(recorded).toEqual(['byoi']);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('records the consumer even when the converge cannot run at all', async () => {
|
|
167
|
+
const result = await convergeProviderConfig(db, 'not-installed-provider', {
|
|
168
|
+
files: [],
|
|
169
|
+
sites: [],
|
|
170
|
+
unresolved: [{ moduleId: 'byoi', reason: 'no built site' }],
|
|
171
|
+
retention: 5,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(result.success).toBe(false);
|
|
175
|
+
expect(result.error).toContain('not installed');
|
|
176
|
+
// The consumer's fault is recorded whatever happens to the provider run:
|
|
177
|
+
// the two failures are unrelated and the operator needs both.
|
|
178
|
+
expect(result.unresolved).toEqual(['byoi']);
|
|
179
|
+
expect(stateOf('byoi')?.state).toBe('ERROR');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('refuses a generated project that predates the converge, naming the redeploy', async () => {
|
|
183
|
+
const generated = join(dir, 'modules', 'caddy', 'generated', 'ansible', 'roles');
|
|
184
|
+
mkdirSync(generated, { recursive: true });
|
|
185
|
+
writeFileSync(join(generated, 'main.yml'), '- name: does not read the new vars\n', 'utf-8');
|
|
186
|
+
|
|
187
|
+
const result = await convergeProviderConfig(db, 'caddy', artifacts());
|
|
188
|
+
|
|
189
|
+
expect(result.success).toBe(false);
|
|
190
|
+
expect(result.error).toContain('predates the provider converge');
|
|
191
|
+
expect(result.error).toContain('celilo module deploy caddy');
|
|
192
|
+
// Nothing was written: the guard scans the tree for the variable name, and
|
|
193
|
+
// the vars file would land inside that tree carrying it. Writing first
|
|
194
|
+
// makes the guard find its own output and pass forever (celilo#1248).
|
|
195
|
+
expect(result.varsPath).toBe(undefined);
|
|
196
|
+
expect(existsSync(providerConfigVarsPath(join(dir, 'modules', 'caddy', 'generated')))).toBe(
|
|
197
|
+
false,
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('ignores a module that is not installed rather than inventing a row', () => {
|
|
202
|
+
const recorded = recordUnresolvedConsumers(db, 'caddy', [
|
|
203
|
+
{ moduleId: 'never-installed', reason: 'no built site' },
|
|
204
|
+
]);
|
|
205
|
+
|
|
206
|
+
expect(recorded).toEqual([]);
|
|
207
|
+
expect(stateOf('never-installed')).toBe(undefined);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provider converge (openspec/changes/providers-converge-declared-state).
|
|
3
|
+
*
|
|
4
|
+
* A provider renders its own config from the rows it declares, and celilo makes
|
|
5
|
+
* the host match by running that provider's own Ansible role. One writer on the
|
|
6
|
+
* box, one path from declared state to a running config.
|
|
7
|
+
*
|
|
8
|
+
* Generalised from `static-content-converge.ts`, which already does this for
|
|
9
|
+
* static site content: desired state into the provider's generated inventory,
|
|
10
|
+
* then a tag-scoped `executeAnsible`. The difference is who decides what the
|
|
11
|
+
* desired state is. There, core walks every route row in the fleet and throws
|
|
12
|
+
* when any one module's site is missing (celilo#1383 — two out-of-tree modules
|
|
13
|
+
* with no `site/dist` made the public ingress undeployable). Here the provider
|
|
14
|
+
* hands over what it rendered, and a consumer it could not render is recorded
|
|
15
|
+
* against that consumer.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, rmSync } from 'node:fs';
|
|
19
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { eq } from 'drizzle-orm';
|
|
22
|
+
import { stringify as stringifyYaml } from 'yaml';
|
|
23
|
+
import type { DbClient } from '../db/client';
|
|
24
|
+
import { modules } from '../db/schema';
|
|
25
|
+
import { ansibleTreeMentions } from './static-content-converge';
|
|
26
|
+
|
|
27
|
+
/** One file the provider wants on its host, rendered by the provider. */
|
|
28
|
+
export interface ProviderConfigFile {
|
|
29
|
+
/** Absolute path on the provider host. */
|
|
30
|
+
path: string;
|
|
31
|
+
/** The rendered contents. */
|
|
32
|
+
content: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A consumer's site the provider host must carry. */
|
|
36
|
+
export interface ProviderSite {
|
|
37
|
+
slug: string;
|
|
38
|
+
moduleId: string;
|
|
39
|
+
contentHash: string;
|
|
40
|
+
hostnames: string[];
|
|
41
|
+
sourceDir: string;
|
|
42
|
+
overlayDir?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A consumer the provider could not render, and why. */
|
|
46
|
+
export interface UnresolvedConsumer {
|
|
47
|
+
moduleId: string;
|
|
48
|
+
reason: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Everything the provider rendered for one converge. */
|
|
52
|
+
export interface ProviderArtifacts {
|
|
53
|
+
files: ProviderConfigFile[];
|
|
54
|
+
sites: ProviderSite[];
|
|
55
|
+
unresolved: UnresolvedConsumer[];
|
|
56
|
+
retention: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Where the provider's desired state lands inside its generated project.
|
|
61
|
+
*
|
|
62
|
+
* `group_vars/all/` is the existing auto-load directory — the same one
|
|
63
|
+
* `static_content.yml` uses — so no playbook change is needed to read it.
|
|
64
|
+
*/
|
|
65
|
+
export function providerConfigVarsPath(generatedPath: string): string {
|
|
66
|
+
return join(generatedPath, 'ansible', 'inventory', 'group_vars', 'all', 'provider_config.yml');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Render the vars file. Presentation function (Rule 10.1) — deterministic YAML,
|
|
71
|
+
* so an unchanged desired state writes a byte-identical file and the role's
|
|
72
|
+
* config task reports no change.
|
|
73
|
+
*
|
|
74
|
+
* `static_releases` keeps its name: the role tasks that converge `/srv/www`
|
|
75
|
+
* already read it, and renaming a working variable to match a new writer would
|
|
76
|
+
* be churn with a live-site failure mode.
|
|
77
|
+
*/
|
|
78
|
+
export function providerConfigVarsYaml(artifacts: ProviderArtifacts): string {
|
|
79
|
+
const vars = {
|
|
80
|
+
provider_config_files: artifacts.files.map((file) => ({
|
|
81
|
+
path: file.path,
|
|
82
|
+
content: file.content,
|
|
83
|
+
})),
|
|
84
|
+
static_release_retention: artifacts.retention,
|
|
85
|
+
static_releases: artifacts.sites.map((site) => ({
|
|
86
|
+
slug: site.slug,
|
|
87
|
+
content_hash: site.contentHash,
|
|
88
|
+
hostnames: site.hostnames,
|
|
89
|
+
source_dir: site.sourceDir,
|
|
90
|
+
// Absent when there is no overlay, so the role's `when:` guard reads a
|
|
91
|
+
// clean variable rather than an empty string a copy task would choke on.
|
|
92
|
+
...(site.overlayDir ? { overlay_dir: site.overlayDir } : {}),
|
|
93
|
+
})),
|
|
94
|
+
};
|
|
95
|
+
const header =
|
|
96
|
+
'# Desired provider state, generated by celilo (providers-converge-declared-state).\n' +
|
|
97
|
+
'# Hand edits are overwritten by the next converge.\n';
|
|
98
|
+
return `${header}${stringifyYaml(vars, { lineWidth: 0, sortMapEntries: true })}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Record each consumer the provider could not render, against that consumer.
|
|
103
|
+
*
|
|
104
|
+
* The provider is not at fault for a consumer that ships no built site, and the
|
|
105
|
+
* fleet is not at fault either: before this, one such module threw in core's
|
|
106
|
+
* plan and no site converged at all (celilo#1383). The state has to be honest
|
|
107
|
+
* about which module is broken (celilo#1363), so the row says ERROR and carries
|
|
108
|
+
* the reason the provider gave.
|
|
109
|
+
*
|
|
110
|
+
* A module that is not installed is skipped rather than inserted: celilo cannot
|
|
111
|
+
* record a state for something it does not have.
|
|
112
|
+
*/
|
|
113
|
+
export function recordUnresolvedConsumers(
|
|
114
|
+
db: DbClient,
|
|
115
|
+
providerModuleId: string,
|
|
116
|
+
unresolved: UnresolvedConsumer[],
|
|
117
|
+
): string[] {
|
|
118
|
+
const recorded: string[] = [];
|
|
119
|
+
|
|
120
|
+
for (const consumer of unresolved) {
|
|
121
|
+
const existing = db.select().from(modules).where(eq(modules.id, consumer.moduleId)).get();
|
|
122
|
+
if (!existing) continue;
|
|
123
|
+
|
|
124
|
+
db.update(modules)
|
|
125
|
+
.set({
|
|
126
|
+
state: 'ERROR',
|
|
127
|
+
errorMessage: `'${providerModuleId}' could not serve this module: ${consumer.reason}`,
|
|
128
|
+
updatedAt: new Date(),
|
|
129
|
+
})
|
|
130
|
+
.where(eq(modules.id, consumer.moduleId))
|
|
131
|
+
.run();
|
|
132
|
+
recorded.push(consumer.moduleId);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return recorded;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** What one converge did, for the caller to report. */
|
|
139
|
+
export interface ProviderConvergeResult {
|
|
140
|
+
success: boolean;
|
|
141
|
+
error?: string;
|
|
142
|
+
/** The written vars file, absolute — for logs and tests. */
|
|
143
|
+
varsPath?: string;
|
|
144
|
+
/** Consumers recorded in ERROR because the provider could not render them. */
|
|
145
|
+
unresolved: string[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The tags a converge runs. The provider's whole playbook re-runs package
|
|
150
|
+
* installation and service enablement, which a route change has no business
|
|
151
|
+
* touching; these two cover the file placement and `/srv/www`.
|
|
152
|
+
*/
|
|
153
|
+
const CONVERGE_TAGS = ['provider_config', 'static_content'];
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Plan, write, converge: make the provider's host match what the provider
|
|
157
|
+
* rendered.
|
|
158
|
+
*
|
|
159
|
+
* `generatedPath` is rendered on demand when it is missing, because a
|
|
160
|
+
* successful deploy deletes the generated tree by design (D4 of
|
|
161
|
+
* control-plane-stops-building-modules) and generation is fast and local.
|
|
162
|
+
*/
|
|
163
|
+
export async function convergeProviderConfig(
|
|
164
|
+
db: DbClient,
|
|
165
|
+
providerModuleId: string,
|
|
166
|
+
artifacts: ProviderArtifacts,
|
|
167
|
+
options?: { tags?: string[] },
|
|
168
|
+
): Promise<ProviderConvergeResult> {
|
|
169
|
+
const unresolved = recordUnresolvedConsumers(db, providerModuleId, artifacts.unresolved);
|
|
170
|
+
|
|
171
|
+
const module = db.select().from(modules).where(eq(modules.id, providerModuleId)).get();
|
|
172
|
+
if (!module) {
|
|
173
|
+
return {
|
|
174
|
+
success: false,
|
|
175
|
+
error: `Provider module '${providerModuleId}' is not installed`,
|
|
176
|
+
unresolved,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const generatedPath = join(module.sourcePath, 'generated');
|
|
181
|
+
if (!existsSync(generatedPath)) {
|
|
182
|
+
const { generateTemplates } = await import('../templates/generator');
|
|
183
|
+
const rendered = await generateTemplates({
|
|
184
|
+
moduleId: providerModuleId,
|
|
185
|
+
modulePath: module.sourcePath,
|
|
186
|
+
outputPath: generatedPath,
|
|
187
|
+
db,
|
|
188
|
+
skipVariableValidation: false,
|
|
189
|
+
});
|
|
190
|
+
if (!rendered.success) {
|
|
191
|
+
return {
|
|
192
|
+
success: false,
|
|
193
|
+
error:
|
|
194
|
+
`Provider '${providerModuleId}' has no generated deploy project at ${generatedPath} ` +
|
|
195
|
+
`and rendering one failed: ${rendered.error ?? 'unknown error'}. ` +
|
|
196
|
+
`Run \`celilo module deploy ${providerModuleId}\` once, then retry.`,
|
|
197
|
+
unresolved,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// A generated project that predates the converge role tasks would read the
|
|
203
|
+
// new vars file and place nothing, so the desired state would be written and
|
|
204
|
+
// silently never applied. The variable appearing nowhere in the tree is the
|
|
205
|
+
// tell. Generic string check — core names no module.
|
|
206
|
+
//
|
|
207
|
+
// CHECKED BEFORE THE VARS ARE WRITTEN, and the order is the whole point: the
|
|
208
|
+
// vars file lands inside the tree being scanned and contains the variable
|
|
209
|
+
// name, so a guard that runs after the write finds its own output and can
|
|
210
|
+
// never fail. That is celilo#1248's bug — a check that cannot reach its
|
|
211
|
+
// subject — reintroduced by write order rather than by path.
|
|
212
|
+
const ansiblePath = join(generatedPath, 'ansible');
|
|
213
|
+
if (existsSync(ansiblePath) && !ansibleTreeMentions(ansiblePath, 'provider_config_files')) {
|
|
214
|
+
return {
|
|
215
|
+
success: false,
|
|
216
|
+
error: `The generated project for '${providerModuleId}' predates the provider converge (its role never reads provider_config_files). Redeploy the provider (\`celilo module deploy ${providerModuleId}\`) to regenerate it, then retry.`,
|
|
217
|
+
unresolved,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const varsPath = providerConfigVarsPath(generatedPath);
|
|
222
|
+
await mkdir(join(varsPath, '..'), { recursive: true });
|
|
223
|
+
await writeFile(varsPath, providerConfigVarsYaml(artifacts), 'utf-8');
|
|
224
|
+
|
|
225
|
+
const { executeAnsible } = await import('./deploy-ansible');
|
|
226
|
+
const result = await executeAnsible(generatedPath, { tags: options?.tags ?? CONVERGE_TAGS });
|
|
227
|
+
if (!result.success) {
|
|
228
|
+
return {
|
|
229
|
+
success: false,
|
|
230
|
+
error: `Provider converge failed on ${providerModuleId}: ${result.error ?? 'unknown error'}`,
|
|
231
|
+
varsPath,
|
|
232
|
+
unresolved,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// The converge rendered the tree, used it, and is done with it: desired state
|
|
237
|
+
// is rebuilt from the declared rows every time and never kept as a record
|
|
238
|
+
// (design D5). On the failure paths above the tree stays for inspection.
|
|
239
|
+
rmSync(generatedPath, { recursive: true, force: true });
|
|
240
|
+
return { success: true, varsPath, unresolved };
|
|
241
|
+
}
|
|
@@ -152,6 +152,45 @@ describe('planStaticContent — the declared release set (D10 task 4.1)', () =>
|
|
|
152
152
|
|
|
153
153
|
expect(() => planStaticContent(db)).toThrow(/no built site/);
|
|
154
154
|
});
|
|
155
|
+
|
|
156
|
+
function pause(moduleId: string) {
|
|
157
|
+
db.update(modules).set({ state: 'PAUSED' }).where(eq(modules.id, moduleId)).run();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
it("a paused module's slug is left alone, even one that would throw, and the rest converge (celilo#1383)", () => {
|
|
161
|
+
insertRoute({ slug: 'lunacycle', moduleId: 'lunacycle', hostname: 'lunacycle.net' });
|
|
162
|
+
insertRoute({
|
|
163
|
+
slug: 'lunacycle',
|
|
164
|
+
moduleId: 'lunacycle',
|
|
165
|
+
hostname: 'www.lunacycle.net',
|
|
166
|
+
contentHash: HASH2,
|
|
167
|
+
});
|
|
168
|
+
rmSync(join(dir, 'modules', 'lunacycle', 'site', 'dist'), { recursive: true, force: true });
|
|
169
|
+
insertRoute({
|
|
170
|
+
slug: 'hello-foo',
|
|
171
|
+
moduleId: 'hello-foo',
|
|
172
|
+
hostname: 'www.example.com',
|
|
173
|
+
path: '/foo',
|
|
174
|
+
});
|
|
175
|
+
pause('lunacycle');
|
|
176
|
+
|
|
177
|
+
expect(planStaticContent(db).map((r) => r.slug)).toEqual(['hello-foo']);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('a paused module that is publishing (an unpause redeploy) still converges its own slug', () => {
|
|
181
|
+
insertRoute({ slug: 'lunacycle', moduleId: 'lunacycle', hostname: 'lunacycle.net' });
|
|
182
|
+
pause('lunacycle');
|
|
183
|
+
|
|
184
|
+
expect(planStaticContent(db, 'lunacycle').map((r) => r.slug)).toEqual(['lunacycle']);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('a slug shared by a paused and an active module is still a contradiction', () => {
|
|
188
|
+
insertRoute({ slug: 'about', moduleId: 'lunacycle', hostname: 'a.example.com' });
|
|
189
|
+
insertRoute({ slug: 'about', moduleId: 'hello-foo', hostname: 'b.example.com' });
|
|
190
|
+
pause('lunacycle');
|
|
191
|
+
|
|
192
|
+
expect(() => planStaticContent(db)).toThrow(/claimed by 2 modules/);
|
|
193
|
+
});
|
|
155
194
|
});
|
|
156
195
|
|
|
157
196
|
describe('resolveStaticContentRetention — operator config with a default', () => {
|
|
@@ -32,6 +32,7 @@ import type { DbClient } from '../db/client';
|
|
|
32
32
|
import { moduleConfigs, modules, webRoutes } from '../db/schema';
|
|
33
33
|
import { resolveModuleStateWebRoot, resolveModuleWebRoot } from '../module/web-root';
|
|
34
34
|
import { parseStoredConfigValue } from './module-config';
|
|
35
|
+
import { listPausedModules } from './module-pause';
|
|
35
36
|
|
|
36
37
|
/** One content-hashed release the provider host must serve. */
|
|
37
38
|
export interface StaticRelease {
|
|
@@ -133,7 +134,7 @@ export function resolveStaticContentRetention(db: DbClient, providerModuleId: st
|
|
|
133
134
|
* cannot express, so it fails here with both rows named rather than silently
|
|
134
135
|
* converging one of them.
|
|
135
136
|
*/
|
|
136
|
-
export function planStaticContent(db: DbClient): StaticRelease[] {
|
|
137
|
+
export function planStaticContent(db: DbClient, publishingModuleId?: string): StaticRelease[] {
|
|
137
138
|
const rows = db
|
|
138
139
|
.select()
|
|
139
140
|
.from(webRoutes)
|
|
@@ -147,6 +148,14 @@ export function planStaticContent(db: DbClient): StaticRelease[] {
|
|
|
147
148
|
bySlug.set(row.slug, group);
|
|
148
149
|
}
|
|
149
150
|
|
|
151
|
+
// A paused module's site is left exactly as it is — pause preserves state
|
|
152
|
+
// (module-pause spec) — so its slug is neither converged nor validated. The
|
|
153
|
+
// exception is the module publishing right now: an unpause redeploy runs
|
|
154
|
+
// on_install while the module is still PAUSED (executeUnpause clears the
|
|
155
|
+
// pause only after the redeploy succeeds). celilo#1383.
|
|
156
|
+
const paused = new Set(listPausedModules(db).map((m) => m.id));
|
|
157
|
+
if (publishingModuleId) paused.delete(publishingModuleId);
|
|
158
|
+
|
|
150
159
|
const releases: StaticRelease[] = [];
|
|
151
160
|
for (const [slug, group] of bySlug) {
|
|
152
161
|
const moduleIds = [...new Set(group.map((r) => r.moduleId))];
|
|
@@ -157,6 +166,7 @@ export function planStaticContent(db: DbClient): StaticRelease[] {
|
|
|
157
166
|
}
|
|
158
167
|
const moduleId = moduleIds[0];
|
|
159
168
|
if (!moduleId) continue;
|
|
169
|
+
if (paused.has(moduleId)) continue;
|
|
160
170
|
|
|
161
171
|
const hashes = [...new Set(group.map((r) => r.contentHash))];
|
|
162
172
|
if (hashes.length > 1) {
|
|
@@ -201,9 +211,13 @@ export function planStaticContent(db: DbClient): StaticRelease[] {
|
|
|
201
211
|
* The full desired state: the release set plus the retention count resolved
|
|
202
212
|
* for THIS provider module. The unit the vars file and the tests speak in.
|
|
203
213
|
*/
|
|
204
|
-
export function buildStaticContentPlan(
|
|
214
|
+
export function buildStaticContentPlan(
|
|
215
|
+
db: DbClient,
|
|
216
|
+
providerModuleId: string,
|
|
217
|
+
publishingModuleId?: string,
|
|
218
|
+
): StaticContentPlan {
|
|
205
219
|
return {
|
|
206
|
-
releases: planStaticContent(db),
|
|
220
|
+
releases: planStaticContent(db, publishingModuleId),
|
|
207
221
|
retention: resolveStaticContentRetention(db, providerModuleId),
|
|
208
222
|
};
|
|
209
223
|
}
|
|
@@ -246,10 +260,11 @@ export async function writeStaticContentVars(
|
|
|
246
260
|
db: DbClient,
|
|
247
261
|
providerModuleId: string,
|
|
248
262
|
generatedPath: string,
|
|
263
|
+
publishingModuleId?: string,
|
|
249
264
|
): Promise<StaticContentConvergeResult> {
|
|
250
265
|
const varsPath = staticContentVarsPath(generatedPath);
|
|
251
266
|
|
|
252
|
-
const plan = buildStaticContentPlan(db, providerModuleId);
|
|
267
|
+
const plan = buildStaticContentPlan(db, providerModuleId, publishingModuleId);
|
|
253
268
|
|
|
254
269
|
await mkdir(join(varsPath, '..'), { recursive: true });
|
|
255
270
|
await writeFile(varsPath, staticContentVarsYaml(plan), 'utf-8');
|
|
@@ -303,6 +318,7 @@ export function ansibleTreeMentions(dir: string, needle: string): boolean {
|
|
|
303
318
|
export async function convergeStaticContent(
|
|
304
319
|
db: DbClient,
|
|
305
320
|
providerModuleId: string,
|
|
321
|
+
publishingModuleId?: string,
|
|
306
322
|
): Promise<StaticContentConvergeResult> {
|
|
307
323
|
const module = db.select().from(modules).where(eq(modules.id, providerModuleId)).get();
|
|
308
324
|
if (!module) {
|
|
@@ -336,7 +352,12 @@ export async function convergeStaticContent(
|
|
|
336
352
|
}
|
|
337
353
|
}
|
|
338
354
|
|
|
339
|
-
const written = await writeStaticContentVars(
|
|
355
|
+
const written = await writeStaticContentVars(
|
|
356
|
+
db,
|
|
357
|
+
providerModuleId,
|
|
358
|
+
generatedPath,
|
|
359
|
+
publishingModuleId,
|
|
360
|
+
);
|
|
340
361
|
|
|
341
362
|
// A generated project that predates the converge role tasks would read the
|
|
342
363
|
// new vars file and converge nothing. `static_releases` appearing nowhere in
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { planAfterImportVerdict, validateTerraformPlanSafety } from './terraform-safety';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The plan terraform actually produced on celilo-mgr at 2026-09-16T00:04Z,
|
|
6
|
+
* after `module deploy signal` imported LXC 207 and re-planned. Abridged to the
|
|
7
|
+
* lines the parser reads, and kept verbatim otherwise: a fixture written from
|
|
8
|
+
* memory of the format would pass while the product still destroyed the box.
|
|
9
|
+
*/
|
|
10
|
+
const POST_IMPORT_REPLACEMENT_PLAN = `
|
|
11
|
+
proxmox_lxc.signal: Refreshing state... [id=node3/lxc/207]
|
|
12
|
+
|
|
13
|
+
Terraform used the selected providers to generate the following execution
|
|
14
|
+
plan. Resource actions are indicated with the following symbols:
|
|
15
|
+
-/+ destroy and then create replacement
|
|
16
|
+
|
|
17
|
+
Terraform will perform the following actions:
|
|
18
|
+
|
|
19
|
+
# proxmox_lxc.signal must be replaced
|
|
20
|
+
-/+ resource "proxmox_lxc" "signal" {
|
|
21
|
+
+ ostemplate = "datacenter:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst" # forces replacement
|
|
22
|
+
~ rootfs {
|
|
23
|
+
+ storage = "datacenter" # forces replacement
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
Plan: 1 to add, 0 to change, 1 to destroy.
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
const CLEAN_PLAN = `
|
|
31
|
+
proxmox_lxc.signal: Refreshing state... [id=node3/lxc/207]
|
|
32
|
+
|
|
33
|
+
No changes. Your infrastructure matches the configuration.
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
const IN_PLACE_UPDATE_PLAN = `
|
|
37
|
+
# proxmox_lxc.signal will be updated in-place
|
|
38
|
+
~ resource "proxmox_lxc" "signal" {
|
|
39
|
+
~ memory = 1024 -> 2048
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
Plan: 0 to add, 1 to change, 0 to destroy.
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
describe('planAfterImportVerdict', () => {
|
|
46
|
+
// THE recurrence gate for celilo#1374. This exact plan was applied without
|
|
47
|
+
// validation and destroyed signal's container on a converged fleet. If this
|
|
48
|
+
// test ever passes the plan through, the defect is back.
|
|
49
|
+
test('refuses the replacement plan an import produces', () => {
|
|
50
|
+
const verdict = planAfterImportVerdict(2, POST_IMPORT_REPLACEMENT_PLAN);
|
|
51
|
+
|
|
52
|
+
expect(verdict.action).toBe('refuse');
|
|
53
|
+
if (verdict.action !== 'refuse') throw new Error('unreachable');
|
|
54
|
+
expect(verdict.error).toContain('after importing an existing resource');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// The outcome the recovery path is FOR: the import reconciled state with
|
|
58
|
+
// reality and there is nothing left to apply. Adopt, don't apply.
|
|
59
|
+
test('applies nothing when the post-import plan is clean', () => {
|
|
60
|
+
expect(planAfterImportVerdict(0, CLEAN_PLAN)).toEqual({ action: 'done' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// A resize must still go through — refusing everything would be a different
|
|
64
|
+
// bug wearing the same fix (terraform-safety.ts rule 2, ISS-0055).
|
|
65
|
+
test('lets an in-place update through', () => {
|
|
66
|
+
expect(planAfterImportVerdict(2, IN_PLACE_UPDATE_PLAN)).toEqual({ action: 'apply' });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// An unreadable plan is not evidence of safety (D7).
|
|
70
|
+
test('refuses when the plan itself failed', () => {
|
|
71
|
+
const verdict = planAfterImportVerdict(1, 'Error: failed to refresh state');
|
|
72
|
+
|
|
73
|
+
expect(verdict.action).toBe('refuse');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('validateTerraformPlanSafety', () => {
|
|
78
|
+
// Characterises what was already true: the validator would have caught this.
|
|
79
|
+
// The defect was never a missing rule, it was a plan that never reached one.
|
|
80
|
+
test('already refuses a replacement — it was simply never asked', () => {
|
|
81
|
+
expect(validateTerraformPlanSafety(POST_IMPORT_REPLACEMENT_PLAN).safe).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|