@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.
- package/package.json +2 -2
- package/scripts/lamaste-priv +205 -2
- package/src/index.js +4 -4
- package/src/lib/node-version.js +114 -0
- package/src/lib/relay-services.js +15 -1
- package/src/lib/service-config.js +113 -0
- package/src/tasks/gatekeeper.js +8 -3
- package/src/tasks/node.js +34 -75
- package/src/tasks/redeploy.js +36 -0
- package/vendor/docs/00-introduction/how-it-works.md +2 -2
- package/vendor/docs/00-introduction/quickstart.md +5 -5
- package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
- package/vendor/docs/01-concepts/authentication.md +6 -3
- package/vendor/docs/01-concepts/authorization.md +29 -17
- package/vendor/docs/01-concepts/certificates.md +3 -3
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/nginx-reverse-proxy.md +8 -3
- package/vendor/docs/01-concepts/security-model.md +98 -34
- package/vendor/docs/01-concepts/tickets.md +2 -2
- package/vendor/docs/01-concepts/tunneling.md +4 -4
- package/vendor/docs/02-guides/agent-setup.md +34 -16
- package/vendor/docs/02-guides/certificate-management.md +2 -2
- package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
- package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
- package/vendor/docs/02-guides/installation.md +3 -3
- package/vendor/docs/02-guides/onboarding.md +2 -2
- package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
- package/vendor/docs/03-architecture/installer-flow.md +2 -2
- package/vendor/docs/03-architecture/installer.md +4 -4
- package/vendor/docs/03-architecture/management-flow.md +1 -1
- package/vendor/docs/03-architecture/nginx-configuration.md +91 -26
- package/vendor/docs/03-architecture/overview.md +8 -8
- package/vendor/docs/03-architecture/panel-client.md +6 -6
- package/vendor/docs/03-architecture/panel-server.md +2 -2
- package/vendor/docs/03-architecture/state-management.md +2 -2
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/agent-hosted-plugins.md +407 -0
- package/vendor/docs/04-api-reference/app-host.md +360 -0
- package/vendor/docs/04-api-reference/audit-and-notifications.md +57 -0
- package/vendor/docs/04-api-reference/certificates.md +95 -38
- package/vendor/docs/04-api-reference/gatekeeper.md +5 -3
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +333 -0
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/overview.md +137 -111
- package/vendor/docs/04-api-reference/registry-and-oidc.md +7 -3
- package/vendor/docs/04-api-reference/tunnels.md +15 -13
- package/vendor/docs/05-operations/backup-and-restore.md +1 -1
- package/vendor/docs/05-operations/uninstalling.md +1 -1
- package/vendor/docs/05-operations/upgrades.md +25 -20
- package/vendor/docs/06-reference/config-files.md +190 -97
- package/vendor/docs/06-reference/glossary.md +3 -3
- package/vendor/docs/06-reference/installer-flags.md +13 -13
- package/vendor/docs/06-reference/ports-and-services.md +1 -1
- package/vendor/docs/06-reference/troubleshooting.md +33 -8
- package/vendor/docs/_index.json +20 -0
- package/vendor/docs/gatekeeper-design.md +22 -19
- package/vendor/docs/identity-directory-design.md +57 -56
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- package/vendor/gatekeeper/dist/lib/types.d.ts +12 -0
- package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.d.ts +2 -0
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +47 -8
- package/vendor/gatekeeper/dist/server/index.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.js +233 -141
- package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
- package/vendor/gatekeeper/package.json +4 -3
- package/vendor/server/bin/lamaste-server.js +18 -1
- package/vendor/server/package.json +2 -2
- package/vendor/server/src/commands/audit.js +60 -0
- package/vendor/server/src/commands/plugins.js +963 -12
- package/vendor/server/src/commands/push-install.js +317 -0
- package/vendor/server/src/commands/registry-credential.js +2 -1
- package/vendor/server/src/commands/uninstall.js +28 -1
- package/vendor/server/src/ndjson.js +4 -2
- package/vendor/server/src/panel-api.js +32 -4
- package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +1 -0
- package/vendor/server-ui/dist/assets/index-DBp3cmaU.js +84 -0
- package/vendor/server-ui/dist/index.html +2 -2
- package/vendor/serverd/package.json +2 -2
- package/vendor/serverd/src/index.js +56 -4
- package/vendor/serverd/src/lib/actor.js +24 -0
- package/vendor/serverd/src/lib/agent-jobs.js +330 -0
- package/vendor/serverd/src/lib/app-error.js +10 -0
- package/vendor/serverd/src/lib/audit.js +145 -0
- package/vendor/serverd/src/lib/hook-effects.js +123 -0
- package/vendor/serverd/src/lib/hook-pipelines.js +902 -0
- package/vendor/serverd/src/lib/migrations/state/0006_plugin_agent.sql +4 -0
- package/vendor/serverd/src/lib/migrations/state/0007_audit_and_notifications.sql +29 -0
- package/vendor/serverd/src/lib/migrations/state/0008_plugin_hooks.sql +70 -0
- package/vendor/serverd/src/lib/migrations/state/0009_plugin_agent_half.sql +53 -0
- package/vendor/serverd/src/lib/migrations/state/0010_plugin_routes_and_retained_data.sql +47 -0
- package/vendor/serverd/src/lib/migrations/state/0011_agent_job_signature.sql +14 -0
- package/vendor/serverd/src/lib/mtls.js +89 -8
- package/vendor/serverd/src/lib/nginx.js +298 -44
- package/vendor/serverd/src/lib/notifications.js +107 -0
- package/vendor/serverd/src/lib/plugin-hooks.js +560 -0
- package/vendor/serverd/src/lib/plugin-hosting.js +688 -0
- package/vendor/serverd/src/lib/plugin-lifecycle.js +603 -0
- package/vendor/serverd/src/lib/plugin-operations.js +295 -0
- package/vendor/serverd/src/lib/plugin-packages.js +249 -0
- package/vendor/serverd/src/lib/plugins.js +298 -68
- package/vendor/serverd/src/lib/push-install.js +88 -2
- package/vendor/serverd/src/lib/relay-hooks.js +100 -0
- package/vendor/serverd/src/lib/relay-signing.js +43 -0
- package/vendor/serverd/src/lib/request-hooks.js +79 -0
- package/vendor/serverd/src/lib/retained-data.js +99 -0
- package/vendor/serverd/src/lib/trust-proxy.js +34 -0
- package/vendor/serverd/src/lib/tunnel-hooks.js +88 -0
- package/vendor/serverd/src/lib/vhost-reconcile.js +260 -0
- package/vendor/serverd/src/middleware/twofa-session.js +11 -1
- package/vendor/serverd/src/routes/agent-forwarder.js +37 -0
- package/vendor/serverd/src/routes/management/agent-panel-authz.js +97 -0
- package/vendor/serverd/src/routes/management/agents.js +262 -1
- package/vendor/serverd/src/routes/management/audit.js +81 -0
- package/vendor/serverd/src/routes/management/certs.js +1 -15
- package/vendor/serverd/src/routes/management/plugins.js +569 -54
- package/vendor/serverd/src/routes/management/tunnels.js +26 -0
- package/vendor/serverd/src/routes/management.js +4 -0
- package/vendor/server-ui/dist/assets/index-B5Wp8lu-.js +0 -257
- package/vendor/server-ui/dist/assets/index-BqWWFlTU.css +0 -1
|
@@ -6,10 +6,45 @@
|
|
|
6
6
|
* install <package> Install a plugin by npm package name
|
|
7
7
|
* enable <name> Enable a plugin
|
|
8
8
|
* disable <name> Disable a plugin
|
|
9
|
-
* uninstall <name>
|
|
9
|
+
* uninstall <name> [--force] [--purge]
|
|
10
|
+
* Remove a plugin (must be disabled first); a hosted one
|
|
11
|
+
* through its uninstall pipeline (an operation); --purge also
|
|
12
|
+
* deletes the data of the agent half Lamaste installed
|
|
10
13
|
* oidc-clients <name> The OIDC clients a plugin declares on Authelia
|
|
11
14
|
* rotate-secret <name> <clientId>
|
|
12
15
|
* New secret for a confidential OIDC client (shown once)
|
|
16
|
+
* hosting <name> Which agent hosts an agent-hosted plugin's agent half
|
|
17
|
+
* eligibility <name> Which enrolled agents can host the plugin, and why the others cannot
|
|
18
|
+
* host <name> --agent <label>
|
|
19
|
+
* Host the plugin on an agent: the install pipeline (its hooks,
|
|
20
|
+
* the agent half Lamaste installs, internal tunnel, secret,
|
|
21
|
+
* capability), followed to its end
|
|
22
|
+
* unhost <name> [--force] [--purge]
|
|
23
|
+
* Remove the hosting: the uninstall pipeline; --purge also deletes
|
|
24
|
+
* the agent half's data directory and service account
|
|
25
|
+
* rotate-hosting-secret <name>
|
|
26
|
+
* New installation secret; the agent picks it up on its next sync
|
|
27
|
+
* hooks <name> The plugin's lifecycle hooks and what needs approval: the relay
|
|
28
|
+
* hook set (stages, routes, fail-open hooks, effects), the agent
|
|
29
|
+
* half Lamaste installs, the approval digest; agent hooks
|
|
30
|
+
* approve-hooks <name> --digest <sha256>
|
|
31
|
+
* Approve with the approval digest `hooks` shows (or a pending update's)
|
|
32
|
+
* auto-approve <name> on|off
|
|
33
|
+
* Approve what this plugin's later versions bring automatically (audited)
|
|
34
|
+
* update <name> [--version <v>]
|
|
35
|
+
* Update an agent-hosted plugin in place (default: latest)
|
|
36
|
+
* discard-update <name>
|
|
37
|
+
* Drop an update that waits for approval
|
|
38
|
+
* operations <name> Recent hosting / update / uninstall operations and their steps
|
|
39
|
+
* retained-data [<name>]
|
|
40
|
+
* What agents keep of agent halves removed without purge — of every
|
|
41
|
+
* plugin (uninstalled ones too), or of one
|
|
42
|
+
* purge-data <name> --agent <label>
|
|
43
|
+
* Delete what an agent keeps of the plugin's agent half (its data
|
|
44
|
+
* directory and account) through its app host helper — an operation,
|
|
45
|
+
* followed to its end
|
|
46
|
+
* forget-data <name> --agent <label>
|
|
47
|
+
* Drop only the relay's record of it; the data stays on the agent
|
|
13
48
|
*
|
|
14
49
|
* Every subcommand goes through the running panel (`/api/plugins/*`, admin
|
|
15
50
|
* certificate): the panel owns the plugin registry (SQLite `state.db`), runs
|
|
@@ -22,6 +57,7 @@
|
|
|
22
57
|
|
|
23
58
|
import chalk from 'chalk';
|
|
24
59
|
import { execa } from 'execa';
|
|
60
|
+
import { AGENT_LABEL_REGEX } from '@lamalibre/lamaste';
|
|
25
61
|
import { PANEL_SERVICE } from '../config.js';
|
|
26
62
|
import { panelRequest } from '../panel-api.js';
|
|
27
63
|
import { emit, emitStep, emitError, emitComplete, emitLog } from '../ndjson.js';
|
|
@@ -46,11 +82,39 @@ export async function runPlugins(args, { json }) {
|
|
|
46
82
|
case 'disable':
|
|
47
83
|
return disablePluginCmd(args[1], { json });
|
|
48
84
|
case 'uninstall':
|
|
49
|
-
return uninstallPluginCmd(args[1], { json });
|
|
85
|
+
return uninstallPluginCmd(args[1], args.slice(2), { json });
|
|
50
86
|
case 'oidc-clients':
|
|
51
87
|
return oidcClientsCmd(args[1], { json });
|
|
52
88
|
case 'rotate-secret':
|
|
53
89
|
return rotateSecretCmd(args[1], args[2], { json });
|
|
90
|
+
case 'hosting':
|
|
91
|
+
return hostingCmd(args[1], { json });
|
|
92
|
+
case 'eligibility':
|
|
93
|
+
return eligibilityCmd(args[1], { json });
|
|
94
|
+
case 'host':
|
|
95
|
+
return hostCmd(args[1], args.slice(2), { json });
|
|
96
|
+
case 'unhost':
|
|
97
|
+
return unhostCmd(args[1], args.slice(2), { json });
|
|
98
|
+
case 'rotate-hosting-secret':
|
|
99
|
+
return rotateHostingSecretCmd(args[1], { json });
|
|
100
|
+
case 'hooks':
|
|
101
|
+
return hooksCmd(args[1], { json });
|
|
102
|
+
case 'approve-hooks':
|
|
103
|
+
return approveHooksCmd(args[1], args.slice(2), { json });
|
|
104
|
+
case 'auto-approve':
|
|
105
|
+
return autoApproveCmd(args[1], args[2], { json });
|
|
106
|
+
case 'update':
|
|
107
|
+
return updateCmd(args[1], args.slice(2), { json });
|
|
108
|
+
case 'discard-update':
|
|
109
|
+
return discardUpdateCmd(args[1], { json });
|
|
110
|
+
case 'operations':
|
|
111
|
+
return operationsCmd(args[1], { json });
|
|
112
|
+
case 'retained-data':
|
|
113
|
+
return retainedDataCmd(args.slice(1), { json });
|
|
114
|
+
case 'purge-data':
|
|
115
|
+
return purgeDataCmd(args.slice(1), { json });
|
|
116
|
+
case 'forget-data':
|
|
117
|
+
return forgetDataCmd(args.slice(1), { json });
|
|
54
118
|
default:
|
|
55
119
|
printPluginUsage();
|
|
56
120
|
process.exit(sub ? 1 : 0);
|
|
@@ -68,10 +132,38 @@ ${b('Subcommands:')}
|
|
|
68
132
|
${c('install <package>')} Install a plugin (e.g. @lamalibre/herd-server)
|
|
69
133
|
${c('enable <name>')} Enable a plugin
|
|
70
134
|
${c('disable <name>')} Disable a plugin
|
|
71
|
-
${c('uninstall <name>')}
|
|
135
|
+
${c('uninstall <name> [--force] [--purge]')}
|
|
136
|
+
Remove a disabled plugin (a hosted one through its uninstall pipeline;
|
|
137
|
+
--purge deletes the data of the agent half Lamaste installed)
|
|
72
138
|
${c('oidc-clients <name>')} OIDC clients the plugin declares on Authelia (no secrets)
|
|
73
139
|
${c('rotate-secret <name> <clientId>')}
|
|
74
140
|
Replace a confidential client's secret — printed once
|
|
141
|
+
${c('hosting <name>')} Which agent hosts the plugin's agent half, port, health
|
|
142
|
+
${c('eligibility <name>')} Which agents can host the plugin, and why the others cannot
|
|
143
|
+
${c('host <name> --agent <label>')}
|
|
144
|
+
Host an agent-hosted plugin on an agent (install pipeline)
|
|
145
|
+
${c('unhost <name> [--force] [--purge]')}
|
|
146
|
+
Remove the hosting (uninstall pipeline; --force passes failing hooks,
|
|
147
|
+
--purge deletes the agent half's data and service account)
|
|
148
|
+
${c('rotate-hosting-secret <name>')}
|
|
149
|
+
New installation secret (the agent fetches it on its next sync)
|
|
150
|
+
${c('hooks <name>')} Lifecycle hooks and what needs approval: relay hooks, agent half
|
|
151
|
+
${c('approve-hooks <name> --digest <sha256>')}
|
|
152
|
+
Approve with the approval digest "hooks" showed (or a pending update's)
|
|
153
|
+
${c('auto-approve <name> on|off')}
|
|
154
|
+
Approve what this plugin's later versions bring automatically
|
|
155
|
+
${c('update <name> [--version <v>]')}
|
|
156
|
+
Update an agent-hosted plugin in place (default: latest)
|
|
157
|
+
${c('discard-update <name>')}
|
|
158
|
+
Drop an update that waits for approval
|
|
159
|
+
${c('operations <name>')} Recent operations of the plugin and their steps
|
|
160
|
+
${c('retained-data [<name>]')}
|
|
161
|
+
What agents keep of agent halves removed without purge (every
|
|
162
|
+
plugin's, uninstalled ones too, or one plugin's)
|
|
163
|
+
${c('purge-data <name> --agent <label>')}
|
|
164
|
+
Delete it on the agent — its data directory and account (a push)
|
|
165
|
+
${c('forget-data <name> --agent <label>')}
|
|
166
|
+
Drop only the relay's record; the data stays on the agent
|
|
75
167
|
`);
|
|
76
168
|
}
|
|
77
169
|
|
|
@@ -107,11 +199,754 @@ async function listPlugins({ json }) {
|
|
|
107
199
|
for (const p of plugins) {
|
|
108
200
|
const status = p.status === 'enabled' ? g('enabled') : r('disabled');
|
|
109
201
|
const hosting = p.hosting === 'agent' ? chalk.yellow('agent-hosted') : d('server-hosted');
|
|
110
|
-
|
|
202
|
+
const hostedOn = p.agentHosting
|
|
203
|
+
? ` ${d('on')} ${c(p.agentHosting.agentLabel)}:${p.agentHosting.port} ${healthWord(p.agentHosting.health)}` +
|
|
204
|
+
(p.agentHosting.agentHalf ? d(` agent half ${p.agentHosting.agentHalf.version}`) : '')
|
|
205
|
+
: '';
|
|
206
|
+
console.log(
|
|
207
|
+
` ${c(p.name)} ${d(p.version)} ${status} ${hosting}${hostedOn} ${d(p.packageName)}`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
console.log('');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** One coloured word for a hosting's health. */
|
|
214
|
+
function healthWord(health) {
|
|
215
|
+
if (!health || health.state === 'unknown') return chalk.dim('(health unknown)');
|
|
216
|
+
if (health.state === 'up') return chalk.green('up');
|
|
217
|
+
return chalk.red(`down (${health.reason ?? 'unknown'})`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Print a hosting record.
|
|
222
|
+
* @param {Record<string, unknown>} hosting
|
|
223
|
+
*/
|
|
224
|
+
function printHosting(hosting) {
|
|
225
|
+
const d = chalk.dim;
|
|
226
|
+
console.log('');
|
|
227
|
+
console.log(chalk.bold(` Hosting of ${hosting.plugin}`));
|
|
228
|
+
console.log(d(' ' + '\u2500'.repeat(40)));
|
|
229
|
+
console.log(` agent: ${chalk.cyan(hosting.agentLabel)}`);
|
|
230
|
+
console.log(
|
|
231
|
+
` port: ${hosting.port} ${d('(relay loopback, internal tunnel ' + hosting.tunnelId + ')')}`,
|
|
232
|
+
);
|
|
233
|
+
console.log(
|
|
234
|
+
` secret: issued ${hosting.secretIssuedAt}${hosting.secretAcknowledged ? '' : d(' — not yet fetched by the agent')}${hosting.rotationPending ? chalk.yellow(' (rotation pending: previous secret still accepted)') : ''}`,
|
|
235
|
+
);
|
|
236
|
+
const h = hosting.health ?? {};
|
|
237
|
+
console.log(
|
|
238
|
+
` health: ${healthWord(h)}${h.since ? d(` since ${h.since}`) : ''}${h.lastError && h.state !== 'up' ? d(` — ${h.lastError}`) : ''}`,
|
|
239
|
+
);
|
|
240
|
+
if (hosting.agentHalf) {
|
|
241
|
+
console.log(
|
|
242
|
+
` agent half: ${hosting.agentHalf.version} ${d(`installed by Lamaste ${hosting.agentHalf.installedAt}`)}`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
console.log('');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* @param {string | undefined} name
|
|
250
|
+
* @param {{ json: boolean }} options
|
|
251
|
+
*/
|
|
252
|
+
async function hostingCmd(name, { json }) {
|
|
253
|
+
if (!name) fail('Usage: lamaste-server plugins hosting <name>', json);
|
|
254
|
+
let result;
|
|
255
|
+
try {
|
|
256
|
+
result = await panelRequest('GET', `/api/plugins/${encodeURIComponent(name)}/hosting`);
|
|
257
|
+
} catch (err) {
|
|
258
|
+
fail(err.message, json);
|
|
259
|
+
}
|
|
260
|
+
if (json) emit(result);
|
|
261
|
+
else printHosting(result.hosting);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Which enrolled machine agents can host the plugin, and why the others
|
|
266
|
+
* cannot: its `agent.requires` and, when Lamaste installs the agent half,
|
|
267
|
+
* what installing it needs, against what each agent reported on its sync.
|
|
268
|
+
*
|
|
269
|
+
* @param {string | undefined} name
|
|
270
|
+
* @param {{ json: boolean }} options
|
|
271
|
+
*/
|
|
272
|
+
async function eligibilityCmd(name, { json }) {
|
|
273
|
+
if (!name) fail('Usage: lamaste-server plugins eligibility <name>', json);
|
|
274
|
+
let result;
|
|
275
|
+
try {
|
|
276
|
+
result = await panelRequest(
|
|
277
|
+
'GET',
|
|
278
|
+
`/api/plugins/${encodeURIComponent(name)}/hosting/eligibility`,
|
|
279
|
+
);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
fail(err.message, json);
|
|
282
|
+
}
|
|
283
|
+
if (json) {
|
|
284
|
+
emit(result);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const { agents } = result;
|
|
288
|
+
if (agents.length === 0) {
|
|
289
|
+
console.log(`\n No enrolled machine agent — enroll one first.\n`);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const d = chalk.dim;
|
|
293
|
+
const width = Math.max('AGENT'.length, ...agents.map((a) => a.label.length));
|
|
294
|
+
const column = 'ELIGIBLE'.length;
|
|
295
|
+
console.log('');
|
|
296
|
+
console.log(chalk.bold(` Agents that can host ${name}`));
|
|
297
|
+
console.log(d(' ' + '\u2500'.repeat(40)));
|
|
298
|
+
console.log(d(` ${'AGENT'.padEnd(width)} ${'ELIGIBLE'.padEnd(column)} REASONS`));
|
|
299
|
+
for (const agent of agents) {
|
|
300
|
+
const word = agent.eligible
|
|
301
|
+
? chalk.green('yes'.padEnd(column))
|
|
302
|
+
: chalk.red('no'.padEnd(column));
|
|
303
|
+
const [first, ...more] = agent.reasons;
|
|
304
|
+
console.log(` ${chalk.cyan(agent.label.padEnd(width))} ${word} ${first ?? ''}`);
|
|
305
|
+
for (const reason of more) console.log(` ${' '.repeat(width + column + 2)} ${reason}`);
|
|
306
|
+
}
|
|
307
|
+
console.log('');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* @param {string | undefined} name
|
|
312
|
+
* @param {string[]} rest
|
|
313
|
+
* @param {{ json: boolean }} options
|
|
314
|
+
*/
|
|
315
|
+
async function hostCmd(name, rest, { json }) {
|
|
316
|
+
const agentIdx = rest.indexOf('--agent');
|
|
317
|
+
const agentLabel = agentIdx !== -1 ? rest[agentIdx + 1] : undefined;
|
|
318
|
+
if (!name || !agentLabel) fail('Usage: lamaste-server plugins host <name> --agent <label>', json);
|
|
319
|
+
if (!json) console.log(`\n Hosting ${chalk.cyan(name)} on ${chalk.cyan(agentLabel)}`);
|
|
320
|
+
let result;
|
|
321
|
+
try {
|
|
322
|
+
result = await panelRequest('POST', `/api/plugins/${encodeURIComponent(name)}/hosting`, {
|
|
323
|
+
agentLabel,
|
|
324
|
+
});
|
|
325
|
+
} catch (err) {
|
|
326
|
+
failRefused(err, json, { step: 'host', name, agentLabel, action: 'install' });
|
|
327
|
+
}
|
|
328
|
+
const operation = await followOperation(name, result.operation, { json });
|
|
329
|
+
if (operation.status !== 'succeeded') process.exit(1);
|
|
330
|
+
const { hosting } = await panelRequest('GET', `/api/plugins/${encodeURIComponent(name)}/hosting`);
|
|
331
|
+
if (json) {
|
|
332
|
+
emitComplete({ operation, hosting });
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
printHosting(hosting);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* @param {string | undefined} name
|
|
340
|
+
* @param {string[]} rest
|
|
341
|
+
* @param {{ json: boolean }} options
|
|
342
|
+
*/
|
|
343
|
+
async function unhostCmd(name, rest, { json }) {
|
|
344
|
+
if (!name) fail('Usage: lamaste-server plugins unhost <name> [--force] [--purge]', json);
|
|
345
|
+
const force = rest.includes('--force');
|
|
346
|
+
const purge = rest.includes('--purge');
|
|
347
|
+
let result;
|
|
348
|
+
try {
|
|
349
|
+
result = await panelRequest(
|
|
350
|
+
'DELETE',
|
|
351
|
+
`/api/plugins/${encodeURIComponent(name)}/hosting${removalQuery({ force, purge })}`,
|
|
352
|
+
);
|
|
353
|
+
} catch (err) {
|
|
354
|
+
failRefused(err, json, { name, action: 'uninstall' });
|
|
355
|
+
}
|
|
356
|
+
if (!json) {
|
|
357
|
+
const how = [force ? 'forced' : null, purge ? `deleting ${await agentHalfDataDir(name)}` : null]
|
|
358
|
+
.filter(Boolean)
|
|
359
|
+
.join(', ');
|
|
360
|
+
console.log(`\n Removing the hosting of ${chalk.cyan(name)}${how ? ` (${how})` : ''}`);
|
|
361
|
+
}
|
|
362
|
+
await finishOperation(name, result.operation, { json });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
// Operations
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
const STEP_MARK = {
|
|
370
|
+
done: chalk.green('✓'),
|
|
371
|
+
failed: chalk.red('✗'),
|
|
372
|
+
skipped: chalk.dim('–'),
|
|
373
|
+
running: chalk.cyan('…'),
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
/** One finished step of an operation, with why it failed or what it did. */
|
|
377
|
+
function printStep(step) {
|
|
378
|
+
const why = step.error ?? step.detail;
|
|
379
|
+
console.log(
|
|
380
|
+
` ${STEP_MARK[step.status] ?? ' '} ${step.name}${why ? chalk.dim(` — ${why}`) : ''}`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** An operation's warnings. */
|
|
385
|
+
function printWarnings(warnings) {
|
|
386
|
+
for (const warning of warnings ?? []) console.log(` ${chalk.yellow('!')} ${warning}`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Follow an operation until it ends, printing each step as it finishes
|
|
391
|
+
* (NDJSON steps with --json). Hosting waits on the agent's sync, so this
|
|
392
|
+
* can take a few minutes.
|
|
393
|
+
*
|
|
394
|
+
* @param {string} name
|
|
395
|
+
* @param {{ id: string }} operation
|
|
396
|
+
* @param {{ json: boolean }} options
|
|
397
|
+
*/
|
|
398
|
+
async function followOperation(name, operation, { json }) {
|
|
399
|
+
const printed = new Set();
|
|
400
|
+
let current = operation;
|
|
401
|
+
for (;;) {
|
|
402
|
+
for (const [i, step] of (current.steps ?? []).entries()) {
|
|
403
|
+
if (step.status === 'running' || printed.has(i)) continue;
|
|
404
|
+
printed.add(i);
|
|
405
|
+
if (json) {
|
|
406
|
+
emitStep(
|
|
407
|
+
step.name,
|
|
408
|
+
step.status === 'done' ? 'complete' : step.status,
|
|
409
|
+
step.error ?? step.detail,
|
|
410
|
+
);
|
|
411
|
+
} else {
|
|
412
|
+
printStep(step);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (current.status !== 'running') break;
|
|
416
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
417
|
+
({ operation: current } = await panelRequest(
|
|
418
|
+
'GET',
|
|
419
|
+
`/api/plugins/${encodeURIComponent(name)}/operations/${encodeURIComponent(operation.id)}`,
|
|
420
|
+
));
|
|
421
|
+
}
|
|
422
|
+
if (!json) {
|
|
423
|
+
printWarnings(current.warnings);
|
|
424
|
+
const word =
|
|
425
|
+
current.status === 'succeeded'
|
|
426
|
+
? chalk.green('succeeded')
|
|
427
|
+
: current.status === 'rolled-back'
|
|
428
|
+
? chalk.yellow('rolled back')
|
|
429
|
+
: chalk.red(current.status);
|
|
430
|
+
console.log(`\n ${current.kind} ${word}${current.error ? `: ${current.error}` : ''}\n`);
|
|
431
|
+
// An update that added a confidential OIDC client: its secret, shown once.
|
|
432
|
+
printCreatedSecrets(current.oidcClients);
|
|
433
|
+
}
|
|
434
|
+
return current;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Follow an operation the panel started to its end and exit non-zero
|
|
439
|
+
* unless it succeeded (with --json, its outcome as the complete event).
|
|
440
|
+
*
|
|
441
|
+
* @param {string} name
|
|
442
|
+
* @param {{ id: string }} operation
|
|
443
|
+
* @param {{ json: boolean }} options
|
|
444
|
+
*/
|
|
445
|
+
async function finishOperation(name, operation, { json }) {
|
|
446
|
+
const finished = await followOperation(name, operation, { json });
|
|
447
|
+
if (json) emitComplete({ name, operation: finished });
|
|
448
|
+
if (finished.status !== 'succeeded') process.exit(1);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* @param {string | undefined} name
|
|
453
|
+
* @param {{ json: boolean }} options
|
|
454
|
+
*/
|
|
455
|
+
async function operationsCmd(name, { json }) {
|
|
456
|
+
if (!name) fail('Usage: lamaste-server plugins operations <name>', json);
|
|
457
|
+
const { operations } = await panelRequest(
|
|
458
|
+
'GET',
|
|
459
|
+
`/api/plugins/${encodeURIComponent(name)}/operations`,
|
|
460
|
+
);
|
|
461
|
+
if (json) {
|
|
462
|
+
emit({ operations });
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (operations.length === 0) {
|
|
466
|
+
console.log(`\n No operations for ${chalk.cyan(name)}.\n`);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
console.log('');
|
|
470
|
+
for (const op of operations) {
|
|
471
|
+
console.log(
|
|
472
|
+
` ${chalk.bold(op.kind)} ${chalk.dim(op.startedAt)} by ${op.actor} — ${op.status}${op.error ? chalk.dim(`: ${op.error}`) : ''}`,
|
|
473
|
+
);
|
|
474
|
+
for (const step of op.steps) printStep(step);
|
|
475
|
+
printWarnings(op.warnings);
|
|
476
|
+
}
|
|
477
|
+
console.log('');
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
// Retained data
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The arguments of `purge-data|forget-data <name> --agent <label>`. Pure,
|
|
486
|
+
* exported for tests.
|
|
487
|
+
*
|
|
488
|
+
* @param {'purge-data' | 'forget-data'} sub
|
|
489
|
+
* @param {string[]} args what follows the subcommand
|
|
490
|
+
* @returns {{ ok: true, name: string, agentLabel: string } | { ok: false, error: string }}
|
|
491
|
+
*/
|
|
492
|
+
export function parseRetainedDataArgs(sub, args) {
|
|
493
|
+
const usage = `Usage: lamaste-server plugins ${sub} <name> --agent <label>`;
|
|
494
|
+
let name = null;
|
|
495
|
+
let agentLabel = null;
|
|
496
|
+
for (let i = 0; i < args.length; i++) {
|
|
497
|
+
const arg = args[i];
|
|
498
|
+
if (arg === '--agent') {
|
|
499
|
+
const value = args[i + 1];
|
|
500
|
+
if (value === undefined || value.startsWith('--')) {
|
|
501
|
+
return { ok: false, error: `--agent needs a value — ${usage}` };
|
|
502
|
+
}
|
|
503
|
+
agentLabel = value;
|
|
504
|
+
i++;
|
|
505
|
+
} else if (arg.startsWith('--')) {
|
|
506
|
+
return { ok: false, error: `Unknown option ${arg} — ${usage}` };
|
|
507
|
+
} else if (name === null) {
|
|
508
|
+
name = arg;
|
|
509
|
+
} else {
|
|
510
|
+
return { ok: false, error: `Unexpected argument "${arg}" — ${usage}` };
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (name === null || agentLabel === null) return { ok: false, error: usage };
|
|
514
|
+
if (!AGENT_LABEL_REGEX.test(agentLabel)) {
|
|
515
|
+
return { ok: false, error: `"${agentLabel}" is not an agent label` };
|
|
516
|
+
}
|
|
517
|
+
return { ok: true, name, agentLabel };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Each record of what agents keep, marked with whether its plugin is still
|
|
522
|
+
* installed — a record outlives its plugin's uninstall. Pure, exported for
|
|
523
|
+
* tests.
|
|
524
|
+
*
|
|
525
|
+
* @param {Array<Record<string, unknown>>} records
|
|
526
|
+
* @param {ReadonlySet<string>} installed the names of the installed plugins
|
|
527
|
+
*/
|
|
528
|
+
export function retainedDataRows(records, installed) {
|
|
529
|
+
return records.map((record) => ({ ...record, installed: installed.has(record.plugin) }));
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* `/api/plugins/<name>/retained-data`, with `query` as its query string.
|
|
534
|
+
*
|
|
535
|
+
* @param {string} name
|
|
536
|
+
* @param {Record<string, string>} [query]
|
|
537
|
+
*/
|
|
538
|
+
function retainedDataPath(name, query) {
|
|
539
|
+
const base = `/api/plugins/${encodeURIComponent(name)}/retained-data`;
|
|
540
|
+
return query ? `${base}?${new URLSearchParams(query)}` : base;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* `retained-data [<name>]`: every plugin's records (with whether the plugin
|
|
545
|
+
* is still installed), or one plugin's, by the name it had.
|
|
546
|
+
*
|
|
547
|
+
* @param {string[]} args
|
|
548
|
+
* @param {{ json: boolean }} options
|
|
549
|
+
*/
|
|
550
|
+
async function retainedDataCmd(args, { json }) {
|
|
551
|
+
if (args.length > 1 || args.some((a) => a.startsWith('--'))) {
|
|
552
|
+
fail('Usage: lamaste-server plugins retained-data [<name>]', json);
|
|
553
|
+
}
|
|
554
|
+
const name = args[0];
|
|
555
|
+
let rows;
|
|
556
|
+
try {
|
|
557
|
+
if (name) {
|
|
558
|
+
({ retainedData: rows } = await panelRequest('GET', retainedDataPath(name)));
|
|
559
|
+
} else {
|
|
560
|
+
const { plugins = [], retainedData = [] } = await panelRequest('GET', '/api/plugins');
|
|
561
|
+
rows = retainedDataRows(retainedData, new Set(plugins.map((p) => p.name)));
|
|
562
|
+
}
|
|
563
|
+
} catch (err) {
|
|
564
|
+
fail(err.message, json);
|
|
565
|
+
}
|
|
566
|
+
if (json) {
|
|
567
|
+
emit({ retainedData: rows });
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (rows.length === 0) {
|
|
571
|
+
console.log(
|
|
572
|
+
name
|
|
573
|
+
? `\n No agent keeps data of ${chalk.cyan(name)}'s agent half.\n`
|
|
574
|
+
: '\n No agent keeps data of a removed agent half.\n',
|
|
575
|
+
);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const d = chalk.dim;
|
|
579
|
+
console.log('');
|
|
580
|
+
console.log(chalk.bold(name ? ` Retained data of ${name}` : ' Retained data'));
|
|
581
|
+
console.log(d(' ' + '\u2500'.repeat(40)));
|
|
582
|
+
for (const r of rows) {
|
|
583
|
+
const uninstalled = r.installed === false ? chalk.yellow(' (uninstalled)') : '';
|
|
584
|
+
console.log(
|
|
585
|
+
` ${chalk.cyan(r.plugin)}${uninstalled} ${d('on')} ${chalk.cyan(r.agentLabel)} ` +
|
|
586
|
+
`${d('route')} ${r.route} ${r.package} ${d(`since ${r.since}`)}`,
|
|
587
|
+
);
|
|
588
|
+
console.log(d(` ${r.dataDir}, account ${r.serviceUser}`));
|
|
589
|
+
}
|
|
590
|
+
console.log('');
|
|
591
|
+
console.log(
|
|
592
|
+
d(' Purge it on the agent: lamaste-server plugins purge-data <name> --agent <label>'),
|
|
593
|
+
);
|
|
594
|
+
console.log(
|
|
595
|
+
d(' Forget the record only: lamaste-server plugins forget-data <name> --agent <label>'),
|
|
596
|
+
);
|
|
597
|
+
console.log('');
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* The record of what agent `agentLabel` keeps of plugin `name`, or null —
|
|
602
|
+
* read before a purge or a forget to say what it concerns.
|
|
603
|
+
*/
|
|
604
|
+
async function findRetainedRecord(name, agentLabel) {
|
|
605
|
+
const { retainedData } = await panelRequest('GET', retainedDataPath(name));
|
|
606
|
+
return retainedData.find((r) => r.agentLabel === agentLabel) ?? null;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* `purge-data <name> --agent <label>`: delete what the agent keeps — its
|
|
611
|
+
* data directory and account — through its app host helper. Once the panel
|
|
612
|
+
* has started the operation it says what is being deleted, follows the
|
|
613
|
+
* operation to its end and exits non-zero unless it succeeded.
|
|
614
|
+
*
|
|
615
|
+
* @param {string[]} args
|
|
616
|
+
* @param {{ json: boolean }} options
|
|
617
|
+
*/
|
|
618
|
+
async function purgeDataCmd(args, { json }) {
|
|
619
|
+
const parsed = parseRetainedDataArgs('purge-data', args);
|
|
620
|
+
if (!parsed.ok) fail(parsed.error, json);
|
|
621
|
+
const { name, agentLabel } = parsed;
|
|
622
|
+
let record;
|
|
623
|
+
try {
|
|
624
|
+
record = await findRetainedRecord(name, agentLabel);
|
|
625
|
+
} catch (err) {
|
|
626
|
+
fail(err.message, json);
|
|
627
|
+
}
|
|
628
|
+
if (!record) fail(`Agent "${agentLabel}" keeps no data of plugin "${name}"`, json);
|
|
629
|
+
let result;
|
|
630
|
+
try {
|
|
631
|
+
result = await panelRequest('DELETE', retainedDataPath(name, { agentLabel }));
|
|
632
|
+
} catch (err) {
|
|
633
|
+
failRefused(err, json, { name, agentLabel, action: 'uninstall' });
|
|
634
|
+
}
|
|
635
|
+
if (!json) {
|
|
636
|
+
console.log(
|
|
637
|
+
`\n Purging what ${chalk.cyan(agentLabel)} keeps of ${chalk.cyan(name)} (route ${record.route}): ` +
|
|
638
|
+
`deleting ${record.dataDir} and the account ${record.serviceUser}`,
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
await finishOperation(name, result.operation, { json });
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* `forget-data <name> --agent <label>`: drop the relay's record only. The
|
|
646
|
+
* agent is not touched — said, with how to remove the data there.
|
|
647
|
+
*
|
|
648
|
+
* @param {string[]} args
|
|
649
|
+
* @param {{ json: boolean }} options
|
|
650
|
+
*/
|
|
651
|
+
async function forgetDataCmd(args, { json }) {
|
|
652
|
+
const parsed = parseRetainedDataArgs('forget-data', args);
|
|
653
|
+
if (!parsed.ok) fail(parsed.error, json);
|
|
654
|
+
const { name, agentLabel } = parsed;
|
|
655
|
+
let result;
|
|
656
|
+
try {
|
|
657
|
+
result = await panelRequest('DELETE', retainedDataPath(name, { agentLabel, forget: 'true' }));
|
|
658
|
+
} catch (err) {
|
|
659
|
+
fail(err.message, json);
|
|
660
|
+
}
|
|
661
|
+
const { forgotten } = result;
|
|
662
|
+
if (json) {
|
|
663
|
+
emitComplete({ name, forgotten });
|
|
664
|
+
return;
|
|
111
665
|
}
|
|
666
|
+
console.log(
|
|
667
|
+
`\n Forgot the relay's record of what ${chalk.cyan(agentLabel)} keeps of ${chalk.cyan(name)}.`,
|
|
668
|
+
);
|
|
669
|
+
console.log(
|
|
670
|
+
chalk.yellow(
|
|
671
|
+
` Nothing on ${agentLabel} changed: ${forgotten.dataDir} and the account ${forgotten.serviceUser}` +
|
|
672
|
+
`\n stay there — remove them on the machine with` +
|
|
673
|
+
` sudo lamaste-agent apphost remove ${forgotten.route} --purge`,
|
|
674
|
+
),
|
|
675
|
+
);
|
|
112
676
|
console.log('');
|
|
113
677
|
}
|
|
114
678
|
|
|
679
|
+
// ---------------------------------------------------------------------------
|
|
680
|
+
// Lifecycle hooks
|
|
681
|
+
// ---------------------------------------------------------------------------
|
|
682
|
+
|
|
683
|
+
/** Print a relay hook set preview — the relay half of what an approval covers. */
|
|
684
|
+
function printRelayHookSet(relay, title) {
|
|
685
|
+
const d = chalk.dim;
|
|
686
|
+
console.log(chalk.bold(` ${title}`));
|
|
687
|
+
console.log(
|
|
688
|
+
` module: ${relay.entry ?? d('(none — shared hooks only)')}${relay.entrySha256 ? d(` sha256 ${relay.entrySha256}`) : ''}`,
|
|
689
|
+
);
|
|
690
|
+
for (const stage of relay.stages) {
|
|
691
|
+
console.log(` stage: ${stage.stage} ${d(`(${stage.timeoutMs} ms)`)}`);
|
|
692
|
+
}
|
|
693
|
+
for (const hook of relay.request) {
|
|
694
|
+
const who = hook.use ?? 'plugin module';
|
|
695
|
+
const failOpen =
|
|
696
|
+
hook.failOpen.length > 0 ? chalk.yellow(` FAIL-OPEN: ${hook.failOpen.join(', ')}`) : '';
|
|
697
|
+
console.log(
|
|
698
|
+
` request: ${hook.stage} ${who} on ${hook.routes.join(', ')}${failOpen}` +
|
|
699
|
+
d(` (body ${hook.bodyMaxBytes ?? 0} B, ${hook.timeoutMs} ms, onError ${hook.onError})`),
|
|
700
|
+
);
|
|
701
|
+
if (hook.inactiveRoutes.length > 0) {
|
|
702
|
+
console.log(
|
|
703
|
+
d(` not active until the user surface: ${hook.inactiveRoutes.join(', ')}`),
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
console.log(` effects: ${relay.effects.length > 0 ? relay.effects.join(', ') : d('none')}`);
|
|
708
|
+
if (relay.packs.length > 0) console.log(` packs: ${relay.packs.join(', ')}`);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Print an agent half preview — what Lamaste installs on the hosting agent
|
|
713
|
+
* once approved: the package at the version and tarball integrity the relay
|
|
714
|
+
* pinned, its service and the system packages installed for it.
|
|
715
|
+
*/
|
|
716
|
+
function printAgentHalf(agentHalf, title) {
|
|
717
|
+
const d = chalk.dim;
|
|
718
|
+
const line = (label, value) => console.log(` ${`${label}:`.padEnd(11)}${value}`);
|
|
719
|
+
console.log(chalk.bold(` ${title}`));
|
|
720
|
+
line('package', chalk.cyan(`${agentHalf.package}@${agentHalf.version}`));
|
|
721
|
+
line('integrity', agentHalf.integrity);
|
|
722
|
+
line('entry', `${agentHalf.entry} ${d(`memory ${agentHalf.memoryMax ?? 'not limited'}`)}`);
|
|
723
|
+
line('runs as', `${agentHalf.serviceUser} ${d(`unit ${agentHalf.unitName}`)}`);
|
|
724
|
+
line('data', `${agentHalf.dataDir} ${d('(kept when it is removed, unless purged)')}`);
|
|
725
|
+
line(
|
|
726
|
+
'packages',
|
|
727
|
+
agentHalf.systemPackages.length > 0
|
|
728
|
+
? `${agentHalf.systemPackages.join(', ')} ${d('(system packages, apt-get once approved)')}`
|
|
729
|
+
: d('none'),
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Print what a version brings that needs approval — the approval digest
|
|
735
|
+
* (the one value `approve-hooks` takes), its relay hook set and the agent
|
|
736
|
+
* half Lamaste installs.
|
|
737
|
+
*/
|
|
738
|
+
function printApprovalContents({ digest, relay, agentHalf }) {
|
|
739
|
+
console.log(` approval digest: ${chalk.cyan(digest)}`);
|
|
740
|
+
if (relay) {
|
|
741
|
+
console.log('');
|
|
742
|
+
printRelayHookSet(relay, 'Relay hooks — run in a sandbox on the relay');
|
|
743
|
+
}
|
|
744
|
+
if (agentHalf) {
|
|
745
|
+
console.log('');
|
|
746
|
+
printAgentHalf(agentHalf, 'Agent half — Lamaste installs it on the hosting agent');
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** The command that approves `digest` for plugin `name`. */
|
|
751
|
+
function approveCommand(name, digest) {
|
|
752
|
+
return chalk.cyan(`lamaste-server plugins approve-hooks ${name} --digest ${digest}`);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* @param {string | undefined} name
|
|
757
|
+
* @param {{ json: boolean }} options
|
|
758
|
+
*/
|
|
759
|
+
async function hooksCmd(name, { json }) {
|
|
760
|
+
if (!name) fail('Usage: lamaste-server plugins hooks <name>', json);
|
|
761
|
+
let result;
|
|
762
|
+
try {
|
|
763
|
+
result = await panelRequest('GET', `/api/plugins/${encodeURIComponent(name)}/hooks`);
|
|
764
|
+
} catch (err) {
|
|
765
|
+
fail(err.message, json);
|
|
766
|
+
}
|
|
767
|
+
if (json) {
|
|
768
|
+
emit(result);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const { hooks } = result;
|
|
772
|
+
const { approval } = hooks;
|
|
773
|
+
console.log('');
|
|
774
|
+
if (!approval.required && !hooks.agent && !hooks.pendingUpdate) {
|
|
775
|
+
console.log(
|
|
776
|
+
` Plugin ${chalk.cyan(name)} declares no lifecycle hooks and no agent half for Lamaste to install.\n`,
|
|
777
|
+
);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
if (approval.required) {
|
|
781
|
+
const state =
|
|
782
|
+
approval.state === 'approved'
|
|
783
|
+
? chalk.green(
|
|
784
|
+
`approved by ${approval.approvedBy} at ${approval.approvedAt} (version ${approval.approvedVersion})`,
|
|
785
|
+
)
|
|
786
|
+
: chalk.yellow('waiting for approval');
|
|
787
|
+
console.log(chalk.bold(` What ${name} brings — `) + state);
|
|
788
|
+
printApprovalContents({
|
|
789
|
+
digest: approval.digest,
|
|
790
|
+
relay: hooks.relay,
|
|
791
|
+
agentHalf: hooks.agentHalf,
|
|
792
|
+
});
|
|
793
|
+
console.log(
|
|
794
|
+
`\n auto-approve for later versions: ${hooks.autoApprove ? chalk.yellow('on') : 'off'}`,
|
|
795
|
+
);
|
|
796
|
+
if (approval.state !== 'approved') {
|
|
797
|
+
console.log(`\n Approve with: ${approveCommand(name, approval.digest)}`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
if (hooks.agent) {
|
|
801
|
+
console.log(chalk.bold(`\n Agent hooks (run by the agent half; no approval)`));
|
|
802
|
+
for (const stage of hooks.agent.stages) {
|
|
803
|
+
console.log(` ${stage.stage} ${chalk.dim(`(${stage.timeoutMs} ms)`)}`);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
if (hooks.pendingUpdate) {
|
|
807
|
+
const update = hooks.pendingUpdate;
|
|
808
|
+
console.log('');
|
|
809
|
+
console.log(chalk.bold(` Update to ${update.version} — waits for approval`));
|
|
810
|
+
printApprovalContents(update);
|
|
811
|
+
console.log(`\n Approve and update with: ${approveCommand(name, update.digest)}`);
|
|
812
|
+
}
|
|
813
|
+
console.log('');
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* @param {string | undefined} name
|
|
818
|
+
* @param {string[]} rest
|
|
819
|
+
* @param {{ json: boolean }} options
|
|
820
|
+
*/
|
|
821
|
+
async function approveHooksCmd(name, rest, { json }) {
|
|
822
|
+
const idx = rest.indexOf('--digest');
|
|
823
|
+
const digest = idx !== -1 ? rest[idx + 1] : undefined;
|
|
824
|
+
if (!name || !digest || !/^[a-f0-9]{64}$/.test(digest)) {
|
|
825
|
+
fail(
|
|
826
|
+
'Usage: lamaste-server plugins approve-hooks <name> --digest <sha256> (the approval digest `hooks <name>` shows)',
|
|
827
|
+
json,
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
let result;
|
|
831
|
+
try {
|
|
832
|
+
result = await panelRequest('POST', `/api/plugins/${encodeURIComponent(name)}/hooks/approve`, {
|
|
833
|
+
digest,
|
|
834
|
+
});
|
|
835
|
+
} catch (err) {
|
|
836
|
+
// Approving a pending update starts it: its agent half is a push.
|
|
837
|
+
failRefused(err, json, { name, action: 'update' });
|
|
838
|
+
}
|
|
839
|
+
if (result.operation) {
|
|
840
|
+
if (!json) console.log(`\n Approved; updating ${chalk.cyan(name)}`);
|
|
841
|
+
await finishOperation(name, result.operation, { json });
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
if (json) emitComplete({ name, approved: digest });
|
|
845
|
+
else console.log(`\n What ${chalk.cyan(name)} brings is ${chalk.green('approved')}.\n`);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* @param {string | undefined} name
|
|
850
|
+
* @param {string | undefined} value
|
|
851
|
+
* @param {{ json: boolean }} options
|
|
852
|
+
*/
|
|
853
|
+
async function autoApproveCmd(name, value, { json }) {
|
|
854
|
+
if (!name || (value !== 'on' && value !== 'off')) {
|
|
855
|
+
fail('Usage: lamaste-server plugins auto-approve <name> on|off', json);
|
|
856
|
+
}
|
|
857
|
+
try {
|
|
858
|
+
await panelRequest('PUT', `/api/plugins/${encodeURIComponent(name)}/hooks/auto-approve`, {
|
|
859
|
+
enabled: value === 'on',
|
|
860
|
+
});
|
|
861
|
+
} catch (err) {
|
|
862
|
+
fail(err.message, json);
|
|
863
|
+
}
|
|
864
|
+
if (json) emitComplete({ name, autoApprove: value === 'on' });
|
|
865
|
+
else
|
|
866
|
+
console.log(
|
|
867
|
+
`\n Auto-approve of hook-set changes for ${chalk.cyan(name)}: ${value === 'on' ? chalk.yellow('on') : 'off'}\n`,
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* @param {string | undefined} name
|
|
873
|
+
* @param {string[]} rest
|
|
874
|
+
* @param {{ json: boolean }} options
|
|
875
|
+
*/
|
|
876
|
+
async function updateCmd(name, rest, { json }) {
|
|
877
|
+
if (!name) fail('Usage: lamaste-server plugins update <name> [--version <v>]', json);
|
|
878
|
+
const idx = rest.indexOf('--version');
|
|
879
|
+
const version = idx !== -1 ? rest[idx + 1] : undefined;
|
|
880
|
+
let result;
|
|
881
|
+
try {
|
|
882
|
+
result = await panelRequest(
|
|
883
|
+
'POST',
|
|
884
|
+
`/api/plugins/${encodeURIComponent(name)}/update`,
|
|
885
|
+
version ? { version } : {},
|
|
886
|
+
{ timeoutMs: NPM_CALL_TIMEOUT_MS },
|
|
887
|
+
);
|
|
888
|
+
} catch (err) {
|
|
889
|
+
failRefused(err, json, { name, action: 'update' });
|
|
890
|
+
}
|
|
891
|
+
if (result.upToDate) {
|
|
892
|
+
if (json) emitComplete({ name, upToDate: true, version: result.version });
|
|
893
|
+
else console.log(`\n ${chalk.cyan(name)} is already at ${result.version}.\n`);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
if (result.pendingUpdate) {
|
|
897
|
+
if (json) {
|
|
898
|
+
emitComplete({ name, pendingUpdate: result.pendingUpdate });
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
const update = result.pendingUpdate;
|
|
902
|
+
console.log('');
|
|
903
|
+
console.log(chalk.bold(` ${name} ${update.version} waits for approval`));
|
|
904
|
+
printApprovalContents(update);
|
|
905
|
+
console.log(`\n Approve and update with: ${approveCommand(name, update.digest)}\n`);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
if (!json) console.log(`\n Updating ${chalk.cyan(name)}`);
|
|
909
|
+
await finishOperation(name, result.operation, { json });
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* @param {string | undefined} name
|
|
914
|
+
* @param {{ json: boolean }} options
|
|
915
|
+
*/
|
|
916
|
+
async function discardUpdateCmd(name, { json }) {
|
|
917
|
+
if (!name) fail('Usage: lamaste-server plugins discard-update <name>', json);
|
|
918
|
+
try {
|
|
919
|
+
await panelRequest('DELETE', `/api/plugins/${encodeURIComponent(name)}/update`);
|
|
920
|
+
} catch (err) {
|
|
921
|
+
fail(err.message, json);
|
|
922
|
+
}
|
|
923
|
+
if (json) emitComplete({ name, pendingUpdate: null });
|
|
924
|
+
else console.log(`\n The pending update of ${chalk.cyan(name)} was discarded.\n`);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* @param {string | undefined} name
|
|
929
|
+
* @param {{ json: boolean }} options
|
|
930
|
+
*/
|
|
931
|
+
async function rotateHostingSecretCmd(name, { json }) {
|
|
932
|
+
if (!name) fail('Usage: lamaste-server plugins rotate-hosting-secret <name>', json);
|
|
933
|
+
let result;
|
|
934
|
+
try {
|
|
935
|
+
result = await panelRequest(
|
|
936
|
+
'POST',
|
|
937
|
+
`/api/plugins/${encodeURIComponent(name)}/hosting/rotate-secret`,
|
|
938
|
+
);
|
|
939
|
+
} catch (err) {
|
|
940
|
+
fail(err.message, json);
|
|
941
|
+
}
|
|
942
|
+
if (json) emitComplete({ hosting: result.hosting });
|
|
943
|
+
else {
|
|
944
|
+
console.log(`\n New installation secret issued for ${chalk.cyan(name)}.`);
|
|
945
|
+
console.log(` The previous secret stays accepted until the agent acknowledges the new one.`);
|
|
946
|
+
printHosting(result.hosting);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
115
950
|
/**
|
|
116
951
|
* Print the secrets of confidential OIDC clients an install or enable created.
|
|
117
952
|
* They are shown once — the panel stores only their digests.
|
|
@@ -143,6 +978,92 @@ function fail(message, json, step) {
|
|
|
143
978
|
process.exit(1);
|
|
144
979
|
}
|
|
145
980
|
|
|
981
|
+
/** What Lamaste does with the agent half for each push-install action. */
|
|
982
|
+
const PUSH_VERB = { install: 'installs', update: 'updates', uninstall: 'removes' };
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Exit with a refused plugin operation (hosting, removing a hosting,
|
|
986
|
+
* uninstalling, updating): the panel's message, the reasons an agent
|
|
987
|
+
* cannot host the plugin, and what lifts a refusal the administrator can
|
|
988
|
+
* lift. NDJSON carries the refusal's `code` and `reasons`.
|
|
989
|
+
*
|
|
990
|
+
* @param {Error & { code?: string, reasons?: string[] }} err from panelRequest
|
|
991
|
+
* @param {boolean} json
|
|
992
|
+
* @param {{ step?: string, name: string, agentLabel?: string, action: 'install' | 'update' | 'uninstall' }} context
|
|
993
|
+
* @returns {never}
|
|
994
|
+
*/
|
|
995
|
+
function failRefused(err, json, { step, name, agentLabel, action }) {
|
|
996
|
+
const reasons = err.reasons ?? [];
|
|
997
|
+
if (json) {
|
|
998
|
+
if (step) emitStep(step, 'failed', err.message);
|
|
999
|
+
emitError(err.message, false, {
|
|
1000
|
+
...(err.code ? { code: err.code } : {}),
|
|
1001
|
+
...(err.reasons ? { reasons } : {}),
|
|
1002
|
+
});
|
|
1003
|
+
process.exit(1);
|
|
1004
|
+
}
|
|
1005
|
+
const d = chalk.dim;
|
|
1006
|
+
console.error(`\n ${chalk.red(err.message)}`);
|
|
1007
|
+
if (err.code === 'agent_not_eligible') {
|
|
1008
|
+
for (const reason of reasons) console.error(` - ${reason}`);
|
|
1009
|
+
console.error(
|
|
1010
|
+
d(
|
|
1011
|
+
`\n Every agent and why: lamaste-server plugins eligibility ${name}` +
|
|
1012
|
+
`\n An agent reports its host on its sync (about every 30 seconds).`,
|
|
1013
|
+
),
|
|
1014
|
+
);
|
|
1015
|
+
} else if (err.code === 'push_install_refused') {
|
|
1016
|
+
const agent = agentLabel ? `agent "${agentLabel}"` : 'the hosting agent';
|
|
1017
|
+
console.error(
|
|
1018
|
+
d(
|
|
1019
|
+
`\n Lamaste ${PUSH_VERB[action]} the agent half by push install: push install must be on for` +
|
|
1020
|
+
`\n this relay and enabled for ${agent} with its window open, and the agent's policy must` +
|
|
1021
|
+
`\n allow "${action}" of the agent package from this address (the CLI calls the panel` +
|
|
1022
|
+
`\n from 127.0.0.1).`,
|
|
1023
|
+
),
|
|
1024
|
+
);
|
|
1025
|
+
} else if (err.code === 'hooks_not_approved') {
|
|
1026
|
+
console.error(
|
|
1027
|
+
d(`\n Review what it brings with: lamaste-server plugins hooks ${name}, then approve it.`),
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
console.error('');
|
|
1031
|
+
process.exit(1);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* The data directory of the agent half Lamaste installed for plugin `name`
|
|
1036
|
+
* (from its preview), or a description of it when the panel does not name
|
|
1037
|
+
* one — what a purge deletes, said before it runs.
|
|
1038
|
+
*
|
|
1039
|
+
* @param {string} name
|
|
1040
|
+
* @returns {Promise<string>}
|
|
1041
|
+
*/
|
|
1042
|
+
async function agentHalfDataDir(name) {
|
|
1043
|
+
const unnamed = "the agent half's data directory";
|
|
1044
|
+
try {
|
|
1045
|
+
const { hooks } = await panelRequest('GET', `/api/plugins/${encodeURIComponent(name)}/hooks`);
|
|
1046
|
+
return hooks.agentHalf?.dataDir ?? unnamed;
|
|
1047
|
+
} catch {
|
|
1048
|
+
return unnamed;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* `?force=true&purge=true` for removing a plugin's hosting, with only the
|
|
1054
|
+
* flags that are set: `force` lets failing pre-teardown hooks pass, `purge`
|
|
1055
|
+
* deletes the data and service account of the agent half Lamaste installed.
|
|
1056
|
+
*
|
|
1057
|
+
* @param {{ force: boolean, purge: boolean }} flags
|
|
1058
|
+
*/
|
|
1059
|
+
function removalQuery({ force, purge }) {
|
|
1060
|
+
const params = new URLSearchParams();
|
|
1061
|
+
if (force) params.set('force', 'true');
|
|
1062
|
+
if (purge) params.set('purge', 'true');
|
|
1063
|
+
const text = params.toString();
|
|
1064
|
+
return text ? `?${text}` : '';
|
|
1065
|
+
}
|
|
1066
|
+
|
|
146
1067
|
/**
|
|
147
1068
|
* @param {string | undefined} packageName
|
|
148
1069
|
* @param {{ json: boolean }} options
|
|
@@ -173,7 +1094,7 @@ async function installPluginCmd(packageName, { json }) {
|
|
|
173
1094
|
const { plugin, oidcClients } = result;
|
|
174
1095
|
if (json) {
|
|
175
1096
|
emitStep('install', 'complete');
|
|
176
|
-
emitComplete({ plugin, oidcClients });
|
|
1097
|
+
emitComplete({ plugin, oidcClients, hooks: result.hooks });
|
|
177
1098
|
return;
|
|
178
1099
|
}
|
|
179
1100
|
const hosting = plugin.hosting === 'agent' ? ', agent-hosted' : '';
|
|
@@ -181,6 +1102,19 @@ async function installPluginCmd(packageName, { json }) {
|
|
|
181
1102
|
console.log(` Plugin ${chalk.cyan(plugin.name)} installed (${chalk.dim(`disabled${hosting}`)})`);
|
|
182
1103
|
console.log(` Run ${chalk.cyan(`lamaste-server plugins enable ${plugin.name}`)} to activate.`);
|
|
183
1104
|
console.log('');
|
|
1105
|
+
const approval = result.hooks?.approval;
|
|
1106
|
+
if (approval?.required) {
|
|
1107
|
+
console.log(chalk.bold(` What it brings — review before hosting`));
|
|
1108
|
+
printApprovalContents({
|
|
1109
|
+
digest: approval.digest,
|
|
1110
|
+
relay: result.hooks.relay,
|
|
1111
|
+
agentHalf: result.hooks.agentHalf,
|
|
1112
|
+
});
|
|
1113
|
+
if (approval.state === 'pending') {
|
|
1114
|
+
console.log(`\n Approve with: ${approveCommand(plugin.name, approval.digest)}`);
|
|
1115
|
+
}
|
|
1116
|
+
console.log('');
|
|
1117
|
+
}
|
|
184
1118
|
printCreatedSecrets(oidcClients);
|
|
185
1119
|
}
|
|
186
1120
|
|
|
@@ -241,23 +1175,40 @@ async function disablePluginCmd(name, { json }) {
|
|
|
241
1175
|
* @param {string | undefined} name
|
|
242
1176
|
* @param {{ json: boolean }} options
|
|
243
1177
|
*/
|
|
244
|
-
async function uninstallPluginCmd(name, { json }) {
|
|
245
|
-
if (!name) fail('Usage: lamaste-server plugins uninstall <name>', json);
|
|
1178
|
+
async function uninstallPluginCmd(name, rest, { json }) {
|
|
1179
|
+
if (!name) fail('Usage: lamaste-server plugins uninstall <name> [--force] [--purge]', json);
|
|
1180
|
+
const force = rest.includes('--force');
|
|
1181
|
+
const purge = rest.includes('--purge');
|
|
246
1182
|
|
|
247
1183
|
if (json) emitStep('uninstall', 'running', `Uninstalling ${name}`);
|
|
248
|
-
else
|
|
1184
|
+
else {
|
|
1185
|
+
const deleting = purge ? ` (deleting ${await agentHalfDataDir(name)})` : '';
|
|
1186
|
+
process.stderr.write(` Uninstalling ${chalk.cyan(name)}${deleting}...`);
|
|
1187
|
+
}
|
|
249
1188
|
|
|
1189
|
+
let result;
|
|
250
1190
|
try {
|
|
251
1191
|
// The panel refuses an enabled plugin (409): nothing of it is mounted
|
|
252
1192
|
// once it is disabled, so no restart follows.
|
|
253
|
-
await panelRequest(
|
|
254
|
-
|
|
255
|
-
|
|
1193
|
+
result = await panelRequest(
|
|
1194
|
+
'DELETE',
|
|
1195
|
+
`/api/plugins/${encodeURIComponent(name)}${removalQuery({ force, purge })}`,
|
|
1196
|
+
null,
|
|
1197
|
+
{ timeoutMs: NPM_CALL_TIMEOUT_MS },
|
|
1198
|
+
);
|
|
256
1199
|
} catch (err) {
|
|
257
1200
|
if (!json) console.log(` ${chalk.red('failed')}`);
|
|
258
|
-
|
|
1201
|
+
failRefused(err, json, { step: 'uninstall', name, action: 'uninstall' });
|
|
259
1202
|
}
|
|
260
1203
|
|
|
1204
|
+
if (result.operation) {
|
|
1205
|
+
// Hosted: its uninstall pipeline runs first.
|
|
1206
|
+
if (!json) console.log('');
|
|
1207
|
+
const operation = await followOperation(name, result.operation, { json });
|
|
1208
|
+
if (json) emitComplete({ name, removed: operation.status === 'succeeded', operation });
|
|
1209
|
+
if (operation.status !== 'succeeded') process.exit(1);
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
261
1212
|
if (json) {
|
|
262
1213
|
emitStep('uninstall', 'complete');
|
|
263
1214
|
emitComplete({ name, removed: true });
|