@celilo/cli 0.14.2 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +18 -2
- package/drizzle/0018_drop_alert_policy_snapshot.sql +46 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +4 -4
- package/src/capabilities/public-web-publish.test.ts +15 -15
- package/src/cli/commands/alerts-list.ts +10 -0
- package/src/cli/commands/alerts-poll.ts +12 -6
- package/src/cli/commands/alerts-sweep.ts +22 -81
- package/src/cli/commands/backup-sweep.ts +65 -0
- package/src/cli/commands/module-operations.test.ts +93 -0
- package/src/cli/commands/module-operations.ts +134 -0
- package/src/cli/commands/module-upgrade.test.ts +32 -20
- package/src/cli/commands/module-upgrade.ts +37 -32
- package/src/cli/commands/monitor.ts +26 -6
- package/src/cli/commands/system-audit.ts +9 -68
- package/src/cli/completion.ts +18 -1
- package/src/cli/index.ts +11 -0
- package/src/db/schema.ts +5 -3
- package/src/hooks/capability-loader.ts +0 -12
- package/src/manifest/schema.ts +4 -1
- package/src/module/packaging/build.ts +4 -0
- package/src/services/alerting/builtin-source.ts +18 -51
- package/src/services/alerting/delivery-loop.test.ts +5 -1
- package/src/services/alerting/format.test.ts +0 -1
- package/src/services/alerting/inbound-poller.test.ts +44 -8
- package/src/services/alerting/inbound-poller.ts +65 -28
- package/src/services/alerting/notify-deps.ts +113 -0
- package/src/services/alerting/run-monitor.ts +0 -1
- package/src/services/alerting/store.test.ts +1 -1
- package/src/services/alerting/store.ts +0 -2
- package/src/services/alerting/sweep-runner.test.ts +11 -2
- package/src/services/alerting/sweep-runner.ts +14 -7
- package/src/services/audit/backup-source.ts +54 -0
- package/src/services/audit/backups.test.ts +7 -2
- package/src/services/audit/backups.ts +10 -18
- package/src/services/backup-cipher.test.ts +188 -0
- package/src/services/backup-cipher.ts +178 -0
- package/src/services/backup-create.ts +20 -30
- package/src/services/backup-envelope-roundtrip.test.ts +6 -26
- package/src/services/backup-restore.ts +10 -16
- package/src/services/backup-schedule.ts +35 -0
- package/src/services/backup-sweep.test.ts +148 -0
- package/src/services/backup-sweep.ts +124 -0
- package/src/services/deploy-posture.ts +15 -2
- package/src/services/machine-probe.test.ts +50 -0
- package/src/services/machine-probe.ts +73 -0
- package/src/services/module-operations.test.ts +67 -6
- package/src/services/module-operations.ts +69 -19
- package/src/services/module-subscriptions.test.ts +33 -2
- package/src/services/module-subscriptions.ts +10 -1
- package/src/services/module-validator/typescript-build.test.ts +20 -1
- package/src/services/module-validator/typescript-build.ts +9 -5
- package/src/services/restore-from-file.ts +6 -21
- package/src/templates/generator.test.ts +88 -0
- package/src/templates/generator.ts +119 -16
|
@@ -248,12 +248,51 @@ export function targetNodeFromTfState(state: {
|
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
/**
|
|
251
|
-
*
|
|
252
|
-
* state
|
|
253
|
-
*
|
|
254
|
-
*
|
|
251
|
+
* The storage an existing container's root disk actually lives on, from
|
|
252
|
+
* terraform state. Prefers the LXC `rootfs.storage` attribute, falls back to the
|
|
253
|
+
* `<storage>:vm-<vmid>-disk-0` volume prefix, then to a VM's data `disk` block.
|
|
254
|
+
* Returns null when there's no such resource (e.g. an empty/fresh state).
|
|
255
255
|
*/
|
|
256
|
-
|
|
256
|
+
export function storageFromTfState(state: {
|
|
257
|
+
resources?: Array<{
|
|
258
|
+
type?: string;
|
|
259
|
+
instances?: Array<{
|
|
260
|
+
attributes?: {
|
|
261
|
+
rootfs?:
|
|
262
|
+
| Array<{ storage?: string; volume?: string }>
|
|
263
|
+
| { storage?: string; volume?: string };
|
|
264
|
+
disk?: Array<{ storage?: string; type?: string }>;
|
|
265
|
+
};
|
|
266
|
+
}>;
|
|
267
|
+
}>;
|
|
268
|
+
}): string | null {
|
|
269
|
+
const guest = state.resources?.find(
|
|
270
|
+
(r) => r.type === 'proxmox_lxc' || r.type === 'proxmox_vm_qemu',
|
|
271
|
+
);
|
|
272
|
+
const attrs = guest?.instances?.[0]?.attributes;
|
|
273
|
+
if (!attrs) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
// The provider models `rootfs` as a single-element block list; older state
|
|
277
|
+
// shapes record it as a bare object.
|
|
278
|
+
const rootfs = Array.isArray(attrs.rootfs) ? attrs.rootfs[0] : attrs.rootfs;
|
|
279
|
+
if (rootfs?.storage) {
|
|
280
|
+
return rootfs.storage;
|
|
281
|
+
}
|
|
282
|
+
// volume format: "<storage>:vm-<vmid>-disk-0"
|
|
283
|
+
const volumeStorage = rootfs?.volume?.split(':')[0];
|
|
284
|
+
if (volumeStorage) {
|
|
285
|
+
return volumeStorage;
|
|
286
|
+
}
|
|
287
|
+
// VMs: the data disk carries the storage that matters (the cloudinit disk
|
|
288
|
+
// rides on the same storage — see the `disk` block ordering note in the
|
|
289
|
+
// vm templates).
|
|
290
|
+
const disks = attrs.disk ?? [];
|
|
291
|
+
return (disks.find((d) => d.type === 'disk') ?? disks[0])?.storage || null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Parse a module's terraform state, or null when absent/unreadable. */
|
|
295
|
+
async function readTfState(moduleId: string): Promise<Record<string, unknown> | null> {
|
|
257
296
|
const statePath = join(
|
|
258
297
|
getModuleStoragePath(),
|
|
259
298
|
moduleId,
|
|
@@ -265,12 +304,33 @@ async function readDeployedTargetNode(moduleId: string): Promise<string | null>
|
|
|
265
304
|
return null;
|
|
266
305
|
}
|
|
267
306
|
try {
|
|
268
|
-
return
|
|
307
|
+
return JSON.parse(await readFile(statePath, 'utf-8'));
|
|
269
308
|
} catch {
|
|
270
309
|
return null;
|
|
271
310
|
}
|
|
272
311
|
}
|
|
273
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Read the node a module's container is currently deployed on, from its terraform
|
|
315
|
+
* state file. This is celilo's authoritative record of placement (ISS-0090).
|
|
316
|
+
* Returns null when no state exists yet (a first deploy) or it can't be read —
|
|
317
|
+
* the caller then falls back to the service `default_target_node`.
|
|
318
|
+
*/
|
|
319
|
+
async function readDeployedTargetNode(moduleId: string): Promise<string | null> {
|
|
320
|
+
const state = await readTfState(moduleId);
|
|
321
|
+
return state ? targetNodeFromTfState(state) : null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Read the storage a module's container is currently deployed on, from its
|
|
326
|
+
* terraform state file. Returns null on a first deploy — the caller then falls
|
|
327
|
+
* back to the service default storage.
|
|
328
|
+
*/
|
|
329
|
+
async function readDeployedStorage(moduleId: string): Promise<string | null> {
|
|
330
|
+
const state = await readTfState(moduleId);
|
|
331
|
+
return state ? storageFromTfState(state) : null;
|
|
332
|
+
}
|
|
333
|
+
|
|
274
334
|
/**
|
|
275
335
|
* The node a module's container ACTUALLY lives on, from Proxmox (ISS-0090).
|
|
276
336
|
* Proxmox is the ultimate source of truth for current location — it sees a
|
|
@@ -314,6 +374,27 @@ export function decideTargetNode(opts: {
|
|
|
314
374
|
return { node: opts.defaultNode, source: 'default' };
|
|
315
375
|
}
|
|
316
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Decide which storage to target for a deploy. Pure (Rule 10). The sibling of
|
|
379
|
+
* `decideTargetNode`: the service's `storage` governs only a FIRST placement.
|
|
380
|
+
* A container already living on `local-lvm` must keep living there when the
|
|
381
|
+
* service default later changes to `datacenter` — emitting the new default
|
|
382
|
+
* makes terraform plan a destroy-and-recreate ("forces replacement") of a
|
|
383
|
+
* running container and lose its volume. Relocating storage is a deliberate
|
|
384
|
+
* migration, never a side effect of a default drifting.
|
|
385
|
+
*
|
|
386
|
+
* ponytail: terraform state only, no Proxmox-reality tier like decideTargetNode
|
|
387
|
+
* has. A hand `pct move-volume` is invisible until the next apply refreshes
|
|
388
|
+
* state; add a Proxmox read if that ever bites.
|
|
389
|
+
*/
|
|
390
|
+
export function decideStorage(opts: {
|
|
391
|
+
stateStorage: string | null;
|
|
392
|
+
defaultStorage: string;
|
|
393
|
+
}): { storage: string; source: 'state' | 'default' } {
|
|
394
|
+
if (opts.stateStorage) return { storage: opts.stateStorage, source: 'state' };
|
|
395
|
+
return { storage: opts.defaultStorage, source: 'default' };
|
|
396
|
+
}
|
|
397
|
+
|
|
317
398
|
/**
|
|
318
399
|
* Discover template files in directory recursively
|
|
319
400
|
*
|
|
@@ -784,6 +865,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
784
865
|
// Resolved live each generate (ISS-0090) and injected into the context below,
|
|
785
866
|
// never cached in the DB. undefined for non-Proxmox / machine deploys.
|
|
786
867
|
let resolvedTargetNode: string | undefined;
|
|
868
|
+
let resolvedStorage: string | undefined;
|
|
787
869
|
if (isContainerService && isProxmoxService && infrastructureSelection?.serviceId) {
|
|
788
870
|
const service = await db
|
|
789
871
|
.select()
|
|
@@ -824,11 +906,24 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
824
906
|
);
|
|
825
907
|
}
|
|
826
908
|
|
|
827
|
-
//
|
|
828
|
-
//
|
|
829
|
-
//
|
|
909
|
+
// Storage is sticky for the same reason placement is: the service default
|
|
910
|
+
// governs only a FIRST create. Emitting a changed default at an existing
|
|
911
|
+
// container makes terraform plan a destroy-and-recreate.
|
|
912
|
+
const storageDecision = decideStorage({
|
|
913
|
+
stateStorage: await readDeployedStorage(moduleId),
|
|
914
|
+
defaultStorage: providerConfig.storage,
|
|
915
|
+
});
|
|
916
|
+
resolvedStorage = storageDecision.storage;
|
|
917
|
+
if (storageDecision.source === 'state' && resolvedStorage !== providerConfig.storage) {
|
|
918
|
+
log.info(
|
|
919
|
+
`${moduleId} → storage '${resolvedStorage}' (from terraform state; service default is '${providerConfig.storage}'). Moving storage requires a deliberate migration.`,
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Persist only the non-drift provider values. target_node and storage are
|
|
924
|
+
// reality — they're injected into the resolution context below, never
|
|
925
|
+
// cached (ISS-0090); drop any stale __infra_* rows a prior generate left.
|
|
830
926
|
upsertModuleConfig(db, moduleId, '__infra_lxc_template', providerConfig.lxc_template);
|
|
831
|
-
upsertModuleConfig(db, moduleId, '__infra_storage', providerConfig.storage);
|
|
832
927
|
// vm_template is only present once a VM template exists for the service.
|
|
833
928
|
// Persist it when set so `type: vm` modules can resolve $self:vm_template;
|
|
834
929
|
// a `type: vm` deploy against a service without one then fails loudly at
|
|
@@ -837,8 +932,12 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
837
932
|
upsertModuleConfig(db, moduleId, '__infra_vm_template', providerConfig.vm_template);
|
|
838
933
|
}
|
|
839
934
|
deleteModuleConfig(db, moduleId, '__infra_target_node');
|
|
935
|
+
deleteModuleConfig(db, moduleId, '__infra_storage');
|
|
840
936
|
|
|
841
|
-
log.success(
|
|
937
|
+
log.success(
|
|
938
|
+
`Infrastructure resolved: target_node=${decision.node} (${decision.source}), ` +
|
|
939
|
+
`storage=${storageDecision.storage} (${storageDecision.source})`,
|
|
940
|
+
);
|
|
842
941
|
}
|
|
843
942
|
}
|
|
844
943
|
|
|
@@ -870,15 +969,19 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
870
969
|
context.selfConfig.target_ip = ipConfig.value!;
|
|
871
970
|
}
|
|
872
971
|
|
|
873
|
-
// target_node
|
|
874
|
-
// never from a cached
|
|
972
|
+
// target_node and storage are the live-resolved reality of an existing
|
|
973
|
+
// container (ISS-0090) — inject them directly, never from a cached
|
|
974
|
+
// __infra_* row (which drifts against the service default).
|
|
875
975
|
if (resolvedTargetNode) {
|
|
876
976
|
context.selfConfig.target_node = resolvedTargetNode;
|
|
877
977
|
}
|
|
978
|
+
if (resolvedStorage) {
|
|
979
|
+
context.selfConfig.storage = resolvedStorage;
|
|
980
|
+
}
|
|
878
981
|
|
|
879
|
-
// lxc_template /
|
|
880
|
-
// them back from the __infra_* rows persisted above.
|
|
881
|
-
const infraKeys = ['lxc_template', '
|
|
982
|
+
// lxc_template / vm_template are provider config (intent, not drift-prone) —
|
|
983
|
+
// read them back from the __infra_* rows persisted above.
|
|
984
|
+
const infraKeys = ['lxc_template', 'vm_template'];
|
|
882
985
|
for (const key of infraKeys) {
|
|
883
986
|
const infraConfig = db
|
|
884
987
|
.select()
|