@celilo/cli 0.11.0-alpha.0 → 0.12.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/AGENTS.md +7 -4
- package/CELILO_CORE_MODULES.md +7 -6
- package/CELILO_SUBSYSTEMS.md +47 -5
- 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 +8 -8
- package/schemas/system_config.json +1 -1
- package/src/ansible/inventory.test.ts +1 -1
- package/src/ansible/inventory.ts +3 -3
- 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/commands/api.ts +1 -1
- package/src/cli/commands/backup-pull.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-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/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 +19 -0
- package/src/cli/index.ts +155 -4
- 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
|
@@ -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
|
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';
|
|
@@ -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';
|
|
@@ -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
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module where command — host discovery: which system(s) serve a module.
|
|
3
|
+
*
|
|
4
|
+
* Answers "which host is providing services for <module>?" without an ssh
|
|
5
|
+
* scavenger hunt (ce-jje). Data source: the deployed-systems layer
|
|
6
|
+
* (module_systems) for the addressable hosts, reconciled live against Proxmox
|
|
7
|
+
* for the real node. Reachability is a role-based hint derived from the zone —
|
|
8
|
+
* never a hardcoded address (see CLAUDE.md network-model rules).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { eq } from 'drizzle-orm';
|
|
12
|
+
import { getDb } from '../../db/client';
|
|
13
|
+
import type { NetworkZone } from '../../db/schema';
|
|
14
|
+
import { modules } from '../../db/schema';
|
|
15
|
+
import { getModuleSystems } from '../../services/deployed-systems';
|
|
16
|
+
import { formatPlacementLine, reconcilePlacement } from '../../services/placement-reconcile';
|
|
17
|
+
import { getArg, hasFlag, validateRequiredArgs } from '../parser';
|
|
18
|
+
import type { CommandResult } from '../types';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Role-based reachability hint for a zone. Says HOW to reach a host by the
|
|
22
|
+
* zone's firewall posture, not a literal address (subnets are operator config).
|
|
23
|
+
*/
|
|
24
|
+
export function reachabilityHint(zone: NetworkZone | string): string {
|
|
25
|
+
switch (zone) {
|
|
26
|
+
case 'internal':
|
|
27
|
+
return 'reachable directly from the internal/home LAN';
|
|
28
|
+
case 'external':
|
|
29
|
+
return 'public zone — reachable at its own address (cloud/VPS)';
|
|
30
|
+
case 'dmz':
|
|
31
|
+
case 'app':
|
|
32
|
+
case 'secure':
|
|
33
|
+
return `firewall-segmented (${zone}) — reach via the firewall's natIp DNAT, not routable directly from the LAN`;
|
|
34
|
+
default:
|
|
35
|
+
return `zone ${zone}`;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Handle module where command.
|
|
41
|
+
*
|
|
42
|
+
* Usage: celilo module where <id> [--json]
|
|
43
|
+
*
|
|
44
|
+
* @param args - Command arguments (module id)
|
|
45
|
+
* @param flags - Command flags (--json)
|
|
46
|
+
* @returns Command result
|
|
47
|
+
*/
|
|
48
|
+
export async function handleModuleWhere(
|
|
49
|
+
args: string[],
|
|
50
|
+
flags: Record<string, string | boolean> = {},
|
|
51
|
+
): Promise<CommandResult> {
|
|
52
|
+
const error = validateRequiredArgs(args, 1);
|
|
53
|
+
if (error) {
|
|
54
|
+
return {
|
|
55
|
+
success: false,
|
|
56
|
+
error: `${error}\n\nUsage: celilo module where <id> [--json]`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const moduleId = getArg(args, 0);
|
|
61
|
+
if (!moduleId) {
|
|
62
|
+
return { success: false, error: 'Module ID is required' };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const db = getDb();
|
|
66
|
+
const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
67
|
+
|
|
68
|
+
if (!module) {
|
|
69
|
+
// CI/build infra (the celilo-builder VM, Forgejo runners) is NOT a module
|
|
70
|
+
// and is not in module_systems — point the operator at where that topology
|
|
71
|
+
// actually lives rather than a bare "not found".
|
|
72
|
+
return {
|
|
73
|
+
success: false,
|
|
74
|
+
error: `Module not found: ${moduleId}\n\nHost discovery covers deployed modules only. CI/build infrastructure (the celilo-builder VM, Forgejo CI runners) is not tracked here — see openspec/specs/forgejo-runner/spec.md and openspec/changes/build-bus-poll-cd/proposal.md for that topology.`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const systems = getModuleSystems(moduleId, db);
|
|
79
|
+
|
|
80
|
+
if (systems.length === 0) {
|
|
81
|
+
const message = `Module '${moduleId}' has no deployed systems (API-only module, or not yet deployed).`;
|
|
82
|
+
if (hasFlag(flags, 'json')) {
|
|
83
|
+
return {
|
|
84
|
+
success: true,
|
|
85
|
+
message: JSON.stringify({ module: moduleId, systems: [] }, null, 2),
|
|
86
|
+
rawOutput: true,
|
|
87
|
+
data: { module: moduleId, systems: [] },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { success: true, message };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Reconcile the real node live from Proxmox (never throws; degrades to
|
|
94
|
+
// "node unknown" on outage) — same live-placement source as module status.
|
|
95
|
+
const placements = await reconcilePlacement(systems);
|
|
96
|
+
|
|
97
|
+
const data = placements.map(({ system, resolution }) => ({
|
|
98
|
+
name: system.name,
|
|
99
|
+
hostname: system.hostname,
|
|
100
|
+
ipv4_address: system.ipv4_address,
|
|
101
|
+
zone: system.zone,
|
|
102
|
+
vmid: system.infrastructure.vmid ?? null,
|
|
103
|
+
infra_type: system.infrastructure.type,
|
|
104
|
+
placement: formatPlacementLine(system, resolution),
|
|
105
|
+
reachability: reachabilityHint(system.zone),
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
if (hasFlag(flags, 'json')) {
|
|
109
|
+
return {
|
|
110
|
+
success: true,
|
|
111
|
+
message: JSON.stringify({ module: moduleId, systems: data }, null, 2),
|
|
112
|
+
rawOutput: true,
|
|
113
|
+
data: { module: moduleId, systems: data },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const lines = [`Module '${moduleId}' is served by:`, ''];
|
|
118
|
+
for (const sys of data) {
|
|
119
|
+
lines.push(` ${sys.hostname} (${sys.ipv4_address})`);
|
|
120
|
+
lines.push(` placement: ${sys.placement}`);
|
|
121
|
+
lines.push(` reachability: ${sys.reachability}`);
|
|
122
|
+
lines.push('');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
success: true,
|
|
127
|
+
message: lines.join('\n').trimEnd(),
|
|
128
|
+
data: { module: moduleId, systems: data },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `celilo proxmox node list` — live per-node capacity from the Proxmox cluster
|
|
3
|
-
* (ISS-0060, Phase 1 of
|
|
3
|
+
* (ISS-0060, Phase 1 of openspec/changes/proxmox-capacity-lifecycle/proposal.md).
|
|
4
4
|
*
|
|
5
5
|
* Reads reality from the Proxmox API via `ProxmoxClient`, never a cached DB
|
|
6
6
|
* value — the foundation for capacity-aware placement (ISS-0061) and
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Alpha-helper tests —
|
|
2
|
+
* Alpha-helper tests — openspec/changes/publilo-cli/proposal.md Phase 3 payoff.
|
|
3
3
|
*
|
|
4
4
|
* The pure functions in alpha.ts (parsePackageSpec, stripAlphaSuffix,
|
|
5
5
|
* isAlphaVersion, pickNextAlphaN, decideAlphaSkip) need no I/O to
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Alpha-tagging logic — `--alpha` publish + `--promote` graduation.
|
|
3
3
|
*
|
|
4
|
-
* The semantics live in `
|
|
4
|
+
* The semantics live in `openspec/specs/alpha-publish-tagging/spec.md`; this
|
|
5
5
|
* module is the implementation surface those flags drive. Per
|
|
6
|
-
*
|
|
6
|
+
* openspec/changes/publilo-cli/proposal.md Phase 3, the alpha-specific helpers live here
|
|
7
7
|
* (not in helpers.ts) so it's clear where to look when extending the
|
|
8
|
-
* model — e.g. to add `--beta` once `
|
|
8
|
+
* model — e.g. to add `--beta` once `openspec/changes/build-bus-poll-cd/proposal.md`'s validation
|
|
9
9
|
* signal lands.
|
|
10
10
|
*
|
|
11
11
|
* Two-layer design for testability:
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @changesets/cli integration —
|
|
2
|
+
* @changesets/cli integration — openspec/changes/publilo-cli/proposal.md Phase 4.
|
|
3
3
|
*
|
|
4
4
|
* Changesets is the "what versions does this publish produce" piece
|
|
5
5
|
* of the workflow. During dev, the operator runs `bun changeset` to
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { spawnSync } from 'node:child_process';
|
|
18
18
|
import { existsSync, readFileSync } from 'node:fs';
|
|
19
19
|
import { join } from 'node:path';
|
|
20
|
-
import {
|
|
20
|
+
import { getPublishPackages, readGlobalInstalledVersion, readPkg } from './helpers';
|
|
21
21
|
import type { GlobalUpdateItem, PackageJson } from './types';
|
|
22
22
|
|
|
23
23
|
// ─── Planner ───────────────────────────────────────────────────────
|
|
@@ -97,7 +97,11 @@ export function planGlobalUpdate(input: PlanGlobalUpdateInput): GlobalUpdateItem
|
|
|
97
97
|
return [];
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
const ourNames = new Set(
|
|
100
|
+
const ourNames = new Set(
|
|
101
|
+
getPublishPackages()
|
|
102
|
+
.map((p) => readPkg(p).name)
|
|
103
|
+
.filter((n): n is string => !!n),
|
|
104
|
+
);
|
|
101
105
|
const installed = Object.keys(globalPkg.dependencies ?? {}).filter((n) => ourNames.has(n));
|
|
102
106
|
|
|
103
107
|
return decideGlobalUpdates({
|