@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
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import type { RunNamedHookResult } from '../hooks/run-named-hook';
|
|
3
|
+
import type { HookLogger } from '../hooks/types';
|
|
4
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
5
|
+
import {
|
|
6
|
+
type ConsumerCandidate,
|
|
7
|
+
planProviderBackfill,
|
|
8
|
+
runProviderBackfill,
|
|
9
|
+
} from './provider-arrival';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The pure half of provider arrival: which consumers get re-run, and which are
|
|
13
|
+
* skipped and why. All of it is decided without a database, a hook runner, or a
|
|
14
|
+
* deployed anything, which is the point of the split.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
function manifest(input: { requires?: string[]; optional?: string[] }): ModuleManifest {
|
|
18
|
+
return {
|
|
19
|
+
requires: { capabilities: (input.requires ?? []).map((name) => ({ name, version: '1.0.0' })) },
|
|
20
|
+
optional: { capabilities: (input.optional ?? []).map((name) => ({ name, version: '1.0.0' })) },
|
|
21
|
+
} as unknown as ModuleManifest;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const consumer = (
|
|
25
|
+
moduleId: string,
|
|
26
|
+
caps: { requires?: string[]; optional?: string[] },
|
|
27
|
+
state = 'VERIFIED',
|
|
28
|
+
): ConsumerCandidate => ({ moduleId, manifest: manifest(caps), state });
|
|
29
|
+
|
|
30
|
+
describe('planProviderBackfill', () => {
|
|
31
|
+
it('re-runs consumers of the capabilities the arriving provider actually provides', () => {
|
|
32
|
+
const plan = planProviderBackfill(
|
|
33
|
+
'iptables',
|
|
34
|
+
['firewall'],
|
|
35
|
+
[
|
|
36
|
+
consumer('caddy', { requires: ['firewall'] }),
|
|
37
|
+
consumer('forgejo', { requires: ['public_web'] }),
|
|
38
|
+
],
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
expect(plan.map((t) => t.consumerId)).toEqual(['caddy']);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `optional` is the edge `remove-guard.ts` already counts, and it is the one
|
|
46
|
+
* the field was added to express — "this hook will use monitoring if it is
|
|
47
|
+
* there" describes exactly a module with something to gain the moment the
|
|
48
|
+
* provider appears. Counting only `requires` would leave it unregistered.
|
|
49
|
+
*/
|
|
50
|
+
it('includes an OPTIONAL dependent, not only a required one', () => {
|
|
51
|
+
const plan = planProviderBackfill(
|
|
52
|
+
'technitium',
|
|
53
|
+
['dns_internal'],
|
|
54
|
+
[consumer('caddy-internal', { optional: ['dns_internal'] })],
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(plan.map((t) => t.consumerId)).toEqual(['caddy-internal']);
|
|
58
|
+
expect(plan[0]?.skip).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('never treats the provider as its own consumer', () => {
|
|
62
|
+
// A module that both provides and requires a capability would otherwise
|
|
63
|
+
// have its on_install run a second time inside the deploy that just ran it.
|
|
64
|
+
const plan = planProviderBackfill(
|
|
65
|
+
'caddy',
|
|
66
|
+
['public_web'],
|
|
67
|
+
[
|
|
68
|
+
consumer('caddy', { requires: ['public_web', 'firewall'] }),
|
|
69
|
+
consumer('forgejo', { requires: ['public_web'] }),
|
|
70
|
+
],
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
expect(plan.map((t) => t.consumerId)).toEqual(['forgejo']);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('skips a consumer in a pre-deploy state, and says so rather than dropping it', () => {
|
|
77
|
+
// Capabilities are registered at IMPORT, not deploy, so an imported module
|
|
78
|
+
// has never resolved anything and has nothing to re-register.
|
|
79
|
+
const plan = planProviderBackfill(
|
|
80
|
+
'iptables',
|
|
81
|
+
['firewall'],
|
|
82
|
+
[
|
|
83
|
+
consumer('imported', { requires: ['firewall'] }, 'IMPORTED'),
|
|
84
|
+
consumer('validated', { requires: ['firewall'] }, 'VALIDATED'),
|
|
85
|
+
consumer('configured', { requires: ['firewall'] }, 'CONFIGURED'),
|
|
86
|
+
],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
expect(plan.map((t) => t.skip)).toEqual(['not-deployed', 'not-deployed', 'not-deployed']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('skips a PAUSED consumer separately from a never-deployed one', () => {
|
|
93
|
+
const plan = planProviderBackfill(
|
|
94
|
+
'iptables',
|
|
95
|
+
['firewall'],
|
|
96
|
+
[consumer('paused', { requires: ['firewall'] }, 'PAUSED')],
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
expect(plan[0]?.skip).toBe('paused');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('names every capability the consumer takes from this provider, once', () => {
|
|
103
|
+
const plan = planProviderBackfill(
|
|
104
|
+
'greenwave',
|
|
105
|
+
['firewall', 'dhcp_server'],
|
|
106
|
+
[consumer('caddy', { requires: ['firewall'], optional: ['dhcp_server'] })],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(plan).toHaveLength(1);
|
|
110
|
+
expect(plan[0]?.capabilityNames).toEqual(['dhcp_server', 'firewall']);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('is empty for a module that provides nothing', () => {
|
|
114
|
+
expect(
|
|
115
|
+
planProviderBackfill('forgejo', [], [consumer('caddy', { requires: ['firewall'] })]),
|
|
116
|
+
).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('is ordered by consumer id, so a failure is reproducible', () => {
|
|
120
|
+
const plan = planProviderBackfill(
|
|
121
|
+
'iptables',
|
|
122
|
+
['firewall'],
|
|
123
|
+
[consumer('zulu', { requires: ['firewall'] }), consumer('alpha', { requires: ['firewall'] })],
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(plan.map((t) => t.consumerId)).toEqual(['alpha', 'zulu']);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const silentLogger = (): HookLogger & { warnings: string[] } => {
|
|
131
|
+
const warnings: string[] = [];
|
|
132
|
+
return {
|
|
133
|
+
warnings,
|
|
134
|
+
info: () => {},
|
|
135
|
+
warn: (m: string) => warnings.push(m),
|
|
136
|
+
error: () => {},
|
|
137
|
+
success: () => {},
|
|
138
|
+
debug: () => {},
|
|
139
|
+
} as unknown as HookLogger & { warnings: string[] };
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const ok = (): RunNamedHookResult => ({ success: true }) as RunNamedHookResult;
|
|
143
|
+
const fails = (error: string): RunNamedHookResult =>
|
|
144
|
+
({ success: false, error }) as RunNamedHookResult;
|
|
145
|
+
|
|
146
|
+
describe('runProviderBackfill', () => {
|
|
147
|
+
/**
|
|
148
|
+
* The property the whole thing exists for. Stopping at the first failure
|
|
149
|
+
* would leave the remaining consumers unregistered against a provider that is
|
|
150
|
+
* now live — the same gap, just narrower.
|
|
151
|
+
*/
|
|
152
|
+
it('attempts every consumer even after one fails, and names each failure', async () => {
|
|
153
|
+
const attempted: string[] = [];
|
|
154
|
+
const result = await runProviderBackfill(
|
|
155
|
+
'iptables',
|
|
156
|
+
[
|
|
157
|
+
{ consumerId: 'alpha', capabilityNames: ['firewall'] },
|
|
158
|
+
{ consumerId: 'bravo', capabilityNames: ['firewall'] },
|
|
159
|
+
{ consumerId: 'charlie', capabilityNames: ['firewall'] },
|
|
160
|
+
],
|
|
161
|
+
{} as never,
|
|
162
|
+
silentLogger(),
|
|
163
|
+
async (id) => {
|
|
164
|
+
attempted.push(id);
|
|
165
|
+
return id === 'bravo' ? fails('ssh timed out') : ok();
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
expect(attempted).toEqual(['alpha', 'bravo', 'charlie']);
|
|
170
|
+
expect(result.rerun).toEqual(['alpha', 'charlie']);
|
|
171
|
+
expect(result.failures).toEqual([{ consumerId: 'bravo', error: 'ssh timed out' }]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('names the consumer and the retry command in the warning an operator reads', async () => {
|
|
175
|
+
const logger = silentLogger();
|
|
176
|
+
await runProviderBackfill(
|
|
177
|
+
'iptables',
|
|
178
|
+
[{ consumerId: 'bravo', capabilityNames: ['firewall'] }],
|
|
179
|
+
{} as never,
|
|
180
|
+
logger,
|
|
181
|
+
async () => fails('ssh timed out'),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
expect(logger.warnings.join('\n')).toContain('bravo');
|
|
185
|
+
expect(logger.warnings.join('\n')).toContain('celilo module deploy bravo');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('does not run a skipped consumer at all', async () => {
|
|
189
|
+
const attempted: string[] = [];
|
|
190
|
+
const result = await runProviderBackfill(
|
|
191
|
+
'iptables',
|
|
192
|
+
[
|
|
193
|
+
{ consumerId: 'paused', capabilityNames: ['firewall'], skip: 'paused' },
|
|
194
|
+
{ consumerId: 'fresh', capabilityNames: ['firewall'], skip: 'not-deployed' },
|
|
195
|
+
],
|
|
196
|
+
{} as never,
|
|
197
|
+
silentLogger(),
|
|
198
|
+
async (id) => {
|
|
199
|
+
attempted.push(id);
|
|
200
|
+
return ok();
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(attempted).toEqual([]);
|
|
205
|
+
expect(result.skipped).toEqual([
|
|
206
|
+
{ consumerId: 'paused', reason: 'paused' },
|
|
207
|
+
{ consumerId: 'fresh', reason: 'not-deployed' },
|
|
208
|
+
]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* `runNamedHook` reports a paused module as a plain success, so trusting the
|
|
213
|
+
* flag alone would log a re-registration that never happened — the silence
|
|
214
|
+
* this change exists to end, reintroduced one layer down.
|
|
215
|
+
*/
|
|
216
|
+
it('reads skippedPaused rather than trusting success, for a module paused mid-dispatch', async () => {
|
|
217
|
+
const result = await runProviderBackfill(
|
|
218
|
+
'iptables',
|
|
219
|
+
[{ consumerId: 'racer', capabilityNames: ['firewall'] }],
|
|
220
|
+
{} as never,
|
|
221
|
+
silentLogger(),
|
|
222
|
+
async () => ({ success: true, skippedPaused: true }) as RunNamedHookResult,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
expect(result.rerun).toEqual([]);
|
|
226
|
+
expect(result.skipped).toEqual([{ consumerId: 'racer', reason: 'paused' }]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('does not count a consumer with no on_install as re-registered', async () => {
|
|
230
|
+
const result = await runProviderBackfill(
|
|
231
|
+
'iptables',
|
|
232
|
+
[{ consumerId: 'hookless', capabilityNames: ['firewall'] }],
|
|
233
|
+
{} as never,
|
|
234
|
+
silentLogger(),
|
|
235
|
+
async () => ({ success: true, notDefined: true }) as RunNamedHookResult,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
expect(result.rerun).toEqual([]);
|
|
239
|
+
expect(result.failures).toEqual([]);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-running a provider's consumers when that provider arrives
|
|
3
|
+
* (openspec/changes/capability-owned-tables, stage 1).
|
|
4
|
+
*
|
|
5
|
+
* celilo handled one side of the capability relationship generically and the
|
|
6
|
+
* other by hand. A consumer leaving goes through `consumer-cleanup.ts`, which
|
|
7
|
+
* finds every provider it used and calls each one's `on_consumer_removed` with
|
|
8
|
+
* no capability names in the dispatch. A provider ARRIVING had three
|
|
9
|
+
* hand-written pieces covering two capabilities, and `firewall` — which three
|
|
10
|
+
* modules provide — had none at all (celilo#1011).
|
|
11
|
+
*
|
|
12
|
+
* This is the mirror of `consumer-cleanup.ts`, and it is deliberately built the
|
|
13
|
+
* same way, down to sharing that file's `PRE_DEPLOY_STATES`.
|
|
14
|
+
*
|
|
15
|
+
* PULL, NOT PUSH (design D7). When a provider arrives, celilo re-runs the
|
|
16
|
+
* CONSUMERS' `on_install` and lets each consumer re-register through the path
|
|
17
|
+
* that worked the first time. It does NOT replay history into the provider by
|
|
18
|
+
* calling the provider's own hooks on a consumer's behalf. Two of the three
|
|
19
|
+
* pieces this replaces pushed, and one of them had already drifted:
|
|
20
|
+
* `backfillWebRouteDns` cannot go through `on_system_event`, because that hook
|
|
21
|
+
* concatenates `<hostname>.<zone>` and would corrupt an already-qualified name,
|
|
22
|
+
* so it reaches past the hook into `registerRecord` and now differs from the
|
|
23
|
+
* live path in ways nothing checks.
|
|
24
|
+
*
|
|
25
|
+
* Split plan/execute (Rule 10.4): which consumers, and why one is skipped, is
|
|
26
|
+
* pure and worth testing on its own. The rest is "run each hook and record what
|
|
27
|
+
* happened".
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { eq } from 'drizzle-orm';
|
|
31
|
+
import type { DbClient } from '../db/client';
|
|
32
|
+
import { capabilities, modules } from '../db/schema';
|
|
33
|
+
import { createConsoleLogger } from '../hooks/logger';
|
|
34
|
+
import { type RunNamedHookResult, runNamedHook } from '../hooks/run-named-hook';
|
|
35
|
+
import type { HookLogger } from '../hooks/types';
|
|
36
|
+
import { type ModuleManifest, ModuleManifestSchema } from '../manifest/schema';
|
|
37
|
+
import { PRE_DEPLOY_STATES } from './consumer-cleanup';
|
|
38
|
+
|
|
39
|
+
export type BackfillSkipReason = 'paused' | 'not-deployed';
|
|
40
|
+
|
|
41
|
+
export interface BackfillTarget {
|
|
42
|
+
/** The consumer module to re-run. */
|
|
43
|
+
consumerId: string;
|
|
44
|
+
/** Which of the arriving provider's capabilities it consumes — for the log line. */
|
|
45
|
+
capabilityNames: string[];
|
|
46
|
+
/** Set when the consumer will NOT be re-run. */
|
|
47
|
+
skip?: BackfillSkipReason;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ConsumerCandidate {
|
|
51
|
+
moduleId: string;
|
|
52
|
+
manifest: ModuleManifest;
|
|
53
|
+
state: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Which consumers must be re-run now that `provider` is here.
|
|
58
|
+
*
|
|
59
|
+
* Pure. Sorted by consumer id so dispatch order is deterministic and a failure
|
|
60
|
+
* is reproducible.
|
|
61
|
+
*
|
|
62
|
+
* `requires` AND `optional`, which is the edge `remove-guard.ts` already counts
|
|
63
|
+
* and the one `optional` was added to express — "this hook will use monitoring
|
|
64
|
+
* if it is there" describes a module that has something to gain the moment the
|
|
65
|
+
* provider appears.
|
|
66
|
+
*
|
|
67
|
+
* The provider is never its own consumer. A module that both provides and
|
|
68
|
+
* requires a capability would otherwise have its `on_install` run a second time
|
|
69
|
+
* inside its own deploy, which just finished running it.
|
|
70
|
+
*/
|
|
71
|
+
export function planProviderBackfill(
|
|
72
|
+
provider: string,
|
|
73
|
+
providedCapabilities: Iterable<string>,
|
|
74
|
+
candidates: ConsumerCandidate[],
|
|
75
|
+
): BackfillTarget[] {
|
|
76
|
+
const provided = new Set(providedCapabilities);
|
|
77
|
+
if (provided.size === 0) return [];
|
|
78
|
+
|
|
79
|
+
const targets: BackfillTarget[] = [];
|
|
80
|
+
for (const candidate of candidates) {
|
|
81
|
+
if (candidate.moduleId === provider) continue;
|
|
82
|
+
|
|
83
|
+
const consumed = new Set([
|
|
84
|
+
...(candidate.manifest.requires?.capabilities ?? []).map((c) => c.name),
|
|
85
|
+
...(candidate.manifest.optional?.capabilities ?? []).map((c) => c.name),
|
|
86
|
+
]);
|
|
87
|
+
const capabilityNames = [...consumed].filter((name) => provided.has(name)).sort();
|
|
88
|
+
if (capabilityNames.length === 0) continue;
|
|
89
|
+
|
|
90
|
+
if (candidate.state === 'PAUSED') {
|
|
91
|
+
targets.push({ consumerId: candidate.moduleId, capabilityNames, skip: 'paused' });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (PRE_DEPLOY_STATES.has(candidate.state)) {
|
|
95
|
+
targets.push({ consumerId: candidate.moduleId, capabilityNames, skip: 'not-deployed' });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
targets.push({ consumerId: candidate.moduleId, capabilityNames });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return targets.sort((a, b) => a.consumerId.localeCompare(b.consumerId));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Read the plan's inputs out of the DB.
|
|
106
|
+
*
|
|
107
|
+
* A module's manifest lives in `modules.manifestData`; a manifest that no
|
|
108
|
+
* longer parses is skipped rather than fatal, matching `module-remove.ts`'s
|
|
109
|
+
* dependency scan — a provider deploy must not die on some unrelated module's
|
|
110
|
+
* bad row.
|
|
111
|
+
*/
|
|
112
|
+
export function loadProviderBackfillPlan(provider: string, db: DbClient): BackfillTarget[] {
|
|
113
|
+
const provided = db
|
|
114
|
+
.select({ capabilityName: capabilities.capabilityName })
|
|
115
|
+
.from(capabilities)
|
|
116
|
+
.where(eq(capabilities.moduleId, provider))
|
|
117
|
+
.all()
|
|
118
|
+
.map((row) => row.capabilityName);
|
|
119
|
+
if (provided.length === 0) return [];
|
|
120
|
+
|
|
121
|
+
const candidates: ConsumerCandidate[] = [];
|
|
122
|
+
for (const row of db.select().from(modules).all()) {
|
|
123
|
+
const parsed = ModuleManifestSchema.safeParse(row.manifestData);
|
|
124
|
+
if (!parsed.success) continue;
|
|
125
|
+
candidates.push({ moduleId: row.id, manifest: parsed.data, state: row.state });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return planProviderBackfill(provider, provided, candidates);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface BackfillFailure {
|
|
132
|
+
consumerId: string;
|
|
133
|
+
error: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface ProviderBackfillResult {
|
|
137
|
+
/** Consumers whose `on_install` was re-run successfully. */
|
|
138
|
+
rerun: string[];
|
|
139
|
+
skipped: Array<{ consumerId: string; reason: BackfillSkipReason }>;
|
|
140
|
+
failures: BackfillFailure[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Run the plan. Sequential, deterministic, and it CONTINUES PAST A FAILURE:
|
|
145
|
+
* every consumer is attempted even if an earlier one threw, because stopping
|
|
146
|
+
* early would leave the remaining consumers unregistered against a provider
|
|
147
|
+
* that is now live, which is the gap this exists to close.
|
|
148
|
+
*
|
|
149
|
+
* A failure never fails the provider's own deploy. The provider deployed fine;
|
|
150
|
+
* what failed is one consumer's re-registration, and the honest report is a
|
|
151
|
+
* named consumer with its error and the command that retries it. The caller
|
|
152
|
+
* surfaces that — silence here would report a clean deploy over a fleet where
|
|
153
|
+
* some consumers never re-registered.
|
|
154
|
+
*/
|
|
155
|
+
export async function runProviderBackfill(
|
|
156
|
+
provider: string,
|
|
157
|
+
plan: BackfillTarget[],
|
|
158
|
+
db: DbClient,
|
|
159
|
+
logger: HookLogger,
|
|
160
|
+
/** Injectable hook runner — tests drive the failure paths through it. */
|
|
161
|
+
runHook: (consumerId: string) => Promise<RunNamedHookResult> = (consumerId) =>
|
|
162
|
+
runNamedHook(consumerId, 'on_install', db, createConsoleLogger(consumerId, 'on_install'), {}),
|
|
163
|
+
): Promise<ProviderBackfillResult> {
|
|
164
|
+
const result: ProviderBackfillResult = { rerun: [], skipped: [], failures: [] };
|
|
165
|
+
|
|
166
|
+
for (const target of plan) {
|
|
167
|
+
if (target.skip) {
|
|
168
|
+
result.skipped.push({ consumerId: target.consumerId, reason: target.skip });
|
|
169
|
+
// Reported, not silent. A paused consumer rebinds on its next deploy, so
|
|
170
|
+
// it is genuinely fine — but the operator should know which modules are
|
|
171
|
+
// NOT yet talking to the provider that just arrived.
|
|
172
|
+
if (target.skip === 'paused') {
|
|
173
|
+
logger.warn(
|
|
174
|
+
`${target.consumerId} is paused, so it was not re-run against the new '${provider}' — it consumes ${target.capabilityNames.join(', ')} and will rebind on its next deploy.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const hookResult = await runHook(target.consumerId);
|
|
181
|
+
|
|
182
|
+
// A module paused BETWEEN the plan and this dispatch. `runNamedHook`
|
|
183
|
+
// reports that as success, so taking it at face value would log a
|
|
184
|
+
// re-registration that never happened.
|
|
185
|
+
if (hookResult.skippedPaused) {
|
|
186
|
+
result.skipped.push({ consumerId: target.consumerId, reason: 'paused' });
|
|
187
|
+
logger.warn(
|
|
188
|
+
`${target.consumerId} was paused while '${provider}' was arriving, so it was not re-run — it will rebind on its next deploy.`,
|
|
189
|
+
);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (hookResult.success) {
|
|
194
|
+
// `notDefined` means the consumer declares no `on_install` — there is
|
|
195
|
+
// nothing to re-run, and the dispatch succeeding is the right answer.
|
|
196
|
+
if (!hookResult.notDefined) {
|
|
197
|
+
result.rerun.push(target.consumerId);
|
|
198
|
+
logger.info(
|
|
199
|
+
`${target.consumerId} re-registered with '${provider}' (${target.capabilityNames.join(', ')})`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const error = hookResult.error ?? 'unknown error';
|
|
206
|
+
result.failures.push({ consumerId: target.consumerId, error });
|
|
207
|
+
logger.warn(
|
|
208
|
+
`${target.consumerId} failed to re-register with '${provider}': ${error}. Run \`celilo module deploy ${target.consumerId}\` to retry.`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
getOutputFilename,
|
|
14
14
|
injectProxmoxDns,
|
|
15
15
|
isTemplateFile,
|
|
16
|
+
omitUntaggedProxmoxVlan,
|
|
16
17
|
readTemplateFiles,
|
|
17
18
|
storageFromTfState,
|
|
18
19
|
targetNodeFromTfState,
|
|
@@ -770,6 +771,40 @@ resource "proxmox_lxc" "container" {
|
|
|
770
771
|
);
|
|
771
772
|
});
|
|
772
773
|
});
|
|
774
|
+
|
|
775
|
+
describe('omitUntaggedProxmoxVlan', () => {
|
|
776
|
+
const LXC = [
|
|
777
|
+
'resource "proxmox_lxc" "dns" {',
|
|
778
|
+
' target_node = "$self:target_node"',
|
|
779
|
+
' network {',
|
|
780
|
+
' bridge = "$self:bridge"',
|
|
781
|
+
' tag = $self:vlan',
|
|
782
|
+
' ip = "$self:target_ip"',
|
|
783
|
+
' }',
|
|
784
|
+
'}',
|
|
785
|
+
].join('\n');
|
|
786
|
+
|
|
787
|
+
test('omits the optional VLAN tag for an untagged zone', () => {
|
|
788
|
+
const out = omitUntaggedProxmoxVlan(LXC, false);
|
|
789
|
+
expect(out).not.toContain('$self:vlan');
|
|
790
|
+
expect(out).toContain(' bridge = "$self:bridge"');
|
|
791
|
+
expect(out).toContain(' ip = "$self:target_ip"');
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
test('retains the VLAN tag when the zone has a VLAN ID', () => {
|
|
795
|
+
expect(omitUntaggedProxmoxVlan(LXC, true)).toBe(LXC);
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
test('preserves literal and non-self VLAN tag expressions', () => {
|
|
799
|
+
const authored = ['tag = 30', 'tag = var.vlan', 'tag = $system:network.dmz.vlan'].join('\n');
|
|
800
|
+
expect(omitUntaggedProxmoxVlan(authored, false)).toBe(authored);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
test('removes an indented tag line with a trailing comment', () => {
|
|
804
|
+
const content = ' tag = $self:vlan # optional VLAN\r\n ip = "dhcp"\r\n';
|
|
805
|
+
expect(omitUntaggedProxmoxVlan(content, false)).toBe(' ip = "dhcp"\r\n');
|
|
806
|
+
});
|
|
807
|
+
});
|
|
773
808
|
});
|
|
774
809
|
|
|
775
810
|
describe("targetNodeFromTfState (ISS-0090 — terraform state is celilo's placement record)", () => {
|
|
@@ -219,6 +219,31 @@ export function injectProxmoxDns(content: string, hasNameserver: boolean): strin
|
|
|
219
219
|
});
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Omit the optional Proxmox network VLAN tag when a zone is explicitly
|
|
224
|
+
* untagged.
|
|
225
|
+
*
|
|
226
|
+
* Celilo represents an untagged zone by leaving `network.<zone>.vlan` unset.
|
|
227
|
+
* Module templates historically rendered `tag = $self:vlan` unconditionally,
|
|
228
|
+
* which makes variable resolution fail even though the Proxmox provider treats
|
|
229
|
+
* an omitted `tag` attribute as the correct untagged configuration. Removing
|
|
230
|
+
* only that exact framework variable line preserves literal tags and other
|
|
231
|
+
* tag expressions authored by a module.
|
|
232
|
+
*
|
|
233
|
+
* Policy function (Rule 10.1) - pure string transformation, no I/O.
|
|
234
|
+
*
|
|
235
|
+
* @param content - Raw terraform template content (pre variable-resolution)
|
|
236
|
+
* @param hasVlan - Whether `$self:vlan` resolves to a configured VLAN ID
|
|
237
|
+
* @returns Content with the optional tag line omitted for an untagged zone
|
|
238
|
+
*/
|
|
239
|
+
export function omitUntaggedProxmoxVlan(content: string, hasVlan: boolean): string {
|
|
240
|
+
if (hasVlan) {
|
|
241
|
+
return content;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return content.replace(/^[ \t]*tag[ \t]*=[ \t]*\$self:vlan[ \t]*(?:#.*)?(?:\r?\n|$)/gm, '');
|
|
245
|
+
}
|
|
246
|
+
|
|
222
247
|
/**
|
|
223
248
|
* Extract the node a Proxmox guest is deployed on from a parsed terraform state
|
|
224
249
|
* object — an LXC (proxmox_lxc) or a VM (proxmox_vm_qemu). Pure logic (Rule 10),
|
|
@@ -1266,7 +1291,10 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
1266
1291
|
// every proxmox_lxc resource before resolution (terraform files only).
|
|
1267
1292
|
const content =
|
|
1268
1293
|
!isAnsibleTemplate && template.targetPath.endsWith('.tf')
|
|
1269
|
-
? injectProxmoxDns(
|
|
1294
|
+
? injectProxmoxDns(
|
|
1295
|
+
omitUntaggedProxmoxVlan(template.content, context.selfConfig.vlan !== undefined),
|
|
1296
|
+
Boolean(context.selfConfig.lxc_nameserver),
|
|
1297
|
+
)
|
|
1270
1298
|
: template.content;
|
|
1271
1299
|
const result = isAnsibleTemplate
|
|
1272
1300
|
? await convertSecretsToJinja(content, context, db)
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
capabilities,
|
|
8
8
|
containerServices,
|
|
9
9
|
ipAllocations,
|
|
10
|
+
machines,
|
|
10
11
|
moduleConfigs,
|
|
11
12
|
moduleInfrastructure,
|
|
12
13
|
moduleSystems,
|
|
@@ -193,6 +194,68 @@ describe('Variable Context', () => {
|
|
|
193
194
|
});
|
|
194
195
|
|
|
195
196
|
describe('buildResolutionContext', () => {
|
|
197
|
+
test('records a local machine by its interface identity instead of its transport sentinel', async () => {
|
|
198
|
+
db.insert(modules)
|
|
199
|
+
.values({
|
|
200
|
+
id: 'celilo-mgmt',
|
|
201
|
+
name: 'Celilo Management',
|
|
202
|
+
version: '1.0.0',
|
|
203
|
+
sourcePath: '/test/celilo-mgmt',
|
|
204
|
+
manifestData: {
|
|
205
|
+
id: 'celilo-mgmt',
|
|
206
|
+
name: 'Celilo Management',
|
|
207
|
+
version: '1.0.0',
|
|
208
|
+
requires: { system: { zone: 'internal' } },
|
|
209
|
+
},
|
|
210
|
+
})
|
|
211
|
+
.run();
|
|
212
|
+
db.insert(moduleConfigs)
|
|
213
|
+
.values({
|
|
214
|
+
moduleId: 'celilo-mgmt',
|
|
215
|
+
key: 'hostname',
|
|
216
|
+
value: 'celilo-mgr',
|
|
217
|
+
valueJson: '"celilo-mgr"',
|
|
218
|
+
})
|
|
219
|
+
.run();
|
|
220
|
+
db.insert(machines)
|
|
221
|
+
.values({
|
|
222
|
+
id: 'local-manager',
|
|
223
|
+
hostname: 'celilo-mgr',
|
|
224
|
+
ipAddress: '127.0.0.1',
|
|
225
|
+
sshUser: 'jem',
|
|
226
|
+
sshKeyEncrypted: JSON.stringify({ encryptedValue: '', iv: '', authTag: '' }),
|
|
227
|
+
hardware: { cpu_cores: 4, memory_mb: 8192, disk_gb: 64 },
|
|
228
|
+
zone: 'internal',
|
|
229
|
+
earmarkedModule: 'celilo-mgmt',
|
|
230
|
+
interfaces: [
|
|
231
|
+
{ name: 'ens18', ipAddress: '10.77.20.32', zone: 'internal' },
|
|
232
|
+
{ name: 'wlan0', ipAddress: '192.168.0.32', zone: 'upstream' },
|
|
233
|
+
],
|
|
234
|
+
})
|
|
235
|
+
.run();
|
|
236
|
+
db.insert(moduleInfrastructure)
|
|
237
|
+
.values({
|
|
238
|
+
id: 'infra-celilo-mgmt',
|
|
239
|
+
moduleId: 'celilo-mgmt',
|
|
240
|
+
infrastructureType: 'machine',
|
|
241
|
+
machineId: 'local-manager',
|
|
242
|
+
})
|
|
243
|
+
.run();
|
|
244
|
+
|
|
245
|
+
await buildResolutionContext('celilo-mgmt', db);
|
|
246
|
+
|
|
247
|
+
const system = db
|
|
248
|
+
.select()
|
|
249
|
+
.from(moduleSystems)
|
|
250
|
+
.where(eq(moduleSystems.moduleId, 'celilo-mgmt'))
|
|
251
|
+
.get();
|
|
252
|
+
expect(system).toMatchObject({
|
|
253
|
+
hostname: 'celilo-mgr',
|
|
254
|
+
ipv4Address: '10.77.20.32',
|
|
255
|
+
zone: 'internal',
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
196
259
|
test('should build context with module configs', async () => {
|
|
197
260
|
// Insert module first (for foreign key)
|
|
198
261
|
db.$client.run(
|
package/src/variables/context.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { allocateResources, getAllocation } from '../ipam/allocator';
|
|
|
19
19
|
import { type ModuleManifest, getDeclaredSystems, getSingularSystemSpec } from '../manifest/schema';
|
|
20
20
|
import { decryptSecret } from '../secrets/encryption';
|
|
21
21
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
22
|
+
import { resolveMachineIdentityAddress } from '../services/deployed-systems';
|
|
22
23
|
import { upsertModuleConfig } from '../services/module-config';
|
|
23
24
|
import { resolveComputedFields } from './computed/evaluate';
|
|
24
25
|
import { containsComputedMarker } from './computed/marker';
|
|
@@ -419,10 +420,17 @@ async function assembleResolutionContext(
|
|
|
419
420
|
`Machine '${infraSelection.machineId}' for module '${moduleId}' has no ipAddress`,
|
|
420
421
|
);
|
|
421
422
|
}
|
|
423
|
+
const deployedZone = machineRow.zone ?? zone;
|
|
424
|
+
const machineIdentity = resolveMachineIdentityAddress(machineRow, deployedZone);
|
|
425
|
+
if (!machineIdentity) {
|
|
426
|
+
throw new Error(
|
|
427
|
+
`Machine '${infraSelection.machineId}' for module '${moduleId}' has no non-loopback interface in zone '${deployedZone}'`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
422
430
|
upsertDeployedSystem(db, moduleId, {
|
|
423
431
|
name: decl.name,
|
|
424
432
|
hostname,
|
|
425
|
-
ipv4Address:
|
|
433
|
+
ipv4Address: machineIdentity,
|
|
426
434
|
// The machine's own zone, matching recordDeployedSystemForModule and
|
|
427
435
|
// backfillModuleSystems. This is the THIRD place that decides a
|
|
428
436
|
// deployed system's zone, and it ran last — so while the other two
|
|
@@ -430,7 +438,7 @@ async function assembleResolutionContext(
|
|
|
430
438
|
// manifest's, and the control plane stayed recorded as `internal`.
|
|
431
439
|
// `requires.system.zone` is the minimum used to SELECT a host; the
|
|
432
440
|
// machine we selected is where the system actually is.
|
|
433
|
-
zone:
|
|
441
|
+
zone: deployedZone,
|
|
434
442
|
infraType: 'machine',
|
|
435
443
|
machineId: infraSelection.machineId,
|
|
436
444
|
});
|