@lifeaitools/clauth 1.31.1 → 2.0.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/.clauth-skill/SKILL.md +31 -0
- package/.clauth-skill/references/operator-guide.md +27 -0
- package/README.md +48 -0
- package/cli/commands/ops-install.js +211 -0
- package/cli/commands/ops.js +69 -0
- package/cli/commands/serve.js +1241 -1676
- package/cli/commands/watchdog.js +1 -1
- package/cli/index.js +93 -1
- package/cli/ops/coolify-adapter.js +80 -0
- package/cli/ops/deployment-adapter.js +63 -0
- package/cli/ops/job-store.js +116 -0
- package/cli/ops/operation-policy.js +51 -0
- package/cli/ops/pm2-adapter.js +128 -0
- package/cli/ops/serialized-executor.js +9 -0
- package/cli/supervisor-registry.js +112 -6
- package/cli/supervisor-registry.test.js +151 -0
- package/cli/watchdog-registry.js +30 -2
- package/cli/watchdog-registry.test.js +28 -5
- package/package.json +3 -2
- package/scripts/bin/bootstrap-linux +0 -0
- package/scripts/bin/bootstrap-macos +0 -0
- package/scripts/bin/bootstrap-win.exe +0 -0
package/.clauth-skill/SKILL.md
CHANGED
|
@@ -216,6 +216,37 @@ raw secrets and no Vault secret names. Fetch `GET /v/<service>` only for the one
|
|
|
216
216
|
exact credential needed by the next command. Never walk every service and never
|
|
217
217
|
bulk-fetch all secrets.
|
|
218
218
|
|
|
219
|
+
## Managed PM2 Operations Control Plane
|
|
220
|
+
|
|
221
|
+
For LIFEAI development hosts, manage an approved PM2 application through the
|
|
222
|
+
bearer-authenticated clauth operations control plane. Do **not** invoke PM2
|
|
223
|
+
directly from an agent, dashboard, or deployment script. The policy file is the
|
|
224
|
+
allowlist: it names permitted applications, operations, deployment definitions,
|
|
225
|
+
and whether host-wide actions are available.
|
|
226
|
+
|
|
227
|
+
Install or validate the local controller with its explicit policy file:
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
# Linux
|
|
231
|
+
clauth ops install --config /etc/clauth/ops-control-plane.json --dry-run
|
|
232
|
+
clauth ops install --config /etc/clauth/ops-control-plane.json
|
|
233
|
+
|
|
234
|
+
# Windows PowerShell
|
|
235
|
+
clauth ops install --config C:\ProgramData\clauth\ops-control-plane.json --dry-run
|
|
236
|
+
clauth ops install --config C:\ProgramData\clauth\ops-control-plane.json
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The live install replaces only the installer-owned `clauth-deployment` PM2
|
|
240
|
+
process, verifies its loopback `/health` response, and checks that an unauthenticated
|
|
241
|
+
catalog request is rejected. It retrieves its bearer only from the local clauth
|
|
242
|
+
service; never paste, print, or place that bearer in a policy file.
|
|
243
|
+
|
|
244
|
+
Use `clauth ops catalog` or the corresponding scoped MCP operations tools for
|
|
245
|
+
approved process inspection and lifecycle actions. Before a production promotion,
|
|
246
|
+
retain the job receipt and verify the selected application's health route. A
|
|
247
|
+
passing install proves controller setup; it does not by itself prove an arbitrary
|
|
248
|
+
application deployment.
|
|
249
|
+
|
|
219
250
|
## MCP Server (v1.16.9+)
|
|
220
251
|
|
|
221
252
|
clauth runs as an MCP server with five namespaced paths and safe
|
|
@@ -100,6 +100,33 @@ select vault.create_secret('new-token-here', 'CLAUTH_ADMIN_BOOTSTRAP_TOKEN', 'ro
|
|
|
100
100
|
|
|
101
101
|
---
|
|
102
102
|
|
|
103
|
+
## Managed PM2 Operations (LIFEAI development hosts)
|
|
104
|
+
|
|
105
|
+
Use the clauth operations controller for approved PM2 lifecycle and deployment
|
|
106
|
+
actions. Its policy JSON is the server-side allowlist; it must define only the
|
|
107
|
+
applications and operations this host is permitted to expose. Do not grant
|
|
108
|
+
host-wide PM2 actions unless there is an explicit operational requirement.
|
|
109
|
+
|
|
110
|
+
Validate before changing the process manager, then install:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
# Linux
|
|
114
|
+
clauth ops install --config /etc/clauth/ops-control-plane.json --dry-run
|
|
115
|
+
clauth ops install --config /etc/clauth/ops-control-plane.json
|
|
116
|
+
|
|
117
|
+
# Windows PowerShell
|
|
118
|
+
clauth ops install --config C:\ProgramData\clauth\ops-control-plane.json --dry-run
|
|
119
|
+
clauth ops install --config C:\ProgramData\clauth\ops-control-plane.json
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The installer owns only the `clauth-deployment` process. It checks the local
|
|
123
|
+
health endpoint and verifies that the catalog remains bearer-gated. Keep the
|
|
124
|
+
controller bearer in clauth, not in a shell history, policy file, or PM2
|
|
125
|
+
environment dump. Use `clauth ops catalog` and scoped operations commands rather
|
|
126
|
+
than invoking PM2 directly.
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
103
130
|
## Viewing the Audit Log
|
|
104
131
|
|
|
105
132
|
```sql
|
package/README.md
CHANGED
|
@@ -164,6 +164,54 @@ description, timestamps, and `has_key`. It never returns raw secrets or Vault
|
|
|
164
164
|
secret names. Use `GET /v/<service>` only for the one exact secret needed by the
|
|
165
165
|
next command. Do not bulk-fetch every service.
|
|
166
166
|
|
|
167
|
+
### PM2 deployment control-plane installer
|
|
168
|
+
|
|
169
|
+
On a managed host, store the allowed PM2 targets and deployment definitions in a
|
|
170
|
+
server-side JSON file, then install or update the supervised endpoint:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
# Linux
|
|
174
|
+
clauth ops install --config /etc/clauth/ops-control-plane.json
|
|
175
|
+
|
|
176
|
+
# Windows PowerShell
|
|
177
|
+
clauth ops install --config C:\ProgramData\clauth\ops-control-plane.json
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The installer is idempotent on Linux and Windows: it creates or updates the
|
|
181
|
+
`clauth-deployment` PM2 process, probes its loopback health endpoint, and proves
|
|
182
|
+
that the operations catalog refuses requests without a bearer token. It does not
|
|
183
|
+
store or print a bearer token. Use `--dry-run` to validate the policy file before
|
|
184
|
+
changing PM2.
|
|
185
|
+
|
|
186
|
+
The policy is an explicit application allowlist; keep `allow_host_wide` false
|
|
187
|
+
unless a host-wide administrative operation has been deliberately approved. A
|
|
188
|
+
minimal local policy looks like:
|
|
189
|
+
|
|
190
|
+
```json
|
|
191
|
+
{
|
|
192
|
+
"port": 53141,
|
|
193
|
+
"pm2_name": "clauth-deployment",
|
|
194
|
+
"operations": {
|
|
195
|
+
"agent_enabled": ["restart", "reload"],
|
|
196
|
+
"agent_applications": {
|
|
197
|
+
"restart": ["codeflow-mcp"],
|
|
198
|
+
"reload": ["codeflow-mcp"]
|
|
199
|
+
},
|
|
200
|
+
"allow_host_wide": false
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Verify the policy before installation, then verify the loopback service without
|
|
206
|
+
ever placing a bearer in a shell command:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
clauth ops install --config <policy-path> --dry-run
|
|
210
|
+
clauth ops install --config <policy-path>
|
|
211
|
+
curl -i http://127.0.0.1:<port>/health
|
|
212
|
+
curl -i http://127.0.0.1:<port>/v1/ops/catalog # must return 401 without a bearer
|
|
213
|
+
```
|
|
214
|
+
|
|
167
215
|
---
|
|
168
216
|
|
|
169
217
|
## MCP Server — 5 Namespaces, 63 Tools
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import pm2 from "pm2";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_PORT = 52439;
|
|
7
|
+
const DEFAULT_NAME = "clauth-deployment";
|
|
8
|
+
const INSTALLER_MARKER = "clauth.ops.install.v1";
|
|
9
|
+
const OPS_ENV_KEYS = new Set([
|
|
10
|
+
"CLAUTH_OPS_INSTALLER_MARKER", "CLAUTH_OPS_ENABLED", "CLAUTH_OPS_APPLICATIONS", "CLAUTH_OPS_ADMIN_ENABLED",
|
|
11
|
+
"CLAUTH_OPS_ADMIN_APPLICATIONS", "CLAUTH_OPS_ALLOW_HOST_WIDE", "CLAUTH_OPS_DEPLOYMENTS", "CLAUTH_COOLIFY_PROMOTE_UUIDS",
|
|
12
|
+
]);
|
|
13
|
+
const RUNTIME_ENV_KEYS = new Set(["PATH", "Path", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "SystemRoot", "SYSTEMROOT", "ComSpec", "TMP", "TEMP"]);
|
|
14
|
+
const RESTORABLE_PM2_KEYS = ["node_args", "exec_mode", "instances", "namespace", "watch", "kill_retry_time", "merge_logs", "windowsHide", "stop_exit_codes"];
|
|
15
|
+
|
|
16
|
+
function requiredString(value, label) {
|
|
17
|
+
if (typeof value !== "string" || !value.trim()) throw new Error(`${label} is required`);
|
|
18
|
+
return value.trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function stringList(value, label) {
|
|
22
|
+
if (value == null) return [];
|
|
23
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item.trim())) throw new Error(`${label} must be an array of non-empty strings`);
|
|
24
|
+
return value.map((item) => item.trim());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function applicationPolicy(value, label) {
|
|
28
|
+
if (value == null) return {};
|
|
29
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
30
|
+
return Object.fromEntries(Object.entries(value).map(([operation, targets]) => [operation, stringList(targets, `${label}.${operation}`)]));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* An operation is only usable when it ALSO has a target allowlist.
|
|
35
|
+
*
|
|
36
|
+
* `createOperationPolicy` reads the allowlist from `applications[operation]` and
|
|
37
|
+
* rejects with `service_not_available` when it is empty (operation-policy.js,
|
|
38
|
+
* the `!allowed.length` guard). That code reads like the service is down, but
|
|
39
|
+
* it actually means "no targets configured" — so a config carrying only
|
|
40
|
+
* `agent_enabled` installs cleanly, passes the health and bearer-gate probes,
|
|
41
|
+
* and then rejects every authenticated call. That exact combination shipped to
|
|
42
|
+
* the canary and was misdiagnosed for a day as a missing credential.
|
|
43
|
+
*
|
|
44
|
+
* Read operations (`list`, `describe`, `ping`, `logs`, …) are affected too: the
|
|
45
|
+
* `!allowed.length` guard runs before the read/mutate distinction, so a read op
|
|
46
|
+
* with no allowlist is rejected just the same.
|
|
47
|
+
*
|
|
48
|
+
* Fail at install instead, naming the operations that would silently break.
|
|
49
|
+
*/
|
|
50
|
+
function assertEnabledOperationsHaveTargets(enabled, applications, label) {
|
|
51
|
+
const missing = enabled.filter((operation) => !(applications[operation] || []).length);
|
|
52
|
+
if (!missing.length) return;
|
|
53
|
+
const applicationsLabel = label.replace("_enabled", "_applications");
|
|
54
|
+
throw new Error(
|
|
55
|
+
`${label}: ${missing.join(", ")} enabled but absent from ${applicationsLabel}. An enabled operation with no target allowlist is rejected at runtime with service_not_available. Add a target list (e.g. {"${missing[0]}": ["*"]}) or remove it from the enabled list.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function loadOpsInstallConfig(configPath) {
|
|
60
|
+
const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
61
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("ops installer config must be a JSON object");
|
|
62
|
+
const port = raw.port == null ? DEFAULT_PORT : Number(raw.port);
|
|
63
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("port must be an integer from 1024 through 65535");
|
|
64
|
+
const pm2Name = raw.pm2_name == null ? DEFAULT_NAME : requiredString(raw.pm2_name, "pm2_name");
|
|
65
|
+
const operations = raw.operations && typeof raw.operations === "object" && !Array.isArray(raw.operations) ? raw.operations : {};
|
|
66
|
+
|
|
67
|
+
const enabled = stringList(operations.agent_enabled, "operations.agent_enabled");
|
|
68
|
+
const applications = applicationPolicy(operations.agent_applications, "operations.agent_applications");
|
|
69
|
+
const adminEnabled = stringList(operations.admin_enabled, "operations.admin_enabled");
|
|
70
|
+
const adminApplications = applicationPolicy(operations.admin_applications, "operations.admin_applications");
|
|
71
|
+
|
|
72
|
+
assertEnabledOperationsHaveTargets(enabled, applications, "operations.agent_enabled");
|
|
73
|
+
assertEnabledOperationsHaveTargets(adminEnabled, adminApplications, "operations.admin_enabled");
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
port,
|
|
77
|
+
pm2Name,
|
|
78
|
+
enabled,
|
|
79
|
+
applications,
|
|
80
|
+
adminEnabled,
|
|
81
|
+
adminApplications,
|
|
82
|
+
allowHostWide: operations.allow_host_wide === true,
|
|
83
|
+
deployments: raw.deployments && typeof raw.deployments === "object" && !Array.isArray(raw.deployments) ? raw.deployments : {},
|
|
84
|
+
coolifyPromoteUuids: stringList(raw.coolify_promote_uuids, "coolify_promote_uuids"),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function buildOpsServiceSpec({ config, rootDir }) {
|
|
89
|
+
const cli = path.join(rootDir, "cli", "index.js");
|
|
90
|
+
const runtimeEnv = Object.fromEntries([...RUNTIME_ENV_KEYS].flatMap((key) => process.env[key] == null ? [] : [[key, process.env[key]]]));
|
|
91
|
+
return {
|
|
92
|
+
name: config.pm2Name,
|
|
93
|
+
script: cli,
|
|
94
|
+
args: `serve foreground --port ${config.port} --isolated`,
|
|
95
|
+
cwd: rootDir,
|
|
96
|
+
autorestart: true,
|
|
97
|
+
restart_delay: 1000,
|
|
98
|
+
max_restarts: 20,
|
|
99
|
+
// Do not inherit the invoking agent or shell environment: PM2 otherwise
|
|
100
|
+
// stores every inherited value in its process metadata.
|
|
101
|
+
// PM2 only activates filtering for a non-empty array. Exclude every
|
|
102
|
+
// inherited key, then explicitly add the small runtime allowlist above.
|
|
103
|
+
filter_env: Object.keys(process.env),
|
|
104
|
+
env: {
|
|
105
|
+
...runtimeEnv,
|
|
106
|
+
CLAUTH_OPS_INSTALLER_MARKER: INSTALLER_MARKER,
|
|
107
|
+
CLAUTH_OPS_ENABLED: [...config.enabled, ...(Object.keys(config.deployments).length ? ["deploy"] : []), ...(config.coolifyPromoteUuids.length ? ["coolify_promote"] : [])].join(","),
|
|
108
|
+
CLAUTH_OPS_APPLICATIONS: JSON.stringify(config.applications),
|
|
109
|
+
CLAUTH_OPS_ADMIN_ENABLED: config.adminEnabled.join(","),
|
|
110
|
+
CLAUTH_OPS_ADMIN_APPLICATIONS: JSON.stringify(config.adminApplications),
|
|
111
|
+
CLAUTH_OPS_ALLOW_HOST_WIDE: config.allowHostWide ? "1" : "0",
|
|
112
|
+
CLAUTH_OPS_DEPLOYMENTS: JSON.stringify(config.deployments),
|
|
113
|
+
CLAUTH_COOLIFY_PROMOTE_UUIDS: JSON.stringify(config.coolifyPromoteUuids),
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function pm2Call(method, ...args) {
|
|
119
|
+
return new Promise((resolve, reject) => pm2[method](...args, (error, value) => error ? reject(error) : resolve(value)));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function probe(endpoint) {
|
|
123
|
+
const health = await fetch(`${endpoint}/health`, { signal: AbortSignal.timeout(5000) });
|
|
124
|
+
const healthBody = health.ok ? await health.json() : null;
|
|
125
|
+
if (healthBody?.status !== "ok") throw new Error(`health probe failed at ${endpoint}`);
|
|
126
|
+
const catalog = await fetch(`${endpoint}/v1/ops/catalog`, { signal: AbortSignal.timeout(5000) });
|
|
127
|
+
if (catalog.status !== 401) throw new Error(`bearer gate probe failed: expected HTTP 401, received ${catalog.status}`);
|
|
128
|
+
return { health_status: health.status, bearer_gate_status: catalog.status, listening_port: healthBody.listening_port, process_id: healthBody.process_id };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function argsFor(entry) {
|
|
132
|
+
const value = entry?.pm2_env?.args ?? entry?.args ?? "";
|
|
133
|
+
return Array.isArray(value) ? value.join(" ") : String(value || "");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function ownedService(entry, spec) {
|
|
137
|
+
const env = entry?.pm2_env || {};
|
|
138
|
+
return env.CLAUTH_OPS_INSTALLER_MARKER === INSTALLER_MARKER
|
|
139
|
+
&& path.resolve(env.pm_exec_path || "") === path.resolve(spec.script)
|
|
140
|
+
&& path.resolve(env.pm_cwd || "") === path.resolve(spec.cwd)
|
|
141
|
+
&& argsFor(entry) === spec.args;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function previousSpec(entry) {
|
|
145
|
+
const env = entry.pm2_env || {};
|
|
146
|
+
const restored = {
|
|
147
|
+
name: env.name,
|
|
148
|
+
script: env.pm_exec_path,
|
|
149
|
+
args: argsFor(entry),
|
|
150
|
+
cwd: env.pm_cwd,
|
|
151
|
+
autorestart: env.autorestart !== false,
|
|
152
|
+
restart_delay: env.restart_delay || 1000,
|
|
153
|
+
max_restarts: env.max_restarts || 20,
|
|
154
|
+
filter_env: Object.keys(process.env),
|
|
155
|
+
env: Object.fromEntries(Object.entries(env).filter(([key]) => OPS_ENV_KEYS.has(key) || RUNTIME_ENV_KEYS.has(key))),
|
|
156
|
+
};
|
|
157
|
+
if (env.exec_interpreter !== undefined) restored.interpreter = env.exec_interpreter;
|
|
158
|
+
for (const key of RESTORABLE_PM2_KEYS) if (env[key] !== undefined) restored[key] = env[key];
|
|
159
|
+
return restored;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function rollback(previous, name) {
|
|
163
|
+
try { await pm2Call("delete", name); } catch {}
|
|
164
|
+
if (previous) await pm2Call("start", previous);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function runOpsInstall(opts = {}) {
|
|
168
|
+
const configPath = path.resolve(requiredString(opts.config, "--config"));
|
|
169
|
+
const config = loadOpsInstallConfig(configPath);
|
|
170
|
+
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
171
|
+
const spec = buildOpsServiceSpec({ config, rootDir });
|
|
172
|
+
const endpoint = `http://127.0.0.1:${config.port}`;
|
|
173
|
+
if (opts.dryRun) {
|
|
174
|
+
process.stdout.write(`${JSON.stringify({ action: "install", dry_run: true, platform: process.platform, endpoint, pm2_name: config.pm2Name, policy_file: configPath, operations: Object.keys(config.applications).sort() }, null, 2)}\n`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
await new Promise((resolve, reject) => pm2.connect(false, (error) => error ? reject(error) : resolve()));
|
|
178
|
+
let result;
|
|
179
|
+
let previous = null;
|
|
180
|
+
try {
|
|
181
|
+
const existing = (await pm2Call("list")).find((entry) => (entry.name || entry.pm2_env?.name) === config.pm2Name);
|
|
182
|
+
// PM2 rejects an inline `env` object on restart-by-name. Replacing only
|
|
183
|
+
// this dedicated control-plane process is the supported way to apply a
|
|
184
|
+
// changed server policy without touching any managed application.
|
|
185
|
+
if (existing && !ownedService(existing, spec)) throw new Error(`refusing to replace non-installer PM2 process: ${config.pm2Name}`);
|
|
186
|
+
previous = existing ? previousSpec(existing) : null;
|
|
187
|
+
if (existing) await pm2Call("delete", config.pm2Name);
|
|
188
|
+
await pm2Call("start", spec);
|
|
189
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
190
|
+
try {
|
|
191
|
+
result = await probe(endpoint);
|
|
192
|
+
const current = (await pm2Call("list")).find((entry) => (entry.name || entry.pm2_env?.name) === config.pm2Name);
|
|
193
|
+
const pid = current?.pid ?? current?.pm2_env?.pm_pid;
|
|
194
|
+
const status = current?.pm2_env?.status ?? current?.status;
|
|
195
|
+
if (status !== "online" || !Number.isInteger(pid) || result.process_id !== pid || result.listening_port !== config.port) {
|
|
196
|
+
throw new Error("new PM2 process does not own the verified control-plane endpoint");
|
|
197
|
+
}
|
|
198
|
+
break;
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (attempt === 19) throw error;
|
|
201
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
} catch (error) {
|
|
205
|
+
await rollback(previous, config.pm2Name);
|
|
206
|
+
throw error;
|
|
207
|
+
} finally {
|
|
208
|
+
await new Promise((resolve) => pm2.disconnect(() => resolve()));
|
|
209
|
+
}
|
|
210
|
+
process.stdout.write(`${JSON.stringify({ action: "install", platform: process.platform, endpoint, pm2_name: config.pm2Name, policy_file: configPath, health_status: result.health_status, bearer_gate_status: result.bearer_gate_status }, null, 2)}\n`);
|
|
211
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const DEFAULT_LOCAL_CLAUTH = "http://127.0.0.1:52437";
|
|
2
|
+
|
|
3
|
+
function endpointFrom(opts) {
|
|
4
|
+
const approved = process.env.CLAUTH_OPS_APPROVED_ORIGIN;
|
|
5
|
+
if (!approved) throw new Error("Ops endpoint unavailable: CLAUTH_OPS_APPROVED_ORIGIN is required");
|
|
6
|
+
const approvedUrl = new URL(approved);
|
|
7
|
+
const requested = opts.endpoint ? new URL(opts.endpoint) : approvedUrl;
|
|
8
|
+
if (approvedUrl.protocol !== "https:" || requested.origin !== approvedUrl.origin) throw new Error("Ops endpoint is not the approved Vultr origin");
|
|
9
|
+
return approvedUrl.toString().replace(/\/$/, "");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function bearer(service = process.env.CLAUTH_OPS_TOKEN_SERVICE || "vultr-ops-api-token") {
|
|
13
|
+
const response = await fetch(`${process.env.CLAUTH_LOCAL_URL || DEFAULT_LOCAL_CLAUTH}/v/${encodeURIComponent(service)}`);
|
|
14
|
+
if (!response.ok) throw new Error(`${service} unavailable from local clauth`);
|
|
15
|
+
const token = (await response.text()).trim();
|
|
16
|
+
if (!token) throw new Error("vultr-ops-api-token is empty");
|
|
17
|
+
return token;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function requestOps({ endpoint, token, method, path, body }) {
|
|
21
|
+
const response = await fetch(`${endpoint}${path}`, {
|
|
22
|
+
method,
|
|
23
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", ...(body ? { "Content-Type": "application/json" } : {}) },
|
|
24
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
25
|
+
});
|
|
26
|
+
const payload = await response.json().catch(() => ({ error: "invalid_json_response" }));
|
|
27
|
+
if (!response.ok) throw new Error(payload.error || `ops HTTP ${response.status}`);
|
|
28
|
+
return payload;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function print(value) {
|
|
32
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function runOps(action, opts = {}) {
|
|
36
|
+
const endpoint = endpointFrom(opts);
|
|
37
|
+
const token = await bearer(opts.tokenService);
|
|
38
|
+
const request = (method, path, body) => requestOps({ endpoint, token, method, path, body });
|
|
39
|
+
if (action === "catalog") return print(await request("GET", "/v1/ops/catalog"));
|
|
40
|
+
if (action === "list") return print(await request("GET", "/v1/ops/processes"));
|
|
41
|
+
if (action === "describe") {
|
|
42
|
+
if (!opts.target) throw new Error("--target is required for describe");
|
|
43
|
+
return print(await request("GET", `/v1/ops/processes/${encodeURIComponent(opts.target)}`));
|
|
44
|
+
}
|
|
45
|
+
if (action === "run") {
|
|
46
|
+
if (!opts.operation) throw new Error("--operation is required for run");
|
|
47
|
+
const input = {
|
|
48
|
+
...(opts.target ? { target: opts.target } : {}),
|
|
49
|
+
...(opts.script ? { script: opts.script } : {}),
|
|
50
|
+
...(opts.instances ? { instances: Number(opts.instances) } : {}),
|
|
51
|
+
...(opts.argsJson ? { args: JSON.parse(opts.argsJson) } : {}),
|
|
52
|
+
...(opts.optionsJson ? { options: JSON.parse(opts.optionsJson) } : {}),
|
|
53
|
+
};
|
|
54
|
+
return print(await request("POST", "/v1/ops/operations", { operation: opts.operation, input }));
|
|
55
|
+
}
|
|
56
|
+
if (action === "promote") {
|
|
57
|
+
if (!opts.application) throw new Error("--application is required for promote");
|
|
58
|
+
return print(await request("POST", "/v1/ops/promotions", { application_uuid: opts.application }));
|
|
59
|
+
}
|
|
60
|
+
if (action === "deploy") {
|
|
61
|
+
if (!opts.application) throw new Error("--application is required for deploy");
|
|
62
|
+
return print(await request("POST", "/v1/ops/deployments", { application: opts.application, ...(opts.ref ? { ref: opts.ref } : {}) }));
|
|
63
|
+
}
|
|
64
|
+
if (action === "job") {
|
|
65
|
+
if (!opts.job) throw new Error("--job is required for job");
|
|
66
|
+
return print(await request("GET", `/v1/ops/jobs/${encodeURIComponent(opts.job)}`));
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`unknown ops action: ${action}`);
|
|
69
|
+
}
|