@lamalibre/create-lamaste 4.2.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +205 -2
  3. package/src/index.js +4 -4
  4. package/src/lib/node-version.js +114 -0
  5. package/src/lib/relay-services.js +15 -1
  6. package/src/lib/service-config.js +113 -0
  7. package/src/tasks/gatekeeper.js +8 -3
  8. package/src/tasks/node.js +34 -75
  9. package/src/tasks/redeploy.js +36 -0
  10. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  11. package/vendor/docs/00-introduction/quickstart.md +5 -5
  12. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  13. package/vendor/docs/01-concepts/authentication.md +6 -3
  14. package/vendor/docs/01-concepts/authorization.md +29 -17
  15. package/vendor/docs/01-concepts/certificates.md +3 -3
  16. package/vendor/docs/01-concepts/mtls.md +2 -2
  17. package/vendor/docs/01-concepts/nginx-reverse-proxy.md +8 -3
  18. package/vendor/docs/01-concepts/security-model.md +98 -34
  19. package/vendor/docs/01-concepts/tickets.md +2 -2
  20. package/vendor/docs/01-concepts/tunneling.md +4 -4
  21. package/vendor/docs/02-guides/agent-setup.md +34 -16
  22. package/vendor/docs/02-guides/certificate-management.md +2 -2
  23. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  24. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  25. package/vendor/docs/02-guides/installation.md +3 -3
  26. package/vendor/docs/02-guides/onboarding.md +2 -2
  27. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  28. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  29. package/vendor/docs/03-architecture/installer.md +4 -4
  30. package/vendor/docs/03-architecture/management-flow.md +1 -1
  31. package/vendor/docs/03-architecture/nginx-configuration.md +91 -26
  32. package/vendor/docs/03-architecture/overview.md +8 -8
  33. package/vendor/docs/03-architecture/panel-client.md +6 -6
  34. package/vendor/docs/03-architecture/panel-server.md +2 -2
  35. package/vendor/docs/03-architecture/state-management.md +2 -2
  36. package/vendor/docs/03-architecture/system-overview.md +1 -1
  37. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +407 -0
  38. package/vendor/docs/04-api-reference/app-host.md +360 -0
  39. package/vendor/docs/04-api-reference/audit-and-notifications.md +57 -0
  40. package/vendor/docs/04-api-reference/certificates.md +95 -38
  41. package/vendor/docs/04-api-reference/gatekeeper.md +5 -3
  42. package/vendor/docs/04-api-reference/lifecycle-hooks.md +333 -0
  43. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  44. package/vendor/docs/04-api-reference/overview.md +137 -111
  45. package/vendor/docs/04-api-reference/registry-and-oidc.md +7 -3
  46. package/vendor/docs/04-api-reference/tunnels.md +15 -13
  47. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  48. package/vendor/docs/05-operations/uninstalling.md +1 -1
  49. package/vendor/docs/05-operations/upgrades.md +25 -20
  50. package/vendor/docs/06-reference/config-files.md +190 -97
  51. package/vendor/docs/06-reference/glossary.md +3 -3
  52. package/vendor/docs/06-reference/installer-flags.md +13 -13
  53. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  54. package/vendor/docs/06-reference/troubleshooting.md +33 -8
  55. package/vendor/docs/_index.json +20 -0
  56. package/vendor/docs/gatekeeper-design.md +22 -19
  57. package/vendor/docs/identity-directory-design.md +57 -56
  58. package/vendor/docs/package.json +1 -1
  59. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  60. package/vendor/gatekeeper/dist/lib/types.d.ts +12 -0
  61. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  62. package/vendor/gatekeeper/dist/server/index.d.ts +2 -0
  63. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  64. package/vendor/gatekeeper/dist/server/index.js +47 -8
  65. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  66. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  67. package/vendor/gatekeeper/dist/server/routes/authz.js +233 -141
  68. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  69. package/vendor/gatekeeper/package.json +4 -3
  70. package/vendor/server/bin/lamaste-server.js +18 -1
  71. package/vendor/server/package.json +2 -2
  72. package/vendor/server/src/commands/audit.js +60 -0
  73. package/vendor/server/src/commands/plugins.js +963 -12
  74. package/vendor/server/src/commands/push-install.js +317 -0
  75. package/vendor/server/src/commands/registry-credential.js +2 -1
  76. package/vendor/server/src/commands/uninstall.js +28 -1
  77. package/vendor/server/src/ndjson.js +4 -2
  78. package/vendor/server/src/panel-api.js +32 -4
  79. package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +1 -0
  80. package/vendor/server-ui/dist/assets/index-DBp3cmaU.js +84 -0
  81. package/vendor/server-ui/dist/index.html +2 -2
  82. package/vendor/serverd/package.json +2 -2
  83. package/vendor/serverd/src/index.js +56 -4
  84. package/vendor/serverd/src/lib/actor.js +24 -0
  85. package/vendor/serverd/src/lib/agent-jobs.js +330 -0
  86. package/vendor/serverd/src/lib/app-error.js +10 -0
  87. package/vendor/serverd/src/lib/audit.js +145 -0
  88. package/vendor/serverd/src/lib/hook-effects.js +123 -0
  89. package/vendor/serverd/src/lib/hook-pipelines.js +902 -0
  90. package/vendor/serverd/src/lib/migrations/state/0006_plugin_agent.sql +4 -0
  91. package/vendor/serverd/src/lib/migrations/state/0007_audit_and_notifications.sql +29 -0
  92. package/vendor/serverd/src/lib/migrations/state/0008_plugin_hooks.sql +70 -0
  93. package/vendor/serverd/src/lib/migrations/state/0009_plugin_agent_half.sql +53 -0
  94. package/vendor/serverd/src/lib/migrations/state/0010_plugin_routes_and_retained_data.sql +47 -0
  95. package/vendor/serverd/src/lib/migrations/state/0011_agent_job_signature.sql +14 -0
  96. package/vendor/serverd/src/lib/mtls.js +89 -8
  97. package/vendor/serverd/src/lib/nginx.js +298 -44
  98. package/vendor/serverd/src/lib/notifications.js +107 -0
  99. package/vendor/serverd/src/lib/plugin-hooks.js +560 -0
  100. package/vendor/serverd/src/lib/plugin-hosting.js +688 -0
  101. package/vendor/serverd/src/lib/plugin-lifecycle.js +603 -0
  102. package/vendor/serverd/src/lib/plugin-operations.js +295 -0
  103. package/vendor/serverd/src/lib/plugin-packages.js +249 -0
  104. package/vendor/serverd/src/lib/plugins.js +298 -68
  105. package/vendor/serverd/src/lib/push-install.js +88 -2
  106. package/vendor/serverd/src/lib/relay-hooks.js +100 -0
  107. package/vendor/serverd/src/lib/relay-signing.js +43 -0
  108. package/vendor/serverd/src/lib/request-hooks.js +79 -0
  109. package/vendor/serverd/src/lib/retained-data.js +99 -0
  110. package/vendor/serverd/src/lib/trust-proxy.js +34 -0
  111. package/vendor/serverd/src/lib/tunnel-hooks.js +88 -0
  112. package/vendor/serverd/src/lib/vhost-reconcile.js +260 -0
  113. package/vendor/serverd/src/middleware/twofa-session.js +11 -1
  114. package/vendor/serverd/src/routes/agent-forwarder.js +37 -0
  115. package/vendor/serverd/src/routes/management/agent-panel-authz.js +97 -0
  116. package/vendor/serverd/src/routes/management/agents.js +262 -1
  117. package/vendor/serverd/src/routes/management/audit.js +81 -0
  118. package/vendor/serverd/src/routes/management/certs.js +1 -15
  119. package/vendor/serverd/src/routes/management/plugins.js +569 -54
  120. package/vendor/serverd/src/routes/management/tunnels.js +26 -0
  121. package/vendor/serverd/src/routes/management.js +4 -0
  122. package/vendor/server-ui/dist/assets/index-B5Wp8lu-.js +0 -257
  123. package/vendor/server-ui/dist/assets/index-BqWWFlTU.css +0 -1
@@ -0,0 +1,902 @@
1
+ /**
2
+ * Lifecycle pipelines of a hosted agent-hosted plugin — the onion around
3
+ * each of Lamaste's own steps:
4
+ *
5
+ * relay pre-X → agent pre-X → [ Lamaste: X ] → agent post-X → relay post-X
6
+ *
7
+ * Relay hooks run in the plugin's sandboxed runner (`relay-hooks.js`).
8
+ * Agent hooks are calls to the agent half's `POST /admin/hooks/<stage>`:
9
+ * through the internal tunnel while it exists, otherwise by the agent's own
10
+ * sync on its loopback (an agent job). So that the agent can sign those
11
+ * calls, the installation secret is delivered before the tunnel is granted
12
+ * and revoked only after it is removed (the hosting's `installing` and
13
+ * `removing` phases).
14
+ *
15
+ * Install: a failing *pre* stage stops the pipeline — nothing after it ran,
16
+ * and what Lamaste had prepared is undone without hooks. A failing *post*
17
+ * stage (or a failure of Lamaste's own step after the agent's pre stage)
18
+ * rolls back: the uninstall pipeline runs with `phase: "rollback"` for the
19
+ * sides that had taken part. Uninstall: a failing pre-teardown stage stops
20
+ * it unless forced; post-teardown failures are reported and the uninstall
21
+ * completes. Upgrade: the pipeline runs around replacing the relay package;
22
+ * a failure restores the previous package and hook module.
23
+ *
24
+ * An agent half Lamaste installs (slice 3: `agent.package`, pinned when the
25
+ * version was staged) is installed, switched and removed by the agent's
26
+ * root helper through app jobs (`agent-jobs.js`):
27
+ *
28
+ * install relay pre → reserve → install the agent half → agent pre (job)
29
+ * → grant → wait for the agent half → agent post → relay post
30
+ * uninstall … → agent post-teardown → remove the agent half → revoke → relay post
31
+ * upgrade relay pre → agent pre → install the new agent half → the relay
32
+ * package → agent post → relay post
33
+ *
34
+ * Once an install job is dispatched the agent may act on it, whatever it
35
+ * reports: a rollback then removes the agent half (data kept, and recorded
36
+ * so it can be purged later — `retained-data.js`), and a failed upgrade
37
+ * activates the version that ran before. The agent's helper runs only app
38
+ * jobs the relay signed, so these recoveries are the relay's to start — the
39
+ * agent never undoes an install on its own. Every hosting record names the
40
+ * plugin that owns it; a teardown acts only on the plugin's own.
41
+ *
42
+ * Hook outputs are passed forward in `outputs` (`relay:install:pre-tunnel`, …).
43
+ */
44
+
45
+ import {
46
+ AGENT_LABEL_REGEX,
47
+ PLUGIN_AGENT_CN_PREFIX,
48
+ agentHookSetOf,
49
+ derivePluginRoute,
50
+ pluginHostCapability,
51
+ } from '@lamalibre/lamaste';
52
+ import {
53
+ TUNNEL_MAX_BODY_MB,
54
+ TunnelError,
55
+ PluginHostingError,
56
+ callAgentHalfHook,
57
+ createPluginHosting,
58
+ createTunnel,
59
+ deleteTunnel,
60
+ getPluginHosting,
61
+ pluginHostingOwner,
62
+ pluginHostingPhase,
63
+ probePluginHosting,
64
+ removePluginHosting,
65
+ setPluginHostingAgentHalf,
66
+ setPluginHostingPhase,
67
+ signingSecretFor,
68
+ toggleTunnel,
69
+ } from '@lamalibre/lamaste/server';
70
+ import { StageFailure } from './plugin-operations.js';
71
+ import {
72
+ AppJobSigningError,
73
+ createAgentJob,
74
+ createAppJob,
75
+ cancelAgentJobs,
76
+ waitForAgentJob,
77
+ } from './agent-jobs.js';
78
+ import { installedAgentHalf, installedRelayHooks } from './plugin-hooks.js';
79
+ import { forgetRetainedData, recordRetainedData } from './retained-data.js';
80
+ import { runRelayHook } from './relay-hooks.js';
81
+ import { recordAuditBestEffort } from './audit.js';
82
+ import {
83
+ tunnelNginxDeps,
84
+ tunnelCertbotDeps,
85
+ tunnelChiselDeps,
86
+ tunnelStateDeps,
87
+ } from './tunnel-deps.js';
88
+
89
+ const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
90
+
91
+ /** How long Lamaste waits for the agent half to answer through a newly granted tunnel. */
92
+ export const TUNNEL_READY_TIMEOUT_MS =
93
+ Number(process.env.LAMALIBRE_LAMASTE_TUNNEL_READY_MS) || 150_000;
94
+ const TUNNEL_READY_POLL_MS = 3_000;
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Stages
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /** The pipeline-wide context: who, what, and the outputs so far. */
101
+ function lifecycleInput(ctx, phase) {
102
+ return {
103
+ plugin: ctx.plugin.name,
104
+ version: ctx.version ?? ctx.plugin.version,
105
+ ...(ctx.previousVersion ? { previousVersion: ctx.previousVersion } : {}),
106
+ phase,
107
+ agent: { label: ctx.agentLabel },
108
+ settings: {},
109
+ outputs: { ...ctx.outputs },
110
+ ...(ctx.port ? { port: ctx.port } : {}),
111
+ };
112
+ }
113
+
114
+ function relayStageDecl(ctx, stage) {
115
+ return ctx.relay?.set.stages.find((s) => s.stage === stage) ?? null;
116
+ }
117
+
118
+ function agentStageDecl(ctx, stage) {
119
+ return ctx.agentHooks?.stages.find((s) => s.stage === stage) ?? null;
120
+ }
121
+
122
+ /**
123
+ * Run a relay lifecycle stage when the plugin declares it. Returns false
124
+ * when there was nothing to run; throws {@link StageFailure} when the hook
125
+ * failed or refused.
126
+ */
127
+ async function relayStage(op, ctx, stage, phase) {
128
+ const decl = relayStageDecl(ctx, stage);
129
+ if (!decl) return false;
130
+ await op.step(`relay ${stage}`, async () => {
131
+ const result = await runRelayHook({
132
+ plugin: ctx.plugin.name,
133
+ stage,
134
+ call: { kind: 'app' },
135
+ input: lifecycleInput(ctx, phase),
136
+ timeoutMs: decl.timeoutMs,
137
+ effects: ctx.relay.set.effects,
138
+ logger: ctx.logger,
139
+ });
140
+ if (!result.ok) {
141
+ throw new StageFailure(
142
+ `relay ${stage} failed (${result.kind})${result.message ? `: ${result.message}` : ''}`,
143
+ );
144
+ }
145
+ if (!result.decision.ok) {
146
+ throw new StageFailure(`relay ${stage} refused: ${result.decision.reason}`);
147
+ }
148
+ if (result.decision.output !== undefined) {
149
+ ctx.outputs[`relay:${stage}`] = result.decision.output;
150
+ }
151
+ });
152
+ return true;
153
+ }
154
+
155
+ /**
156
+ * Run an agent lifecycle stage when the agent half declares it — through the
157
+ * tunnel (`via: 'tunnel'`) or by the agent's sync on its loopback (`via: 'job'`).
158
+ */
159
+ async function agentStage(op, ctx, stage, phase, via) {
160
+ const decl = agentStageDecl(ctx, stage);
161
+ if (!decl) return false;
162
+ await op.step(`agent ${stage}`, async () => {
163
+ const input = lifecycleInput(ctx, phase);
164
+ let result;
165
+ if (via === 'tunnel') {
166
+ const record = await ownHostingRecord(ctx);
167
+ if (!record) throw new StageFailure(`agent ${stage}: the plugin is not hosted`);
168
+ result = await callAgentHalfHook({
169
+ plugin: ctx.route,
170
+ port: record.port,
171
+ secret: signingSecretFor(record),
172
+ stage,
173
+ input,
174
+ timeoutMs: decl.timeoutMs,
175
+ });
176
+ } else {
177
+ const jobId = await createAgentJob({
178
+ agentLabel: ctx.agentLabel,
179
+ plugin: ctx.route,
180
+ stage,
181
+ input,
182
+ timeoutMs: decl.timeoutMs,
183
+ });
184
+ result = await waitForAgentJob(jobId);
185
+ }
186
+ if (!result.ok) throw new StageFailure(`agent ${stage} failed: ${result.error}`);
187
+ const { decision } = result;
188
+ if (!decision.ok) throw new StageFailure(`agent ${stage} refused: ${decision.reason}`);
189
+ if (decision.output !== undefined) ctx.outputs[`agent:${stage}`] = decision.output;
190
+ });
191
+ return true;
192
+ }
193
+
194
+ /** {@link relayStage}, recording the stage as skipped when the plugin does not declare it. */
195
+ async function relayStageOrSkip(op, ctx, stage, phase) {
196
+ const ran = await relayStage(op, ctx, stage, phase);
197
+ if (!ran) op.skip(`relay ${stage}`, 'not declared');
198
+ return ran;
199
+ }
200
+
201
+ /** {@link agentStage}, recording the stage as skipped when the agent half does not declare it. */
202
+ async function agentStageOrSkip(op, ctx, stage, phase, via) {
203
+ const ran = await agentStage(op, ctx, stage, phase, via);
204
+ if (!ran) op.skip(`agent ${stage}`, 'not declared');
205
+ return ran;
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Lamaste's own steps
210
+ // ---------------------------------------------------------------------------
211
+
212
+ /**
213
+ * The internal tunnel's workflow: the core tunnel functions wired to the
214
+ * daemon's nginx, certbot, chisel and state, and the wait until the agent
215
+ * half answers through the tunnel. Replaced in tests, which have no chisel
216
+ * to grant through. Each throws TunnelError as the core does.
217
+ */
218
+ const coreTunnelOps = Object.freeze({
219
+ /** Create the plugin's internal tunnel, disabled (the port is held, chisel grants nothing). */
220
+ async create(ctx) {
221
+ const { isActiveAgent } = await import('./plugin-hosting.js');
222
+ return createTunnel({
223
+ exposure: 'internal',
224
+ type: 'plugin',
225
+ pluginName: ctx.plugin.packageName,
226
+ agentLabel: ctx.agentLabel,
227
+ description: `Internal tunnel of plugin ${ctx.plugin.name}`,
228
+ maxBodySizeMb: TUNNEL_MAX_BODY_MB.default,
229
+ enabled: false,
230
+ domain: ctx.domain,
231
+ email: ctx.email,
232
+ nginx: tunnelNginxDeps,
233
+ certbot: tunnelCertbotDeps,
234
+ chisel: tunnelChiselDeps,
235
+ state: tunnelStateDeps,
236
+ agents: { isActiveAgent },
237
+ logger: ctx.logger,
238
+ });
239
+ },
240
+ /** Grant the tunnel (chisel). */
241
+ async enable(ctx) {
242
+ await toggleTunnel({
243
+ id: ctx.tunnelId,
244
+ enabled: true,
245
+ nginx: tunnelNginxDeps,
246
+ chisel: tunnelChiselDeps,
247
+ state: tunnelStateDeps,
248
+ logger: ctx.logger,
249
+ });
250
+ },
251
+ async remove(ctx) {
252
+ await deleteTunnel({
253
+ id: ctx.tunnelId,
254
+ nginx: tunnelNginxDeps,
255
+ chisel: tunnelChiselDeps,
256
+ state: tunnelStateDeps,
257
+ logger: ctx.logger,
258
+ });
259
+ },
260
+ /** Resolve once the agent half answers `/admin/health` through the tunnel of `record`. */
261
+ async waitForAgentHalf(record) {
262
+ const deadline = Date.now() + TUNNEL_READY_TIMEOUT_MS;
263
+ let last = 'no probe yet';
264
+ while (Date.now() < deadline) {
265
+ const probe = await probePluginHosting({
266
+ plugin: record.plugin,
267
+ port: record.port,
268
+ secret: signingSecretFor(record),
269
+ });
270
+ if (probe.ok) return;
271
+ last = `${probe.reason}: ${probe.error}`;
272
+ await new Promise((r) => setTimeout(r, TUNNEL_READY_POLL_MS));
273
+ }
274
+ throw new StageFailure(
275
+ `The agent half did not answer through the tunnel within ${Math.round(TUNNEL_READY_TIMEOUT_MS / 1000)} s (${last})`,
276
+ );
277
+ },
278
+ });
279
+
280
+ let tunnelOps = coreTunnelOps;
281
+
282
+ /** Replace the internal tunnel's workflow (tests); null restores the real one. */
283
+ export function setTunnelOpsForTests(ops) {
284
+ tunnelOps = ops ? { ...coreTunnelOps, ...ops } : coreTunnelOps;
285
+ }
286
+
287
+ /** Add or remove one capability on an agent's record (registry lock inside). */
288
+ async function setHostCapability(ctx, present) {
289
+ const { setAgentCapability } = await import('./plugin-hosting.js');
290
+ try {
291
+ await setAgentCapability(ctx.agentLabel, pluginHostCapability(ctx.route), present);
292
+ } catch (err) {
293
+ // A revoked agent has no record to edit; the capability went with it.
294
+ if (present || err.statusCode !== 400) throw err;
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Reserve: the internal tunnel without its chisel grant (the port is held),
300
+ * the hosting record with its secret (`installing`), and the host capability
301
+ * — enough for the agent to fetch its config and for its agent half to
302
+ * answer hook calls on its loopback. Undone in reverse on failure.
303
+ */
304
+ async function reserveHosting(ctx) {
305
+ let tunnel;
306
+ try {
307
+ tunnel = await tunnelOps.create(ctx);
308
+ } catch (err) {
309
+ if (err instanceof TunnelError) throw new StageFailure(err.message);
310
+ throw err;
311
+ }
312
+ ctx.tunnelId = tunnel.id;
313
+ ctx.port = tunnel.port;
314
+ try {
315
+ await createPluginHosting(STATE_DIR, {
316
+ plugin: ctx.route,
317
+ pluginName: ctx.plugin.name,
318
+ agentLabel: ctx.agentLabel,
319
+ port: tunnel.port,
320
+ tunnelId: tunnel.id,
321
+ phase: 'installing',
322
+ });
323
+ } catch (err) {
324
+ await removeTunnel(ctx);
325
+ if (err instanceof PluginHostingError) throw new StageFailure(err.message);
326
+ throw err;
327
+ }
328
+ try {
329
+ await setHostCapability(ctx, true);
330
+ } catch (err) {
331
+ await removePluginHosting(STATE_DIR, ctx.route).catch(() => undefined);
332
+ await removeTunnel(ctx);
333
+ throw err;
334
+ }
335
+ ctx.reserved = true;
336
+ }
337
+
338
+ async function removeTunnel(ctx) {
339
+ if (!ctx.tunnelId) return;
340
+ try {
341
+ await tunnelOps.remove(ctx);
342
+ } catch (err) {
343
+ if (!(err instanceof TunnelError && err.code === 'NOT_FOUND')) throw err;
344
+ }
345
+ ctx.tunnelGranted = false;
346
+ }
347
+
348
+ /**
349
+ * The hosting of the pipeline's plugin, or null. A record on its route that
350
+ * names another plugin as its owner is refused — a pipeline never acts on
351
+ * another plugin's hosting.
352
+ */
353
+ async function ownHostingRecord(ctx) {
354
+ const record = await getPluginHosting(STATE_DIR, ctx.route);
355
+ if (record && pluginHostingOwner(record) !== ctx.plugin.name) {
356
+ throw new StageFailure(
357
+ `The hosting on route "${ctx.route}" belongs to plugin "${pluginHostingOwner(record)}", not "${ctx.plugin.name}"`,
358
+ );
359
+ }
360
+ return record;
361
+ }
362
+
363
+ /** Wait until the agent half answers through the tunnel. */
364
+ async function waitForAgentHalf(ctx) {
365
+ const record = await ownHostingRecord(ctx);
366
+ if (!record) throw new StageFailure('The plugin is no longer hosted');
367
+ await tunnelOps.waitForAgentHalf(record);
368
+ }
369
+
370
+ /** Grant the tunnel (chisel), then wait until the agent half answers through it. */
371
+ async function grantTunnel(ctx) {
372
+ try {
373
+ await tunnelOps.enable(ctx);
374
+ } catch (err) {
375
+ if (err instanceof TunnelError) throw new StageFailure(err.message);
376
+ throw err;
377
+ }
378
+ ctx.tunnelGranted = true;
379
+ await waitForAgentHalf(ctx);
380
+ }
381
+
382
+ /** Revoke the secret and the capability (the record goes). */
383
+ async function releaseHosting(ctx) {
384
+ await setHostCapability(ctx, false);
385
+ await removePluginHosting(STATE_DIR, ctx.route);
386
+ ctx.reserved = false;
387
+ }
388
+
389
+ // ---------------------------------------------------------------------------
390
+ // The agent half Lamaste installs (app jobs through the agent's root helper)
391
+ // ---------------------------------------------------------------------------
392
+
393
+ /**
394
+ * Dispatch one app job for the plugin to its hosting agent and wait for its
395
+ * result. `onDispatched` runs once the job exists — from then on the agent
396
+ * may act on it, whatever it reports later. A job the relay cannot sign
397
+ * (its signing key is unusable) is never created: the step fails.
398
+ */
399
+ async function runAppJob(ctx, kind, params, onDispatched) {
400
+ let id;
401
+ try {
402
+ id = await createAppJob({ agentLabel: ctx.agentLabel, plugin: ctx.route, kind, ...params });
403
+ } catch (err) {
404
+ if (err instanceof AppJobSigningError) throw new StageFailure(err.message);
405
+ throw err;
406
+ }
407
+ onDispatched?.();
408
+ return waitForAgentJob(id);
409
+ }
410
+
411
+ /** Record on the hosting which version of the agent half the agent runs (null: none). */
412
+ async function recordAgentHalf(ctx, pkg, version) {
413
+ await setPluginHostingAgentHalf(
414
+ STATE_DIR,
415
+ ctx.route,
416
+ version === null ? null : { package: pkg, version, installedAt: new Date().toISOString() },
417
+ );
418
+ }
419
+
420
+ /**
421
+ * Have the hosting agent install the pinned agent half (`ctx.agentHalf`)
422
+ * and start it; for an upgrade the helper switches `current` to it. Sets
423
+ * `ctx.appInstall` to what may now be on the agent: `dispatched` as soon as
424
+ * the job exists — the agent may act on it whatever it reports, so what
425
+ * undoes the install runs in any case (removing is idempotent) — then
426
+ * `failed` when the helper reported a failure, `installed` when it
427
+ * reported the install. The install is audited only once the reported
428
+ * version and unit state are verified.
429
+ */
430
+ async function installAgentHalf(op, ctx, phase) {
431
+ const spec = ctx.agentHalf;
432
+ const result = await runAppJob(ctx, 'app-install', { spec }, () => {
433
+ ctx.appInstall = 'dispatched';
434
+ });
435
+ if (!result.ok) {
436
+ if (!result.expired) ctx.appInstall = 'failed';
437
+ throw new StageFailure(`Installing the agent half failed: ${result.error}`);
438
+ }
439
+ ctx.appInstall = 'installed';
440
+ const { version, unit } = result.app;
441
+ if (version !== null) await recordAgentHalf(ctx, spec.package, version);
442
+ // Data this agent kept of the same agent package is the installed half's again.
443
+ const retained = await forgetRetainedData(ctx.agentLabel, ctx.route);
444
+ if (version !== spec.version) {
445
+ throw new StageFailure(
446
+ `The agent reports version ${version ?? 'none'} of the agent half after installing ${spec.version}`,
447
+ );
448
+ }
449
+ if (unit !== 'active') {
450
+ throw new StageFailure(
451
+ `The agent half ${spec.version} is installed, but its service is ${unit}`,
452
+ );
453
+ }
454
+ await recordAuditBestEffort(
455
+ {
456
+ actor: op.actor ?? 'admin',
457
+ action: 'plugin.agent-half.installed',
458
+ plugin: ctx.plugin.name,
459
+ details: {
460
+ agentLabel: ctx.agentLabel,
461
+ package: spec.package,
462
+ version: spec.version,
463
+ integrity: spec.integrity,
464
+ ...(retained > 0 ? { retainedDataReused: true } : {}),
465
+ phase,
466
+ operation: op.id,
467
+ },
468
+ },
469
+ ctx.logger,
470
+ );
471
+ }
472
+
473
+ /** The `app-remove` result must say the agent half is gone: no version, no unit. */
474
+ function assertRemoved(result, what) {
475
+ if (!result.ok) throw new StageFailure(`${what} failed: ${result.error}`);
476
+ const { version, unit } = result.app;
477
+ if (version !== null || unit !== 'absent') {
478
+ throw new StageFailure(
479
+ `The agent still reports the agent half after removing it (version ${version ?? 'none'}, service ${unit})`,
480
+ );
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Have the hosting agent remove the agent half: unit, code and config;
486
+ * with `purge` its data directory and service account too. Without purge
487
+ * they stay on the agent, and the relay keeps a record of them
488
+ * (`retained-data.js`) so they can be purged later — the plugin's removal
489
+ * included. After an install the helper reported as failed nothing new is
490
+ * recorded: the helper undoes its own failed work, and data that was there
491
+ * before it is not this package's to claim (a record of it, if any, stays).
492
+ * An install that never reported is recorded: the helper creates the data
493
+ * directory early, and losing track of data that is there would be worse
494
+ * than a record of data that is not (purging it is harmless).
495
+ */
496
+ async function removeAgentHalf(op, ctx, phase, purge) {
497
+ const record = await ownHostingRecord(ctx);
498
+ const pkg = record?.agentHalf?.package ?? ctx.agentHalf?.package ?? null;
499
+ if (pkg === null) {
500
+ // The signed job names the package, so the helper never removes or
501
+ // purges an agent half another package owns.
502
+ throw new StageFailure(
503
+ "The agent half's package is not known, so its removal cannot be signed",
504
+ );
505
+ }
506
+ const helperFailed = ctx.appInstall === 'failed';
507
+ const result = await runAppJob(ctx, 'app-remove', { purge, package: pkg });
508
+ assertRemoved(result, 'Removing the agent half');
509
+ ctx.appInstall = null;
510
+ await recordAgentHalf(ctx, null, null);
511
+ if (purge) {
512
+ await forgetRetainedData(ctx.agentLabel, ctx.route);
513
+ } else if (helperFailed) {
514
+ // Nothing of this install's to keep a record of.
515
+ } else {
516
+ await recordRetainedData({
517
+ agentLabel: ctx.agentLabel,
518
+ route: ctx.route,
519
+ plugin: ctx.plugin.name,
520
+ package: pkg,
521
+ operation: op.id,
522
+ });
523
+ }
524
+ await recordAuditBestEffort(
525
+ {
526
+ actor: op.actor ?? 'admin',
527
+ action: 'plugin.agent-half.removed',
528
+ plugin: ctx.plugin.name,
529
+ details: {
530
+ agentLabel: ctx.agentLabel,
531
+ version: record?.agentHalf?.version ?? null,
532
+ purge,
533
+ phase,
534
+ operation: op.id,
535
+ },
536
+ },
537
+ ctx.logger,
538
+ );
539
+ }
540
+
541
+ /** A failed upgrade: point the agent half back at the version that ran before it. */
542
+ async function activatePreviousAgentHalf(op, ctx) {
543
+ const previous = ctx.previousAgentHalfVersion;
544
+ if (!previous) throw new StageFailure('No earlier version of the agent half is known');
545
+ const pkg = ctx.previousAgentHalfPackage ?? ctx.agentHalf.package;
546
+ const result = await runAppJob(ctx, 'app-activate', { version: previous, package: pkg });
547
+ if (!result.ok) {
548
+ throw new StageFailure(`Activating agent half ${previous} failed: ${result.error}`);
549
+ }
550
+ const { version, unit } = result.app;
551
+ if (version !== null) await recordAgentHalf(ctx, pkg, version);
552
+ if (version !== previous) {
553
+ throw new StageFailure(
554
+ `The agent reports version ${version ?? 'none'} of the agent half after activating ${previous}`,
555
+ );
556
+ }
557
+ if (unit !== 'active') {
558
+ throw new StageFailure(
559
+ `The agent half ${previous} is active again, but its service is ${unit}`,
560
+ );
561
+ }
562
+ await recordAuditBestEffort(
563
+ {
564
+ actor: op.actor ?? 'admin',
565
+ action: 'plugin.agent-half.activated',
566
+ plugin: ctx.plugin.name,
567
+ details: {
568
+ agentLabel: ctx.agentLabel,
569
+ version: previous,
570
+ from: ctx.agentHalf?.version ?? null,
571
+ operation: op.id,
572
+ },
573
+ },
574
+ ctx.logger,
575
+ );
576
+ }
577
+
578
+ /**
579
+ * Remove the agent half as part of a teardown — when Lamaste installed it
580
+ * there, or may have (an install job was dispatched). An agent that is no
581
+ * longer enrolled cannot be reached: the step is skipped and the operator
582
+ * told how to remove it on the machine.
583
+ */
584
+ async function teardownAgentHalf(op, ctx, phase) {
585
+ const { isActiveAgent } = await import('./plugin-hosting.js');
586
+ if (!(await isActiveAgent(ctx.agentLabel))) {
587
+ op.skip('remove the agent half', 'the agent is no longer enrolled');
588
+ op.warn(
589
+ `The agent half stays on agent "${ctx.agentLabel}" — remove it there: sudo lamaste-agent apphost remove ${ctx.route}${ctx.purge ? ' --purge' : ''}`,
590
+ );
591
+ return;
592
+ }
593
+ const purge = phase === 'rollback' ? false : ctx.purge;
594
+ await op.step('remove the agent half', () => removeAgentHalf(op, ctx, phase, purge));
595
+ }
596
+
597
+ /**
598
+ * Purge the data agent `retained.agentLabel` kept of an agent half removed
599
+ * without purge: an `app-remove` job with purge (the unit and code are gone
600
+ * already; the helper removes the data directory and the service account),
601
+ * then the record goes. The plugin may be uninstalled by now.
602
+ *
603
+ * @param {import('./plugin-operations.js').OperationContext} op
604
+ * @param {{ retained: { agentLabel: string, route: string, plugin: string, package: string }, logger?: import('pino').Logger }} input
605
+ */
606
+ export async function runPurgeRetainedData(op, { retained, logger }) {
607
+ const ctx = { agentLabel: retained.agentLabel, route: retained.route };
608
+ await op.step('purge the retained data', async () => {
609
+ const result = await runAppJob(ctx, 'app-remove', {
610
+ purge: true,
611
+ package: retained.package,
612
+ });
613
+ assertRemoved(result, 'Purging the retained data');
614
+ await forgetRetainedData(retained.agentLabel, retained.route);
615
+ });
616
+ await recordAuditBestEffort(
617
+ {
618
+ actor: op.actor ?? 'admin',
619
+ action: 'plugin.agent-half.purged',
620
+ plugin: retained.plugin,
621
+ details: {
622
+ agentLabel: retained.agentLabel,
623
+ route: retained.route,
624
+ package: retained.package,
625
+ operation: op.id,
626
+ },
627
+ },
628
+ logger,
629
+ );
630
+ return { status: 'succeeded' };
631
+ }
632
+
633
+ /**
634
+ * The context every pipeline works from. `agentHalf` is the pinned agent
635
+ * half Lamaste installs (null: none, or installed by hand); `purge`
636
+ * (uninstall) removes its data and service account with it.
637
+ */
638
+ export function pipelineContext({ plugin, agentLabel, domain, email, logger, purge = false }) {
639
+ return {
640
+ plugin,
641
+ route: plugin.route ?? derivePluginRoute(plugin.name),
642
+ agentLabel,
643
+ domain,
644
+ email,
645
+ logger,
646
+ relay: installedRelayHooks(plugin),
647
+ agentHooks: agentHookSetOf({ hooks: plugin.hooks }),
648
+ agentHalf: installedAgentHalf(plugin),
649
+ purge,
650
+ outputs: {},
651
+ touched: { relay: false, agent: false },
652
+ reserved: false,
653
+ tunnelGranted: false,
654
+ tunnelId: null,
655
+ port: null,
656
+ /**
657
+ * What this pipeline may have left of the agent half on the agent: null
658
+ * (nothing), `dispatched` (an install job exists and the agent has not
659
+ * answered — it may have acted on it), `failed` (the helper reported a
660
+ * failure; it may still have left something) or `installed`.
661
+ */
662
+ appInstall: null,
663
+ /** The version (and package) the agent ran before an upgrade switched it. */
664
+ previousAgentHalfVersion: null,
665
+ previousAgentHalfPackage: null,
666
+ };
667
+ }
668
+
669
+ /** Host the plugin on its agent: the install pipeline. */
670
+ export async function runInstallPipeline(op, ctx) {
671
+ if (
672
+ !AGENT_LABEL_REGEX.test(ctx.agentLabel) ||
673
+ ctx.agentLabel.startsWith(PLUGIN_AGENT_CN_PREFIX)
674
+ ) {
675
+ throw new StageFailure('agentLabel must name an enrolled machine agent');
676
+ }
677
+ // relay pre: nothing exists yet; a failure leaves nothing.
678
+ if (await relayStageOrSkip(op, ctx, 'install:pre-tunnel', 'install')) ctx.touched.relay = true;
679
+
680
+ try {
681
+ await op.step('reserve port, secret and capability', () => reserveHosting(ctx));
682
+ } catch (err) {
683
+ if (ctx.touched.relay) await rollback(op, ctx, err);
684
+ throw err;
685
+ }
686
+
687
+ if (ctx.agentHalf) {
688
+ try {
689
+ // The agent fetches the package, checks it against the pinned
690
+ // integrity and has its root helper install and start it — with the
691
+ // secret reserved above, so the agent half can answer the agent's
692
+ // hook calls next.
693
+ await op.step('install the agent half', () => installAgentHalf(op, ctx, 'install'));
694
+ } catch (err) {
695
+ await rollback(op, ctx, err);
696
+ throw err;
697
+ }
698
+ }
699
+
700
+ try {
701
+ // agent pre: on its loopback, before the tunnel exists.
702
+ if (await agentStageOrSkip(op, ctx, 'install:pre-tunnel', 'install', 'job')) {
703
+ ctx.touched.agent = true;
704
+ }
705
+ } catch (err) {
706
+ // A failing pre stage: what Lamaste prepared goes, the relay side's
707
+ // pre stage (if it ran) is rolled back. The agent's own stage failed and
708
+ // cleans up after itself.
709
+ await rollback(op, ctx, err);
710
+ throw err;
711
+ }
712
+
713
+ try {
714
+ await op.step('grant the tunnel and wait for the agent half', () => grantTunnel(ctx));
715
+ if (await agentStageOrSkip(op, ctx, 'install:post-tunnel', 'install', 'tunnel')) {
716
+ ctx.touched.agent = true;
717
+ }
718
+ if (await relayStageOrSkip(op, ctx, 'install:post-tunnel', 'install')) {
719
+ ctx.touched.relay = true;
720
+ }
721
+ await op.step('activate', async () => {
722
+ await setPluginHostingPhase(STATE_DIR, ctx.route, 'active');
723
+ });
724
+ } catch (err) {
725
+ await rollback(op, ctx, err);
726
+ return { status: 'rolled-back', error: String(err.message).slice(0, 500) };
727
+ }
728
+ await recordAuditBestEffort(
729
+ {
730
+ actor: op.actor ?? 'admin',
731
+ action: 'plugin.hosted',
732
+ plugin: ctx.plugin.name,
733
+ details: { agentLabel: ctx.agentLabel, port: ctx.port, operation: op.id },
734
+ },
735
+ ctx.logger,
736
+ );
737
+ return { status: 'succeeded' };
738
+ }
739
+
740
+ /**
741
+ * Roll an unfinished install back: the uninstall pipeline with
742
+ * `phase: "rollback"` for the sides that took part, then Lamaste's own
743
+ * steps in reverse. Never throws; what fails is reported as a warning.
744
+ */
745
+ async function rollback(op, ctx, cause) {
746
+ op.warn(`Rolling back: ${String(cause?.message ?? cause).slice(0, 300)}`);
747
+ await cancelAgentJobs(ctx.route).catch(() => undefined);
748
+ await runTeardown(op, ctx, { phase: 'rollback', force: true });
749
+ }
750
+
751
+ /**
752
+ * The teardown both uninstall and rollback run. With `force`, a failing
753
+ * pre-teardown stage is reported and the teardown goes on.
754
+ */
755
+ async function runTeardown(op, ctx, { phase, force }) {
756
+ const warnOnFailure = async (fn) => {
757
+ try {
758
+ await fn();
759
+ } catch (err) {
760
+ op.warn(String(err.message ?? err));
761
+ }
762
+ };
763
+ // Without `force`, a failing step stops the teardown.
764
+ const soft = force ? warnOnFailure : (fn) => fn();
765
+ const relaySide = phase !== 'rollback' || ctx.touched.relay;
766
+ const agentSide = phase !== 'rollback' || ctx.touched.agent;
767
+
768
+ if (relaySide) await soft(() => relayStage(op, ctx, 'uninstall:pre-teardown', phase));
769
+ if (agentSide && ctx.reserved) {
770
+ await soft(() =>
771
+ agentStage(op, ctx, 'uninstall:pre-teardown', phase, ctx.tunnelGranted ? 'tunnel' : 'job'),
772
+ );
773
+ }
774
+
775
+ if (ctx.reserved || ctx.tunnelId) {
776
+ await soft(() =>
777
+ op.step('remove the tunnel', async () => {
778
+ if (ctx.reserved) await setPluginHostingPhase(STATE_DIR, ctx.route, 'removing');
779
+ await removeTunnel(ctx);
780
+ }),
781
+ );
782
+ }
783
+ // The agent still holds the secret: its post-teardown runs on its loopback.
784
+ if (agentSide && ctx.reserved) {
785
+ await warnOnFailure(() => agentStage(op, ctx, 'uninstall:post-teardown', phase, 'job'));
786
+ }
787
+ // The agent half Lamaste installed goes before the secret it runs with.
788
+ if (ctx.reserved && ctx.appInstall !== null) {
789
+ await soft(() => teardownAgentHalf(op, ctx, phase));
790
+ }
791
+ if (ctx.reserved) {
792
+ await soft(() => op.step('revoke the secret and the capability', () => releaseHosting(ctx)));
793
+ }
794
+ if (relaySide) await warnOnFailure(() => relayStage(op, ctx, 'uninstall:post-teardown', phase));
795
+ }
796
+
797
+ /**
798
+ * Remove a plugin's hosting: the uninstall pipeline. With `force`, failing
799
+ * pre-teardown stages do not stop it (a broken or gone agent must not keep
800
+ * a plugin on the relay); the admin chose that, and it is audited. The
801
+ * agent half Lamaste installed is removed (`ctx.purge`: with its data); one
802
+ * whose install never finished is removed as well, in case it ran.
803
+ */
804
+ export async function runUninstallPipeline(op, ctx, { force = false } = {}) {
805
+ // Only the plugin's own hosting is ever torn down (throws on another's).
806
+ const record = await ownHostingRecord(ctx);
807
+ if (!record) {
808
+ op.skip('teardown', 'the plugin is not hosted');
809
+ return { status: 'succeeded' };
810
+ }
811
+ const phase = pluginHostingPhase(record);
812
+ ctx.agentLabel = record.agentLabel;
813
+ ctx.tunnelId = record.tunnelId;
814
+ ctx.port = record.port;
815
+ ctx.reserved = true;
816
+ ctx.tunnelGranted = phase === 'active';
817
+ ctx.appInstall = record.agentHalf
818
+ ? 'installed'
819
+ : ctx.agentHalf !== null && phase !== 'active'
820
+ ? 'dispatched'
821
+ : null;
822
+ await runTeardown(op, ctx, { phase: 'uninstall', force });
823
+ await cancelAgentJobs(ctx.route).catch(() => undefined);
824
+ await recordAuditBestEffort(
825
+ {
826
+ actor: op.actor ?? 'admin',
827
+ action: 'plugin.unhosted',
828
+ plugin: ctx.plugin.name,
829
+ details: {
830
+ agentLabel: record.agentLabel,
831
+ force,
832
+ ...(ctx.purge ? { purge: true } : {}),
833
+ operation: op.id,
834
+ warnings: op.warnings.length,
835
+ },
836
+ },
837
+ ctx.logger,
838
+ );
839
+ return { status: 'succeeded' };
840
+ }
841
+
842
+ /**
843
+ * The install pipeline around an upgrade of the relay package (the agent
844
+ * half is already running; its stages go through the tunnel). `apply` is
845
+ * Lamaste's step — install the new package and its hook module; `revert`
846
+ * restores the previous ones. Agent and relay stages see `phase: "upgrade"`.
847
+ *
848
+ * A hosted plugin whose agent half Lamaste installs gets the new version of
849
+ * it first (after the agent's pre stage): the agent's helper installs it
850
+ * and switches `current`, and Lamaste waits until it answers through the
851
+ * tunnel. A failure after that switch activates the version that ran
852
+ * before (`ctx.previousAgentHalfVersion`).
853
+ */
854
+ export async function runUpgradePipeline(op, ctx, { apply, revert, hosted }) {
855
+ let applied = false;
856
+ try {
857
+ if (hosted) {
858
+ await relayStageOrSkip(op, ctx, 'install:pre-tunnel', 'upgrade');
859
+ const record = await ownHostingRecord(ctx);
860
+ ctx.agentLabel = record?.agentLabel ?? ctx.agentLabel;
861
+ ctx.port = record?.port ?? null;
862
+ ctx.previousAgentHalfVersion = record?.agentHalf?.version ?? null;
863
+ ctx.previousAgentHalfPackage = record?.agentHalf?.package ?? null;
864
+ await agentStageOrSkip(op, ctx, 'install:pre-tunnel', 'upgrade', 'tunnel');
865
+ if (ctx.agentHalf) {
866
+ await op.step('install the new agent half', async () => {
867
+ await installAgentHalf(op, ctx, 'upgrade');
868
+ await waitForAgentHalf(ctx);
869
+ });
870
+ }
871
+ }
872
+ await op.step('install the new version', async () => {
873
+ await apply();
874
+ applied = true;
875
+ });
876
+ if (hosted) {
877
+ await agentStageOrSkip(op, ctx, 'install:post-tunnel', 'upgrade', 'tunnel');
878
+ await relayStageOrSkip(op, ctx, 'install:post-tunnel', 'upgrade');
879
+ }
880
+ } catch (err) {
881
+ op.warn(`Reverting: ${String(err.message ?? err).slice(0, 300)}`);
882
+ try {
883
+ await op.step('restore the previous version', () => revert({ applied }));
884
+ } catch (revertErr) {
885
+ op.warn(`Could not restore the previous version: ${revertErr.message}`);
886
+ }
887
+ // Once the install job was dispatched the helper may have switched
888
+ // `current`, whatever it reported — the version that ran before goes
889
+ // back (activating it is idempotent).
890
+ const switched = ctx.appInstall !== null;
891
+ if (switched) {
892
+ try {
893
+ await op.step('activate the previous agent half', () => activatePreviousAgentHalf(op, ctx));
894
+ } catch (activateErr) {
895
+ op.warn(`Could not activate the previous agent half: ${activateErr.message}`);
896
+ }
897
+ }
898
+ if (!applied && !switched) throw err;
899
+ return { status: 'rolled-back', error: String(err.message).slice(0, 500) };
900
+ }
901
+ return { status: 'succeeded' };
902
+ }