@celilo/cli 0.11.0 → 0.12.1
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/AGENTS.md +7 -4
- package/CELILO_CORE_MODULES.md +7 -6
- package/CELILO_SUBSYSTEMS.md +52 -6
- package/MODULE_PRIMITIVES.md +164 -0
- package/README.md +5 -5
- package/drizzle/0015_port_forwards.sql +12 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +13 -5
- package/schemas/system_config.json +1 -1
- package/src/ansible/inventory.test.ts +1 -1
- package/src/ansible/inventory.ts +3 -3
- package/src/api/protocol.test.ts +1 -1
- package/src/api/remote-client.test.ts +1 -1
- package/src/api/serve.ts +5 -5
- package/src/capabilities/public-web-publish.test.ts +209 -0
- package/src/capabilities/validation.test.ts +1 -1
- package/src/capabilities/well-known.test.ts +1 -1
- package/src/capabilities/well-known.ts +2 -2
- package/src/cli/command-tree-parser.ts +3 -1
- package/src/cli/commands/api.ts +1 -1
- package/src/cli/commands/apt-upgrade.test.ts +33 -0
- package/src/cli/commands/apt-upgrade.ts +63 -0
- package/src/cli/commands/backup-pull.ts +1 -1
- package/src/cli/commands/commands-json.ts +29 -0
- package/src/cli/commands/completion.ts +1 -1
- package/src/cli/commands/events.ts +1 -1
- package/src/cli/commands/module-changeset.ts +1 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-list.ts +16 -2
- package/src/cli/commands/module-publish.ts +1 -1
- package/src/cli/commands/module-remove.ts +26 -1
- package/src/cli/commands/module-types.ts +1 -1
- package/src/cli/commands/module-upgrade.ts +1 -1
- package/src/cli/commands/module-version.ts +1 -1
- package/src/cli/commands/module-where.test.ts +26 -0
- package/src/cli/commands/module-where.ts +130 -0
- package/src/cli/commands/proxmox-node-list.ts +1 -1
- package/src/cli/commands/publish/alpha.test.ts +1 -1
- package/src/cli/commands/publish/alpha.ts +3 -3
- package/src/cli/commands/publish/changesets.ts +1 -1
- package/src/cli/commands/publish/global-install.ts +6 -2
- package/src/cli/commands/publish/helpers.ts +21 -14
- package/src/cli/commands/publish/index.ts +4 -4
- package/src/cli/commands/publish/plan.ts +3 -3
- package/src/cli/commands/publish/preflight.ts +2 -2
- package/src/cli/commands/publish/types.ts +2 -2
- package/src/cli/commands/publish/workspace.test.ts +1 -1
- package/src/cli/commands/publish/workspace.ts +2 -2
- package/src/cli/commands/registry-owner.test.ts +166 -0
- package/src/cli/commands/registry-owner.ts +124 -0
- package/src/cli/commands/registry-token.test.ts +109 -0
- package/src/cli/commands/registry-token.ts +194 -0
- package/src/cli/commands/restore.ts +1 -1
- package/src/cli/commands/service-add-proxmox.ts +1 -1
- package/src/cli/commands/service-list.ts +15 -2
- package/src/cli/commands/subscribers-list.ts +1 -1
- package/src/cli/commands/system-apply-config-equivalence.test.ts +1 -1
- package/src/cli/commands/system-apply-config.ts +1 -1
- package/src/cli/commands/system-init-deprecation.test.ts +1 -1
- package/src/cli/commands/system-init.ts +2 -2
- package/src/cli/commands/token.test.ts +26 -0
- package/src/cli/commands/token.ts +160 -0
- package/src/cli/completion.ts +21 -0
- package/src/cli/generate-zsh-completion.test.ts +22 -4
- package/src/cli/generate-zsh-completion.ts +7 -3
- package/src/cli/index.ts +170 -7
- package/src/cli/parser.ts +1 -1
- package/src/cli/restore-command.test.ts +1 -1
- package/src/db/client.ts +1 -1
- package/src/db/schema.ts +50 -7
- package/src/hooks/capability-loader-firewall.test.ts +4 -3
- package/src/hooks/capability-loader.ts +100 -10
- package/src/hooks/define-hook.test.ts +24 -0
- package/src/hooks/executor.test.ts +1 -1
- package/src/hooks/executor.ts +2 -2
- package/src/hooks/types.ts +1 -1
- package/src/manifest/contracts/v1.ts +2 -2
- package/src/manifest/schema.ts +13 -13
- package/src/manifest/template-validator.ts +1 -1
- package/src/module/packaging/build.ts +19 -0
- package/src/module/packaging/workspace-deps.test.ts +94 -0
- package/src/module/packaging/workspace-deps.ts +185 -0
- package/src/module/versioning/changeset-version.ts +1 -1
- package/src/policy/no-hand-built-ssh.test.ts +90 -0
- package/src/registry/client.test.ts +86 -0
- package/src/registry/client.ts +67 -1
- package/src/services/api-access.ts +1 -1
- package/src/services/aspect-approvals.ts +1 -1
- package/src/services/aspect-runner.ts +3 -3
- package/src/services/aspect-template-resolver.test.ts +1 -1
- package/src/services/aspect-template-resolver.ts +1 -1
- package/src/services/build-bus/delivery-events.ts +1 -1
- package/src/services/build-bus/fan-out.ts +1 -1
- package/src/services/build-bus/hook-dispatch.ts +1 -1
- package/src/services/build-bus/receiver-server.ts +1 -1
- package/src/services/build-bus/status.test.ts +1 -1
- package/src/services/build-bus/status.ts +1 -1
- package/src/services/build-bus/subscriber-store.ts +1 -1
- package/src/services/bus-interview.ts +1 -1
- package/src/services/celilo-events.ts +1 -1
- package/src/services/celilo-mgmt-hooks.test.ts +1 -1
- package/src/services/cross-module-read.ts +1 -1
- package/src/services/deploy-posture.ts +1 -1
- package/src/services/deployed-systems.test.ts +1 -1
- package/src/services/deployed-systems.ts +4 -4
- package/src/services/dns-provider-backfill.ts +2 -2
- package/src/services/events-daemon.ts +1 -1
- package/src/services/machine-pool.ts +3 -3
- package/src/services/module-deploy.ts +93 -8
- package/src/services/module-subscriptions.ts +1 -1
- package/src/services/module-types-generator.ts +1 -1
- package/src/services/module-validator/git-hygiene.ts +1 -1
- package/src/services/port-forwards.test.ts +92 -0
- package/src/services/port-forwards.ts +86 -0
- package/src/services/proxmox-reconcile.ts +2 -2
- package/src/services/public-web-republish.test.ts +189 -0
- package/src/services/public-web-republish.ts +84 -0
- package/src/services/restore-from-file.ts +1 -1
- package/src/services/restore-preflight.ts +1 -1
- package/src/services/system-identity.ts +1 -1
- package/src/services/system-init.test.ts +2 -2
- package/src/services/system-init.ts +2 -2
- package/src/services/web-route-cleanup.test.ts +250 -0
- package/src/services/web-route-cleanup.ts +144 -0
- package/src/templates/generator.ts +2 -2
- package/src/types/infrastructure.ts +1 -1
- package/src/variables/computed/computed-integration.test.ts +1 -1
- package/src/variables/computed/evaluate.ts +1 -1
- package/src/variables/computed/parse.ts +1 -1
- package/src/variables/context.test.ts +1 -1
- package/src/variables/context.ts +5 -5
- package/src/variables/lxc-nameserver.test.ts +1 -1
- package/src/variables/resolver.ts +2 -2
- package/src/variables/types.ts +1 -1
- package/src/api/protocol.ts +0 -159
- package/src/api/remote-client.ts +0 -218
- package/src/cli/command-registry.ts +0 -1488
|
@@ -489,3 +489,212 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
|
|
|
489
489
|
}
|
|
490
490
|
});
|
|
491
491
|
});
|
|
492
|
+
|
|
493
|
+
describe('register_route — public DNS wiring (D1/M1 #328)', () => {
|
|
494
|
+
beforeEach(() => {
|
|
495
|
+
execSyncSpy = spyOn(childProcess, 'execSync').mockReturnValue(Buffer.from(''));
|
|
496
|
+
});
|
|
497
|
+
afterEach(() => {
|
|
498
|
+
execSyncSpy.mockRestore();
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
function makeRegistrar(): {
|
|
502
|
+
registrar: {
|
|
503
|
+
registerHost: (r: { fqdn: string; ip?: string }) => Promise<{ success: boolean }>;
|
|
504
|
+
};
|
|
505
|
+
calls: Array<{ fqdn: string; ip?: string }>;
|
|
506
|
+
} {
|
|
507
|
+
const calls: Array<{ fqdn: string; ip?: string }> = [];
|
|
508
|
+
return {
|
|
509
|
+
calls,
|
|
510
|
+
registrar: {
|
|
511
|
+
async registerHost(r) {
|
|
512
|
+
calls.push(r);
|
|
513
|
+
return { success: true };
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
test('registers the public A record at externalIp for a NEW hostname', async () => {
|
|
520
|
+
const { ops } = makeRouteOps();
|
|
521
|
+
const { registrar, calls } = makeRegistrar();
|
|
522
|
+
const cap = createPublicWeb({
|
|
523
|
+
moduleId: 'nexus',
|
|
524
|
+
logger: noopLogger,
|
|
525
|
+
config: { target_ip: '10.0.10.20/24' },
|
|
526
|
+
secrets: {},
|
|
527
|
+
routeOps: ops,
|
|
528
|
+
hostnames: ['www.example.com'],
|
|
529
|
+
caddyModuleId: 'caddy',
|
|
530
|
+
dnsManagedDomains: ['example.com'],
|
|
531
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
|
|
532
|
+
dnsRegistrar: registrar as any,
|
|
533
|
+
externalIp: '100.100.0.100',
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
await cap.register_route({
|
|
537
|
+
type: 'static',
|
|
538
|
+
path: '/',
|
|
539
|
+
hostname: 'nexus.example.com',
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
expect(calls).toEqual([{ fqdn: 'nexus.example.com', ip: '100.100.0.100' }]);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test('skips the default hostname (caddy already registered it at install)', async () => {
|
|
546
|
+
const { ops } = makeRouteOps();
|
|
547
|
+
const { registrar, calls } = makeRegistrar();
|
|
548
|
+
const cap = createPublicWeb({
|
|
549
|
+
moduleId: 'root',
|
|
550
|
+
logger: noopLogger,
|
|
551
|
+
config: { target_ip: '10.0.10.20/24' },
|
|
552
|
+
secrets: {},
|
|
553
|
+
routeOps: ops,
|
|
554
|
+
hostnames: ['www.example.com'],
|
|
555
|
+
caddyModuleId: 'caddy',
|
|
556
|
+
dnsManagedDomains: ['example.com'],
|
|
557
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
|
|
558
|
+
dnsRegistrar: registrar as any,
|
|
559
|
+
externalIp: '100.100.0.100',
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
await cap.register_route({ type: 'static', path: '/', hostname: 'www.example.com' });
|
|
563
|
+
|
|
564
|
+
expect(calls).toHaveLength(0);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// D2 / ce-phz — reachability wiring is authoritative and LOUD: a new
|
|
568
|
+
// hostname that can't be wired FAILS the deploy instead of silently
|
|
569
|
+
// reporting success (the "served but unreachable" anti-pattern).
|
|
570
|
+
|
|
571
|
+
test('fails loudly when externalIp is unknown for a NEW hostname', async () => {
|
|
572
|
+
const { ops } = makeRouteOps();
|
|
573
|
+
const { registrar, calls } = makeRegistrar();
|
|
574
|
+
const cap = createPublicWeb({
|
|
575
|
+
moduleId: 'nexus',
|
|
576
|
+
logger: noopLogger,
|
|
577
|
+
config: { target_ip: '10.0.10.20/24' },
|
|
578
|
+
secrets: {},
|
|
579
|
+
routeOps: ops,
|
|
580
|
+
hostnames: ['www.example.com'],
|
|
581
|
+
caddyModuleId: 'caddy',
|
|
582
|
+
dnsManagedDomains: ['example.com'],
|
|
583
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
|
|
584
|
+
dnsRegistrar: registrar as any,
|
|
585
|
+
// externalIp omitted
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
await expect(
|
|
589
|
+
cap.register_route({ type: 'static', path: '/', hostname: 'nexus.example.com' }),
|
|
590
|
+
).rejects.toThrow('no known external IP');
|
|
591
|
+
expect(calls).toHaveLength(0);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test('fails loudly when no dns_registrar is available for a NEW hostname', async () => {
|
|
595
|
+
const { ops } = makeRouteOps();
|
|
596
|
+
const cap = createPublicWeb({
|
|
597
|
+
moduleId: 'nexus',
|
|
598
|
+
logger: noopLogger,
|
|
599
|
+
config: { target_ip: '10.0.10.20/24' },
|
|
600
|
+
secrets: {},
|
|
601
|
+
routeOps: ops,
|
|
602
|
+
hostnames: ['www.example.com'],
|
|
603
|
+
caddyModuleId: 'caddy',
|
|
604
|
+
dnsManagedDomains: ['example.com'],
|
|
605
|
+
// dnsRegistrar + externalIp both omitted
|
|
606
|
+
externalIp: '100.100.0.100',
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
await expect(
|
|
610
|
+
cap.register_route({ type: 'static', path: '/', hostname: 'nexus.example.com' }),
|
|
611
|
+
).rejects.toThrow('no dns_registrar capability');
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test('fails loudly when the registrar reports the public registration failed', async () => {
|
|
615
|
+
const { ops } = makeRouteOps();
|
|
616
|
+
const registrar = {
|
|
617
|
+
async registerHost() {
|
|
618
|
+
return { success: false, error: 'DDNS auth rejected' };
|
|
619
|
+
},
|
|
620
|
+
};
|
|
621
|
+
const cap = createPublicWeb({
|
|
622
|
+
moduleId: 'nexus',
|
|
623
|
+
logger: noopLogger,
|
|
624
|
+
config: { target_ip: '10.0.10.20/24' },
|
|
625
|
+
secrets: {},
|
|
626
|
+
routeOps: ops,
|
|
627
|
+
hostnames: ['www.example.com'],
|
|
628
|
+
caddyModuleId: 'caddy',
|
|
629
|
+
dnsManagedDomains: ['example.com'],
|
|
630
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
|
|
631
|
+
dnsRegistrar: registrar as any,
|
|
632
|
+
externalIp: '100.100.0.100',
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
await expect(
|
|
636
|
+
cap.register_route({ type: 'static', path: '/', hostname: 'nexus.example.com' }),
|
|
637
|
+
).rejects.toThrow('DDNS auth rejected');
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test('fails loudly when an internal resolver is present but has no target IP', async () => {
|
|
641
|
+
const { ops } = makeRouteOps();
|
|
642
|
+
const { registrar } = makeRegistrar();
|
|
643
|
+
const dnsInternal = {
|
|
644
|
+
async registerRecord() {},
|
|
645
|
+
async deleteRecord() {},
|
|
646
|
+
};
|
|
647
|
+
const cap = createPublicWeb({
|
|
648
|
+
moduleId: 'nexus',
|
|
649
|
+
logger: noopLogger,
|
|
650
|
+
// No target_ip and no firewallNatIp → internalDnsIp is falsy.
|
|
651
|
+
config: {},
|
|
652
|
+
secrets: {},
|
|
653
|
+
routeOps: ops,
|
|
654
|
+
hostnames: ['www.example.com'],
|
|
655
|
+
caddyModuleId: 'caddy',
|
|
656
|
+
dnsManagedDomains: ['example.com'],
|
|
657
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
|
|
658
|
+
dnsRegistrar: registrar as any,
|
|
659
|
+
externalIp: '100.100.0.100',
|
|
660
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal dns_internal stub
|
|
661
|
+
dnsInternal: dnsInternal as any,
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
await expect(
|
|
665
|
+
cap.register_route({ type: 'static', path: '/', hostname: 'nexus.example.com' }),
|
|
666
|
+
).rejects.toThrow('no internal DNS target IP');
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
test('wires internal DNS when a resolver is present and reachable', async () => {
|
|
670
|
+
const { ops } = makeRouteOps();
|
|
671
|
+
const { registrar } = makeRegistrar();
|
|
672
|
+
const internalCalls: Array<{ host: string; value: string }> = [];
|
|
673
|
+
const dnsInternal = {
|
|
674
|
+
async registerRecord(r: { host: string; value: string }) {
|
|
675
|
+
internalCalls.push({ host: r.host, value: r.value });
|
|
676
|
+
},
|
|
677
|
+
async deleteRecord() {},
|
|
678
|
+
};
|
|
679
|
+
const cap = createPublicWeb({
|
|
680
|
+
moduleId: 'nexus',
|
|
681
|
+
logger: noopLogger,
|
|
682
|
+
config: { target_ip: '10.0.10.20/24' },
|
|
683
|
+
secrets: {},
|
|
684
|
+
routeOps: ops,
|
|
685
|
+
hostnames: ['www.example.com'],
|
|
686
|
+
caddyModuleId: 'caddy',
|
|
687
|
+
dnsManagedDomains: ['example.com'],
|
|
688
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal registrar stub
|
|
689
|
+
dnsRegistrar: registrar as any,
|
|
690
|
+
externalIp: '100.100.0.100',
|
|
691
|
+
firewallNatIp: '192.168.0.253',
|
|
692
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal dns_internal stub
|
|
693
|
+
dnsInternal: dnsInternal as any,
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
await cap.register_route({ type: 'static', path: '/', hostname: 'nexus.example.com' });
|
|
697
|
+
|
|
698
|
+
expect(internalCalls).toEqual([{ host: 'nexus.example.com', value: '192.168.0.253' }]);
|
|
699
|
+
});
|
|
700
|
+
});
|
|
@@ -245,7 +245,7 @@ describe('Capability Access Validation', () => {
|
|
|
245
245
|
// cross_module_read is a privilege (allow-listed to celilo-mgmt),
|
|
246
246
|
// not a provider-backed capability. validateCapabilityAccess must
|
|
247
247
|
// not demand a providing module for it — otherwise celilo-mgmt
|
|
248
|
-
// can't be imported (regression:
|
|
248
|
+
// can't be imported (regression: openspec/specs/progressive-zone-disclosure/spec.md).
|
|
249
249
|
const manifest: ModuleManifest = {
|
|
250
250
|
celilo_contract: '1.0',
|
|
251
251
|
id: 'celilo-mgmt',
|
|
@@ -177,7 +177,7 @@ describe('Well-Known Capabilities Registry', () => {
|
|
|
177
177
|
|
|
178
178
|
test('the internal DNS resolver lives in the dmz (ISS-0156)', () => {
|
|
179
179
|
// Moved out of `internal`: the resolver must see protected-zone query
|
|
180
|
-
// sources for source-based split-horizon views (
|
|
180
|
+
// sources for source-based split-horizon views (openspec/specs/internal-dns-zone-views/spec.md).
|
|
181
181
|
expect(WELL_KNOWN_CAPABILITIES.dns_internal.required_zone).toBe('dmz');
|
|
182
182
|
});
|
|
183
183
|
});
|
|
@@ -76,7 +76,7 @@ export const WELL_KNOWN_CAPABILITIES: Record<string, WellKnownCapability> = {
|
|
|
76
76
|
* dns_internal - Internal DNS resolver
|
|
77
77
|
* Example: Technitium / knot-unbound for split-horizon DNS
|
|
78
78
|
* Security: a PROTECTED zone (dmz), NOT `internal` (ISS-0156,
|
|
79
|
-
*
|
|
79
|
+
* openspec/specs/internal-dns-zone-views/spec.md). The resolver must see each querying client's
|
|
80
80
|
* real source IP to serve source-based split-horizon views; fw-main NATs
|
|
81
81
|
* protected↔`internal`, so an `internal`-placed resolver sees every protected
|
|
82
82
|
* query as fw-main's address and can't tell the zones apart. Placed in `dmz`
|
|
@@ -105,7 +105,7 @@ export const WELL_KNOWN_CAPABILITIES: Record<string, WellKnownCapability> = {
|
|
|
105
105
|
* Security: MUST be in secure zone (handles authentication)
|
|
106
106
|
*
|
|
107
107
|
* Note: `oidc.issuer_url` is no longer derived here. Per the firm rule in
|
|
108
|
-
*
|
|
108
|
+
* openspec/changes/manifest-v2/proposal.md D9, well-known capability data
|
|
109
109
|
* templates must not cross-reference other capabilities. The IDP-providing
|
|
110
110
|
* module declares an explicit user-set `domain` field in its own manifest,
|
|
111
111
|
* derives `auth_url` from `$self:domain`, and exposes it through
|
|
@@ -177,7 +177,9 @@ export class CommandTreeParser {
|
|
|
177
177
|
'usage',
|
|
178
178
|
'options',
|
|
179
179
|
'examples',
|
|
180
|
-
'commands'
|
|
180
|
+
// NB: 'commands' is intentionally NOT excluded — it's a real command
|
|
181
|
+
// (`celilo commands`). The "Commands:" section header is filtered earlier
|
|
182
|
+
// by the section-header regex, so it never reaches this check.
|
|
181
183
|
'description',
|
|
182
184
|
'example',
|
|
183
185
|
'for',
|
package/src/cli/commands/api.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* `celilo api ...` — manage remote-API principals (Slice 2a).
|
|
3
3
|
*
|
|
4
4
|
* grant / list / revoke operate on the api_principals table; authorized-keys
|
|
5
|
-
* renders the forced-command file for the API account. See
|
|
5
|
+
* renders the forced-command file for the API account. See openspec/changes/replace-ssh-cli-api/proposal.md.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { handleAptUpgrade } from './apt-upgrade';
|
|
3
|
+
|
|
4
|
+
describe('handleAptUpgrade', () => {
|
|
5
|
+
test('runs the three steps in order when each succeeds', async () => {
|
|
6
|
+
const seen: string[][] = [];
|
|
7
|
+
const result = await handleAptUpgrade([], {}, (argv) => {
|
|
8
|
+
seen.push(argv);
|
|
9
|
+
return { status: 0 };
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
expect(result.success).toBe(true);
|
|
13
|
+
expect(seen).toEqual([
|
|
14
|
+
['sudo', 'apt-get', 'update'],
|
|
15
|
+
['sudo', 'apt-get', '-y', '--only-upgrade', 'install', 'celilo', 'celilo-bootstrap'],
|
|
16
|
+
['/usr/local/bin/celilo', 'system', 'migrate'],
|
|
17
|
+
]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('stops at the first failing step and does not run later ones', async () => {
|
|
21
|
+
const seen: string[][] = [];
|
|
22
|
+
const result = await handleAptUpgrade([], {}, (argv) => {
|
|
23
|
+
seen.push(argv);
|
|
24
|
+
// Fail the apt upgrade step (index 1).
|
|
25
|
+
return { status: argv.includes('install') ? 100 : 0 };
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(result.success).toBe(false);
|
|
29
|
+
if (!result.success) expect(result.error).toContain('apt-get upgrade');
|
|
30
|
+
// update + install ran; migrate did NOT.
|
|
31
|
+
expect(seen).toHaveLength(2);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo apt-upgrade` — upgrade the deb-installed celilo packages and apply
|
|
3
|
+
* pending DB migrations. The management server (celilo-bootstrap) installs
|
|
4
|
+
* celilo via apt, so keeping it current means the apt chain, not `bun update -g`
|
|
5
|
+
* (that path is `system update`'s self-update, for npm-global installs).
|
|
6
|
+
*
|
|
7
|
+
* Steps (ISS-0100 — the postinst does NOT auto-apply migrations):
|
|
8
|
+
* 1. apt-get update
|
|
9
|
+
* 2. apt-get -y --only-upgrade install celilo celilo-bootstrap
|
|
10
|
+
* 3. a FRESH `celilo system migrate` — spawned as the just-installed binary so
|
|
11
|
+
* the new version's migrations run, not the ones loaded in this process.
|
|
12
|
+
*
|
|
13
|
+
* This is the RW target behind the MCP's `celilo_apt_upgrade` tool. It runs as
|
|
14
|
+
* the celilo user (via api-serve); the apt steps sudo to root, gated by the
|
|
15
|
+
* scoped /etc/sudoers.d/celilo-apt-upgrade grant that celilo-bootstrap ships.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawnSync } from 'node:child_process';
|
|
19
|
+
import type { CommandResult } from '../types';
|
|
20
|
+
|
|
21
|
+
/** Wrapper the deb installs; the fresh migrate step runs the upgraded binary. */
|
|
22
|
+
const CELILO_BIN = '/usr/local/bin/celilo';
|
|
23
|
+
|
|
24
|
+
/** One command to run in the chain — argv plus a human label for output. */
|
|
25
|
+
interface Step {
|
|
26
|
+
label: string;
|
|
27
|
+
argv: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const STEPS: Step[] = [
|
|
31
|
+
{ label: 'apt-get update', argv: ['sudo', 'apt-get', 'update'] },
|
|
32
|
+
{
|
|
33
|
+
label: 'apt-get upgrade celilo, celilo-bootstrap',
|
|
34
|
+
argv: ['sudo', 'apt-get', '-y', '--only-upgrade', 'install', 'celilo', 'celilo-bootstrap'],
|
|
35
|
+
},
|
|
36
|
+
{ label: 'apply DB migrations', argv: [CELILO_BIN, 'system', 'migrate'] },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** Run one argv, inheriting stdio so its output streams through api-serve. */
|
|
40
|
+
export type StepRunner = (argv: string[]) => { status: number | null };
|
|
41
|
+
|
|
42
|
+
const defaultRunner: StepRunner = (argv) => spawnSync(argv[0], argv.slice(1), { stdio: 'inherit' });
|
|
43
|
+
|
|
44
|
+
export async function handleAptUpgrade(
|
|
45
|
+
_args: string[],
|
|
46
|
+
_flags: Record<string, string | boolean>,
|
|
47
|
+
runStep: StepRunner = defaultRunner,
|
|
48
|
+
): Promise<CommandResult> {
|
|
49
|
+
for (const step of STEPS) {
|
|
50
|
+
process.stdout.write(`\n▸ ${step.label}\n`);
|
|
51
|
+
const { status } = runStep(step.argv);
|
|
52
|
+
if (status !== 0) {
|
|
53
|
+
return {
|
|
54
|
+
success: false,
|
|
55
|
+
error: `apt-upgrade failed at "${step.label}" (exit ${status ?? 'signal'}). Nothing further was run.`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
success: true,
|
|
61
|
+
message: 'celilo apt packages upgraded and migrations applied.',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* celilo backup pull --storage <id> --module celilo-mgmt
|
|
16
16
|
* celilo restore --from <printed-path> --force
|
|
17
17
|
*
|
|
18
|
-
* Part of P5 (
|
|
18
|
+
* Part of P5 (openspec/specs/management-server-backup/spec.md), apps/celilo/designs/P5_MIGRATION_E2E.md.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { tmpdir } from 'node:os';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commands Command
|
|
3
|
+
*
|
|
4
|
+
* Serializes the CLI command registry (`COMMANDS`) as JSON so an external
|
|
5
|
+
* consumer — `@celilo/mcp` above all — can fetch the *live* surface of whatever
|
|
6
|
+
* celilo version this server runs and generate its tool set from it, rather than
|
|
7
|
+
* compiling a copy that drifts (design D3). The registry is already structured
|
|
8
|
+
* data; this just prints it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { COMMANDS } from '@celilo/core';
|
|
12
|
+
import type { CommandResult } from '../types';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Handle `celilo commands [--json]`.
|
|
16
|
+
*
|
|
17
|
+
* The only useful output is the JSON tree, so we emit it whether or not `--json`
|
|
18
|
+
* is passed; the flag exists to make the intent explicit and completable.
|
|
19
|
+
*/
|
|
20
|
+
export async function handleCommands(
|
|
21
|
+
_args: string[],
|
|
22
|
+
_flags: Record<string, boolean | string> = {},
|
|
23
|
+
): Promise<CommandResult> {
|
|
24
|
+
return {
|
|
25
|
+
success: true,
|
|
26
|
+
message: JSON.stringify(COMMANDS),
|
|
27
|
+
rawOutput: true,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Generate shell completion scripts for bash/zsh
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { COMMANDS } from '
|
|
6
|
+
import { COMMANDS } from '@celilo/core';
|
|
7
7
|
import { generateBashCompletion, generateFishCompletion } from '../completion';
|
|
8
8
|
import { generateRichZshCompletion } from '../generate-zsh-completion';
|
|
9
9
|
import { celiloIntro } from '../prompts';
|
|
@@ -79,7 +79,7 @@ function snakeCaseKeys(obj: Record<string, unknown>): Record<string, unknown> {
|
|
|
79
79
|
|
|
80
80
|
/**
|
|
81
81
|
* `celilo events run-hook <module> <sub-name> [<event_id>]` — the generic
|
|
82
|
-
* runner a `hook:` subscription resolves to (
|
|
82
|
+
* runner a `hook:` subscription resolves to (openspec/specs/event-driven-hook-subscriptions/spec.md).
|
|
83
83
|
*
|
|
84
84
|
* The dispatcher spawns this as a fault-isolated subprocess. It re-reads the
|
|
85
85
|
* module's manifest to find the named subscription's `hook` + `hook_inputs`,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `celilo module changeset [<module-dir>] --bump <major|minor|patch> [-m <msg>]`
|
|
3
|
-
* — author a changeset for a module (ISS-0151 /
|
|
3
|
+
* — author a changeset for a module (ISS-0151 / openspec/changes/module-version-semantics/proposal.md).
|
|
4
4
|
*
|
|
5
5
|
* Writes `.changeset/<name>.md` keyed by the MODULE id (Option A), the same
|
|
6
6
|
* on-disk format `celilo module version` consumes. This is the thin authoring
|
|
@@ -57,7 +57,7 @@ Examples:
|
|
|
57
57
|
*/
|
|
58
58
|
/**
|
|
59
59
|
* Run the base-module-aspect approval flow after a module's primary
|
|
60
|
-
* import succeeds. Per
|
|
60
|
+
* import succeeds. Per openspec/specs/base-module-aspects/spec.md D2:
|
|
61
61
|
*
|
|
62
62
|
* - Modules without a `base_module_aspect` block: no-op.
|
|
63
63
|
* - Re-imports of a version that's already been approved with the
|
|
@@ -1,20 +1,34 @@
|
|
|
1
1
|
import { getDb } from '../../db/client';
|
|
2
2
|
import { modules } from '../../db/schema';
|
|
3
|
+
import { hasFlag } from '../parser';
|
|
3
4
|
import type { CommandResult } from '../types';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Handle module list command
|
|
7
8
|
*
|
|
8
|
-
* Usage: celilo module list
|
|
9
|
+
* Usage: celilo module list [--json]
|
|
9
10
|
*
|
|
10
11
|
* @returns Command result
|
|
11
12
|
*/
|
|
12
|
-
export async function handleModuleList(
|
|
13
|
+
export async function handleModuleList(
|
|
14
|
+
flags: Record<string, string | boolean> = {},
|
|
15
|
+
): Promise<CommandResult> {
|
|
13
16
|
const db = getDb();
|
|
14
17
|
|
|
15
18
|
// Query all modules
|
|
16
19
|
const moduleRows = db.select().from(modules).all();
|
|
17
20
|
|
|
21
|
+
// Stable machine-readable roster — the backbone the MCP composite
|
|
22
|
+
// troubleshooting tools correlate audit findings against (ce-77i.5).
|
|
23
|
+
if (hasFlag(flags, 'json')) {
|
|
24
|
+
return {
|
|
25
|
+
success: true,
|
|
26
|
+
message: JSON.stringify({ modules: moduleRows }, null, 2),
|
|
27
|
+
rawOutput: true,
|
|
28
|
+
data: moduleRows,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
18
32
|
if (moduleRows.length === 0) {
|
|
19
33
|
return {
|
|
20
34
|
success: true,
|
|
@@ -166,7 +166,7 @@ export async function publishOneModule(
|
|
|
166
166
|
// Stale-check: src commits past the last manifest.yml commit. Only fires
|
|
167
167
|
// when the dir is in git history; brand-new uncommitted modules pass.
|
|
168
168
|
//
|
|
169
|
-
// Skipped for changeset/pin modules (ISS-0151 /
|
|
169
|
+
// Skipped for changeset/pin modules (ISS-0151 / openspec/changes/module-version-semantics/proposal.md): a
|
|
170
170
|
// changeset module's version is authored via .changeset/ + `celilo module
|
|
171
171
|
// version` and ordered by +N, so source-after-manifest is normal, not drift;
|
|
172
172
|
// a pin module's version is checked against its upstream resolver. The gate
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from '../../services/celilo-events';
|
|
25
25
|
import { getContainerService, getServiceCredentials } from '../../services/container-service';
|
|
26
26
|
import { completeOperation, failOperation, startOperation } from '../../services/module-operations';
|
|
27
|
+
import { cleanupWebRoutesForModule } from '../../services/web-route-cleanup';
|
|
27
28
|
import { getArg, hasFlag, validateRequiredArgs } from '../parser';
|
|
28
29
|
import { log } from '../prompts';
|
|
29
30
|
import type { CommandResult } from '../types';
|
|
@@ -204,6 +205,30 @@ async function performModuleRemove(
|
|
|
204
205
|
}
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
// Reclaim public_web routes BEFORE terraform destroy, so the provider host
|
|
209
|
+
// is still reachable for the /srv/www cleanup. Runs after on_uninstall so a
|
|
210
|
+
// module that tears its own routes down still wins (this then no-ops).
|
|
211
|
+
//
|
|
212
|
+
// Without this, removal deleted the rows via the FK cascade on `modules.id`
|
|
213
|
+
// and emitted nothing, so the provider kept serving a route for a module that
|
|
214
|
+
// no longer existed. See services/web-route-cleanup.ts.
|
|
215
|
+
try {
|
|
216
|
+
const routeCleanup = await cleanupWebRoutesForModule({ moduleId, db });
|
|
217
|
+
if (routeCleanup.routesRemoved > 0) {
|
|
218
|
+
const reclaimed =
|
|
219
|
+
routeCleanup.assetDirsRemoved.length > 0
|
|
220
|
+
? ` and reclaimed ${routeCleanup.assetDirsRemoved.join(', ')}`
|
|
221
|
+
: '';
|
|
222
|
+
log.info(`Removed ${routeCleanup.routesRemoved} web route(s) for ${moduleId}${reclaimed}`);
|
|
223
|
+
}
|
|
224
|
+
for (const warning of routeCleanup.warnings) {
|
|
225
|
+
log.warn(warning);
|
|
226
|
+
}
|
|
227
|
+
} catch (error) {
|
|
228
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
229
|
+
log.warn(`Web route cleanup failed (continuing): ${msg}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
207
232
|
// Check if module has infrastructure that needs to be destroyed
|
|
208
233
|
const infra = db
|
|
209
234
|
.select()
|
|
@@ -301,7 +326,7 @@ async function performModuleRemove(
|
|
|
301
326
|
}
|
|
302
327
|
})();
|
|
303
328
|
|
|
304
|
-
// Announce teardown on the bus (D5,
|
|
329
|
+
// Announce teardown on the bus (D5, openspec/specs/event-driven-hook-subscriptions/spec.md). A
|
|
305
330
|
// dns_internal provider's system.destroyed.* subscription runs its
|
|
306
331
|
// on_system_event hook (op: deregister) to remove each host's records.
|
|
307
332
|
if (removedSystems.length > 0) {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* manifest.yml. The generated file exposes a `<ModuleName>Config`
|
|
7
7
|
* interface that hook scripts import via `defineHook<ConfigT, ...>`.
|
|
8
8
|
*
|
|
9
|
-
* See `
|
|
9
|
+
* See `openspec/changes/hook-api-v2/proposal.md` D2 for the design rationale.
|
|
10
10
|
*
|
|
11
11
|
* Subcommands:
|
|
12
12
|
* celilo module types generate <module-dir> — write types.d.ts
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `celilo module upgrade [name]` — the build-bus CD verb (ISS-0138) + the
|
|
3
|
-
* registry-poll it drives (ISS-0139).
|
|
3
|
+
* registry-poll it drives (ISS-0139). openspec/changes/build-bus-poll-cd/proposal.md.
|
|
4
4
|
*
|
|
5
5
|
* module upgrade <name> one module: registry-latest → update → [backup,
|
|
6
6
|
* posture-gated] → deploy → verify.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `celilo module version [<module-dir>]` — stamp a changeset-kind module's
|
|
3
|
-
* payload version into manifest.yml (ISS-0151 /
|
|
3
|
+
* payload version into manifest.yml (ISS-0151 / openspec/changes/module-version-semantics/proposal.md).
|
|
4
4
|
*
|
|
5
5
|
* Reads `.changeset/*.md` (keyed by the MODULE id — Option A), computes the next
|
|
6
6
|
* `manifest.yml#version`, prepends a CHANGELOG entry, and deletes the consumed
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { NetworkZone } from '../../db/schema';
|
|
3
|
+
import { reachabilityHint } from './module-where';
|
|
4
|
+
|
|
5
|
+
describe('reachabilityHint (ce-jje — role-based, never a literal address)', () => {
|
|
6
|
+
test('internal is reachable directly from the LAN', () => {
|
|
7
|
+
expect(reachabilityHint('internal')).toContain('directly from the internal/home LAN');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test('external is a public zone reachable at its own address', () => {
|
|
11
|
+
expect(reachabilityHint('external')).toContain('public');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('firewall-segmented zones route via the natIp DNAT', () => {
|
|
15
|
+
for (const zone of ['dmz', 'app', 'secure'] as NetworkZone[]) {
|
|
16
|
+
expect(reachabilityHint(zone)).toContain('natIp DNAT');
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('no hint hardcodes an IP address (CLAUDE.md network-model rule)', () => {
|
|
21
|
+
const zones: NetworkZone[] = ['internal', 'dmz', 'app', 'secure', 'external'];
|
|
22
|
+
for (const zone of zones) {
|
|
23
|
+
expect(reachabilityHint(zone)).not.toMatch(/\d+\.\d+\.\d+\.\d+/);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
});
|