@celilo/cli 0.15.0 → 0.16.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/CELILO_SUBSYSTEMS.md +1 -0
- package/package.json +2 -2
- package/src/cli/commands/module-config.test.ts +77 -1
- package/src/cli/commands/module-config.ts +45 -3
- package/src/cli/commands/module-journal.test.ts +47 -0
- package/src/cli/commands/module-journal.ts +98 -0
- package/src/cli/completion.ts +2 -0
- package/src/cli/generate-zsh-completion.ts +4 -0
- package/src/cli/index.ts +3 -0
- package/src/services/alerting/inbound-poller.test.ts +191 -0
- package/src/services/alerting/inbound-poller.ts +31 -7
- package/src/services/alerting/inbound.test.ts +213 -2
- package/src/services/alerting/inbound.ts +161 -32
- package/src/services/alerting/interview-responder.test.ts +0 -32
- package/src/services/alerting/interview-responder.ts +6 -17
- package/src/services/alerting/tokens.ts +39 -1
- package/src/services/module-journal.test.ts +302 -0
- package/src/services/module-journal.ts +160 -0
package/CELILO_SUBSYSTEMS.md
CHANGED
|
@@ -30,6 +30,7 @@ see `openspec/specs/`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MOD
|
|
|
30
30
|
- **Control-plane network (`secure-mgmt`)** — `apps/celilo/src/hooks/capability-loader.ts` — `loadControlPlaneSubnet` returns the subnet of the zone `celilo-mgmt` is deployed in. `secure-mgmt` is a placement zone AND the control-plane tier, deliberately NOT in `ZONE_TIER_ORDER` (it is not part of the `dmz → app → secure` data-plane chain; it reaches every tier by trust). The firewall's `trustedSubnets` derives from this rather than assuming celilo-mgr sits on `internal`. Reported as an actionable gap by `checkControlPlaneNetwork` in `apps/celilo/src/services/fleet-checks.ts` when the management address matches no configured subnet.
|
|
31
31
|
- **A deployed system's zone** — `apps/celilo/src/services/deployed-systems.ts` — for machine-pool deploys the zone recorded is the ZONE OF THE MACHINE, not `requires.system.zone` (which is only the minimum used to *select* a host, as with sizing). Three writers must agree: `recordDeployedSystemForModule`, `backfillModuleSystems`, and `apps/celilo/src/variables/context.ts` — the last runs latest and will overwrite the others.
|
|
32
32
|
- **Host discovery ("which host serves module X?")** — `apps/celilo/src/cli/commands/module-where.ts` (`celilo module where <id> [--json]`, MCP `celilo_module_where`) — reads deployed hosts from `module_systems` via `getModuleSystems`, reconciles the live Proxmox node via `reconcilePlacement`, and adds a role-based reachability hint per zone. CI/build infra (builder VM, Forgejo runners) is out of scope (not in `module_systems`).
|
|
33
|
+
- **Daemon-journal diagnostic ("what does the module's daemon know that celilo doesn't?")** — `apps/celilo/src/services/module-journal.ts` + `apps/celilo/src/cli/commands/module-journal.ts` (`celilo module journal <id> [--unit|--lines|--since|--grep|--json]`, MCP `celilo_module_journal` on the RO principal) — resolves hosts via `getModuleSystems` and reads each one's systemd journal through the `tailLog` remote primitive. General to every module, not one. **READ-ONLY by construction**: `planJournalRead` is pure and the only remote command the op can emit is a `journalctl` read, so it cannot reconfigure a module, send anything, or consume inbound messages the collection path is waiting for (`module-journal.test.ts` asserts on the exact command reaching the SSH seam). Sibling to `celilo module logs`, which reads the LOCAL Ansible deploy log, not the remote daemon. Unit defaults to the glob `<module-id>*`. An unreachable host reports UNREADABLE, never as an empty journal. Rationale: openspec/changes/fix-signal-inbound-delivery Decision 5.
|
|
33
34
|
|
|
34
35
|
## Capability system (cross-module data & functions)
|
|
35
36
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"@aws-sdk/client-s3": "^3.1024.0",
|
|
60
60
|
"@celilo/capabilities": "^0.9.1",
|
|
61
61
|
"@celilo/cli-display": "^0.1.9",
|
|
62
|
-
"@celilo/core": "^0.
|
|
62
|
+
"@celilo/core": "^0.3.0",
|
|
63
63
|
"@celilo/event-bus": "^0.1.9",
|
|
64
64
|
"@clack/prompts": "^1.1.0",
|
|
65
65
|
"ajv": "^8.18.0",
|
|
@@ -11,7 +11,13 @@ import { tmpdir } from 'node:os';
|
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { type DbClient, getDb } from '../../db/client';
|
|
13
13
|
import { modules } from '../../db/schema';
|
|
14
|
-
import {
|
|
14
|
+
import { resolveDeployPosture } from '../../services/deploy-posture';
|
|
15
|
+
import {
|
|
16
|
+
FRAMEWORK_CONFIG_KEYS,
|
|
17
|
+
handleModuleConfigSet,
|
|
18
|
+
validateFrameworkConfigValue,
|
|
19
|
+
} from './module-config';
|
|
20
|
+
import { pickUpgradePolicy } from './module-upgrade';
|
|
15
21
|
|
|
16
22
|
describe('handleModuleConfigSet — infra-key contract (ISS-0069)', () => {
|
|
17
23
|
let tempDir: string;
|
|
@@ -75,4 +81,74 @@ describe('handleModuleConfigSet — infra-key contract (ISS-0069)', () => {
|
|
|
75
81
|
expect(result.error).not.toContain('vmid'); // infra keys filtered from the hint
|
|
76
82
|
}
|
|
77
83
|
});
|
|
84
|
+
|
|
85
|
+
// #515: these describe how celilo TREATS a module, so they must not depend on
|
|
86
|
+
// the module having declared them. `testmod` declares neither — which is the
|
|
87
|
+
// bug exactly: `upgrade_policy` was declared by NO module, so `always-safe`
|
|
88
|
+
// (the only control over unattended-upgrade risk) could never be set.
|
|
89
|
+
test('accepts upgrade_policy on a module that does not declare it', async () => {
|
|
90
|
+
const result = await handleModuleConfigSet(['testmod', 'upgrade_policy', 'always-safe']);
|
|
91
|
+
expect(result.success).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('accepts auto_upgrade on a module that does not declare it', async () => {
|
|
95
|
+
const result = await handleModuleConfigSet(['testmod', 'auto_upgrade', 'true']);
|
|
96
|
+
expect(result.success).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('rejects a mistyped policy instead of silently falling back to by-semver', async () => {
|
|
100
|
+
// `pickUpgradePolicy` fails OPEN: an unrecognized value becomes `by-semver`,
|
|
101
|
+
// which on a patch means fast posture and NO backup. Accepting this would
|
|
102
|
+
// leave the operator believing the safe floor was armed.
|
|
103
|
+
const result = await handleModuleConfigSet(['testmod', 'upgrade_policy', 'alwayssafe']);
|
|
104
|
+
expect(result.success).toBe(false);
|
|
105
|
+
if (!result.success) {
|
|
106
|
+
expect(result.error).toContain('always-safe');
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('rejects a non-boolean auto_upgrade', async () => {
|
|
111
|
+
const result = await handleModuleConfigSet(['testmod', 'auto_upgrade', 'yes']);
|
|
112
|
+
expect(result.success).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('the valid-keys hint advertises the celilo-managed keys', async () => {
|
|
116
|
+
const result = await handleModuleConfigSet(['testmod', 'nope', 'x']);
|
|
117
|
+
expect(result.success).toBe(false);
|
|
118
|
+
if (!result.success) {
|
|
119
|
+
expect(result.error).toContain('upgrade_policy');
|
|
120
|
+
expect(result.error).toContain('auto_upgrade');
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('validateFrameworkConfigValue (pure)', () => {
|
|
126
|
+
test('passes through non-framework keys untouched', () => {
|
|
127
|
+
expect(validateFrameworkConfigValue('app_port', 'anything')).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('accepts every documented value', () => {
|
|
131
|
+
for (const [key, values] of Object.entries(FRAMEWORK_CONFIG_KEYS)) {
|
|
132
|
+
for (const v of values) expect(validateFrameworkConfigValue(key, v)).toBeNull();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('rejects an unlisted value', () => {
|
|
137
|
+
expect(validateFrameworkConfigValue('upgrade_policy', 'always_safe')).toContain('Allowed');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// The point of the whole control: with always-safe set, a PATCH upgrade — which
|
|
142
|
+
// by default is fast posture and skips the pre-deploy backup — becomes safe.
|
|
143
|
+
// #515 made this unreachable, so this asserts the chain end to end.
|
|
144
|
+
describe('always-safe actually changes posture on a patch (#515)', () => {
|
|
145
|
+
test('patch is fast by default, safe once always-safe is chosen', () => {
|
|
146
|
+
const patch = { installed: '1.0.3', next: '1.0.4' };
|
|
147
|
+
expect(
|
|
148
|
+
resolveDeployPosture({ ...patch, modulePolicy: pickUpgradePolicy(undefined) }).posture,
|
|
149
|
+
).toBe('fast');
|
|
150
|
+
expect(
|
|
151
|
+
resolveDeployPosture({ ...patch, modulePolicy: pickUpgradePolicy('always-safe') }).posture,
|
|
152
|
+
).toBe('safe');
|
|
153
|
+
});
|
|
78
154
|
});
|
|
@@ -14,6 +14,42 @@ import {
|
|
|
14
14
|
import { getArg, validateRequiredArgs } from '../parser';
|
|
15
15
|
import type { CommandResult } from '../types';
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Operator keys that EVERY module accepts, whether or not its manifest declares
|
|
19
|
+
* them, with their permitted values.
|
|
20
|
+
*
|
|
21
|
+
* These describe how celilo TREATS a module (its CD policy), not how the module
|
|
22
|
+
* configures itself, so gating them on `variables.owns` had it backwards: it
|
|
23
|
+
* required each module author to opt into being manageable. The failure was
|
|
24
|
+
* silent — `upgrade_policy` was declared by no module at all, so
|
|
25
|
+
* `pickUpgradePolicy()` could only ever read `undefined` and fall back to
|
|
26
|
+
* `by-semver`, leaving `always-safe` (the ONLY control over unattended-upgrade
|
|
27
|
+
* risk) permanently unreachable. `auto_upgrade` worked only because lunacycle
|
|
28
|
+
* happened to declare it. See #515.
|
|
29
|
+
*
|
|
30
|
+
* Values are checked at SET time rather than coerced at read time. Both readers
|
|
31
|
+
* fail OPEN on an unrecognized value — `pickUpgradePolicy()` returns
|
|
32
|
+
* `by-semver`, which for a patch means fast posture and NO backup. So a typo
|
|
33
|
+
* like `alwayssafe` would leave the operator believing they had armed the safe
|
|
34
|
+
* floor while nothing changed. A safety control that fails open on a typo is
|
|
35
|
+
* worse than no control.
|
|
36
|
+
*/
|
|
37
|
+
export const FRAMEWORK_CONFIG_KEYS: Record<string, readonly string[]> = {
|
|
38
|
+
auto_upgrade: ['true', 'false'],
|
|
39
|
+
upgrade_policy: ['by-semver', 'always-safe', 'always-fast'],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* PURE (Rule 10.1): validate a framework key's value. Returns an error message,
|
|
44
|
+
* or null when the key is not a framework key or the value is permitted.
|
|
45
|
+
*/
|
|
46
|
+
export function validateFrameworkConfigValue(key: string, value: string): string | null {
|
|
47
|
+
const allowed = FRAMEWORK_CONFIG_KEYS[key];
|
|
48
|
+
if (!allowed) return null;
|
|
49
|
+
if (allowed.includes(value)) return null;
|
|
50
|
+
return `Invalid value '${value}' for '${key}'.\n\nAllowed: ${allowed.join(', ')}\n\nRejected rather than coerced: an unrecognized value silently falls back to the PERMISSIVE default (upgrade_policy → by-semver, which skips the pre-deploy backup on a patch), so a typo would look like it took effect.`;
|
|
51
|
+
}
|
|
52
|
+
|
|
17
53
|
/**
|
|
18
54
|
* Handle module config set command
|
|
19
55
|
*
|
|
@@ -54,6 +90,11 @@ export async function handleModuleConfigSet(args: string[]): Promise<CommandResu
|
|
|
54
90
|
};
|
|
55
91
|
}
|
|
56
92
|
|
|
93
|
+
// Framework keys bypass the manifest check entirely — see FRAMEWORK_CONFIG_KEYS.
|
|
94
|
+
const frameworkError = validateFrameworkConfigValue(key, value);
|
|
95
|
+
if (frameworkError) return { success: false, error: frameworkError };
|
|
96
|
+
const isFrameworkKey = key in FRAMEWORK_CONFIG_KEYS;
|
|
97
|
+
|
|
57
98
|
// Validate key against manifest
|
|
58
99
|
const manifest = module.manifestData as Record<string, unknown>;
|
|
59
100
|
const variables = manifest.variables as
|
|
@@ -63,14 +104,15 @@ export async function handleModuleConfigSet(args: string[]): Promise<CommandResu
|
|
|
63
104
|
|
|
64
105
|
// Check if key is declared in manifest
|
|
65
106
|
const declaredVar = declaredVars.find((v) => v.name === key);
|
|
66
|
-
if (!declaredVar) {
|
|
107
|
+
if (!declaredVar && !isFrameworkKey) {
|
|
67
108
|
const settableKeys = declaredVars
|
|
68
109
|
.filter((v) => v.source !== 'infrastructure')
|
|
69
110
|
.map((v) => v.name)
|
|
70
111
|
.join(', ');
|
|
112
|
+
const frameworkKeys = Object.keys(FRAMEWORK_CONFIG_KEYS).join(', ');
|
|
71
113
|
return {
|
|
72
114
|
success: false,
|
|
73
|
-
error: `Invalid config key '${key}' for module ${moduleId}.\n\nValid keys: ${settableKeys || '(none declared)'}`,
|
|
115
|
+
error: `Invalid config key '${key}' for module ${moduleId}.\n\nValid keys: ${settableKeys || '(none declared)'}\nCelilo-managed keys (any module): ${frameworkKeys}`,
|
|
74
116
|
};
|
|
75
117
|
}
|
|
76
118
|
|
|
@@ -79,7 +121,7 @@ export async function handleModuleConfigSet(args: string[]): Promise<CommandResu
|
|
|
79
121
|
// variables (vmid, target_ip, target_node, gateway, vlan, lxc_template) are
|
|
80
122
|
// derived by the deploy (IPAM allocates vmid/IP; the container service supplies
|
|
81
123
|
// node/template/gateway/vlan), so a value set here would be ignored.
|
|
82
|
-
if (declaredVar
|
|
124
|
+
if (declaredVar?.source === 'infrastructure') {
|
|
83
125
|
return {
|
|
84
126
|
success: false,
|
|
85
127
|
error: `'${key}' is infrastructure-managed by celilo (source: infrastructure) — not operator-settable.\nThe deploy derives it automatically, so a value set here would be silently ignored.\n • node placement: set the service default for NEW deploys (celilo service reconfigure); move an existing container with 'celilo proxmox migrate' (ISS-0062).\n • vmid / IP: auto-allocated by IPAM.`,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { JournalReport } from '../../services/module-journal';
|
|
3
|
+
import { formatJournalReport } from './module-journal';
|
|
4
|
+
|
|
5
|
+
const system = (over: Partial<JournalReport['systems'][number]> = {}) => ({
|
|
6
|
+
system: 'signal',
|
|
7
|
+
hostname: 'signal',
|
|
8
|
+
ipv4Address: '10.0.20.40',
|
|
9
|
+
unit: 'signal*',
|
|
10
|
+
ok: true,
|
|
11
|
+
lines: [] as string[],
|
|
12
|
+
...over,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('formatJournalReport', () => {
|
|
16
|
+
test('renders the journal lines under their host', () => {
|
|
17
|
+
const { message, allOk } = formatJournalReport({
|
|
18
|
+
moduleId: 'signal',
|
|
19
|
+
systems: [system({ lines: ['Received sync sent message'] })],
|
|
20
|
+
});
|
|
21
|
+
expect(allOk).toBe(true);
|
|
22
|
+
expect(message).toContain('signal (10.0.20.40)');
|
|
23
|
+
expect(message).toContain('Received sync sent message');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// The defect class of #501: unreadable must not present as quiet.
|
|
27
|
+
test('an unreadable host and an empty journal do not look the same', () => {
|
|
28
|
+
const empty = formatJournalReport({ moduleId: 'signal', systems: [system()] });
|
|
29
|
+
const broken = formatJournalReport({
|
|
30
|
+
moduleId: 'signal',
|
|
31
|
+
systems: [system({ ok: false, error: 'Connection timed out' })],
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(empty.allOk).toBe(true);
|
|
35
|
+
expect(broken.allOk).toBe(false);
|
|
36
|
+
expect(broken.message).not.toBe(empty.message);
|
|
37
|
+
expect(broken.message).toContain('UNREADABLE');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('one unreadable host among several fails the whole read', () => {
|
|
41
|
+
const { allOk } = formatJournalReport({
|
|
42
|
+
moduleId: 'forgejo',
|
|
43
|
+
systems: [system({ lines: ['ok'] }), system({ ok: false, error: 'no route to host' })],
|
|
44
|
+
});
|
|
45
|
+
expect(allOk).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module journal command — read a deployed module's runtime daemon logs
|
|
3
|
+
* (`journalctl -u <unit>`) through celilo, without SSH to the host.
|
|
4
|
+
*
|
|
5
|
+
* Sibling to `celilo module logs`, not an extension of it: `logs` reads the
|
|
6
|
+
* LOCAL deploy log written by Ansible on celilo-mgr, `journal` reads the
|
|
7
|
+
* REMOTE daemon's journal on the host that runs the module. Different source,
|
|
8
|
+
* different failure modes (a host can be unreachable; a log file cannot), and
|
|
9
|
+
* different answers to different questions. Folding them together would have
|
|
10
|
+
* meant one command whose meaning depended on a flag.
|
|
11
|
+
*
|
|
12
|
+
* Read-only by construction — see services/module-journal.ts.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { getDb } from '../../db/client';
|
|
16
|
+
import { type JournalReport, readModuleJournal } from '../../services/module-journal';
|
|
17
|
+
import { getArg, hasFlag, validateRequiredArgs } from '../parser';
|
|
18
|
+
import type { CommandResult } from '../types';
|
|
19
|
+
|
|
20
|
+
const USAGE =
|
|
21
|
+
'Usage: celilo module journal <id> [--unit <name>] [--lines <n>] [--since <when>] [--grep <pattern>] [--json]';
|
|
22
|
+
|
|
23
|
+
function numericFlag(value: string | boolean | undefined): number | undefined {
|
|
24
|
+
return typeof value === 'string' ? Number(value) : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function stringFlag(value: string | boolean | undefined): string | undefined {
|
|
28
|
+
return typeof value === 'string' ? value : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Handle module journal command.
|
|
33
|
+
*/
|
|
34
|
+
export async function handleModuleJournal(
|
|
35
|
+
args: string[],
|
|
36
|
+
flags: Record<string, string | boolean> = {},
|
|
37
|
+
): Promise<CommandResult> {
|
|
38
|
+
const argError = validateRequiredArgs(args, 1);
|
|
39
|
+
if (argError) {
|
|
40
|
+
return { success: false, error: `${argError}\n\n${USAGE}` };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const moduleId = getArg(args, 0);
|
|
44
|
+
if (!moduleId) {
|
|
45
|
+
return { success: false, error: 'Module ID is required' };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const report = readModuleJournal(
|
|
49
|
+
{
|
|
50
|
+
moduleId,
|
|
51
|
+
unit: stringFlag(flags.unit),
|
|
52
|
+
lines: numericFlag(flags.lines),
|
|
53
|
+
since: stringFlag(flags.since),
|
|
54
|
+
grep: stringFlag(flags.grep),
|
|
55
|
+
},
|
|
56
|
+
getDb(),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if ('error' in report) {
|
|
60
|
+
return { success: false, error: report.error };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (hasFlag(flags, 'json')) {
|
|
64
|
+
return {
|
|
65
|
+
success: true,
|
|
66
|
+
message: JSON.stringify(report, null, 2),
|
|
67
|
+
rawOutput: true,
|
|
68
|
+
data: report,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const { message, allOk } = formatJournalReport(report);
|
|
73
|
+
return allOk ? { success: true, message } : { success: false, error: message };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Presentation. A host celilo could not read is a FAILURE, not a quiet success
|
|
78
|
+
* — the defect this whole change exists to fix is unreadable and empty being
|
|
79
|
+
* byte-identical to the operator.
|
|
80
|
+
*/
|
|
81
|
+
export function formatJournalReport(report: JournalReport): { message: string; allOk: boolean } {
|
|
82
|
+
const out: string[] = [];
|
|
83
|
+
for (const sys of report.systems) {
|
|
84
|
+
out.push(`── ${sys.hostname} (${sys.ipv4Address}) · unit ${sys.unit}`);
|
|
85
|
+
if (!sys.ok) {
|
|
86
|
+
out.push(` ✗ UNREADABLE: ${sys.error}`);
|
|
87
|
+
} else if (sys.lines.length === 0) {
|
|
88
|
+
out.push(' (no matching journal lines)');
|
|
89
|
+
} else {
|
|
90
|
+
out.push(...sys.lines.map((line) => ` ${line}`));
|
|
91
|
+
}
|
|
92
|
+
out.push('');
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
message: out.join('\n').trimEnd(),
|
|
96
|
+
allOk: report.systems.every((s) => s.ok),
|
|
97
|
+
};
|
|
98
|
+
}
|
package/src/cli/completion.ts
CHANGED
|
@@ -181,6 +181,7 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
181
181
|
'destroy',
|
|
182
182
|
'health',
|
|
183
183
|
'logs',
|
|
184
|
+
'journal',
|
|
184
185
|
'run-hook',
|
|
185
186
|
'secret',
|
|
186
187
|
'status',
|
|
@@ -423,6 +424,7 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
423
424
|
'deploy',
|
|
424
425
|
'destroy',
|
|
425
426
|
'logs',
|
|
427
|
+
'journal',
|
|
426
428
|
'remove',
|
|
427
429
|
'build',
|
|
428
430
|
'backup',
|
|
@@ -324,6 +324,10 @@ _celilo_config_keys() {
|
|
|
324
324
|
fi
|
|
325
325
|
config_keys+=("$key:$desc_text")
|
|
326
326
|
done < <(echo "$manifest_json" | jq -r '.variables.owns[]? | "\\(.name)|\\(.description // "No description")|\\(.required // false)"' 2>/dev/null)
|
|
327
|
+
# Celilo-managed keys apply to EVERY module regardless of its manifest
|
|
328
|
+
# (#515), so offer them even when the manifest declares none.
|
|
329
|
+
config_keys+=("auto_upgrade:Let the registry poll upgrade this module unattended (true|false)")
|
|
330
|
+
config_keys+=("upgrade_policy:Deploy posture floor for upgrades (by-semver|always-safe|always-fast)")
|
|
327
331
|
if [[ \${#config_keys[@]} -gt 0 ]]; then
|
|
328
332
|
_describe 'config key' config_keys
|
|
329
333
|
return 0
|
package/src/cli/index.ts
CHANGED
|
@@ -64,6 +64,7 @@ import { handleModuleDeploy } from './commands/module-deploy';
|
|
|
64
64
|
import { handleModuleGenerate } from './commands/module-generate';
|
|
65
65
|
import { handleModuleHealth } from './commands/module-health';
|
|
66
66
|
import { handleModuleImport } from './commands/module-import';
|
|
67
|
+
import { handleModuleJournal } from './commands/module-journal';
|
|
67
68
|
import { handleModuleList } from './commands/module-list';
|
|
68
69
|
import { handleModuleLogs } from './commands/module-logs';
|
|
69
70
|
import { handleModuleOperations } from './commands/module-operations';
|
|
@@ -1468,6 +1469,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1468
1469
|
return handleModuleWhere(parsed.args, parsed.flags);
|
|
1469
1470
|
case 'logs':
|
|
1470
1471
|
return handleModuleLogs(parsed.args, parsed.flags);
|
|
1472
|
+
case 'journal':
|
|
1473
|
+
return handleModuleJournal(parsed.args, parsed.flags);
|
|
1471
1474
|
case 'health':
|
|
1472
1475
|
return handleModuleHealth(parsed.args, parsed.flags);
|
|
1473
1476
|
case 'operations':
|
|
@@ -118,6 +118,7 @@ describe('pollInbound', () => {
|
|
|
118
118
|
});
|
|
119
119
|
|
|
120
120
|
const alertState = () => db.select().from(alerts).where(eq(alerts.id, 'alert-1')).get()?.state;
|
|
121
|
+
const ackedBy = () => db.select().from(alerts).where(eq(alerts.id, 'alert-1')).get()?.ackedBy;
|
|
121
122
|
|
|
122
123
|
test('only transports with routes are polled', () => {
|
|
123
124
|
expect(transportsWithRoutes(db)).toEqual(['signal']);
|
|
@@ -326,9 +327,199 @@ describe('pollInbound', () => {
|
|
|
326
327
|
expect(alertState()).toBe('firing');
|
|
327
328
|
});
|
|
328
329
|
|
|
330
|
+
// The seam that was untested. `acknowledgeAlert` returning null was covered
|
|
331
|
+
// (ack.test.ts), and pollInbound was covered — but not what the REPORT says
|
|
332
|
+
// when the two meet. Observed live: a reply produced `1 acked` while every
|
|
333
|
+
// alert still read `ackedBy: null`, because the counter incremented next to
|
|
334
|
+
// the call instead of observing its result.
|
|
335
|
+
describe('a token whose alert no longer exists', () => {
|
|
336
|
+
test('is NOT counted as acked', async () => {
|
|
337
|
+
const delivery = page(peterRoute.id);
|
|
338
|
+
db.delete(alerts).run(); // the alert this token names is gone
|
|
339
|
+
|
|
340
|
+
const report = await pollInbound(db, deps([inbound(PETER, delivery.token)]));
|
|
341
|
+
|
|
342
|
+
expect(report.acked).toBe(0);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('is reported as stale_target rather than silently', async () => {
|
|
346
|
+
const delivery = page(peterRoute.id);
|
|
347
|
+
db.delete(alerts).run();
|
|
348
|
+
|
|
349
|
+
const report = await pollInbound(db, deps([inbound(PETER, delivery.token)]));
|
|
350
|
+
|
|
351
|
+
expect(report.unheard).toEqual([{ senderAddress: PETER, reason: 'stale_target' }]);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// The token is still single-use: a dead target must not leave it replayable.
|
|
355
|
+
test('still consumes the token', async () => {
|
|
356
|
+
const delivery = page(peterRoute.id);
|
|
357
|
+
db.delete(alerts).run();
|
|
358
|
+
|
|
359
|
+
await pollInbound(db, deps([inbound(PETER, delivery.token)]));
|
|
360
|
+
const second = await pollInbound(db, deps([inbound(PETER, delivery.token)]));
|
|
361
|
+
|
|
362
|
+
expect(second.acked).toBe(0);
|
|
363
|
+
expect(second.unheard).toEqual([{ senderAddress: PETER, reason: 'unknown_token' }]);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// Nobody should be told "someone took it" when nobody did.
|
|
367
|
+
test('broadcasts nothing', async () => {
|
|
368
|
+
const delivery = page(peterRoute.id);
|
|
369
|
+
db.delete(alerts).run();
|
|
370
|
+
|
|
371
|
+
const report = await pollInbound(db, deps([inbound(PETER, delivery.token)]));
|
|
372
|
+
|
|
373
|
+
expect(report.broadcast).toBe(0);
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// The counter must track the database, not the attempt. If these two ever
|
|
378
|
+
// disagree again, the operator sees a success that did not happen.
|
|
379
|
+
test('report.acked agrees with what the alert row actually says', async () => {
|
|
380
|
+
const delivery = page(peterRoute.id);
|
|
381
|
+
|
|
382
|
+
const report = await pollInbound(db, deps([inbound(PETER, delivery.token)]));
|
|
383
|
+
|
|
384
|
+
expect(report.acked).toBe(1);
|
|
385
|
+
expect(alertState()).toBe('acked');
|
|
386
|
+
expect(ackedBy()).toBe(peterRoute.personId);
|
|
387
|
+
});
|
|
388
|
+
|
|
329
389
|
test('a route pointing at a transport nobody uses is not polled', () => {
|
|
330
390
|
db.delete(alerts).run();
|
|
331
391
|
expect(transportsWithRoutes(db)).toEqual(['signal']);
|
|
332
392
|
expect(wifeRoute.transportModuleId).toBe('signal');
|
|
333
393
|
});
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Answering a deploy question, at the SEAM.
|
|
397
|
+
*
|
|
398
|
+
* This is the coverage #533 was missing. `parseInterviewAnswer` was unit
|
|
399
|
+
* tested and correct; `pollInbound` was unit tested and correct; and an
|
|
400
|
+
* answer sent in the shape the page asks for was rejected as `unrecognised`
|
|
401
|
+
* for months, because nothing exercised an interview delivery THROUGH the
|
|
402
|
+
* poller. Two green units, wrong wiring — the same shape as the ack counter
|
|
403
|
+
* above.
|
|
404
|
+
*/
|
|
405
|
+
describe('an answer to a deploy question', () => {
|
|
406
|
+
const ask = (routeId: string, eventId = '42') =>
|
|
407
|
+
mintDelivery(db, {
|
|
408
|
+
kind: 'interview',
|
|
409
|
+
targetId: eventId,
|
|
410
|
+
routeId,
|
|
411
|
+
now: NOW,
|
|
412
|
+
ttlMs: 60_000,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test('is published against the waiting question and counted as answered', async () => {
|
|
416
|
+
const question = ask(peterRoute.id, '42');
|
|
417
|
+
const answered: { eventId: string; value: string }[] = [];
|
|
418
|
+
|
|
419
|
+
const report = await pollInbound(
|
|
420
|
+
db,
|
|
421
|
+
deps([inbound(PETER, `${question.token} admin@example.org`)], {
|
|
422
|
+
answerInterview: (eventId, value) => answered.push({ eventId, value }),
|
|
423
|
+
}),
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
expect(answered).toEqual([{ eventId: '42', value: 'admin@example.org' }]);
|
|
427
|
+
expect(report.answered).toBe(1);
|
|
428
|
+
// An answer is not an acknowledgement; nothing about an alert moved.
|
|
429
|
+
expect(report.acked).toBe(0);
|
|
430
|
+
expect(report.unheard).toEqual([]);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test('a value containing spaces survives verbatim', async () => {
|
|
434
|
+
const question = ask(peterRoute.id);
|
|
435
|
+
const answered: string[] = [];
|
|
436
|
+
|
|
437
|
+
await pollInbound(
|
|
438
|
+
db,
|
|
439
|
+
deps([inbound(PETER, `${question.token} my value: with, punctuation`)], {
|
|
440
|
+
answerInterview: (_id, value) => answered.push(value),
|
|
441
|
+
}),
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
expect(answered).toEqual(['my value: with, punctuation']);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test('the token is consumed, so an answer cannot be replayed', async () => {
|
|
448
|
+
const question = ask(peterRoute.id);
|
|
449
|
+
const answered: string[] = [];
|
|
450
|
+
const withResponder = (body: string) =>
|
|
451
|
+
pollInbound(
|
|
452
|
+
db,
|
|
453
|
+
deps([inbound(PETER, body)], { answerInterview: (_id, v) => answered.push(v) }),
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
await withResponder(`${question.token} first`);
|
|
457
|
+
const second = await withResponder(`${question.token} second`);
|
|
458
|
+
|
|
459
|
+
expect(answered).toEqual(['first']);
|
|
460
|
+
expect(second.answered).toBe(0);
|
|
461
|
+
// `unrecognised`, not `unknown_token`: once the token no longer resolves,
|
|
462
|
+
// `<word> <text>` is indistinguishable from an ordinary sentence, so
|
|
463
|
+
// celilo declines to assert the operator mistyped a token.
|
|
464
|
+
expect(second.unheard).toEqual([{ senderAddress: PETER, reason: 'unrecognised' }]);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// Naming the question but supplying nothing is its own answer, and the
|
|
468
|
+
// token must survive so the operator's next attempt can work.
|
|
469
|
+
test('a token with no value reports needs_value and leaves the token live', async () => {
|
|
470
|
+
const question = ask(peterRoute.id);
|
|
471
|
+
|
|
472
|
+
const first = await pollInbound(
|
|
473
|
+
db,
|
|
474
|
+
deps([inbound(PETER, question.token)], { answerInterview: () => {} }),
|
|
475
|
+
);
|
|
476
|
+
expect(first.answered).toBe(0);
|
|
477
|
+
expect(first.unheard).toEqual([{ senderAddress: PETER, reason: 'needs_value' }]);
|
|
478
|
+
|
|
479
|
+
const answered: string[] = [];
|
|
480
|
+
const second = await pollInbound(
|
|
481
|
+
db,
|
|
482
|
+
deps([inbound(PETER, `${question.token} admin@example.org`)], {
|
|
483
|
+
answerInterview: (_id, v) => answered.push(v),
|
|
484
|
+
}),
|
|
485
|
+
);
|
|
486
|
+
expect(second.answered).toBe(1);
|
|
487
|
+
expect(answered).toEqual(['admin@example.org']);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
// With no responder attached there is nothing to publish against, so the
|
|
491
|
+
// question stays unanswered AND the token stays usable.
|
|
492
|
+
test('with no responder attached the token is not burned', async () => {
|
|
493
|
+
const question = ask(peterRoute.id);
|
|
494
|
+
|
|
495
|
+
const report = await pollInbound(db, deps([inbound(PETER, `${question.token} value`)]));
|
|
496
|
+
expect(report.answered).toBe(0);
|
|
497
|
+
expect(report.unheard).toEqual([{ senderAddress: PETER, reason: 'unrecognised' }]);
|
|
498
|
+
|
|
499
|
+
const answered: string[] = [];
|
|
500
|
+
const retry = await pollInbound(
|
|
501
|
+
db,
|
|
502
|
+
deps([inbound(PETER, `${question.token} value`)], {
|
|
503
|
+
answerInterview: (_id, v) => answered.push(v),
|
|
504
|
+
}),
|
|
505
|
+
);
|
|
506
|
+
expect(retry.answered).toBe(1);
|
|
507
|
+
expect(answered).toEqual(['value']);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test('an answer from a number the question was not sent to is refused', async () => {
|
|
511
|
+
const question = ask(peterRoute.id);
|
|
512
|
+
const answered: string[] = [];
|
|
513
|
+
|
|
514
|
+
const report = await pollInbound(
|
|
515
|
+
db,
|
|
516
|
+
deps([inbound(WIFE, `${question.token} admin@example.org`)], {
|
|
517
|
+
answerInterview: (_id, v) => answered.push(v),
|
|
518
|
+
}),
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
expect(answered).toEqual([]);
|
|
522
|
+
expect(report.unheard).toEqual([{ senderAddress: WIFE, reason: 'wrong_sender' }]);
|
|
523
|
+
});
|
|
524
|
+
});
|
|
334
525
|
});
|