@celilo/cli 0.10.0 → 0.11.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/CELILO_SUBSYSTEMS.md +15 -0
- package/drizzle/0014_api_principals.sql +10 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +7 -5
- package/src/api/protocol.test.ts +76 -0
- package/src/api/remote-client.test.ts +91 -0
- package/src/api/serve.ts +159 -0
- package/src/cli/command-tree-parser.ts +3 -1
- package/src/cli/commands/api.ts +194 -0
- package/src/cli/commands/apt-upgrade.test.ts +33 -0
- package/src/cli/commands/apt-upgrade.ts +63 -0
- package/src/cli/commands/commands-json.ts +29 -0
- package/src/cli/commands/completion.ts +1 -1
- package/src/cli/commands/module-list.ts +16 -2
- package/src/cli/commands/service-list.ts +15 -2
- package/src/cli/completion.ts +24 -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 +109 -2
- package/src/cli/parser.test.ts +13 -0
- package/src/cli/parser.ts +12 -3
- package/src/db/schema.ts +30 -0
- package/src/hooks/capability-loader.test.ts +77 -0
- package/src/hooks/capability-loader.ts +56 -0
- package/src/services/api-access.test.ts +138 -0
- package/src/services/api-access.ts +154 -0
- package/src/services/remote-responder.test.ts +78 -0
- package/src/services/remote-responder.ts +89 -0
- package/src/cli/command-registry.ts +0 -1443
|
@@ -33,6 +33,9 @@ import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
|
|
|
33
33
|
import { getModuleSystems } from '../services/deployed-systems';
|
|
34
34
|
import { withDnsInternalLedger } from '../services/dns-internal-records';
|
|
35
35
|
import { withDnsRegistrationLedger } from '../services/dns-registrations';
|
|
36
|
+
import { resolveComputedFields } from '../variables/computed/evaluate';
|
|
37
|
+
import { containsComputedMarker } from '../variables/computed/marker';
|
|
38
|
+
import { buildProviderLookup } from '../variables/computed/provider-lookup';
|
|
36
39
|
import { loadHookConfigMap } from './load-hook-config';
|
|
37
40
|
|
|
38
41
|
/**
|
|
@@ -351,6 +354,27 @@ export async function loadCapabilityFunctions(
|
|
|
351
354
|
// First registrar wins — multi-registrar setups can't be auto-extended
|
|
352
355
|
// since we'd have to pick which one to add the new domain to.
|
|
353
356
|
if (!dnsRegistrarModuleId) dnsRegistrarModuleId = drp.moduleId;
|
|
357
|
+
|
|
358
|
+
// Canonical source: the registrar's DECLARED `domain_list` computed
|
|
359
|
+
// field, resolved LIVE in the provider's context — the exact value
|
|
360
|
+
// `$capability:dns_registrar.domain_list` resolves to and the same set
|
|
361
|
+
// the registrar's own DDNS validation iterates (for namecheap,
|
|
362
|
+
// keys(secret.ddns_passwords)). The manifest calls this "never
|
|
363
|
+
// persisted, never stale"; deriving domains any other way drifts. In
|
|
364
|
+
// particular a leftover `config.domains` row (from an older registrar
|
|
365
|
+
// manifest — module config survives version bumps) would otherwise be
|
|
366
|
+
// preferred by the heuristic below over the live secret keys and
|
|
367
|
+
// silently exclude a just-added domain, dead-ending new-domain
|
|
368
|
+
// onboarding forever (ce-iku).
|
|
369
|
+
const declaredDomains = await resolveRegistrarDomainList(drp.data, drp.moduleId, db);
|
|
370
|
+
if (declaredDomains) {
|
|
371
|
+
dnsManagedDomains.push(...declaredDomains);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Fallback for registrars that DON'T declare a `domain_list` computed
|
|
376
|
+
// field (older/archived static-config providers): read the config
|
|
377
|
+
// shape directly.
|
|
354
378
|
const drConfig = await loadModuleConfig(drp.moduleId, db);
|
|
355
379
|
// dns_registrar 4.0.0+ exposes a single `domains` array (the
|
|
356
380
|
// primary_domain/additional_domains split was collapsed — see
|
|
@@ -555,6 +579,38 @@ async function loadModuleSecrets(
|
|
|
555
579
|
return result;
|
|
556
580
|
}
|
|
557
581
|
|
|
582
|
+
/**
|
|
583
|
+
* Resolve a dns_registrar provider's DECLARED `domain_list` computed field
|
|
584
|
+
* (e.g. namecheap's `keys(secret.ddns_passwords)`) in the PROVIDER's context —
|
|
585
|
+
* the canonical, always-live set of zones it manages. Returns the string list,
|
|
586
|
+
* or null when the provider declares no such computed field so the caller
|
|
587
|
+
* falls back to reading the config shape directly.
|
|
588
|
+
*
|
|
589
|
+
* This is the same value `$capability:dns_registrar.domain_list` resolves to
|
|
590
|
+
* and the same set the registrar's own DDNS validation iterates, so
|
|
591
|
+
* public_web's hostname check can never drift from what the registrar actually
|
|
592
|
+
* manages (ce-iku).
|
|
593
|
+
*/
|
|
594
|
+
async function resolveRegistrarDomainList(
|
|
595
|
+
rawData: unknown,
|
|
596
|
+
moduleId: string,
|
|
597
|
+
db: DbClient,
|
|
598
|
+
): Promise<string[] | null> {
|
|
599
|
+
try {
|
|
600
|
+
const data = typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
|
|
601
|
+
if (!containsComputedMarker(data)) return null;
|
|
602
|
+
const lookup = await buildProviderLookup(moduleId, db);
|
|
603
|
+
const resolved = resolveComputedFields(data, lookup) as Record<string, unknown>;
|
|
604
|
+
const list = resolved.domain_list;
|
|
605
|
+
if (!Array.isArray(list)) return null;
|
|
606
|
+
return list.filter((d): d is string => typeof d === 'string' && d.length > 0);
|
|
607
|
+
} catch {
|
|
608
|
+
// Provider context couldn't resolve (e.g. undecryptable secret) — let the
|
|
609
|
+
// caller fall back to the config-shape heuristic.
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
558
614
|
/**
|
|
559
615
|
* Build a firewall capability chain from multiple providers.
|
|
560
616
|
*
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { closeDb } from '@/db/client';
|
|
6
|
+
import {
|
|
7
|
+
getPrincipalByName,
|
|
8
|
+
grantPrincipal,
|
|
9
|
+
grantsAllow,
|
|
10
|
+
isAuthorized,
|
|
11
|
+
listPrincipals,
|
|
12
|
+
renderAuthorizedKeys,
|
|
13
|
+
revokePrincipal,
|
|
14
|
+
validateGrant,
|
|
15
|
+
validatePrincipalName,
|
|
16
|
+
validatePublicKey,
|
|
17
|
+
} from './api-access';
|
|
18
|
+
|
|
19
|
+
const VALID_KEY = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI test@laptop';
|
|
20
|
+
|
|
21
|
+
describe('grantsAllow (pure, deny-by-default)', () => {
|
|
22
|
+
test('exact command:subcommand', () => {
|
|
23
|
+
expect(grantsAllow(['module:deploy'], 'module', 'deploy')).toBe(true);
|
|
24
|
+
expect(grantsAllow(['module:deploy'], 'module', 'remove')).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('command:* wildcard', () => {
|
|
28
|
+
expect(grantsAllow(['service:*'], 'service', 'add')).toBe(true);
|
|
29
|
+
expect(grantsAllow(['service:*'], 'service', 'remove')).toBe(true);
|
|
30
|
+
expect(grantsAllow(['service:*'], 'module', 'deploy')).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('global *', () => {
|
|
34
|
+
expect(grantsAllow(['*'], 'anything', 'goes')).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('bare command for a subcommand-less command', () => {
|
|
38
|
+
expect(grantsAllow(['status'], 'status')).toBe(true);
|
|
39
|
+
expect(grantsAllow(['status'], 'module', 'deploy')).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('empty grants deny everything', () => {
|
|
43
|
+
expect(grantsAllow([], 'module', 'deploy')).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('validators', () => {
|
|
48
|
+
test('principal name must be kebab-case', () => {
|
|
49
|
+
expect(() => validatePrincipalName('alice')).not.toThrow();
|
|
50
|
+
expect(() => validatePrincipalName('ci-deployer')).not.toThrow();
|
|
51
|
+
expect(() => validatePrincipalName('Alice')).toThrow();
|
|
52
|
+
expect(() => validatePrincipalName('a b')).toThrow();
|
|
53
|
+
expect(() => validatePrincipalName('under_score')).toThrow();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('public key must look like an SSH key', () => {
|
|
57
|
+
expect(() => validatePublicKey(VALID_KEY)).not.toThrow();
|
|
58
|
+
expect(() => validatePublicKey('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5')).not.toThrow();
|
|
59
|
+
expect(() => validatePublicKey('not a key')).toThrow();
|
|
60
|
+
expect(() => validatePublicKey('ssh-ed25519')).toThrow();
|
|
61
|
+
expect(() => validatePublicKey('rsa-bogus AAAA')).toThrow();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('grant syntax', () => {
|
|
65
|
+
expect(() => validateGrant('module:deploy')).not.toThrow();
|
|
66
|
+
expect(() => validateGrant('service:*')).not.toThrow();
|
|
67
|
+
expect(() => validateGrant('*')).not.toThrow();
|
|
68
|
+
expect(() => validateGrant('status')).not.toThrow();
|
|
69
|
+
expect(() => validateGrant('Module:Deploy')).toThrow();
|
|
70
|
+
expect(() => validateGrant('a:b:c')).toThrow();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('api-access (DB-backed)', () => {
|
|
75
|
+
let dir: string;
|
|
76
|
+
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-api-access-'));
|
|
79
|
+
process.env.CELILO_DB_PATH = join(dir, 'test.db');
|
|
80
|
+
closeDb(); // drop any cached handle so getDb() reopens at the new path
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
closeDb();
|
|
85
|
+
rmSync(dir, { recursive: true, force: true });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('grant → list → getByName → isAuthorized → render → revoke', async () => {
|
|
89
|
+
const { created } = await grantPrincipal({
|
|
90
|
+
name: 'alice',
|
|
91
|
+
publicKey: VALID_KEY,
|
|
92
|
+
grants: ['module:deploy', 'service:*'],
|
|
93
|
+
});
|
|
94
|
+
expect(created).toBe(true);
|
|
95
|
+
|
|
96
|
+
expect((await listPrincipals()).map((p) => p.name)).toEqual(['alice']);
|
|
97
|
+
|
|
98
|
+
const alice = await getPrincipalByName('alice');
|
|
99
|
+
expect(alice?.grants).toEqual(['module:deploy', 'service:*']);
|
|
100
|
+
|
|
101
|
+
expect(await isAuthorized('alice', 'module', 'deploy')).toBe(true);
|
|
102
|
+
expect(await isAuthorized('alice', 'service', 'add')).toBe(true);
|
|
103
|
+
expect(await isAuthorized('alice', 'secret', 'read')).toBe(false);
|
|
104
|
+
expect(await isAuthorized('nobody', 'module', 'deploy')).toBe(false);
|
|
105
|
+
|
|
106
|
+
const authKeys = await renderAuthorizedKeys();
|
|
107
|
+
expect(authKeys).toContain('command="celilo api-serve --principal=alice"');
|
|
108
|
+
expect(authKeys).toContain('no-pty,no-port-forwarding');
|
|
109
|
+
expect(authKeys).toContain(VALID_KEY);
|
|
110
|
+
|
|
111
|
+
expect(await revokePrincipal('alice')).toBe(true);
|
|
112
|
+
expect(await listPrincipals()).toHaveLength(0);
|
|
113
|
+
expect(await revokePrincipal('alice')).toBe(false);
|
|
114
|
+
expect(await renderAuthorizedKeys()).toBe('');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('grant is an upsert (replaces key + grants)', async () => {
|
|
118
|
+
await grantPrincipal({ name: 'bob', publicKey: VALID_KEY, grants: ['status'] });
|
|
119
|
+
const second = await grantPrincipal({
|
|
120
|
+
name: 'bob',
|
|
121
|
+
publicKey: VALID_KEY,
|
|
122
|
+
grants: ['module:deploy'],
|
|
123
|
+
});
|
|
124
|
+
expect(second.created).toBe(false);
|
|
125
|
+
expect((await getPrincipalByName('bob'))?.grants).toEqual(['module:deploy']);
|
|
126
|
+
expect(await listPrincipals()).toHaveLength(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('invalid inputs are rejected before write', async () => {
|
|
130
|
+
await expect(
|
|
131
|
+
grantPrincipal({ name: 'Bad Name', publicKey: VALID_KEY, grants: ['status'] }),
|
|
132
|
+
).rejects.toThrow();
|
|
133
|
+
await expect(
|
|
134
|
+
grantPrincipal({ name: 'good', publicKey: 'garbage', grants: ['status'] }),
|
|
135
|
+
).rejects.toThrow();
|
|
136
|
+
expect(await listPrincipals()).toHaveLength(0);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote API access control (see v2/API_COMMUNICATION.md, Slice 2a).
|
|
3
|
+
*
|
|
4
|
+
* Stores API principals (name + SSH public key + grants), answers authz
|
|
5
|
+
* queries (deny-by-default), and renders the API account's `authorized_keys`
|
|
6
|
+
* with one forced-command line per principal. No SSH plumbing lives here —
|
|
7
|
+
* `renderAuthorizedKeys()` returns the file content; installing it is a
|
|
8
|
+
* separate operational step (Slice 2b).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
import { eq } from 'drizzle-orm';
|
|
13
|
+
import { getDb } from '../db/client';
|
|
14
|
+
import { type ApiPrincipal, apiPrincipals } from '../db/schema';
|
|
15
|
+
|
|
16
|
+
const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
17
|
+
const GRANT_PATTERN = /^(\*|[a-z0-9]+(-[a-z0-9]+)*(:(\*|[a-z0-9]+(-[a-z0-9]+)*))?)$/;
|
|
18
|
+
const PUBKEY_TYPES = [
|
|
19
|
+
'ssh-ed25519',
|
|
20
|
+
'ssh-rsa',
|
|
21
|
+
'ssh-dss',
|
|
22
|
+
'ecdsa-sha2-nistp256',
|
|
23
|
+
'ecdsa-sha2-nistp384',
|
|
24
|
+
'ecdsa-sha2-nistp521',
|
|
25
|
+
'sk-ssh-ed25519@openssh.com',
|
|
26
|
+
'sk-ecdsa-sha2-nistp256@openssh.com',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const FORCED_COMMAND_OPTS = 'no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding';
|
|
30
|
+
|
|
31
|
+
export function validatePrincipalName(name: string): void {
|
|
32
|
+
if (!NAME_PATTERN.test(name)) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Invalid principal name "${name}": use kebab-case (e.g. "alice", "ci-deployer").`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Basic SSH public-key shape check: `<type> <base64> [comment]`. */
|
|
40
|
+
export function validatePublicKey(key: string): void {
|
|
41
|
+
const parts = key.trim().split(/\s+/);
|
|
42
|
+
if (
|
|
43
|
+
parts.length < 2 ||
|
|
44
|
+
!PUBKEY_TYPES.includes(parts[0]) ||
|
|
45
|
+
!/^[A-Za-z0-9+/]+={0,3}$/.test(parts[1])
|
|
46
|
+
) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
'Invalid SSH public key. Expected "<type> <base64> [comment]" (e.g. contents of ~/.ssh/id_ed25519.pub).',
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function validateGrant(grant: string): void {
|
|
54
|
+
if (!GRANT_PATTERN.test(grant)) {
|
|
55
|
+
throw new Error(`Invalid grant "${grant}": use "command:subcommand", "command:*", or "*".`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Does this grant set permit `command`/`subcommand`? Pure — deny-by-default.
|
|
61
|
+
* Matches an exact `command:subcommand`, a `command:*` wildcard, `*`, or a
|
|
62
|
+
* bare `command` grant for a subcommand-less command.
|
|
63
|
+
*/
|
|
64
|
+
export function grantsAllow(grants: string[], command: string, subcommand?: string): boolean {
|
|
65
|
+
const op = subcommand ? `${command}:${subcommand}` : command;
|
|
66
|
+
for (const grant of grants) {
|
|
67
|
+
if (grant === '*') return true;
|
|
68
|
+
if (grant === op) return true;
|
|
69
|
+
if (grant === `${command}:*`) return true;
|
|
70
|
+
if (!subcommand && grant === command) return true;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function listPrincipals(): Promise<ApiPrincipal[]> {
|
|
76
|
+
return getDb().select().from(apiPrincipals).all();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function getPrincipalByName(name: string): Promise<ApiPrincipal | null> {
|
|
80
|
+
const rows = getDb().select().from(apiPrincipals).where(eq(apiPrincipals.name, name)).all();
|
|
81
|
+
return rows[0] ?? null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create or replace a principal's key + grants (upsert by name). Validates all
|
|
86
|
+
* inputs; throws on the first invalid one.
|
|
87
|
+
*/
|
|
88
|
+
export async function grantPrincipal(params: {
|
|
89
|
+
name: string;
|
|
90
|
+
publicKey: string;
|
|
91
|
+
grants: string[];
|
|
92
|
+
}): Promise<{ principal: ApiPrincipal; created: boolean }> {
|
|
93
|
+
const { name, publicKey, grants } = params;
|
|
94
|
+
validatePrincipalName(name);
|
|
95
|
+
validatePublicKey(publicKey);
|
|
96
|
+
for (const grant of grants) validateGrant(grant);
|
|
97
|
+
|
|
98
|
+
const db = getDb();
|
|
99
|
+
const existing = await getPrincipalByName(name);
|
|
100
|
+
|
|
101
|
+
if (existing) {
|
|
102
|
+
db.update(apiPrincipals).set({ publicKey, grants }).where(eq(apiPrincipals.name, name)).run();
|
|
103
|
+
const updated = await getPrincipalByName(name);
|
|
104
|
+
if (!updated) throw new Error(`Principal "${name}" vanished after update`);
|
|
105
|
+
return { principal: updated, created: false };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const row: ApiPrincipal = {
|
|
109
|
+
id: randomUUID(),
|
|
110
|
+
name,
|
|
111
|
+
publicKey,
|
|
112
|
+
grants,
|
|
113
|
+
createdAt: new Date(),
|
|
114
|
+
updatedAt: new Date(),
|
|
115
|
+
};
|
|
116
|
+
db.insert(apiPrincipals).values(row).run();
|
|
117
|
+
return { principal: row, created: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Remove a principal. Returns false if it didn't exist. */
|
|
121
|
+
export async function revokePrincipal(name: string): Promise<boolean> {
|
|
122
|
+
const existing = await getPrincipalByName(name);
|
|
123
|
+
if (!existing) return false;
|
|
124
|
+
getDb().delete(apiPrincipals).where(eq(apiPrincipals.name, name)).run();
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Authz entry point: is `principalName` allowed to run `command`/`subcommand`? */
|
|
129
|
+
export async function isAuthorized(
|
|
130
|
+
principalName: string,
|
|
131
|
+
command: string,
|
|
132
|
+
subcommand?: string,
|
|
133
|
+
): Promise<boolean> {
|
|
134
|
+
const principal = await getPrincipalByName(principalName);
|
|
135
|
+
if (!principal) return false;
|
|
136
|
+
return grantsAllow(principal.grants, command, subcommand);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Render the API account's `authorized_keys` — one forced-command line per
|
|
141
|
+
* principal. The principal name is baked into the command so sshd hands the
|
|
142
|
+
* identity to `api-serve` (see the design doc). Names are kebab-case-validated,
|
|
143
|
+
* so no shell-escaping is required inside the `command=` string.
|
|
144
|
+
*/
|
|
145
|
+
export async function renderAuthorizedKeys(): Promise<string> {
|
|
146
|
+
const principals = await listPrincipals();
|
|
147
|
+
if (principals.length === 0) return '';
|
|
148
|
+
return `${principals
|
|
149
|
+
.map(
|
|
150
|
+
(p) =>
|
|
151
|
+
`command="celilo api-serve --principal=${p.name}",${FORCED_COMMAND_OPTS} ${p.publicKey}`,
|
|
152
|
+
)
|
|
153
|
+
.join('\n')}\n`;
|
|
154
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { type BusEvent, defineEvents, openBus } from '@celilo/event-bus';
|
|
6
|
+
import { type WireInterview, startRemoteResponder } from './remote-responder';
|
|
7
|
+
|
|
8
|
+
const NO_SCHEMAS = defineEvents({});
|
|
9
|
+
|
|
10
|
+
let dir: string;
|
|
11
|
+
let busDbPath: string;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-remresp-'));
|
|
15
|
+
busDbPath = join(dir, 'events.db');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
rmSync(dir, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('forwards an interview.required question to ask and replies with the answer', async () => {
|
|
23
|
+
const asked: WireInterview[] = [];
|
|
24
|
+
const responder = startRemoteResponder({
|
|
25
|
+
busDbPath,
|
|
26
|
+
ask: async (iv) => {
|
|
27
|
+
asked.push(iv);
|
|
28
|
+
return 'acme.example.com';
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
|
|
33
|
+
try {
|
|
34
|
+
const replies = (await bus.query(
|
|
35
|
+
'interview.required.site.hostname' as never,
|
|
36
|
+
{
|
|
37
|
+
scope: 'site',
|
|
38
|
+
key: 'hostname',
|
|
39
|
+
kind: 'text',
|
|
40
|
+
message: 'Hostname for the site?',
|
|
41
|
+
required: true,
|
|
42
|
+
} as never,
|
|
43
|
+
{ timeoutMs: 8000, pollIntervalMs: 100, expect: 'first' } as never,
|
|
44
|
+
)) as BusEvent[];
|
|
45
|
+
|
|
46
|
+
expect(replies).toHaveLength(1);
|
|
47
|
+
expect((replies[0].payload as { value: unknown }).value).toBe('acme.example.com');
|
|
48
|
+
expect(asked).toHaveLength(1);
|
|
49
|
+
expect(asked[0].message).toBe('Hostname for the site?');
|
|
50
|
+
expect(asked[0].kind).toBe('text');
|
|
51
|
+
} finally {
|
|
52
|
+
bus.close();
|
|
53
|
+
responder.close();
|
|
54
|
+
}
|
|
55
|
+
}, 15_000);
|
|
56
|
+
|
|
57
|
+
test('answers responder.probe with kind daemon', async () => {
|
|
58
|
+
const responder = startRemoteResponder({ busDbPath, ask: async () => 'unused' });
|
|
59
|
+
|
|
60
|
+
const bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
|
|
61
|
+
try {
|
|
62
|
+
const replies = (await bus.query(
|
|
63
|
+
'responder.probe' as never,
|
|
64
|
+
{} as never,
|
|
65
|
+
{
|
|
66
|
+
timeoutMs: 8000,
|
|
67
|
+
pollIntervalMs: 100,
|
|
68
|
+
expect: 'first',
|
|
69
|
+
} as never,
|
|
70
|
+
)) as BusEvent[];
|
|
71
|
+
|
|
72
|
+
expect(replies).toHaveLength(1);
|
|
73
|
+
expect((replies[0].payload as { kind: string }).kind).toBe('daemon');
|
|
74
|
+
} finally {
|
|
75
|
+
bus.close();
|
|
76
|
+
responder.close();
|
|
77
|
+
}
|
|
78
|
+
}, 15_000);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote bus responder (`kind: 'daemon'`) — Slice 3.
|
|
3
|
+
*
|
|
4
|
+
* Runs in the `api-serve` parent and bridges the event bus to the wire: watches
|
|
5
|
+
* the generic `interview.required.*` family, forwards each question to the
|
|
6
|
+
* remote client via an injected `ask` round-trip, and replies on the bus with
|
|
7
|
+
* the client's answer. Also answers `responder.probe` so the (non-TTY) command
|
|
8
|
+
* child doesn't fail-fast for lack of a responder.
|
|
9
|
+
*
|
|
10
|
+
* The deploy-specific families (config / secret / ensure / aspect) reuse this
|
|
11
|
+
* same plumbing with per-family adapters — tracked as a follow-up.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { type Bus, defineEvents, openBus } from '@celilo/event-bus';
|
|
15
|
+
import type { InterviewRequiredPayload } from './bus-interview';
|
|
16
|
+
import { RESPONDER_PROBE_EVENT } from './responder-probe';
|
|
17
|
+
|
|
18
|
+
const NO_SCHEMAS = defineEvents({});
|
|
19
|
+
|
|
20
|
+
/** The normalized question handed to the wire (mirrors InterviewMessage). */
|
|
21
|
+
export interface WireInterview {
|
|
22
|
+
id: string;
|
|
23
|
+
kind: InterviewRequiredPayload['kind'];
|
|
24
|
+
message: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
defaultValue?: string;
|
|
27
|
+
placeholder?: string;
|
|
28
|
+
options?: Array<{ value: string; label: string; hint?: string }>;
|
|
29
|
+
required?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RemoteResponderOptions {
|
|
33
|
+
/** Path to the shared bus sqlite db (command child + responder share it). */
|
|
34
|
+
busDbPath: string;
|
|
35
|
+
/** Forward a question to the client and resolve with its answer. */
|
|
36
|
+
ask: (interview: WireInterview) => Promise<unknown>;
|
|
37
|
+
/** `emittedBy` audit label on replies. Defaults to `daemon`. */
|
|
38
|
+
emittedBy?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RemoteResponderHandle {
|
|
42
|
+
close(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function startRemoteResponder(opts: RemoteResponderOptions): RemoteResponderHandle {
|
|
46
|
+
const me = opts.emittedBy ?? 'daemon';
|
|
47
|
+
const bus: Bus = openBus({ dbPath: opts.busDbPath, events: NO_SCHEMAS });
|
|
48
|
+
|
|
49
|
+
const interviewWatch = bus.watch('interview.required.*.*', async (event) => {
|
|
50
|
+
if (event.replyFor !== null) return;
|
|
51
|
+
|
|
52
|
+
const payload = event.payload as InterviewRequiredPayload;
|
|
53
|
+
if (!payload || typeof payload.scope !== 'string' || typeof payload.key !== 'string') {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const value = await opts.ask({
|
|
58
|
+
id: String(event.id),
|
|
59
|
+
kind: payload.kind,
|
|
60
|
+
message: payload.message,
|
|
61
|
+
description: payload.description,
|
|
62
|
+
defaultValue: payload.defaultValue,
|
|
63
|
+
placeholder: payload.placeholder,
|
|
64
|
+
options: payload.options,
|
|
65
|
+
required: payload.required,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
bus.emitRaw(`${event.type}.reply`, { value }, { replyFor: event.id, emittedBy: me });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Liveness probe: the non-TTY command child emits `responder.probe` before
|
|
72
|
+
// asking, to confirm someone is listening. Answer with our kind.
|
|
73
|
+
const probeWatch = bus.watch(RESPONDER_PROBE_EVENT, async (event) => {
|
|
74
|
+
if (event.replyFor !== null) return;
|
|
75
|
+
bus.emitRaw(
|
|
76
|
+
`${event.type}.reply`,
|
|
77
|
+
{ kind: 'daemon', emittedBy: me },
|
|
78
|
+
{ replyFor: event.id, emittedBy: me },
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
close: () => {
|
|
84
|
+
interviewWatch.close();
|
|
85
|
+
probeWatch.close();
|
|
86
|
+
bus.close();
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|