@celilo/cli 0.5.0-alpha.0 → 0.5.0-alpha.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/drizzle/0009_dns_registrations.sql +13 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/api-clients/proxmox.test.ts +30 -0
- package/src/api-clients/proxmox.ts +57 -0
- package/src/cli/command-registry.ts +33 -1
- package/src/cli/commands/dns.ts +57 -0
- package/src/cli/commands/events.ts +51 -19
- package/src/cli/commands/module-upgrade.test.ts +37 -0
- package/src/cli/commands/module-upgrade.ts +16 -0
- package/src/cli/commands/publish/alpha.test.ts +26 -0
- package/src/cli/commands/publish/alpha.ts +23 -0
- package/src/cli/commands/publish/types.ts +7 -2
- package/src/cli/commands/publish/workspace.ts +11 -1
- package/src/cli/completion.ts +6 -0
- package/src/cli/index.ts +55 -5
- package/src/db/schema.ts +36 -0
- package/src/hooks/capability-loader.ts +30 -2
- package/src/hooks/run-named-hook.ts +28 -2
- package/src/hooks/types.ts +2 -1
- package/src/manifest/contracts/v1.ts +16 -0
- package/src/manifest/schema.ts +10 -0
- package/src/services/dns-provider-backfill.ts +14 -2
- package/src/services/dns-registrations.test.ts +120 -0
- package/src/services/dns-registrations.ts +108 -0
- package/src/services/events-daemon.test.ts +59 -0
- package/src/services/events-daemon.ts +191 -57
- package/src/services/module-validator/capability-versions.test.ts +1 -1
- package/src/templates/generator.test.ts +30 -3
- package/src/templates/generator.ts +80 -5
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
readInstalledUnit,
|
|
9
9
|
renderLaunchdPlist,
|
|
10
10
|
renderSystemdUnit,
|
|
11
|
+
resolveRunAsUser,
|
|
11
12
|
uninstallDaemon,
|
|
12
13
|
} from './events-daemon';
|
|
13
14
|
|
|
@@ -19,6 +20,7 @@ describe('renderSystemdUnit', () => {
|
|
|
19
20
|
pollMs: 1000,
|
|
20
21
|
concurrency: 4,
|
|
21
22
|
home: '/home/op',
|
|
23
|
+
scope: 'user',
|
|
22
24
|
});
|
|
23
25
|
expect(out).toContain(
|
|
24
26
|
'ExecStart=/usr/local/bin/celilo events run --poll-ms 1000 --concurrency 4',
|
|
@@ -26,6 +28,7 @@ describe('renderSystemdUnit', () => {
|
|
|
26
28
|
expect(out).toContain('Environment=EVENT_BUS_DB=/var/lib/celilo/events.db');
|
|
27
29
|
expect(out).toContain('Restart=on-failure');
|
|
28
30
|
expect(out).toContain('WantedBy=default.target');
|
|
31
|
+
expect(out).not.toContain('User=');
|
|
29
32
|
});
|
|
30
33
|
|
|
31
34
|
it('honors --poll-ms and --concurrency overrides', () => {
|
|
@@ -35,9 +38,26 @@ describe('renderSystemdUnit', () => {
|
|
|
35
38
|
pollMs: 250,
|
|
36
39
|
concurrency: 8,
|
|
37
40
|
home: '/h',
|
|
41
|
+
scope: 'user',
|
|
38
42
|
});
|
|
39
43
|
expect(out).toContain('--poll-ms 250 --concurrency 8');
|
|
40
44
|
});
|
|
45
|
+
|
|
46
|
+
it('system scope sets explicit User= and multi-user.target', () => {
|
|
47
|
+
const out = renderSystemdUnit({
|
|
48
|
+
celiloPath: '/usr/local/bin/celilo',
|
|
49
|
+
busDbPath: '/var/celilo/events.db',
|
|
50
|
+
pollMs: 1000,
|
|
51
|
+
concurrency: 4,
|
|
52
|
+
home: '/root',
|
|
53
|
+
scope: 'system',
|
|
54
|
+
runAsUser: 'celilo',
|
|
55
|
+
});
|
|
56
|
+
expect(out).toContain('User=celilo');
|
|
57
|
+
expect(out).toContain('WantedBy=multi-user.target');
|
|
58
|
+
expect(out).toContain('journalctl -u celilo-events.service');
|
|
59
|
+
expect(out).not.toContain('journalctl --user');
|
|
60
|
+
});
|
|
41
61
|
});
|
|
42
62
|
|
|
43
63
|
describe('renderLaunchdPlist', () => {
|
|
@@ -48,6 +68,7 @@ describe('renderLaunchdPlist', () => {
|
|
|
48
68
|
pollMs: 1000,
|
|
49
69
|
concurrency: 4,
|
|
50
70
|
home: '/Users/op',
|
|
71
|
+
scope: 'user',
|
|
51
72
|
});
|
|
52
73
|
expect(out).toContain('<string>/Users/op/Library/Logs/celilo-events.out.log</string>');
|
|
53
74
|
expect(out).toContain('<string>/Users/op/Library/Logs/celilo-events.err.log</string>');
|
|
@@ -55,6 +76,24 @@ describe('renderLaunchdPlist', () => {
|
|
|
55
76
|
expect(out).toContain('<string>com.celilo.events</string>');
|
|
56
77
|
expect(out).toContain('<key>RunAtLoad</key>');
|
|
57
78
|
expect(out).toContain('<key>KeepAlive</key>');
|
|
79
|
+
expect(out).not.toContain('<key>UserName</key>');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('system scope sets UserName and logs under /Library/Logs', () => {
|
|
83
|
+
const out = renderLaunchdPlist({
|
|
84
|
+
celiloPath: '/c',
|
|
85
|
+
busDbPath: '/db',
|
|
86
|
+
pollMs: 1000,
|
|
87
|
+
concurrency: 4,
|
|
88
|
+
home: '/Users/op',
|
|
89
|
+
scope: 'system',
|
|
90
|
+
runAsUser: 'celilo',
|
|
91
|
+
});
|
|
92
|
+
expect(out).toContain('<key>UserName</key>');
|
|
93
|
+
expect(out).toContain('<string>celilo</string>');
|
|
94
|
+
expect(out).toContain('<string>/Library/Logs/celilo-events.out.log</string>');
|
|
95
|
+
expect(out).toContain('<string>/Library/Logs/celilo-events.err.log</string>');
|
|
96
|
+
expect(out).not.toContain('/Users/op/Library/Logs');
|
|
58
97
|
});
|
|
59
98
|
});
|
|
60
99
|
|
|
@@ -69,6 +108,24 @@ describe('getDaemonUnitPath', () => {
|
|
|
69
108
|
'/Users/op/Library/LaunchAgents/com.celilo.events.plist',
|
|
70
109
|
);
|
|
71
110
|
});
|
|
111
|
+
it('returns the system paths for system scope', () => {
|
|
112
|
+
expect(getDaemonUnitPath('linux', '/home/op', 'system')).toBe(
|
|
113
|
+
'/etc/systemd/system/celilo-events.service',
|
|
114
|
+
);
|
|
115
|
+
expect(getDaemonUnitPath('darwin', '/Users/op', 'system')).toBe(
|
|
116
|
+
'/Library/LaunchDaemons/com.celilo.events.plist',
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('resolveRunAsUser', () => {
|
|
122
|
+
it('honors an explicit override', () => {
|
|
123
|
+
expect(resolveRunAsUser('/var/celilo/events.db', 'celilo')).toBe('celilo');
|
|
124
|
+
});
|
|
125
|
+
it('falls back to the current user when the state dir is missing', () => {
|
|
126
|
+
const me = resolveRunAsUser('/no/such/dir/events.db');
|
|
127
|
+
expect(me.length).toBeGreaterThan(0);
|
|
128
|
+
});
|
|
72
129
|
});
|
|
73
130
|
|
|
74
131
|
describe('installDaemon / uninstallDaemon roundtrip', () => {
|
|
@@ -99,6 +156,8 @@ describe('installDaemon / uninstallDaemon roundtrip', () => {
|
|
|
99
156
|
busDbPath: '/var/lib/celilo/events.db',
|
|
100
157
|
});
|
|
101
158
|
expect(existsSync(installed.unitPath)).toBe(true);
|
|
159
|
+
expect(installed.scope).toBe('user');
|
|
160
|
+
expect(installed.runAsUser).toBeUndefined();
|
|
102
161
|
expect(installed.unitPath).toBe(join(home, '.config/systemd/user/celilo-events.service'));
|
|
103
162
|
expect(installed.nextSteps[0]).toContain('systemctl --user daemon-reload');
|
|
104
163
|
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Generate and install / uninstall a
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* Generate and install / uninstall a supervisor unit for the SQLite
|
|
3
|
+
* event-bus dispatcher. Linux gets a systemd unit; macOS gets a
|
|
4
|
+
* launchd plist. The command WRITES the unit file but never touches
|
|
5
|
+
* the supervisor state — the operator (or the celilo-mgmt Ansible
|
|
6
|
+
* role) runs the enable/bootstrap steps themselves so the effect is
|
|
7
|
+
* visible.
|
|
8
|
+
*
|
|
9
|
+
* Two scopes (designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md A1):
|
|
10
|
+
* - `user` (default): per-user unit under $HOME — dev-laptop shape.
|
|
11
|
+
* Dies with the login session unless lingering is enabled.
|
|
12
|
+
* - `system`: /etc/systemd/system unit (Linux) or a root-owned
|
|
13
|
+
* /Library/LaunchDaemons plist (macOS) — the management-plane
|
|
14
|
+
* shape; survives logout and reboot. The unit pins an explicit
|
|
15
|
+
* run-as user (the celilo state-dir owner) so every handler
|
|
16
|
+
* subprocess the dispatcher spawns stays signalable by that user
|
|
17
|
+
* (ISS-0068 / A1b).
|
|
8
18
|
*
|
|
9
19
|
* Why split write-vs-enable: a celilo command silently flipping
|
|
10
20
|
* systemd state would be hard to reason about during incidents. Pure
|
|
@@ -12,17 +22,28 @@
|
|
|
12
22
|
* predictable.
|
|
13
23
|
*/
|
|
14
24
|
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
25
|
+
import { execFileSync } from 'node:child_process';
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { homedir, platform as nodePlatform, userInfo } from 'node:os';
|
|
17
28
|
import { dirname, join } from 'node:path';
|
|
18
29
|
import { getEventBusPath } from '../config/paths';
|
|
19
30
|
|
|
20
31
|
export type SupervisorPlatform = 'linux' | 'darwin';
|
|
32
|
+
export type SupervisorScope = 'user' | 'system';
|
|
21
33
|
|
|
22
34
|
export interface InstallDaemonOptions {
|
|
23
35
|
celiloPath?: string;
|
|
24
36
|
pollMs?: number;
|
|
25
37
|
concurrency?: number;
|
|
38
|
+
/** Unit scope. Defaults to `user`. */
|
|
39
|
+
scope?: SupervisorScope;
|
|
40
|
+
/**
|
|
41
|
+
* Run-as user for system-scope units. Defaults to the owner of the
|
|
42
|
+
* bus DB's directory (the celilo state dir) — `celilo` on a
|
|
43
|
+
* deb-bootstrapped box, root on a local install. Ignored for user
|
|
44
|
+
* scope (user units already run as their owner).
|
|
45
|
+
*/
|
|
46
|
+
runAsUser?: string;
|
|
26
47
|
/** Override platform detection. Mainly for tests. */
|
|
27
48
|
platform?: SupervisorPlatform;
|
|
28
49
|
/** Override the bus DB path. Defaults to celilo's getEventBusPath(). */
|
|
@@ -33,15 +54,19 @@ export interface InstallDaemonOptions {
|
|
|
33
54
|
|
|
34
55
|
export interface InstallDaemonResult {
|
|
35
56
|
platform: SupervisorPlatform;
|
|
57
|
+
scope: SupervisorScope;
|
|
36
58
|
unitPath: string;
|
|
37
59
|
unitContent: string;
|
|
38
60
|
celiloPath: string;
|
|
39
61
|
busDbPath: string;
|
|
62
|
+
/** Explicit run-as user for system scope; undefined for user scope. */
|
|
63
|
+
runAsUser?: string;
|
|
40
64
|
nextSteps: string[];
|
|
41
65
|
}
|
|
42
66
|
|
|
43
67
|
export interface UninstallDaemonResult {
|
|
44
68
|
platform: SupervisorPlatform;
|
|
69
|
+
scope: SupervisorScope;
|
|
45
70
|
unitPath: string;
|
|
46
71
|
removed: boolean;
|
|
47
72
|
nextSteps: string[];
|
|
@@ -59,19 +84,27 @@ export function detectPlatform(): SupervisorPlatform {
|
|
|
59
84
|
if (p === 'linux') return 'linux';
|
|
60
85
|
if (p === 'darwin') return 'darwin';
|
|
61
86
|
throw new Error(
|
|
62
|
-
`celilo events daemon: unsupported platform "${p}". Only linux (systemd
|
|
87
|
+
`celilo events daemon: unsupported platform "${p}". Only linux (systemd) and darwin (launchd) are supported in v1.`,
|
|
63
88
|
);
|
|
64
89
|
}
|
|
65
90
|
|
|
66
91
|
/**
|
|
67
|
-
* Resolve where to write the unit file for a given platform +
|
|
68
|
-
*
|
|
92
|
+
* Resolve where to write the unit file for a given platform + scope.
|
|
93
|
+
* `home` only matters for user scope; system paths are fixed.
|
|
69
94
|
*/
|
|
70
|
-
export function getDaemonUnitPath(
|
|
95
|
+
export function getDaemonUnitPath(
|
|
96
|
+
platform: SupervisorPlatform,
|
|
97
|
+
home: string,
|
|
98
|
+
scope: SupervisorScope = 'user',
|
|
99
|
+
): string {
|
|
71
100
|
if (platform === 'linux') {
|
|
72
|
-
return
|
|
101
|
+
return scope === 'system'
|
|
102
|
+
? join('/etc/systemd/system', SYSTEMD_UNIT_NAME)
|
|
103
|
+
: join(home, '.config', 'systemd', 'user', SYSTEMD_UNIT_NAME);
|
|
73
104
|
}
|
|
74
|
-
return
|
|
105
|
+
return scope === 'system'
|
|
106
|
+
? join('/Library/LaunchDaemons', `${LAUNCHD_LABEL}.plist`)
|
|
107
|
+
: join(home, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
|
|
75
108
|
}
|
|
76
109
|
|
|
77
110
|
/**
|
|
@@ -100,15 +133,42 @@ export function resolveCeliloPath(override?: string): string {
|
|
|
100
133
|
return found;
|
|
101
134
|
}
|
|
102
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Run-as user for a system-scope unit: the owner of the celilo state
|
|
138
|
+
* dir (the bus DB's directory). Falls back to the current user when
|
|
139
|
+
* the dir doesn't exist yet or the uid can't be resolved to a name —
|
|
140
|
+
* a fresh dev box where nothing has been bootstrapped.
|
|
141
|
+
*/
|
|
142
|
+
export function resolveRunAsUser(busDbPath: string, override?: string): string {
|
|
143
|
+
if (override) return override;
|
|
144
|
+
try {
|
|
145
|
+
const ownerUid = statSync(dirname(busDbPath)).uid;
|
|
146
|
+
if (ownerUid === process.getuid?.()) return userInfo().username;
|
|
147
|
+
const name = execFileSync('id', ['-nu', String(ownerUid)], { encoding: 'utf-8' }).trim();
|
|
148
|
+
if (name) return name;
|
|
149
|
+
} catch {
|
|
150
|
+
// State dir missing or uid unresolvable — fall through.
|
|
151
|
+
}
|
|
152
|
+
return userInfo().username;
|
|
153
|
+
}
|
|
154
|
+
|
|
103
155
|
interface UnitInputs {
|
|
104
156
|
celiloPath: string;
|
|
105
157
|
busDbPath: string;
|
|
106
158
|
pollMs: number;
|
|
107
159
|
concurrency: number;
|
|
108
160
|
home: string;
|
|
161
|
+
scope: SupervisorScope;
|
|
162
|
+
/** Required for system scope; ignored for user scope. */
|
|
163
|
+
runAsUser?: string;
|
|
109
164
|
}
|
|
110
165
|
|
|
111
166
|
export function renderSystemdUnit(input: UnitInputs): string {
|
|
167
|
+
const userLine = input.scope === 'system' && input.runAsUser ? `User=${input.runAsUser}\n` : '';
|
|
168
|
+
const journalHint =
|
|
169
|
+
input.scope === 'system'
|
|
170
|
+
? `journalctl -u ${SYSTEMD_UNIT_NAME}`
|
|
171
|
+
: `journalctl --user -u ${SYSTEMD_UNIT_NAME}`;
|
|
112
172
|
return `[Unit]
|
|
113
173
|
Description=Celilo SQLite Event Bus Dispatcher
|
|
114
174
|
Documentation=https://github.com/psbanka/infra/blob/main/design/SQLITE_EVENT_BUS.md
|
|
@@ -116,30 +176,36 @@ After=network.target
|
|
|
116
176
|
|
|
117
177
|
[Service]
|
|
118
178
|
Type=simple
|
|
119
|
-
ExecStart=${input.celiloPath} events run --poll-ms ${input.pollMs} --concurrency ${input.concurrency}
|
|
179
|
+
${userLine}ExecStart=${input.celiloPath} events run --poll-ms ${input.pollMs} --concurrency ${input.concurrency}
|
|
120
180
|
Restart=on-failure
|
|
121
181
|
RestartSec=10s
|
|
122
182
|
Environment=EVENT_BUS_DB=${input.busDbPath}
|
|
123
|
-
# stdout/stderr are captured by
|
|
183
|
+
# stdout/stderr are captured by ${journalHint}.
|
|
124
184
|
StandardOutput=journal
|
|
125
185
|
StandardError=journal
|
|
126
186
|
|
|
127
187
|
[Install]
|
|
128
|
-
WantedBy
|
|
188
|
+
WantedBy=${input.scope === 'system' ? 'multi-user.target' : 'default.target'}
|
|
129
189
|
`;
|
|
130
190
|
}
|
|
131
191
|
|
|
132
192
|
export function renderLaunchdPlist(input: UnitInputs): string {
|
|
133
|
-
|
|
134
|
-
//
|
|
135
|
-
//
|
|
193
|
+
// System daemons log under /Library/Logs (no $HOME at boot); user
|
|
194
|
+
// agents under the user's Library/Logs. Indentation matters very
|
|
195
|
+
// little to launchd but we want it readable when an operator opens
|
|
196
|
+
// the file manually to debug.
|
|
197
|
+
const logDir = input.scope === 'system' ? '/Library/Logs' : join(input.home, 'Library', 'Logs');
|
|
198
|
+
const userNameBlock =
|
|
199
|
+
input.scope === 'system' && input.runAsUser
|
|
200
|
+
? ` <key>UserName</key>\n <string>${input.runAsUser}</string>\n`
|
|
201
|
+
: '';
|
|
136
202
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
137
203
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
138
204
|
<plist version="1.0">
|
|
139
205
|
<dict>
|
|
140
206
|
<key>Label</key>
|
|
141
207
|
<string>${LAUNCHD_LABEL}</string>
|
|
142
|
-
<key>ProgramArguments</key>
|
|
208
|
+
${userNameBlock} <key>ProgramArguments</key>
|
|
143
209
|
<array>
|
|
144
210
|
<string>${input.celiloPath}</string>
|
|
145
211
|
<string>events</string>
|
|
@@ -167,50 +233,105 @@ export function renderLaunchdPlist(input: UnitInputs): string {
|
|
|
167
233
|
`;
|
|
168
234
|
}
|
|
169
235
|
|
|
236
|
+
function nextStepsFor(
|
|
237
|
+
platform: SupervisorPlatform,
|
|
238
|
+
scope: SupervisorScope,
|
|
239
|
+
unitPath: string,
|
|
240
|
+
): string[] {
|
|
241
|
+
if (platform === 'linux') {
|
|
242
|
+
return scope === 'system'
|
|
243
|
+
? [
|
|
244
|
+
'Reload systemd: sudo systemctl daemon-reload',
|
|
245
|
+
`Enable + start: sudo systemctl enable --now ${SYSTEMD_UNIT_NAME}`,
|
|
246
|
+
`Tail logs: journalctl -u ${SYSTEMD_UNIT_NAME} -f`,
|
|
247
|
+
`Status: systemctl status ${SYSTEMD_UNIT_NAME}`,
|
|
248
|
+
]
|
|
249
|
+
: [
|
|
250
|
+
'Reload systemd: systemctl --user daemon-reload',
|
|
251
|
+
`Enable + start: systemctl --user enable --now ${SYSTEMD_UNIT_NAME}`,
|
|
252
|
+
`Tail logs: journalctl --user -u ${SYSTEMD_UNIT_NAME} -f`,
|
|
253
|
+
`Status: systemctl --user status ${SYSTEMD_UNIT_NAME}`,
|
|
254
|
+
];
|
|
255
|
+
}
|
|
256
|
+
return scope === 'system'
|
|
257
|
+
? [
|
|
258
|
+
`Load + start: sudo launchctl bootstrap system ${unitPath}`,
|
|
259
|
+
'Tail stdout: tail -f /Library/Logs/celilo-events.out.log',
|
|
260
|
+
'Tail stderr: tail -f /Library/Logs/celilo-events.err.log',
|
|
261
|
+
`Status: sudo launchctl print system/${LAUNCHD_LABEL}`,
|
|
262
|
+
]
|
|
263
|
+
: [
|
|
264
|
+
`Load + start: launchctl load -w ${unitPath}`,
|
|
265
|
+
'Tail stdout: tail -f ~/Library/Logs/celilo-events.out.log',
|
|
266
|
+
'Tail stderr: tail -f ~/Library/Logs/celilo-events.err.log',
|
|
267
|
+
`Status: launchctl list | grep ${LAUNCHD_LABEL}`,
|
|
268
|
+
];
|
|
269
|
+
}
|
|
270
|
+
|
|
170
271
|
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
272
|
+
* Resolve everything an install would do — platform, paths, run-as
|
|
273
|
+
* user, rendered unit content — WITHOUT writing anything. This is the
|
|
274
|
+
* single renderer behind both `installDaemon` and the CLI's `--print`
|
|
275
|
+
* mode, which the celilo-mgmt Ansible role uses: the deb wrapper drops
|
|
276
|
+
* to the unprivileged celilo user, so the role renders via `--print`
|
|
277
|
+
* and performs the root-owned write itself with `copy:`.
|
|
173
278
|
*/
|
|
174
|
-
export function
|
|
279
|
+
export function planDaemonInstall(opts: InstallDaemonOptions = {}): InstallDaemonResult {
|
|
175
280
|
const platform = opts.platform ?? detectPlatform();
|
|
281
|
+
const scope = opts.scope ?? 'user';
|
|
176
282
|
const home = opts.home ?? homedir();
|
|
177
283
|
const celiloPath = resolveCeliloPath(opts.celiloPath);
|
|
178
284
|
const busDbPath = opts.busDbPath ?? getEventBusPath();
|
|
179
285
|
const pollMs = opts.pollMs ?? 1000;
|
|
180
286
|
const concurrency = opts.concurrency ?? 4;
|
|
287
|
+
const runAsUser = scope === 'system' ? resolveRunAsUser(busDbPath, opts.runAsUser) : undefined;
|
|
181
288
|
|
|
182
|
-
const unitPath = getDaemonUnitPath(platform, home);
|
|
183
|
-
const unitInputs: UnitInputs = {
|
|
289
|
+
const unitPath = getDaemonUnitPath(platform, home, scope);
|
|
290
|
+
const unitInputs: UnitInputs = {
|
|
291
|
+
celiloPath,
|
|
292
|
+
busDbPath,
|
|
293
|
+
pollMs,
|
|
294
|
+
concurrency,
|
|
295
|
+
home,
|
|
296
|
+
scope,
|
|
297
|
+
runAsUser,
|
|
298
|
+
};
|
|
184
299
|
const unitContent =
|
|
185
300
|
platform === 'linux' ? renderSystemdUnit(unitInputs) : renderLaunchdPlist(unitInputs);
|
|
186
301
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
: [
|
|
199
|
-
`Load + start: launchctl load -w ${unitPath}`,
|
|
200
|
-
'Tail stdout: tail -f ~/Library/Logs/celilo-events.out.log',
|
|
201
|
-
'Tail stderr: tail -f ~/Library/Logs/celilo-events.err.log',
|
|
202
|
-
`Status: launchctl list | grep ${LAUNCHD_LABEL}`,
|
|
203
|
-
];
|
|
302
|
+
return {
|
|
303
|
+
platform,
|
|
304
|
+
scope,
|
|
305
|
+
unitPath,
|
|
306
|
+
unitContent,
|
|
307
|
+
celiloPath,
|
|
308
|
+
busDbPath,
|
|
309
|
+
runAsUser,
|
|
310
|
+
nextSteps: nextStepsFor(platform, scope, unitPath),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
204
313
|
|
|
205
|
-
|
|
314
|
+
/**
|
|
315
|
+
* Write the supervisor unit file. Idempotent: rewrites if present.
|
|
316
|
+
* Returns the path written + the operator-facing next steps.
|
|
317
|
+
*
|
|
318
|
+
* System scope writes root-owned paths (/etc/systemd/system,
|
|
319
|
+
* /Library/LaunchDaemons) — run as root or the write fails loudly.
|
|
320
|
+
*/
|
|
321
|
+
export function installDaemon(opts: InstallDaemonOptions = {}): InstallDaemonResult {
|
|
322
|
+
const plan = planDaemonInstall(opts);
|
|
323
|
+
mkdirSync(dirname(plan.unitPath), { recursive: true });
|
|
324
|
+
writeFileSync(plan.unitPath, plan.unitContent, { mode: 0o644 });
|
|
325
|
+
return plan;
|
|
206
326
|
}
|
|
207
327
|
|
|
208
328
|
export function uninstallDaemon(
|
|
209
|
-
opts: { platform?: SupervisorPlatform; home?: string } = {},
|
|
329
|
+
opts: { platform?: SupervisorPlatform; scope?: SupervisorScope; home?: string } = {},
|
|
210
330
|
): UninstallDaemonResult {
|
|
211
331
|
const platform = opts.platform ?? detectPlatform();
|
|
332
|
+
const scope = opts.scope ?? 'user';
|
|
212
333
|
const home = opts.home ?? homedir();
|
|
213
|
-
const unitPath = getDaemonUnitPath(platform, home);
|
|
334
|
+
const unitPath = getDaemonUnitPath(platform, home, scope);
|
|
214
335
|
|
|
215
336
|
let removed = false;
|
|
216
337
|
if (existsSync(unitPath)) {
|
|
@@ -218,27 +339,40 @@ export function uninstallDaemon(
|
|
|
218
339
|
removed = true;
|
|
219
340
|
}
|
|
220
341
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
342
|
+
let nextSteps: string[];
|
|
343
|
+
if (!removed) {
|
|
344
|
+
nextSteps =
|
|
345
|
+
platform === 'linux'
|
|
346
|
+
? ['No unit file present; nothing to clean up.']
|
|
347
|
+
: ['No plist present; nothing to clean up.'];
|
|
348
|
+
} else if (platform === 'linux') {
|
|
349
|
+
nextSteps =
|
|
350
|
+
scope === 'system'
|
|
224
351
|
? [
|
|
352
|
+
`Disable + stop: sudo systemctl disable --now ${SYSTEMD_UNIT_NAME}`,
|
|
353
|
+
'Reload systemd: sudo systemctl daemon-reload',
|
|
354
|
+
]
|
|
355
|
+
: [
|
|
225
356
|
`Disable + stop: systemctl --user disable --now ${SYSTEMD_UNIT_NAME}`,
|
|
226
357
|
'Reload systemd: systemctl --user daemon-reload',
|
|
227
|
-
]
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
358
|
+
];
|
|
359
|
+
} else {
|
|
360
|
+
nextSteps =
|
|
361
|
+
scope === 'system'
|
|
362
|
+
? [`Unload: sudo launchctl bootout system/${LAUNCHD_LABEL}`]
|
|
363
|
+
: [`Unload: launchctl unload ${unitPath}`];
|
|
364
|
+
}
|
|
232
365
|
|
|
233
|
-
return { platform, unitPath, removed, nextSteps };
|
|
366
|
+
return { platform, scope, unitPath, removed, nextSteps };
|
|
234
367
|
}
|
|
235
368
|
|
|
236
369
|
export function readInstalledUnit(
|
|
237
|
-
opts: { platform?: SupervisorPlatform; home?: string } = {},
|
|
370
|
+
opts: { platform?: SupervisorPlatform; scope?: SupervisorScope; home?: string } = {},
|
|
238
371
|
): { exists: true; path: string; content: string } | { exists: false; path: string } {
|
|
239
372
|
const platform = opts.platform ?? detectPlatform();
|
|
373
|
+
const scope = opts.scope ?? 'user';
|
|
240
374
|
const home = opts.home ?? homedir();
|
|
241
|
-
const unitPath = getDaemonUnitPath(platform, home);
|
|
375
|
+
const unitPath = getDaemonUnitPath(platform, home, scope);
|
|
242
376
|
if (!existsSync(unitPath)) return { exists: false, path: unitPath };
|
|
243
377
|
return { exists: true, path: unitPath, content: readFileSync(unitPath, 'utf-8') };
|
|
244
378
|
}
|
|
@@ -31,7 +31,7 @@ describe('validateCapabilityVersions', () => {
|
|
|
31
31
|
expect(errors).toHaveLength(1);
|
|
32
32
|
expect(errors[0]).toContain('provides[public_web]');
|
|
33
33
|
expect(errors[0]).toContain('1.0.0');
|
|
34
|
-
expect(errors[0]).toContain('3.
|
|
34
|
+
expect(errors[0]).toContain('3.1.0');
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
test('error when provides[X].version is newer than runtime', () => {
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
injectProxmoxLxcDns,
|
|
13
13
|
isTemplateFile,
|
|
14
14
|
readTemplateFiles,
|
|
15
|
+
targetNodeFromTfState,
|
|
15
16
|
writeGeneratedFiles,
|
|
16
17
|
} from './generator';
|
|
17
18
|
import type { GeneratedFile } from './types';
|
|
@@ -651,7 +652,7 @@ resource "proxmox_lxc" "container" {
|
|
|
651
652
|
expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
|
|
652
653
|
expect(out).toContain(' lifecycle {');
|
|
653
654
|
expect(out).toContain(
|
|
654
|
-
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume]',
|
|
655
|
+
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]',
|
|
655
656
|
);
|
|
656
657
|
// Injected immediately after the opening line, before author attributes.
|
|
657
658
|
const lines = out.split('\n');
|
|
@@ -668,7 +669,7 @@ resource "proxmox_lxc" "container" {
|
|
|
668
669
|
expect(out).not.toContain('nameserver = "$self:lxc_nameserver"');
|
|
669
670
|
expect(out).toContain(' lifecycle {');
|
|
670
671
|
expect(out).toContain(
|
|
671
|
-
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume]',
|
|
672
|
+
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]',
|
|
672
673
|
);
|
|
673
674
|
});
|
|
674
675
|
|
|
@@ -712,8 +713,34 @@ resource "proxmox_lxc" "container" {
|
|
|
712
713
|
expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
|
|
713
714
|
expect(out).toContain(' lifecycle {');
|
|
714
715
|
expect(out).toContain(
|
|
715
|
-
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume]',
|
|
716
|
+
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]',
|
|
716
717
|
);
|
|
717
718
|
});
|
|
718
719
|
});
|
|
719
720
|
});
|
|
721
|
+
|
|
722
|
+
describe("targetNodeFromTfState (ISS-0090 — terraform state is celilo's placement record)", () => {
|
|
723
|
+
test('reads the node from the proxmox_lxc target_node attribute', () => {
|
|
724
|
+
const state = {
|
|
725
|
+
resources: [
|
|
726
|
+
{
|
|
727
|
+
type: 'proxmox_lxc',
|
|
728
|
+
instances: [{ attributes: { target_node: 'node2', id: 'node2/lxc/200' } }],
|
|
729
|
+
},
|
|
730
|
+
],
|
|
731
|
+
};
|
|
732
|
+
expect(targetNodeFromTfState(state)).toBe('node2');
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
test('falls back to parsing the resource id when target_node is absent', () => {
|
|
736
|
+
const state = {
|
|
737
|
+
resources: [{ type: 'proxmox_lxc', instances: [{ attributes: { id: 'node3/lxc/201' } }] }],
|
|
738
|
+
};
|
|
739
|
+
expect(targetNodeFromTfState(state)).toBe('node3');
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test('returns null when there is no proxmox_lxc resource (fresh/empty state)', () => {
|
|
743
|
+
expect(targetNodeFromTfState({ resources: [] })).toBeNull();
|
|
744
|
+
expect(targetNodeFromTfState({})).toBeNull();
|
|
745
|
+
});
|
|
746
|
+
});
|