@celilo/cli 0.15.0 → 0.16.2
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 +3 -3
- 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/module/packaging/release-metadata.test.ts +77 -3
- package/src/module/packaging/release-metadata.ts +11 -1
- 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.2",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -59,8 +59,8 @@
|
|
|
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.
|
|
63
|
-
"@celilo/event-bus": "^0.1.
|
|
62
|
+
"@celilo/core": "^0.3.0",
|
|
63
|
+
"@celilo/event-bus": "^0.1.10",
|
|
64
64
|
"@clack/prompts": "^1.1.0",
|
|
65
65
|
"ajv": "^8.18.0",
|
|
66
66
|
"drizzle-orm": "^0.36.4",
|
|
@@ -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':
|
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
type GitCommandRunner,
|
|
8
|
+
buildReleaseMetadata,
|
|
9
|
+
collectGitInfo,
|
|
10
|
+
makeRealGitRunner,
|
|
11
|
+
} from './release-metadata';
|
|
3
12
|
|
|
4
13
|
describe('buildReleaseMetadata', () => {
|
|
5
14
|
test('produces a stable shape from injected inputs', () => {
|
|
@@ -77,7 +86,7 @@ describe('collectGitInfo', () => {
|
|
|
77
86
|
expect(calls).toEqual([
|
|
78
87
|
['rev-parse', 'HEAD'],
|
|
79
88
|
['rev-parse', '--abbrev-ref', 'HEAD'],
|
|
80
|
-
['status', '--porcelain'],
|
|
89
|
+
['status', '--porcelain', '--', '.'],
|
|
81
90
|
]);
|
|
82
91
|
});
|
|
83
92
|
|
|
@@ -101,3 +110,68 @@ describe('collectGitInfo', () => {
|
|
|
101
110
|
expect(collectGitInfo('/tmp/x', run).dirty).toBe(true);
|
|
102
111
|
});
|
|
103
112
|
});
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Recurrence gate for #544, against a REAL git repo rather than a fake runner.
|
|
116
|
+
*
|
|
117
|
+
* The tests above pin the argv `collectGitInfo` constructs. That is what let the
|
|
118
|
+
* bug live: they asserted which command was built, never what it concluded, so
|
|
119
|
+
* they stayed green while the function reported the whole repository's dirt for
|
|
120
|
+
* every module. A fake `GitCommandRunner` cannot catch this at all — the defect
|
|
121
|
+
* IS git's real scoping behavior, which a stub by definition does not model.
|
|
122
|
+
*
|
|
123
|
+
* So this drives the real `makeRealGitRunner()` against a real checkout. Delete
|
|
124
|
+
* the `-- .` pathspec and the first test here fails; that is the whole point.
|
|
125
|
+
*/
|
|
126
|
+
describe('collectGitInfo scopes dirt to sourceDir (#544, real git)', () => {
|
|
127
|
+
let repo: string;
|
|
128
|
+
let moduleDir: string;
|
|
129
|
+
const git = (args: string[], cwd: string) =>
|
|
130
|
+
execFileSync('git', args, { cwd, encoding: 'utf-8' });
|
|
131
|
+
|
|
132
|
+
beforeEach(() => {
|
|
133
|
+
repo = mkdtempSync(join(tmpdir(), 'celilo-gitinfo-'));
|
|
134
|
+
moduleDir = join(repo, 'modules', 'probe');
|
|
135
|
+
mkdirSync(moduleDir, { recursive: true });
|
|
136
|
+
writeFileSync(join(moduleDir, 'manifest.yml'), 'id: probe\n');
|
|
137
|
+
writeFileSync(join(repo, 'bun.lock'), 'lockfile v1\n');
|
|
138
|
+
|
|
139
|
+
git(['init', '-q'], repo);
|
|
140
|
+
git(['config', 'user.email', 'test@celilo.test'], repo);
|
|
141
|
+
git(['config', 'user.name', 'Test'], repo);
|
|
142
|
+
git(['add', '-A'], repo);
|
|
143
|
+
git(['commit', '-qm', 'init'], repo);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
afterEach(() => rmSync(repo, { recursive: true, force: true }));
|
|
147
|
+
|
|
148
|
+
test('a tracked file rewritten OUTSIDE the module does not make it dirty', () => {
|
|
149
|
+
// Exactly what broke the release: `bun install` rewrites the tracked root
|
|
150
|
+
// `bun.lock` between module publishes. The module itself is untouched.
|
|
151
|
+
writeFileSync(join(repo, 'bun.lock'), 'lockfile v1\nrewritten by bun install\n');
|
|
152
|
+
|
|
153
|
+
expect(collectGitInfo(moduleDir, makeRealGitRunner()).dirty).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("a sibling module's dirt does not make this module dirty", () => {
|
|
157
|
+
const sibling = join(repo, 'modules', 'other');
|
|
158
|
+
mkdirSync(sibling, { recursive: true });
|
|
159
|
+
writeFileSync(join(sibling, 'manifest.yml'), 'id: other\n');
|
|
160
|
+
|
|
161
|
+
expect(collectGitInfo(moduleDir, makeRealGitRunner()).dirty).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("the module's OWN dirt still blocks the publish", () => {
|
|
165
|
+
// The control. Scoping must not have simply disabled the check — this is
|
|
166
|
+
// the case the guard exists for, and it must still fire.
|
|
167
|
+
writeFileSync(join(moduleDir, 'manifest.yml'), 'id: probe\nversion: 9.9.9\n');
|
|
168
|
+
|
|
169
|
+
expect(collectGitInfo(moduleDir, makeRealGitRunner()).dirty).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('an untracked file inside the module still blocks the publish', () => {
|
|
173
|
+
writeFileSync(join(moduleDir, 'stray.txt'), 'oops\n');
|
|
174
|
+
|
|
175
|
+
expect(collectGitInfo(moduleDir, makeRealGitRunner()).dirty).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -129,7 +129,17 @@ export function collectGitInfo(sourceDir: string, run: GitCommandRunner): GitInf
|
|
|
129
129
|
// `git status --porcelain` prints one line per modified/untracked file.
|
|
130
130
|
// Empty output = clean. Null (command failure) is treated as not-dirty
|
|
131
131
|
// because we don't want to falsely block a publish.
|
|
132
|
-
|
|
132
|
+
//
|
|
133
|
+
// The `-- .` pathspec is load-bearing (#544). `git status` reports the WHOLE
|
|
134
|
+
// repository regardless of cwd, so passing `sourceDir` as cwd scoped nothing:
|
|
135
|
+
// every caller asks about one module, and got back the dirt of all of them
|
|
136
|
+
// plus the repo root. The release pipeline runs `bun install` between module
|
|
137
|
+
// publishes, which rewrites the tracked `bun.lock` at the root — so modules
|
|
138
|
+
// 1..N published fine and the next one failed with "Working tree at
|
|
139
|
+
// modules/<clean-module> has uncommitted changes", naming a directory that
|
|
140
|
+
// was clean and sending you to inspect it. Order-dependent, so it looked
|
|
141
|
+
// like a random module failing.
|
|
142
|
+
const status = run(['status', '--porcelain', '--', '.'], sourceDir);
|
|
133
143
|
const dirty = status !== null && status.length > 0;
|
|
134
144
|
|
|
135
145
|
return {
|