@celilo/cli 0.3.30 → 0.4.0-alpha.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/drizzle/0005_module_operations.sql +12 -0
- package/drizzle/0006_base_module_aspects.sql +15 -0
- package/drizzle/0007_module_systems.sql +17 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +5 -4
- package/schemas/system_config.json +14 -28
- package/src/ansible/inventory.test.ts +46 -62
- package/src/ansible/inventory.ts +48 -25
- package/src/capabilities/registration.ts +25 -7
- package/src/capabilities/validation.test.ts +30 -0
- package/src/capabilities/validation.ts +8 -0
- package/src/cli/backup-rename.test.ts +95 -0
- package/src/cli/cli.test.ts +17 -23
- package/src/cli/command-registry.ts +199 -0
- package/src/cli/commands/backup-list.ts +1 -1
- package/src/cli/commands/events.ts +96 -0
- package/src/cli/commands/machine-add.ts +103 -59
- package/src/cli/commands/module-import.ts +153 -4
- package/src/cli/commands/module-remove.ts +86 -17
- package/src/cli/commands/module-status.ts +6 -2
- package/src/cli/commands/publish/alpha.test.ts +185 -0
- package/src/cli/commands/publish/alpha.ts +226 -0
- package/src/cli/commands/publish/changesets.test.ts +89 -0
- package/src/cli/commands/publish/changesets.ts +144 -0
- package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
- package/src/cli/commands/publish/consumer-pins.ts +149 -0
- package/src/cli/commands/publish/execute.ts +131 -0
- package/src/cli/commands/publish/global-install.test.ts +154 -0
- package/src/cli/commands/publish/global-install.ts +171 -0
- package/src/cli/commands/publish/helpers.ts +227 -0
- package/src/cli/commands/publish/index.ts +365 -0
- package/src/cli/commands/publish/module-registry.test.ts +40 -0
- package/src/cli/commands/publish/module-registry.ts +64 -0
- package/src/cli/commands/publish/plan.ts +107 -0
- package/src/cli/commands/publish/preflight.ts +238 -0
- package/src/cli/commands/publish/types.ts +264 -0
- package/src/cli/commands/publish/workspace.test.ts +323 -0
- package/src/cli/commands/publish/workspace.ts +596 -0
- package/src/cli/commands/restore.ts +126 -0
- package/src/cli/commands/storage-add-local.ts +1 -1
- package/src/cli/commands/storage-add-s3.ts +1 -1
- package/src/cli/commands/subscribers-add.ts +68 -0
- package/src/cli/commands/subscribers-list.ts +48 -0
- package/src/cli/commands/subscribers-remove.ts +38 -0
- package/src/cli/commands/subscribers-serve.ts +77 -0
- package/src/cli/commands/subscribers-status.ts +33 -0
- package/src/cli/commands/subscribers-test.ts +71 -0
- package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
- package/src/cli/commands/system-apply-config.test.ts +70 -0
- package/src/cli/commands/system-apply-config.ts +130 -0
- package/src/cli/commands/system-audit.ts +2 -1
- package/src/cli/commands/system-init-deprecation.test.ts +90 -0
- package/src/cli/commands/system-init.ts +36 -70
- package/src/cli/commands/system-update.ts +3 -2
- package/src/cli/completion.ts +22 -1
- package/src/cli/index.ts +214 -6
- package/src/cli/interactive-config.test.ts +19 -0
- package/src/cli/restore-command.test.ts +131 -0
- package/src/db/client.ts +42 -0
- package/src/db/schema.test.ts +13 -16
- package/src/db/schema.ts +161 -9
- package/src/hooks/capability-loader-firewall.test.ts +6 -15
- package/src/hooks/capability-loader.test.ts +2 -3
- package/src/hooks/capability-loader.ts +36 -2
- package/src/hooks/define-hook.test.ts +4 -0
- package/src/hooks/executor.test.ts +18 -0
- package/src/hooks/executor.ts +21 -2
- package/src/hooks/load-hook-config.test.ts +26 -24
- package/src/hooks/load-hook-config.ts +11 -2
- package/src/hooks/run-named-hook.ts +16 -0
- package/src/hooks/types.ts +9 -1
- package/src/manifest/contracts/v1.ts +70 -0
- package/src/manifest/schema.ts +262 -16
- package/src/manifest/validate-privileged.test.ts +84 -0
- package/src/manifest/validate.test.ts +156 -0
- package/src/manifest/validate.ts +69 -0
- package/src/module/import.ts +12 -0
- package/src/services/aspect-approvals.test.ts +231 -0
- package/src/services/aspect-approvals.ts +120 -0
- package/src/services/aspect-runner.test.ts +493 -0
- package/src/services/aspect-runner.ts +438 -0
- package/src/services/aspect-template-resolver.test.ts +101 -0
- package/src/services/aspect-template-resolver.ts +122 -0
- package/src/services/backup-create.ts +104 -25
- package/src/services/backup-envelope-roundtrip.test.ts +199 -0
- package/src/services/backup-in-flight-refusal.test.ts +163 -0
- package/src/services/backup-manifest.test.ts +115 -0
- package/src/services/backup-manifest.ts +163 -0
- package/src/services/backup-restore.ts +154 -19
- package/src/services/build-bus/delivery-events.ts +92 -0
- package/src/services/build-bus/event-factory.ts +54 -0
- package/src/services/build-bus/fan-out.test.ts +279 -0
- package/src/services/build-bus/fan-out.ts +161 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
- package/src/services/build-bus/hook-dispatch.test.ts +207 -0
- package/src/services/build-bus/hook-dispatch.ts +198 -0
- package/src/services/build-bus/hook-dispatcher.ts +115 -0
- package/src/services/build-bus/index.ts +41 -0
- package/src/services/build-bus/receiver-server.test.ts +179 -0
- package/src/services/build-bus/receiver-server.ts +159 -0
- package/src/services/build-bus/status.test.ts +212 -0
- package/src/services/build-bus/status.ts +213 -0
- package/src/services/build-bus/subscriber-store.ts +113 -0
- package/src/services/celilo-events.test.ts +70 -0
- package/src/services/celilo-events.ts +92 -0
- package/src/services/celilo-mgmt-hooks.test.ts +296 -0
- package/src/services/config-interview.ts +13 -95
- package/src/services/cross-module-data-manager.ts +2 -31
- package/src/services/cross-module-read.test.ts +250 -0
- package/src/services/cross-module-read.ts +232 -0
- package/src/services/deploy-validation.ts +7 -0
- package/src/services/deployed-systems.test.ts +235 -0
- package/src/services/deployed-systems.ts +308 -0
- package/src/services/dns-provider-backfill.ts +75 -0
- package/src/services/health-runner.ts +19 -3
- package/src/services/infrastructure-variable-resolver.test.ts +6 -32
- package/src/services/infrastructure-variable-resolver.ts +3 -13
- package/src/services/machine-detector.ts +104 -48
- package/src/services/machine-pool.ts +145 -2
- package/src/services/module-config.ts +78 -120
- package/src/services/module-deploy.ts +113 -40
- package/src/services/module-operations.test.ts +154 -0
- package/src/services/module-operations.ts +154 -0
- package/src/services/module-subscriptions.test.ts +58 -0
- package/src/services/module-subscriptions.ts +24 -1
- package/src/services/module-types-generator.test.ts +3 -3
- package/src/services/module-types-generator.ts +7 -2
- package/src/services/proxmox-reconcile.test.ts +333 -0
- package/src/services/proxmox-reconcile.ts +156 -0
- package/src/services/proxmox-state-recovery.ts +3 -24
- package/src/services/restore-from-file.test.ts +177 -0
- package/src/services/restore-from-file.ts +355 -0
- package/src/services/restore-preflight.test.ts +127 -0
- package/src/services/restore-preflight.ts +118 -0
- package/src/services/storage-providers/s3.ts +10 -2
- package/src/services/system-identity.ts +30 -0
- package/src/services/system-init.test.ts +64 -21
- package/src/services/system-init.ts +28 -26
- package/src/templates/generator.test.ts +7 -16
- package/src/templates/generator.ts +28 -115
- package/src/test-utils/integration.ts +5 -2
- package/src/types/infrastructure.ts +8 -0
- package/src/variables/computed/computed-integration.test.ts +191 -0
- package/src/variables/computed/computed.test.ts +177 -0
- package/src/variables/computed/evaluate.ts +271 -0
- package/src/variables/computed/marker.ts +53 -0
- package/src/variables/computed/parse.ts +262 -0
- package/src/variables/computed/provider-lookup.ts +130 -0
- package/src/variables/context.test.ts +89 -28
- package/src/variables/context.ts +196 -191
- package/src/variables/parser.ts +3 -3
- package/src/variables/resolver.test.ts +61 -0
- package/src/variables/resolver.ts +81 -0
- package/src/variables/types.ts +23 -1
- package/src/services/dns-auto-register.ts +0 -211
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Top-level `celilo restore` command.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the file-direct restore service for the fresh-bootstrap
|
|
5
|
+
* ergonomics path:
|
|
6
|
+
*
|
|
7
|
+
* curl -fsSL https://celilo.computer/bootstrap.sh | bash
|
|
8
|
+
* celilo restore --from <file> [--force]
|
|
9
|
+
*
|
|
10
|
+
* Refuses to run when the target already holds celilo state (modules
|
|
11
|
+
* in DB or terraform state on disk) unless --force is passed.
|
|
12
|
+
* Calls restoreFromArtifactFile, then runs Drizzle migrations on the
|
|
13
|
+
* restored DB so a backup from an older celilo still opens cleanly.
|
|
14
|
+
*
|
|
15
|
+
* Phase 4 of v2/SYSTEM_BACKUP_TERRAFORM_STATE.md.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync } from 'node:fs';
|
|
19
|
+
import { resolve } from 'node:path';
|
|
20
|
+
import { migrateRestoredDb, restoreFromArtifactFile } from '../../services/restore-from-file';
|
|
21
|
+
import {
|
|
22
|
+
NonEmptyRestoreTargetError,
|
|
23
|
+
assertRestoreTargetEmpty,
|
|
24
|
+
} from '../../services/restore-preflight';
|
|
25
|
+
import { getArg, hasFlag } from '../parser';
|
|
26
|
+
import { log } from '../prompts';
|
|
27
|
+
import type { CommandResult } from '../types';
|
|
28
|
+
|
|
29
|
+
export async function handleRestore(
|
|
30
|
+
args: string[],
|
|
31
|
+
flags: Record<string, string | boolean> = {},
|
|
32
|
+
): Promise<CommandResult> {
|
|
33
|
+
// --from is required; we don't infer it.
|
|
34
|
+
const fromFlag = flags.from;
|
|
35
|
+
const fromPositional = getArg(args, 0);
|
|
36
|
+
const fromRaw = typeof fromFlag === 'string' ? fromFlag : fromPositional;
|
|
37
|
+
|
|
38
|
+
if (!fromRaw || typeof fromRaw !== 'string') {
|
|
39
|
+
return {
|
|
40
|
+
success: false,
|
|
41
|
+
error: [
|
|
42
|
+
'celilo restore: missing --from <file>',
|
|
43
|
+
'',
|
|
44
|
+
'Usage:',
|
|
45
|
+
' celilo restore --from <path-to-artifact> [--force]',
|
|
46
|
+
'',
|
|
47
|
+
'The artifact is a .backup file produced by `celilo module backup celilo-mgmt`.',
|
|
48
|
+
].join('\n'),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const filePath = resolve(fromRaw);
|
|
53
|
+
if (!existsSync(filePath)) {
|
|
54
|
+
return {
|
|
55
|
+
success: false,
|
|
56
|
+
error: `Artifact not found: ${filePath}`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const force = hasFlag(flags, 'force');
|
|
61
|
+
|
|
62
|
+
// Pre-flight: refuse non-empty target unless --force.
|
|
63
|
+
if (!force) {
|
|
64
|
+
try {
|
|
65
|
+
assertRestoreTargetEmpty();
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (err instanceof NonEmptyRestoreTargetError) {
|
|
68
|
+
return { success: false, error: err.message };
|
|
69
|
+
}
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
log.warn('--force set; non-destructive pre-flight skipped.');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
log.info(`Restoring from ${filePath}...`);
|
|
77
|
+
const result = await restoreFromArtifactFile(filePath, { force });
|
|
78
|
+
|
|
79
|
+
if (!result.success) {
|
|
80
|
+
return { success: false, error: result.error ?? 'restore failed' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Run Drizzle migrations on the restored DB so a backup from an
|
|
84
|
+
// older celilo opens cleanly. No-op if the DB schema is already
|
|
85
|
+
// current. Best-effort: if migrations fail (e.g., a destructive
|
|
86
|
+
// backward migration would be needed), surface the error but the
|
|
87
|
+
// restore itself already landed.
|
|
88
|
+
try {
|
|
89
|
+
await migrateRestoredDb();
|
|
90
|
+
} catch (err) {
|
|
91
|
+
log.warn(
|
|
92
|
+
`Restore complete but migrations failed: ${err instanceof Error ? err.message : String(err)}. The restored DB may need manual migration.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Build the success message.
|
|
97
|
+
const lines: string[] = ['Restore complete.'];
|
|
98
|
+
if (result.systemDbApplied) lines.push(' • celilo.db swapped into place');
|
|
99
|
+
if (result.masterKeyApplied) lines.push(' • master.key swapped into place');
|
|
100
|
+
if (result.sshKeyApplied) lines.push(' • fleet SSH key restored to <data-dir>/.ssh/');
|
|
101
|
+
if (result.crossModuleApplied && result.crossModuleApplied.length > 0) {
|
|
102
|
+
lines.push(
|
|
103
|
+
` • terraform state restored for ${result.crossModuleApplied.length} module(s): ${result.crossModuleApplied.join(', ')}`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
lines.push('');
|
|
107
|
+
lines.push('Next steps:');
|
|
108
|
+
lines.push(' • Verify with `celilo module list` and `celilo status`.');
|
|
109
|
+
lines.push(" • Run `terraform plan` in each module's generated/ dir to confirm no drift.");
|
|
110
|
+
// The restore swapped the DB file; the CLI's own connection was closed
|
|
111
|
+
// and reopens fresh, but a long-running event-bus dispatcher (if you
|
|
112
|
+
// installed one via `celilo events install-daemon`) still holds the old
|
|
113
|
+
// DB. Tell the operator to bounce it — celilo doesn't manage that user
|
|
114
|
+
// unit's lifecycle from here.
|
|
115
|
+
lines.push(
|
|
116
|
+
' • If you run the event-bus daemon (`celilo events install-daemon`), restart it so it',
|
|
117
|
+
);
|
|
118
|
+
lines.push(
|
|
119
|
+
' picks up the restored DB: systemctl --user restart celilo-events.service (Linux).',
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
success: true,
|
|
124
|
+
message: lines.join('\n'),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -160,7 +160,7 @@ export async function handleStorageAddLocal(
|
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
celiloOutro(
|
|
163
|
-
`Storage '${storage.storageId}' (${name}) added and verified!\n\nStorage ID: ${storage.storageId}\nPath: ${resolvedPath}\n\nNext steps:\n celilo backup
|
|
163
|
+
`Storage '${storage.storageId}' (${name}) added and verified!\n\nStorage ID: ${storage.storageId}\nPath: ${resolvedPath}\n\nNext steps:\n celilo module backup <module-id> Create a backup\n celilo storage list List storage destinations`,
|
|
164
164
|
);
|
|
165
165
|
|
|
166
166
|
return { success: true, message: `Added local storage: ${storage.storageId}` };
|
|
@@ -101,7 +101,7 @@ export async function handleStorageAddS3(
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
celiloOutro(
|
|
104
|
-
`Storage '${storage.storageId}' (${name}) added and verified!\n\nStorage ID: ${storage.storageId}\nBucket: ${bucket}\nEndpoint: ${endpoint}\n\nNext steps:\n celilo backup
|
|
104
|
+
`Storage '${storage.storageId}' (${name}) added and verified!\n\nStorage ID: ${storage.storageId}\nBucket: ${bucket}\nEndpoint: ${endpoint}\n\nNext steps:\n celilo module backup <module-id> Create a backup\n celilo storage list List storage destinations`,
|
|
105
105
|
);
|
|
106
106
|
|
|
107
107
|
return { success: true, message: `Added S3 storage: ${storage.storageId}` };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo subscribers add <url> --secret <s> [--name <n>] [--registry <r>] [--tag <t>] [--package-pattern <p>]`
|
|
3
|
+
*
|
|
4
|
+
* Append (or replace, on URL collision) a subscriber in the static
|
|
5
|
+
* subscriber-config file. The secret is required — there's no way
|
|
6
|
+
* to add an unsigned subscriber. Match fields are all optional;
|
|
7
|
+
* omitting them means "match every event."
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Subscriber } from '@celilo/event-bus/build-bus';
|
|
11
|
+
import { addSubscriber, subscriberStorePath } from '../../services/build-bus';
|
|
12
|
+
import type { CommandResult } from '../types';
|
|
13
|
+
|
|
14
|
+
export function handleSubscribersAdd(
|
|
15
|
+
args: string[],
|
|
16
|
+
flags: Record<string, string | boolean>,
|
|
17
|
+
): CommandResult {
|
|
18
|
+
const url = args[0];
|
|
19
|
+
if (!url) {
|
|
20
|
+
return {
|
|
21
|
+
success: false,
|
|
22
|
+
error:
|
|
23
|
+
'Subscriber URL required.\n\nUsage:\n celilo subscribers add <url> --secret <secret> [options]',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const secret = flags.secret;
|
|
27
|
+
if (typeof secret !== 'string' || !secret) {
|
|
28
|
+
return {
|
|
29
|
+
success: false,
|
|
30
|
+
error: '--secret <hmac-secret> is required.',
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!/^https?:\/\//.test(url)) {
|
|
35
|
+
return {
|
|
36
|
+
success: false,
|
|
37
|
+
error: `Subscriber URL must start with http:// or https:// (got "${url}").`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const subscriber: Subscriber = {
|
|
42
|
+
url,
|
|
43
|
+
secret,
|
|
44
|
+
match: {},
|
|
45
|
+
};
|
|
46
|
+
if (typeof flags.name === 'string') subscriber.name = flags.name;
|
|
47
|
+
if (typeof flags.registry === 'string') subscriber.match.registry = flags.registry;
|
|
48
|
+
if (typeof flags.tag === 'string') subscriber.match.tag = flags.tag;
|
|
49
|
+
if (typeof flags['package-pattern'] === 'string') {
|
|
50
|
+
subscriber.match.packagePattern = flags['package-pattern'];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let result: ReturnType<typeof addSubscriber>;
|
|
54
|
+
try {
|
|
55
|
+
result = addSubscriber(subscriber);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
return {
|
|
58
|
+
success: false,
|
|
59
|
+
error: `Could not save subscriber: ${err instanceof Error ? err.message : String(err)}`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const verb = result.replaced ? 'Updated' : 'Added';
|
|
64
|
+
return {
|
|
65
|
+
success: true,
|
|
66
|
+
message: `${verb} subscriber ${subscriber.name ?? subscriber.url}.\n\nStore: ${subscriberStorePath()}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo subscribers list` — show registered build-bus subscribers.
|
|
3
|
+
*
|
|
4
|
+
* Reads the static-config subscriber list ([[v2/BUILD_BUS.md]] Phase
|
|
5
|
+
* 2-lite — registry-server switchboard is deferred). Doesn't print
|
|
6
|
+
* the HMAC secret in cleartext — only a truncated hash so operators
|
|
7
|
+
* can disambiguate subscribers without leaking credentials into
|
|
8
|
+
* terminal history.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { loadSubscribers, subscriberStorePath } from '../../services/build-bus';
|
|
13
|
+
import type { CommandResult } from '../types';
|
|
14
|
+
|
|
15
|
+
export function handleSubscribersList(): CommandResult {
|
|
16
|
+
let subscribers: ReturnType<typeof loadSubscribers>;
|
|
17
|
+
try {
|
|
18
|
+
subscribers = loadSubscribers();
|
|
19
|
+
} catch (err) {
|
|
20
|
+
return {
|
|
21
|
+
success: false,
|
|
22
|
+
error: `Could not load subscribers: ${err instanceof Error ? err.message : String(err)}`,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (subscribers.length === 0) {
|
|
27
|
+
return {
|
|
28
|
+
success: true,
|
|
29
|
+
message: `No subscribers configured.\n\nStore: ${subscriberStorePath()}\nAdd one with: celilo subscribers add <url> --secret <secret>`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const lines: string[] = [`Subscribers (${subscribers.length}) — ${subscriberStorePath()}`, ''];
|
|
34
|
+
for (const s of subscribers) {
|
|
35
|
+
const fingerprint = createHash('sha256').update(s.secret).digest('hex').slice(0, 8);
|
|
36
|
+
const label = s.name ? `${s.name} <${s.url}>` : s.url;
|
|
37
|
+
lines.push(` ${label}`);
|
|
38
|
+
lines.push(` secret fingerprint: ${fingerprint}…`);
|
|
39
|
+
const matchParts: string[] = [];
|
|
40
|
+
if (s.match.registry) matchParts.push(`registry=${s.match.registry}`);
|
|
41
|
+
if (s.match.tag) matchParts.push(`tag=${s.match.tag}`);
|
|
42
|
+
if (s.match.packagePattern) matchParts.push(`pkg=${s.match.packagePattern}`);
|
|
43
|
+
lines.push(` match: ${matchParts.length > 0 ? matchParts.join(', ') : '(any event)'}`);
|
|
44
|
+
lines.push('');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { success: true, message: lines.join('\n').trim() };
|
|
48
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo subscribers remove <url>` — remove a subscriber by URL.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { removeSubscriberByUrl, subscriberStorePath } from '../../services/build-bus';
|
|
6
|
+
import type { CommandResult } from '../types';
|
|
7
|
+
|
|
8
|
+
export function handleSubscribersRemove(args: string[]): CommandResult {
|
|
9
|
+
const url = args[0];
|
|
10
|
+
if (!url) {
|
|
11
|
+
return {
|
|
12
|
+
success: false,
|
|
13
|
+
error: 'Subscriber URL required.\n\nUsage:\n celilo subscribers remove <url>',
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let removed: ReturnType<typeof removeSubscriberByUrl>;
|
|
18
|
+
try {
|
|
19
|
+
removed = removeSubscriberByUrl(url);
|
|
20
|
+
} catch (err) {
|
|
21
|
+
return {
|
|
22
|
+
success: false,
|
|
23
|
+
error: `Could not update subscriber store: ${err instanceof Error ? err.message : String(err)}`,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!removed) {
|
|
28
|
+
return {
|
|
29
|
+
success: false,
|
|
30
|
+
error: `No subscriber found with URL "${url}".\n\nStore: ${subscriberStorePath()}\nList current subscribers with: celilo subscribers list`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
success: true,
|
|
36
|
+
message: `Removed subscriber ${removed.name ?? removed.url}.`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo subscribers serve --port <p> --secret <s>` — long-running
|
|
3
|
+
* HTTP receiver for build-bus webhooks.
|
|
4
|
+
*
|
|
5
|
+
* Verifies signed envelopes against the configured shared secret
|
|
6
|
+
* and emits a `build-bus.publish` event onto the local SQLite bus
|
|
7
|
+
* for every verified delivery. Phase 4's hook dispatcher (also
|
|
8
|
+
* started by this command when --dispatch is set) picks up those
|
|
9
|
+
* local events and runs module-side `on_upstream_publish` hooks.
|
|
10
|
+
*
|
|
11
|
+
* Designed to run under systemd / launchd; blocks indefinitely
|
|
12
|
+
* until killed.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { startHookDispatcher } from '../../services/build-bus/hook-dispatcher';
|
|
16
|
+
import { startReceiverServer } from '../../services/build-bus/receiver-server';
|
|
17
|
+
import type { CommandResult } from '../types';
|
|
18
|
+
|
|
19
|
+
export async function handleSubscribersServe(
|
|
20
|
+
_args: string[],
|
|
21
|
+
flags: Record<string, string | boolean>,
|
|
22
|
+
): Promise<CommandResult> {
|
|
23
|
+
const port = typeof flags.port === 'string' ? Number.parseInt(flags.port, 10) : 8123;
|
|
24
|
+
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
|
25
|
+
return {
|
|
26
|
+
success: false,
|
|
27
|
+
error: `Invalid --port value: ${flags.port}. Must be 1–65535.`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const secret = flags.secret;
|
|
31
|
+
if (typeof secret !== 'string' || !secret) {
|
|
32
|
+
return {
|
|
33
|
+
success: false,
|
|
34
|
+
error: '--secret <shared-hmac-secret> is required.',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// When --dispatch is set (default), this process also runs the
|
|
39
|
+
// hook dispatcher on the same bus. Operators can pass --no-dispatch
|
|
40
|
+
// to run the receiver alone (e.g. when the dispatcher already runs
|
|
41
|
+
// in another process).
|
|
42
|
+
const wantsDispatch = flags['no-dispatch'] !== true;
|
|
43
|
+
|
|
44
|
+
const dispatcher = wantsDispatch ? await startHookDispatcher() : null;
|
|
45
|
+
const server = startReceiverServer({
|
|
46
|
+
port,
|
|
47
|
+
secret,
|
|
48
|
+
onEvent: dispatcher
|
|
49
|
+
? async (envelope) => {
|
|
50
|
+
// handleEvent returns HookRunResult[]; the receiver's
|
|
51
|
+
// onEvent contract is void | Promise<void>. Discard the
|
|
52
|
+
// array — the dispatcher logs its own outcomes.
|
|
53
|
+
await dispatcher.handleEvent(envelope.event);
|
|
54
|
+
}
|
|
55
|
+
: undefined,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
console.log(`✓ build-bus receiver listening on ${server.url}`);
|
|
59
|
+
console.log(` dispatch: ${wantsDispatch ? 'enabled' : 'disabled'}`);
|
|
60
|
+
console.log(` events emit to local bus as "build-bus.publish"`);
|
|
61
|
+
console.log('Ctrl-C to stop.');
|
|
62
|
+
|
|
63
|
+
// Graceful shutdown.
|
|
64
|
+
const stop = async (signal: string) => {
|
|
65
|
+
console.log(`\nReceived ${signal}; shutting down…`);
|
|
66
|
+
await server.stop();
|
|
67
|
+
if (dispatcher) await dispatcher.stop();
|
|
68
|
+
process.exit(0);
|
|
69
|
+
};
|
|
70
|
+
process.on('SIGINT', () => void stop('SIGINT'));
|
|
71
|
+
process.on('SIGTERM', () => void stop('SIGTERM'));
|
|
72
|
+
|
|
73
|
+
// Block indefinitely. The signal handlers exit the process.
|
|
74
|
+
await new Promise(() => {});
|
|
75
|
+
// Unreachable, but TypeScript wants a return.
|
|
76
|
+
return { success: true, message: '' };
|
|
77
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo subscribers status` — per-subscriber delivery summary.
|
|
3
|
+
*
|
|
4
|
+
* Reads recent `webhook.delivered` / `webhook.failed` events from
|
|
5
|
+
* the local SQLite bus, groups by subscriber URL, surfaces totals
|
|
6
|
+
* + success rate + last delivery + recent failures. Use after a
|
|
7
|
+
* publish run to see whether webhooks actually landed, or as the
|
|
8
|
+
* first stop when investigating a stuck propagation.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { formatStatus, loadSubscriberStatus } from '../../services/build-bus';
|
|
12
|
+
import type { CommandResult } from '../types';
|
|
13
|
+
|
|
14
|
+
export function handleSubscribersStatus(): CommandResult {
|
|
15
|
+
let items: ReturnType<typeof loadSubscriberStatus>;
|
|
16
|
+
try {
|
|
17
|
+
items = loadSubscriberStatus();
|
|
18
|
+
} catch (err) {
|
|
19
|
+
return {
|
|
20
|
+
success: false,
|
|
21
|
+
error: `Could not load subscriber status: ${err instanceof Error ? err.message : String(err)}`,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (items.length === 0) {
|
|
25
|
+
return {
|
|
26
|
+
success: true,
|
|
27
|
+
message:
|
|
28
|
+
'No subscribers configured.\n\nAdd one with: celilo subscribers add <url> --secret <secret>',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const header = `Subscriber status (${items.length})`;
|
|
32
|
+
return { success: true, message: `${header}\n\n${formatStatus(items)}` };
|
|
33
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo subscribers test <url>` — fire a synthetic build-bus event
|
|
3
|
+
* at the named subscriber, report delivery outcome.
|
|
4
|
+
*
|
|
5
|
+
* Useful for verifying that a freshly-added subscriber's secret +
|
|
6
|
+
* URL line up with what the receiver expects, without waiting for
|
|
7
|
+
* a real publish to fire the webhook.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
eventsForPublished,
|
|
12
|
+
fanOut,
|
|
13
|
+
formatDeliveryResult,
|
|
14
|
+
loadSubscribers,
|
|
15
|
+
} from '../../services/build-bus';
|
|
16
|
+
import type { CommandResult } from '../types';
|
|
17
|
+
|
|
18
|
+
export async function handleSubscribersTest(args: string[]): Promise<CommandResult> {
|
|
19
|
+
const url = args[0];
|
|
20
|
+
if (!url) {
|
|
21
|
+
return {
|
|
22
|
+
success: false,
|
|
23
|
+
error: 'Subscriber URL required.\n\nUsage:\n celilo subscribers test <url>',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const subscribers = loadSubscribers();
|
|
28
|
+
const target = subscribers.find((s) => s.url === url);
|
|
29
|
+
if (!target) {
|
|
30
|
+
return {
|
|
31
|
+
success: false,
|
|
32
|
+
error: `No subscriber found with URL "${url}". List with: celilo subscribers list`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Synthetic event — uses a fixed test-token name so receivers can
|
|
37
|
+
// distinguish a probe from a real publish in their logs. Registry
|
|
38
|
+
// + tag match the target's expected filter when possible (so the
|
|
39
|
+
// event is actually delivered rather than getting filtered out).
|
|
40
|
+
const events = eventsForPublished({
|
|
41
|
+
published: [{ name: '@celilo-test/probe', version: '0.0.0' }],
|
|
42
|
+
tag: (target.match.tag as 'alpha' | 'latest' | undefined) ?? 'latest',
|
|
43
|
+
registry: target.match.registry ?? 'npm',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// We bypass the global subscriber list and deliver only to the
|
|
47
|
+
// chosen target — the test should always exercise the operator's
|
|
48
|
+
// exact subscriber, regardless of match filters.
|
|
49
|
+
const event = events[0];
|
|
50
|
+
const results = await fanOut(event, [{ ...target, match: {} }]);
|
|
51
|
+
|
|
52
|
+
const lines: string[] = [
|
|
53
|
+
'Synthetic event fired:',
|
|
54
|
+
' package: @celilo-test/probe@0.0.0',
|
|
55
|
+
` registry: ${event.registry}`,
|
|
56
|
+
` tag: ${event.tag}`,
|
|
57
|
+
` event-id: ${event.eventId}`,
|
|
58
|
+
'',
|
|
59
|
+
'Result:',
|
|
60
|
+
` ${formatDeliveryResult(results[0])}`,
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
success: results[0].ok,
|
|
65
|
+
message: lines.join('\n'),
|
|
66
|
+
// CommandResult is success | error; we shoehorn the failure case
|
|
67
|
+
// into success: false with the same message. The CLI surface
|
|
68
|
+
// doesn't distinguish here — the test PRINTED its outcome.
|
|
69
|
+
error: results[0].ok ? undefined : `Delivery failed: ${results[0].error}`,
|
|
70
|
+
} as CommandResult;
|
|
71
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end equivalence test for v2/MANAGEMENT_AS_NETAPP.md Phase 2.
|
|
3
|
+
*
|
|
4
|
+
* The spec calls for "celilo system apply-config" to write the same
|
|
5
|
+
* systemConfig state that the legacy `celilo system init` produces,
|
|
6
|
+
* just without the operator-facing framing. This test runs both
|
|
7
|
+
* paths against a pair of isolated DBs and asserts the resulting
|
|
8
|
+
* systemConfig rows match.
|
|
9
|
+
*
|
|
10
|
+
* Catches the bug class where a future change to system-init (e.g.
|
|
11
|
+
* adding a side-effect or gateway computation) drifts away from
|
|
12
|
+
* what apply-config writes.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
16
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { getDb } from '../../db/client';
|
|
20
|
+
import { loadExistingConfiguration } from '../../services/system-init';
|
|
21
|
+
import { handleSystemApplyConfig } from './system-apply-config';
|
|
22
|
+
import { handleSystemInit } from './system-init';
|
|
23
|
+
|
|
24
|
+
describe('system apply-config equivalence with system init --accept-defaults', () => {
|
|
25
|
+
let tmpDir: string;
|
|
26
|
+
let savedDbPath: string | undefined;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'celilo-apply-config-test-'));
|
|
30
|
+
savedDbPath = process.env.CELILO_DB_PATH;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
if (savedDbPath !== undefined) {
|
|
35
|
+
process.env.CELILO_DB_PATH = savedDbPath;
|
|
36
|
+
} else {
|
|
37
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
38
|
+
}
|
|
39
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('apply-config writes the same systemConfig keys as system init --accept-defaults', async () => {
|
|
43
|
+
const overrides = [
|
|
44
|
+
'network.dmz.subnet=10.99.10.0/24',
|
|
45
|
+
'network.app.subnet=10.99.20.0/24',
|
|
46
|
+
'network.secure.subnet=10.99.30.0/24',
|
|
47
|
+
'network.internal.subnet=192.168.99.0/24',
|
|
48
|
+
'dns.primary=9.9.9.9',
|
|
49
|
+
'dns.fallback=1.1.1.1 8.8.8.8',
|
|
50
|
+
'ssh.public_key=ssh-ed25519 AAAA== test@equivalence',
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
// Path A: legacy system init --accept-defaults.
|
|
54
|
+
process.env.CELILO_DB_PATH = join(tmpDir, 'a.db');
|
|
55
|
+
const aResult = await handleSystemInit(overrides, { 'accept-defaults': true });
|
|
56
|
+
expect(aResult.success).toBe(true);
|
|
57
|
+
const aSnapshot = loadExistingConfiguration(getDb());
|
|
58
|
+
|
|
59
|
+
// Path B: new apply-config.
|
|
60
|
+
process.env.CELILO_DB_PATH = join(tmpDir, 'b.db');
|
|
61
|
+
const bResult = await handleSystemApplyConfig(overrides);
|
|
62
|
+
expect(bResult.success).toBe(true);
|
|
63
|
+
const bSnapshot = loadExistingConfiguration(getDb());
|
|
64
|
+
|
|
65
|
+
// The two paths should write the same set of keys (gateways are
|
|
66
|
+
// auto-computed from subnets in both, since both go through
|
|
67
|
+
// initializeSystem) and the same values.
|
|
68
|
+
expect(Object.keys(bSnapshot).sort()).toEqual(Object.keys(aSnapshot).sort());
|
|
69
|
+
for (const key of Object.keys(aSnapshot)) {
|
|
70
|
+
expect({ key, value: bSnapshot[key] }).toEqual({ key, value: aSnapshot[key] });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('apply-config refuses to run with no overrides (unlike init which has defaults)', async () => {
|
|
75
|
+
process.env.CELILO_DB_PATH = join(tmpDir, 'empty.db');
|
|
76
|
+
const result = await handleSystemApplyConfig([]);
|
|
77
|
+
expect(result.success).toBe(false);
|
|
78
|
+
if (!result.success) {
|
|
79
|
+
expect(result.error).toContain('No config values supplied');
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('apply-config reports malformed positional args clearly', async () => {
|
|
84
|
+
process.env.CELILO_DB_PATH = join(tmpDir, 'bad.db');
|
|
85
|
+
const result = await handleSystemApplyConfig(['not-a-pair', 'dns.primary=1.1.1.1']);
|
|
86
|
+
expect(result.success).toBe(false);
|
|
87
|
+
if (!result.success) {
|
|
88
|
+
expect(result.error).toContain('Expected key=value');
|
|
89
|
+
expect(result.error).toContain('not-a-pair');
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('apply-config writes report the number of values applied', async () => {
|
|
94
|
+
process.env.CELILO_DB_PATH = join(tmpDir, 'count.db');
|
|
95
|
+
const result = await handleSystemApplyConfig([
|
|
96
|
+
'dns.primary=1.1.1.1',
|
|
97
|
+
'network.dmz.subnet=10.0.10.0/24',
|
|
98
|
+
]);
|
|
99
|
+
expect(result.success).toBe(true);
|
|
100
|
+
if (result.success) {
|
|
101
|
+
// initializeSystem() merges with defaults + computed gateways,
|
|
102
|
+
// so the reported count is the FULL config size — not just our
|
|
103
|
+
// two overrides. The exact count depends on getDefaultConfiguration
|
|
104
|
+
// and any auto-computed gateways; assert it's at least 2.
|
|
105
|
+
expect(result.message).toMatch(/Applied \d+ config value\(s\)/);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `celilo system apply-config`.
|
|
3
|
+
*
|
|
4
|
+
* The parser is pure (key=value handling); we test it directly. The
|
|
5
|
+
* actual DB write goes through `initializeSystem` which is already
|
|
6
|
+
* covered by system-init's tests — we just confirm apply-config's
|
|
7
|
+
* thin wrapper hands the right shape to it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import { parseKeyValueArgs } from './system-apply-config';
|
|
12
|
+
|
|
13
|
+
describe('parseKeyValueArgs', () => {
|
|
14
|
+
test('empty args → empty overrides + no errors', () => {
|
|
15
|
+
expect(parseKeyValueArgs([])).toEqual({ overrides: {}, errors: [] });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('parses a single key=value', () => {
|
|
19
|
+
expect(parseKeyValueArgs(['network.dmz.subnet=10.0.10.0/24'])).toEqual({
|
|
20
|
+
overrides: { 'network.dmz.subnet': '10.0.10.0/24' },
|
|
21
|
+
errors: [],
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('parses multiple key=value pairs', () => {
|
|
26
|
+
const result = parseKeyValueArgs([
|
|
27
|
+
'network.dmz.subnet=10.0.10.0/24',
|
|
28
|
+
'dns.primary=1.1.1.1',
|
|
29
|
+
'dns.fallback=8.8.8.8 1.1.1.1',
|
|
30
|
+
]);
|
|
31
|
+
expect(result.overrides).toEqual({
|
|
32
|
+
'network.dmz.subnet': '10.0.10.0/24',
|
|
33
|
+
'dns.primary': '1.1.1.1',
|
|
34
|
+
'dns.fallback': '8.8.8.8 1.1.1.1',
|
|
35
|
+
});
|
|
36
|
+
expect(result.errors).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('records error for positional without "="', () => {
|
|
40
|
+
const result = parseKeyValueArgs(['not-a-pair']);
|
|
41
|
+
expect(result.overrides).toEqual({});
|
|
42
|
+
expect(result.errors).toHaveLength(1);
|
|
43
|
+
expect(result.errors[0]).toContain('Expected key=value');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('only-equal at index 0 is rejected (empty key)', () => {
|
|
47
|
+
// `=value` has eqIndex 0, which the parser rejects so we don't
|
|
48
|
+
// accept anonymous keys.
|
|
49
|
+
const result = parseKeyValueArgs(['=orphan']);
|
|
50
|
+
expect(result.errors).toHaveLength(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('preserves values containing "=" (splits on first "=" only)', () => {
|
|
54
|
+
// SSH public keys end with `user@host`, but trailing `=` characters
|
|
55
|
+
// appear in base64-encoded key bodies. Make sure those survive.
|
|
56
|
+
const sshKey = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA== user@host';
|
|
57
|
+
const result = parseKeyValueArgs([`ssh.public_key=${sshKey}`]);
|
|
58
|
+
expect(result.overrides['ssh.public_key']).toBe(sshKey);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('keys can contain dots (system config convention)', () => {
|
|
62
|
+
const result = parseKeyValueArgs(['network.dmz.subnet=10.0.10.0/24']);
|
|
63
|
+
expect(result.overrides['network.dmz.subnet']).toBe('10.0.10.0/24');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('later occurrences of a key overwrite earlier ones', () => {
|
|
67
|
+
const result = parseKeyValueArgs(['dns.primary=1.1.1.1', 'dns.primary=8.8.8.8']);
|
|
68
|
+
expect(result.overrides['dns.primary']).toBe('8.8.8.8');
|
|
69
|
+
});
|
|
70
|
+
});
|