@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.
@@ -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 per-user supervisor unit for the
3
- * SQLite event-bus dispatcher. Linux gets a systemd user unit; macOS
4
- * gets a launchd plist. The command WRITES the unit file but never
5
- * touches the supervisor state — the operator runs `systemctl --user
6
- * enable --now celilo-events` (or `launchctl load`) themselves so the
7
- * effect is visible.
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 { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
16
- import { homedir, platform as nodePlatform } from 'node:os';
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 user) and darwin (launchd) are supported in v1.`,
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 + user.
68
- * User-mode locations only; system-wide is opt-in for a future phase.
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(platform: SupervisorPlatform, home: string): string {
95
+ export function getDaemonUnitPath(
96
+ platform: SupervisorPlatform,
97
+ home: string,
98
+ scope: SupervisorScope = 'user',
99
+ ): string {
71
100
  if (platform === 'linux') {
72
- return join(home, '.config', 'systemd', 'user', SYSTEMD_UNIT_NAME);
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 join(home, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
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 journalctl --user -u celilo-events.service.
183
+ # stdout/stderr are captured by ${journalHint}.
124
184
  StandardOutput=journal
125
185
  StandardError=journal
126
186
 
127
187
  [Install]
128
- WantedBy=default.target
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
- const logDir = join(input.home, 'Library', 'Logs');
134
- // Indentation matters very little to launchd but we want it readable
135
- // when an operator opens the file manually to debug.
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
- * Write the supervisor unit file. Idempotent: rewrites if present.
172
- * Returns the path written + the operator-facing next steps.
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 installDaemon(opts: InstallDaemonOptions = {}): InstallDaemonResult {
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 = { celiloPath, busDbPath, pollMs, concurrency, home };
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
- mkdirSync(dirname(unitPath), { recursive: true });
188
- writeFileSync(unitPath, unitContent, { mode: 0o644 });
189
-
190
- const nextSteps =
191
- platform === 'linux'
192
- ? [
193
- 'Reload systemd: systemctl --user daemon-reload',
194
- `Enable + start: systemctl --user enable --now ${SYSTEMD_UNIT_NAME}`,
195
- `Tail logs: journalctl --user -u ${SYSTEMD_UNIT_NAME} -f`,
196
- `Status: systemctl --user status ${SYSTEMD_UNIT_NAME}`,
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
- return { platform, unitPath, unitContent, celiloPath, busDbPath, nextSteps };
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
- const nextSteps =
222
- platform === 'linux'
223
- ? removed
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
- : ['No unit file present; nothing to clean up.']
229
- : removed
230
- ? [`Unload: launchctl unload ${unitPath}`]
231
- : ['No plist present; nothing to clean up.'];
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.0.0');
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
+ });