@calltelemetry/cli 0.12.11 → 0.12.12
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/dist/commands/config.d.ts +25 -0
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +42 -3
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/db.d.ts +24 -0
- package/dist/commands/db.d.ts.map +1 -1
- package/dist/commands/db.js +43 -4
- package/dist/commands/db.js.map +1 -1
- package/dist/commands/go-parity.d.ts +32 -0
- package/dist/commands/go-parity.d.ts.map +1 -0
- package/dist/commands/go-parity.js +359 -0
- package/dist/commands/go-parity.js.map +1 -0
- package/dist/commands/jtapi.d.ts.map +1 -1
- package/dist/commands/jtapi.js +2 -0
- package/dist/commands/jtapi.js.map +1 -1
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +2 -0
- package/dist/commands/migrate.js.map +1 -1
- package/dist/commands/network.d.ts.map +1 -1
- package/dist/commands/network.js +63 -50
- package/dist/commands/network.js.map +1 -1
- package/dist/commands/offline.d.ts.map +1 -1
- package/dist/commands/offline.js +2 -0
- package/dist/commands/offline.js.map +1 -1
- package/dist/commands/seed.d.ts.map +1 -1
- package/dist/commands/seed.js +2 -0
- package/dist/commands/seed.js.map +1 -1
- package/dist/commands/ship-certs.d.ts +7 -0
- package/dist/commands/ship-certs.d.ts.map +1 -0
- package/dist/commands/ship-certs.js +105 -0
- package/dist/commands/ship-certs.js.map +1 -0
- package/dist/commands/ship-db.d.ts +8 -0
- package/dist/commands/ship-db.d.ts.map +1 -0
- package/dist/commands/ship-db.js +358 -0
- package/dist/commands/ship-db.js.map +1 -0
- package/dist/commands/ship-diag.d.ts +10 -0
- package/dist/commands/ship-diag.d.ts.map +1 -0
- package/dist/commands/ship-diag.js +73 -0
- package/dist/commands/ship-diag.js.map +1 -0
- package/dist/commands/ship-ipv6.d.ts +6 -0
- package/dist/commands/ship-ipv6.d.ts.map +1 -0
- package/dist/commands/ship-ipv6.js +59 -0
- package/dist/commands/ship-ipv6.js.map +1 -0
- package/dist/commands/ship-logging.d.ts +7 -0
- package/dist/commands/ship-logging.d.ts.map +1 -0
- package/dist/commands/ship-logging.js +71 -0
- package/dist/commands/ship-logging.js.map +1 -0
- package/dist/commands/ship-migrate.d.ts +6 -0
- package/dist/commands/ship-migrate.d.ts.map +1 -0
- package/dist/commands/ship-migrate.js +152 -0
- package/dist/commands/ship-migrate.js.map +1 -0
- package/dist/commands/ship-os-updates.d.ts +6 -0
- package/dist/commands/ship-os-updates.d.ts.map +1 -0
- package/dist/commands/ship-os-updates.js +116 -0
- package/dist/commands/ship-os-updates.js.map +1 -0
- package/dist/commands/ship-postgres.d.ts +6 -0
- package/dist/commands/ship-postgres.d.ts.map +1 -0
- package/dist/commands/ship-postgres.js +84 -0
- package/dist/commands/ship-postgres.js.map +1 -0
- package/dist/commands/ship-services.d.ts +7 -0
- package/dist/commands/ship-services.d.ts.map +1 -0
- package/dist/commands/ship-services.js +127 -0
- package/dist/commands/ship-services.js.map +1 -0
- package/dist/commands/ship-sql-monitor.d.ts +6 -0
- package/dist/commands/ship-sql-monitor.d.ts.map +1 -0
- package/dist/commands/ship-sql-monitor.js +89 -0
- package/dist/commands/ship-sql-monitor.js.map +1 -0
- package/dist/commands/ship-status.d.ts +6 -0
- package/dist/commands/ship-status.d.ts.map +1 -0
- package/dist/commands/ship-status.js +21 -0
- package/dist/commands/ship-status.js.map +1 -0
- package/dist/commands/ship-update.d.ts +31 -0
- package/dist/commands/ship-update.d.ts.map +1 -0
- package/dist/commands/ship-update.js +234 -0
- package/dist/commands/ship-update.js.map +1 -0
- package/dist/commands/ship-users.d.ts +7 -0
- package/dist/commands/ship-users.d.ts.map +1 -0
- package/dist/commands/ship-users.js +118 -0
- package/dist/commands/ship-users.js.map +1 -0
- package/dist/commands/update.d.ts +45 -0
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +74 -3
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/users.d.ts.map +1 -1
- package/dist/commands/users.js +51 -0
- package/dist/commands/users.js.map +1 -1
- package/dist/index.js +3 -72
- package/dist/index.js.map +1 -1
- package/dist/lib/network-apply.d.ts +102 -0
- package/dist/lib/network-apply.d.ts.map +1 -0
- package/dist/lib/network-apply.js +472 -0
- package/dist/lib/network-apply.js.map +1 -0
- package/dist/lib/ops-port.d.ts +205 -0
- package/dist/lib/ops-port.d.ts.map +1 -0
- package/dist/lib/ops-port.js +906 -0
- package/dist/lib/ops-port.js.map +1 -0
- package/dist/lib/public-doc.d.ts +37 -0
- package/dist/lib/public-doc.d.ts.map +1 -0
- package/dist/lib/public-doc.js +27 -0
- package/dist/lib/public-doc.js.map +1 -0
- package/dist/lib/ship-identity.d.ts +9 -0
- package/dist/lib/ship-identity.d.ts.map +1 -0
- package/dist/lib/ship-identity.js +12 -0
- package/dist/lib/ship-identity.js.map +1 -0
- package/dist/lib/ship-logo.d.ts +11 -0
- package/dist/lib/ship-logo.d.ts.map +1 -0
- package/dist/lib/ship-logo.js +17 -0
- package/dist/lib/ship-logo.js.map +1 -0
- package/dist/lib/ship-status.d.ts +21 -0
- package/dist/lib/ship-status.d.ts.map +1 -0
- package/dist/lib/ship-status.js +82 -0
- package/dist/lib/ship-status.js.map +1 -0
- package/dist/lib/version.d.ts +1 -1
- package/dist/lib/version.js +1 -1
- package/dist/program.d.ts +22 -0
- package/dist/program.d.ts.map +1 -0
- package/dist/program.js +124 -0
- package/dist/program.js.map +1 -0
- package/dist/shell/commands/diag.d.ts.map +1 -1
- package/dist/shell/commands/diag.js +47 -19
- package/dist/shell/commands/diag.js.map +1 -1
- package/dist/shell/commands/ops-mutates.d.ts +16 -0
- package/dist/shell/commands/ops-mutates.d.ts.map +1 -0
- package/dist/shell/commands/ops-mutates.js +62 -0
- package/dist/shell/commands/ops-mutates.js.map +1 -0
- package/dist/shell/commands/registry.d.ts.map +1 -1
- package/dist/shell/commands/registry.js +43 -0
- package/dist/shell/commands/registry.js.map +1 -1
- package/dist/shell/commands/show.d.ts +12 -0
- package/dist/shell/commands/show.d.ts.map +1 -1
- package/dist/shell/commands/show.js +190 -10
- package/dist/shell/commands/show.js.map +1 -1
- package/dist/shell/commands/telemetry.d.ts +11 -0
- package/dist/shell/commands/telemetry.d.ts.map +1 -0
- package/dist/shell/commands/telemetry.js +49 -0
- package/dist/shell/commands/telemetry.js.map +1 -0
- package/dist/shell/network-onboarding.d.ts +7 -4
- package/dist/shell/network-onboarding.d.ts.map +1 -1
- package/dist/shell/network-onboarding.js +22 -38
- package/dist/shell/network-onboarding.js.map +1 -1
- package/dist/ship-entry.d.ts +3 -0
- package/dist/ship-entry.d.ts.map +1 -0
- package/dist/ship-entry.js +121 -0
- package/dist/ship-entry.js.map +1 -0
- package/package.json +9 -2
|
@@ -0,0 +1,906 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Go clitree catalog ports implemented in TypeScript (TS all-the-way).
|
|
3
|
+
* Injectable runners for unit tests — no Go binary dependency.
|
|
4
|
+
*
|
|
5
|
+
* False-green rules (WS-A):
|
|
6
|
+
* - Mutates fail-closed (no soft mute on write/recreate/RPC that must succeed)
|
|
7
|
+
* - Appliance file paths via getPaths().installDir
|
|
8
|
+
* - .env key updates via Node fs (no bash sed with operator args)
|
|
9
|
+
* - crypto.randomBytes for generated passwords
|
|
10
|
+
*/
|
|
11
|
+
import { randomBytes } from 'node:crypto';
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { exec } from './exec.js';
|
|
15
|
+
import { getPaths } from './paths.js';
|
|
16
|
+
const defaultRun = async (cmd, args) => {
|
|
17
|
+
const r = await exec(cmd, args);
|
|
18
|
+
return { stdout: String(r.stdout ?? ''), stderr: String(r.stderr ?? '') };
|
|
19
|
+
};
|
|
20
|
+
/** Soft-fail runner for pure reads only. Mutates must use runOrThrow / try-catch. */
|
|
21
|
+
async function soft(run, cmd, args) {
|
|
22
|
+
try {
|
|
23
|
+
const r = await run(cmd, args);
|
|
24
|
+
return String(r.stdout ?? '').trim();
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function runOrThrow(run, cmd, args) {
|
|
31
|
+
const r = await run(cmd, args);
|
|
32
|
+
return String(r.stdout ?? '').trim();
|
|
33
|
+
}
|
|
34
|
+
function resolveInstallDir(installDir) {
|
|
35
|
+
return installDir ?? getPaths().installDir;
|
|
36
|
+
}
|
|
37
|
+
function envFilePath(installDir) {
|
|
38
|
+
return join(resolveInstallDir(installDir), '.env');
|
|
39
|
+
}
|
|
40
|
+
function composeFilePath(installDir) {
|
|
41
|
+
return join(resolveInstallDir(installDir), 'docker-compose.yml');
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Update or append a single KEY=value in installDir/.env using Node fs.
|
|
45
|
+
* Throws on I/O failure (callers fail-closed).
|
|
46
|
+
*/
|
|
47
|
+
export function updateEnvKeyFile(key, value, installDir) {
|
|
48
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
49
|
+
throw new Error(`Invalid env key: ${key}`);
|
|
50
|
+
}
|
|
51
|
+
// Values must not contain newlines (env file line safety)
|
|
52
|
+
if (/[\r\n]/.test(value)) {
|
|
53
|
+
throw new Error(`Env value for ${key} must not contain newlines`);
|
|
54
|
+
}
|
|
55
|
+
const path = envFilePath(installDir);
|
|
56
|
+
const dir = resolveInstallDir(installDir);
|
|
57
|
+
if (!existsSync(dir)) {
|
|
58
|
+
mkdirSync(dir, { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
let raw = '';
|
|
61
|
+
if (existsSync(path)) {
|
|
62
|
+
raw = readFileSync(path, 'utf-8');
|
|
63
|
+
}
|
|
64
|
+
const lines = raw.length > 0 ? raw.split('\n') : [];
|
|
65
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') {
|
|
66
|
+
lines.pop();
|
|
67
|
+
}
|
|
68
|
+
let found = false;
|
|
69
|
+
const result = [];
|
|
70
|
+
for (const line of lines) {
|
|
71
|
+
const trimmed = line.trim();
|
|
72
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
73
|
+
result.push(line);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const stripped = trimmed.startsWith('export ') ? trimmed.slice(7) : trimmed;
|
|
77
|
+
const eqIdx = stripped.indexOf('=');
|
|
78
|
+
if (eqIdx === -1) {
|
|
79
|
+
result.push(line);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const lineKey = stripped.slice(0, eqIdx).trim();
|
|
83
|
+
if (lineKey === key && !found) {
|
|
84
|
+
result.push(`${key}=${value}`);
|
|
85
|
+
found = true;
|
|
86
|
+
}
|
|
87
|
+
else if (lineKey === key) {
|
|
88
|
+
// drop duplicates
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
result.push(line);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!found) {
|
|
95
|
+
result.push(`${key}=${value}`);
|
|
96
|
+
}
|
|
97
|
+
const content = result.length > 0 ? result.join('\n') + '\n' : `${key}=${value}\n`;
|
|
98
|
+
writeFileSync(path, content, { mode: 0o600 });
|
|
99
|
+
return { path };
|
|
100
|
+
}
|
|
101
|
+
function readEnvKeysAt(installDir) {
|
|
102
|
+
const path = envFilePath(installDir);
|
|
103
|
+
if (!existsSync(path))
|
|
104
|
+
return {};
|
|
105
|
+
const out = {};
|
|
106
|
+
for (const rawLine of readFileSync(path, 'utf-8').split('\n')) {
|
|
107
|
+
const line = rawLine.trim();
|
|
108
|
+
if (!line || line.startsWith('#'))
|
|
109
|
+
continue;
|
|
110
|
+
const stripped = line.startsWith('export ') ? line.slice(7) : line;
|
|
111
|
+
const eqIdx = stripped.indexOf('=');
|
|
112
|
+
if (eqIdx === -1)
|
|
113
|
+
continue;
|
|
114
|
+
const k = stripped.slice(0, eqIdx).trim();
|
|
115
|
+
let v = stripped.slice(eqIdx + 1).trim();
|
|
116
|
+
if ((v.startsWith('"') && v.endsWith('"')) ||
|
|
117
|
+
(v.startsWith("'") && v.endsWith("'"))) {
|
|
118
|
+
v = v.slice(1, -1);
|
|
119
|
+
}
|
|
120
|
+
if (k)
|
|
121
|
+
out[k] = v;
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
function composeProjectArgs(installDir) {
|
|
126
|
+
const dir = resolveInstallDir(installDir);
|
|
127
|
+
return ['--project-directory', dir, '-f', composeFilePath(installDir)];
|
|
128
|
+
}
|
|
129
|
+
function rpcLooksFailed(out) {
|
|
130
|
+
return /error|Error|failed|Failed|FAULT|undefined function|UndefinedFunction|noproc/i.test(out);
|
|
131
|
+
}
|
|
132
|
+
// ── DB purge groups (Go parity) ──────────────────────────────────────────
|
|
133
|
+
export const PURGE_GROUPS = {
|
|
134
|
+
'ct-events': {
|
|
135
|
+
description: 'CUBE/XCC/CURRI telemetry events',
|
|
136
|
+
tables: ['cube_events', 'xcc_events', 'curri_events', 'reputation_signals'],
|
|
137
|
+
},
|
|
138
|
+
cdr: {
|
|
139
|
+
description: 'Call detail records (partitioned)',
|
|
140
|
+
tables: ['cdrs', 'cmrs'],
|
|
141
|
+
},
|
|
142
|
+
logs: {
|
|
143
|
+
description: 'Application and audit logs',
|
|
144
|
+
tables: ['audit_logs', 'job_logs'],
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
export function listPurgeGroups() {
|
|
148
|
+
const lines = ['Optimized purge groups:', ''];
|
|
149
|
+
for (const [name, g] of Object.entries(PURGE_GROUPS)) {
|
|
150
|
+
lines.push(` ${name.padEnd(14)} ${g.description}`);
|
|
151
|
+
lines.push(` tables: ${g.tables.join(', ')}`);
|
|
152
|
+
}
|
|
153
|
+
lines.push('');
|
|
154
|
+
lines.push('Usage: ct db purge-ct-events [days] [--yes]');
|
|
155
|
+
lines.push(' ct db purge <table|group> <days> [--yes]');
|
|
156
|
+
return lines.join('\n');
|
|
157
|
+
}
|
|
158
|
+
export async function purgeCtEvents(days, opts = {}) {
|
|
159
|
+
if (!Number.isFinite(days) || days < 1) {
|
|
160
|
+
return { ok: false, message: 'Error: days must be a positive number (default 7).' };
|
|
161
|
+
}
|
|
162
|
+
if (!opts.yes && !opts.dryRun) {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
message: `Refusing to purge ct-events older than ${days} days without --yes.`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const run = opts.run ?? defaultRun;
|
|
169
|
+
if (opts.dryRun) {
|
|
170
|
+
return {
|
|
171
|
+
ok: true,
|
|
172
|
+
message: `Would purge ct-events group older than ${days} days (tables: ${PURGE_GROUPS['ct-events'].tables.join(', ')}).`,
|
|
173
|
+
deleted: 0,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const rpc = `Cdrcisco.Maintenance.purge_group("ct-events", ${days})`;
|
|
177
|
+
let out = '';
|
|
178
|
+
try {
|
|
179
|
+
out = await runOrThrow(run, 'docker', [
|
|
180
|
+
'compose',
|
|
181
|
+
...composeProjectArgs(opts.installDir),
|
|
182
|
+
'exec',
|
|
183
|
+
'-T',
|
|
184
|
+
'web',
|
|
185
|
+
'bin/onprem',
|
|
186
|
+
'rpc',
|
|
187
|
+
rpc,
|
|
188
|
+
]);
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
message: `Error: purge-ct-events RPC failed: ${e?.message ?? e}`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
if (!out) {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
message: 'Error: purge-ct-events RPC returned empty output (RPC unavailable or symbol missing: Cdrcisco.Maintenance.purge_group).',
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (rpcLooksFailed(out)) {
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
message: `Error: purge-ct-events RPC rejected: ${out.slice(0, 200)}`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
ok: true,
|
|
210
|
+
message: `Purged ct-events group (older than ${days} days).`,
|
|
211
|
+
deleted: 0,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
export async function factoryReset(opts = {}) {
|
|
215
|
+
const steps = [];
|
|
216
|
+
if (!opts.yes && !opts.dryRun) {
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
message: 'Error: factory-reset requires --yes (destructive).',
|
|
220
|
+
steps,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
if (!opts.experimental && !opts.dryRun) {
|
|
224
|
+
return {
|
|
225
|
+
ok: false,
|
|
226
|
+
message: 'Error: factory-reset is experimental/UAT-only. Pass --experimental --yes (hidden from default help).',
|
|
227
|
+
steps,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const installDir = resolveInstallDir(opts.installDir);
|
|
231
|
+
const postgresData = join(installDir, 'postgres-data');
|
|
232
|
+
const backupsDir = join(installDir, 'backups');
|
|
233
|
+
const run = opts.run ?? defaultRun;
|
|
234
|
+
const planDocker = [
|
|
235
|
+
['docker', ['compose', ...composeProjectArgs(installDir), 'down', '-v']],
|
|
236
|
+
['docker', ['system', 'prune', '-f']],
|
|
237
|
+
['docker', ['compose', ...composeProjectArgs(installDir), 'up', '-d']],
|
|
238
|
+
];
|
|
239
|
+
steps.push(`remove data: ${postgresData} and ${backupsDir}/*`);
|
|
240
|
+
for (const [cmd, args] of planDocker) {
|
|
241
|
+
steps.push(`${cmd} ${args.join(' ')}`);
|
|
242
|
+
}
|
|
243
|
+
if (opts.dryRun) {
|
|
244
|
+
return {
|
|
245
|
+
ok: true,
|
|
246
|
+
message: `Dry-run factory-reset (${steps.length} steps). Experimental/UAT only.`,
|
|
247
|
+
steps,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
// Data removal — fail closed (no "; true" mute)
|
|
251
|
+
try {
|
|
252
|
+
if (existsSync(postgresData)) {
|
|
253
|
+
rmSync(postgresData, { recursive: true, force: true });
|
|
254
|
+
if (existsSync(postgresData)) {
|
|
255
|
+
throw new Error(`path still exists after rm: ${postgresData}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (existsSync(backupsDir)) {
|
|
259
|
+
for (const name of readdirSync(backupsDir)) {
|
|
260
|
+
rmSync(join(backupsDir, name), { recursive: true, force: true });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
catch (e) {
|
|
265
|
+
return {
|
|
266
|
+
ok: false,
|
|
267
|
+
message: `factory-reset failed removing data under ${installDir}: ${e?.message ?? e}`,
|
|
268
|
+
steps,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
for (const [cmd, args] of planDocker) {
|
|
272
|
+
try {
|
|
273
|
+
await runOrThrow(run, cmd, args);
|
|
274
|
+
}
|
|
275
|
+
catch (e) {
|
|
276
|
+
return {
|
|
277
|
+
ok: false,
|
|
278
|
+
message: `factory-reset failed at: ${cmd} ${args.join(' ')}: ${e?.message ?? e}`,
|
|
279
|
+
steps,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
ok: true,
|
|
285
|
+
message: 'Factory reset complete: data volumes removed, stack restarted.',
|
|
286
|
+
steps,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
// ── Update service (single container tag) ────────────────────────────────
|
|
290
|
+
export async function updateService(service, tag, opts = {}) {
|
|
291
|
+
const allowed = ['web', 'vue', 'ct-media', 'jtapi', 'traceroute', 'ct-pdf', 'syslog-ingest'];
|
|
292
|
+
if (!allowed.includes(service)) {
|
|
293
|
+
return {
|
|
294
|
+
ok: false,
|
|
295
|
+
message: `Error: unknown service '${service}'. Allowed: ${allowed.join(', ')}`,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
if (!tag.trim()) {
|
|
299
|
+
return { ok: false, message: 'Error: tag is required.\nUsage: ct update service <svc> <tag>' };
|
|
300
|
+
}
|
|
301
|
+
if (!opts.yes && !opts.dryRun) {
|
|
302
|
+
return { ok: false, message: 'Error: update service requires --yes for non-interactive apply.' };
|
|
303
|
+
}
|
|
304
|
+
const run = opts.run ?? defaultRun;
|
|
305
|
+
const envKey = `${service.toUpperCase().replace(/-/g, '_')}_TAG`;
|
|
306
|
+
const envPath = envFilePath(opts.installDir);
|
|
307
|
+
if (opts.dryRun) {
|
|
308
|
+
return { ok: true, message: `Would set ${envKey}=${tag} in ${envPath} and recreate ${service}.` };
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
updateEnvKeyFile(envKey, tag, opts.installDir);
|
|
312
|
+
}
|
|
313
|
+
catch (e) {
|
|
314
|
+
return {
|
|
315
|
+
ok: false,
|
|
316
|
+
message: `Error writing ${envKey} to ${envPath}: ${e?.message ?? e}`,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
await runOrThrow(run, 'docker', [
|
|
321
|
+
'compose',
|
|
322
|
+
...composeProjectArgs(opts.installDir),
|
|
323
|
+
'up',
|
|
324
|
+
'-d',
|
|
325
|
+
'--no-deps',
|
|
326
|
+
'--force-recreate',
|
|
327
|
+
service,
|
|
328
|
+
]);
|
|
329
|
+
}
|
|
330
|
+
catch (e) {
|
|
331
|
+
return { ok: false, message: `Error updating ${service}: ${e?.message ?? e}` };
|
|
332
|
+
}
|
|
333
|
+
return { ok: true, message: `Updated service ${service} to tag ${tag} (${envPath}).` };
|
|
334
|
+
}
|
|
335
|
+
// ── JTAPI RTP advertise ──────────────────────────────────────────────────
|
|
336
|
+
export async function setJtapiRtpAdvertise(ip, opts = {}) {
|
|
337
|
+
const value = ip.trim();
|
|
338
|
+
if (!value) {
|
|
339
|
+
return { ok: false, message: 'Error: IP required.\nUsage: ct jtapi set-rtp-advertise <ip>' };
|
|
340
|
+
}
|
|
341
|
+
const run = opts.run ?? defaultRun;
|
|
342
|
+
const envPath = envFilePath(opts.installDir);
|
|
343
|
+
try {
|
|
344
|
+
updateEnvKeyFile('JTAPI_RTP_ADVERTISE_IP', value, opts.installDir);
|
|
345
|
+
}
|
|
346
|
+
catch (e) {
|
|
347
|
+
return {
|
|
348
|
+
ok: false,
|
|
349
|
+
message: `Error writing JTAPI_RTP_ADVERTISE_IP to ${envPath}: ${e?.message ?? e}`,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
await runOrThrow(run, 'docker', [
|
|
354
|
+
'compose',
|
|
355
|
+
...composeProjectArgs(opts.installDir),
|
|
356
|
+
'up',
|
|
357
|
+
'-d',
|
|
358
|
+
'--no-deps',
|
|
359
|
+
'--force-recreate',
|
|
360
|
+
'jtapi',
|
|
361
|
+
]);
|
|
362
|
+
}
|
|
363
|
+
catch (e) {
|
|
364
|
+
return {
|
|
365
|
+
ok: false,
|
|
366
|
+
message: `Error recreating jtapi after RTP advertise update: ${e?.message ?? e}`,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
return { ok: true, message: `JTAPI RTP advertise address set to ${value} (${envPath}).` };
|
|
370
|
+
}
|
|
371
|
+
// ── Seed monitor / cancel ────────────────────────────────────────────────
|
|
372
|
+
export async function seedMonitor(opts = {}) {
|
|
373
|
+
const run = opts.run ?? defaultRun;
|
|
374
|
+
let out = '';
|
|
375
|
+
try {
|
|
376
|
+
out = await runOrThrow(run, 'docker', [
|
|
377
|
+
'compose',
|
|
378
|
+
...composeProjectArgs(opts.installDir),
|
|
379
|
+
'exec',
|
|
380
|
+
'-T',
|
|
381
|
+
'web',
|
|
382
|
+
'bin/onprem',
|
|
383
|
+
'rpc',
|
|
384
|
+
'Cdrcisco.Seed.status()',
|
|
385
|
+
]);
|
|
386
|
+
}
|
|
387
|
+
catch (e) {
|
|
388
|
+
return {
|
|
389
|
+
ok: false,
|
|
390
|
+
message: `Error: seed monitor RPC failed: ${e?.message ?? e}`,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
if (!out) {
|
|
394
|
+
return {
|
|
395
|
+
ok: false,
|
|
396
|
+
message: 'Error: seed monitor RPC returned empty output (RPC unavailable or symbol missing: Cdrcisco.Seed.status).',
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
if (rpcLooksFailed(out)) {
|
|
400
|
+
return {
|
|
401
|
+
ok: false,
|
|
402
|
+
message: `Error: seed monitor RPC rejected: ${out.slice(0, 200)}`,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
ok: true,
|
|
407
|
+
message: out,
|
|
408
|
+
counts: {},
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
export async function seedCancel(opts = {}) {
|
|
412
|
+
const run = opts.run ?? defaultRun;
|
|
413
|
+
let out = '';
|
|
414
|
+
try {
|
|
415
|
+
out = await runOrThrow(run, 'docker', [
|
|
416
|
+
'compose',
|
|
417
|
+
...composeProjectArgs(opts.installDir),
|
|
418
|
+
'exec',
|
|
419
|
+
'-T',
|
|
420
|
+
'web',
|
|
421
|
+
'bin/onprem',
|
|
422
|
+
'rpc',
|
|
423
|
+
'Cdrcisco.Seed.cancel_all()',
|
|
424
|
+
]);
|
|
425
|
+
}
|
|
426
|
+
catch (e) {
|
|
427
|
+
return {
|
|
428
|
+
ok: false,
|
|
429
|
+
message: `Error: seed cancel RPC failed: ${e?.message ?? e}`,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
if (!out) {
|
|
433
|
+
return {
|
|
434
|
+
ok: false,
|
|
435
|
+
message: 'Error: seed cancel RPC returned empty output (RPC unavailable or symbol missing: Cdrcisco.Seed.cancel_all).',
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
if (rpcLooksFailed(out)) {
|
|
439
|
+
return {
|
|
440
|
+
ok: false,
|
|
441
|
+
message: `Error: seed cancel RPC rejected: ${out.slice(0, 200)}`,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
return { ok: true, message: `Seed cancel requested: ${out}` };
|
|
445
|
+
}
|
|
446
|
+
// ── Bootstrap admin / users add-admin ─────────────────────────────────────
|
|
447
|
+
export async function bootstrapAdmin(email, password, opts = {}) {
|
|
448
|
+
if (!email.includes('@') || password.length < 8) {
|
|
449
|
+
return { ok: false, message: 'Error: email and password (>=8 chars) required.' };
|
|
450
|
+
}
|
|
451
|
+
if (!opts.experimental) {
|
|
452
|
+
return {
|
|
453
|
+
ok: false,
|
|
454
|
+
message: 'Error: bootstrap-admin is experimental until RPC symbols are verified on the pinned release. Pass --experimental.',
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
const run = opts.run ?? defaultRun;
|
|
458
|
+
const org = opts.orgId ?? 'auto';
|
|
459
|
+
// Escape for Elixir string literal in RPC (Go parity: real password reaches RPC)
|
|
460
|
+
const esc = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
461
|
+
const rpc = org === 'auto' || org === 'new'
|
|
462
|
+
? `Cdrcisco.Identity.Users.bootstrap_admin("${esc(email)}", "${esc(password)}")`
|
|
463
|
+
: `Cdrcisco.Identity.Users.add_admin("${esc(org)}", "${esc(email)}", "${esc(password)}")`;
|
|
464
|
+
let out = '';
|
|
465
|
+
try {
|
|
466
|
+
out = await runOrThrow(run, 'docker', [
|
|
467
|
+
'compose',
|
|
468
|
+
...composeProjectArgs(opts.installDir),
|
|
469
|
+
'exec',
|
|
470
|
+
'-T',
|
|
471
|
+
'-e',
|
|
472
|
+
`CT_BOOTSTRAP_EMAIL=${email}`,
|
|
473
|
+
'web',
|
|
474
|
+
'bin/onprem',
|
|
475
|
+
'rpc',
|
|
476
|
+
rpc,
|
|
477
|
+
]);
|
|
478
|
+
}
|
|
479
|
+
catch (e) {
|
|
480
|
+
return {
|
|
481
|
+
ok: false,
|
|
482
|
+
message: `Error: bootstrap-admin RPC failed: ${e?.message ?? e}`,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
if (!out) {
|
|
486
|
+
return {
|
|
487
|
+
ok: false,
|
|
488
|
+
message: 'Error: bootstrap-admin RPC returned empty output (RPC unavailable or symbol missing: bootstrap_admin/add_admin).',
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
if (rpcLooksFailed(out)) {
|
|
492
|
+
return { ok: false, message: `Error: bootstrap-admin rejected: ${out.slice(0, 200)}` };
|
|
493
|
+
}
|
|
494
|
+
return {
|
|
495
|
+
ok: true,
|
|
496
|
+
message: org === 'new' || org === 'auto'
|
|
497
|
+
? `Bootstrap admin ${email} completed (first-boot / auto-org).`
|
|
498
|
+
: `Add-admin ${email} for org ${org} completed.`,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
export function telemetryStatePath(home = process.env.HOME ?? '/tmp') {
|
|
502
|
+
return `${home}/.ct/telemetry.json`;
|
|
503
|
+
}
|
|
504
|
+
export async function getTelemetryStatus(home) {
|
|
505
|
+
const path = telemetryStatePath(home);
|
|
506
|
+
try {
|
|
507
|
+
if (!existsSync(path))
|
|
508
|
+
return { enabled: false, path };
|
|
509
|
+
const j = JSON.parse(readFileSync(path, 'utf-8'));
|
|
510
|
+
return { enabled: !!j.enabled, path };
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
return { enabled: false, path };
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
export async function setTelemetry(enabled, home) {
|
|
517
|
+
const path = telemetryStatePath(home);
|
|
518
|
+
const { dirname } = await import('node:path');
|
|
519
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
520
|
+
writeFileSync(path, JSON.stringify({ enabled, updatedAt: new Date().toISOString() }, null, 2));
|
|
521
|
+
return {
|
|
522
|
+
ok: true,
|
|
523
|
+
message: enabled
|
|
524
|
+
? 'CLI telemetry enabled (pseudonymous analytics).'
|
|
525
|
+
: 'CLI telemetry disabled.',
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
export function leasePath(home = process.env.HOME ?? '/tmp') {
|
|
529
|
+
return `${home}/.ct/operation-lease.json`;
|
|
530
|
+
}
|
|
531
|
+
export async function operationLeaseStatus(home) {
|
|
532
|
+
const path = leasePath(home);
|
|
533
|
+
if (!existsSync(path)) {
|
|
534
|
+
return { ok: true, message: 'No operation lease held.', lease: null };
|
|
535
|
+
}
|
|
536
|
+
try {
|
|
537
|
+
const lease = JSON.parse(readFileSync(path, 'utf-8'));
|
|
538
|
+
const expired = Date.parse(lease.expiresAt) < Date.now();
|
|
539
|
+
return {
|
|
540
|
+
ok: true,
|
|
541
|
+
message: expired
|
|
542
|
+
? `Stale lease by ${lease.holder} (expired ${lease.expiresAt}).`
|
|
543
|
+
: `Lease held by ${lease.holder} until ${lease.expiresAt}.`,
|
|
544
|
+
lease,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
return { ok: false, message: 'Error: corrupt lease file.' };
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
export async function operationLeaseAcquire(holder, ttlMinutes = 60, home) {
|
|
552
|
+
const { dirname } = await import('node:path');
|
|
553
|
+
const path = leasePath(home);
|
|
554
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
555
|
+
if (existsSync(path)) {
|
|
556
|
+
try {
|
|
557
|
+
const cur = JSON.parse(readFileSync(path, 'utf-8'));
|
|
558
|
+
if (Date.parse(cur.expiresAt) > Date.now() && cur.holder !== holder) {
|
|
559
|
+
return { ok: false, message: `Error: lease held by ${cur.holder} until ${cur.expiresAt}.` };
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
/* overwrite corrupt */
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
const now = new Date();
|
|
567
|
+
const exp = new Date(now.getTime() + ttlMinutes * 60_000);
|
|
568
|
+
const lease = {
|
|
569
|
+
holder,
|
|
570
|
+
acquiredAt: now.toISOString(),
|
|
571
|
+
expiresAt: exp.toISOString(),
|
|
572
|
+
};
|
|
573
|
+
writeFileSync(path, JSON.stringify(lease, null, 2));
|
|
574
|
+
return { ok: true, message: `Lease acquired by ${holder} until ${lease.expiresAt}.` };
|
|
575
|
+
}
|
|
576
|
+
export async function operationLeaseRelease(holder, home) {
|
|
577
|
+
const path = leasePath(home);
|
|
578
|
+
if (!existsSync(path))
|
|
579
|
+
return { ok: true, message: 'No lease to release.' };
|
|
580
|
+
try {
|
|
581
|
+
const cur = JSON.parse(readFileSync(path, 'utf-8'));
|
|
582
|
+
if (cur.holder !== holder) {
|
|
583
|
+
return { ok: false, message: `Error: lease owned by ${cur.holder}, not ${holder}.` };
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
/* remove anyway */
|
|
588
|
+
}
|
|
589
|
+
rmSync(path, { force: true });
|
|
590
|
+
return { ok: true, message: `Lease released by ${holder}.` };
|
|
591
|
+
}
|
|
592
|
+
export async function operationLeaseRecover(home) {
|
|
593
|
+
const path = leasePath(home);
|
|
594
|
+
if (existsSync(path))
|
|
595
|
+
rmSync(path, { force: true });
|
|
596
|
+
return { ok: true, message: 'Stale operation lease recovered (file removed).' };
|
|
597
|
+
}
|
|
598
|
+
// ── Prep cluster / build appliance ───────────────────────────────────────
|
|
599
|
+
export async function prepClusterNode(opts = {}) {
|
|
600
|
+
const steps = [
|
|
601
|
+
'command -v kubectl',
|
|
602
|
+
'command -v helm',
|
|
603
|
+
'command -v git',
|
|
604
|
+
'echo no-k3s-guidance',
|
|
605
|
+
];
|
|
606
|
+
if (opts.dryRun) {
|
|
607
|
+
return {
|
|
608
|
+
ok: true,
|
|
609
|
+
message: `prep-cluster-node dry-run (lab/non-production): ${steps.join('; ')}`,
|
|
610
|
+
steps,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
const run = opts.run ?? defaultRun;
|
|
614
|
+
const executed = [];
|
|
615
|
+
for (const step of ['kubectl', 'helm', 'git']) {
|
|
616
|
+
try {
|
|
617
|
+
await runOrThrow(run, 'bash', ['-c', `command -v ${step}`]);
|
|
618
|
+
executed.push(step);
|
|
619
|
+
}
|
|
620
|
+
catch (e) {
|
|
621
|
+
return {
|
|
622
|
+
ok: false,
|
|
623
|
+
message: `Error: prep-cluster-node missing required tool '${step}': ${e?.message ?? e}`,
|
|
624
|
+
steps: executed,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
try {
|
|
629
|
+
await runOrThrow(run, 'bash', [
|
|
630
|
+
'-c',
|
|
631
|
+
'echo "Use kubeadm/k3s separately; prep-cluster-node installs helpers only (lab profile)."',
|
|
632
|
+
]);
|
|
633
|
+
}
|
|
634
|
+
catch (e) {
|
|
635
|
+
return {
|
|
636
|
+
ok: false,
|
|
637
|
+
message: `Error: prep-cluster-node guidance failed: ${e?.message ?? e}`,
|
|
638
|
+
steps: executed,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
return {
|
|
642
|
+
ok: true,
|
|
643
|
+
message: `prep-cluster-node (lab/non-production): verified tools (${executed.join(', ')}).`,
|
|
644
|
+
steps: executed,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
export async function buildAppliance(opts = {}) {
|
|
648
|
+
if (opts.dryRun) {
|
|
649
|
+
return {
|
|
650
|
+
ok: true,
|
|
651
|
+
message: 'Would download appliance prep installer (lab/non-production path check only).',
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
const run = opts.run ?? defaultRun;
|
|
655
|
+
try {
|
|
656
|
+
const body = await runOrThrow(run, 'curl', [
|
|
657
|
+
'-fsSL',
|
|
658
|
+
'--connect-timeout',
|
|
659
|
+
'10',
|
|
660
|
+
'--max-time',
|
|
661
|
+
'60',
|
|
662
|
+
'https://get.calltelemetry.com/install-ct.sh',
|
|
663
|
+
]);
|
|
664
|
+
if (!body.includes('install') && !body.includes('ct') && body.length < 20) {
|
|
665
|
+
return { ok: false, message: 'Error: build-appliance installer response too short or empty.' };
|
|
666
|
+
}
|
|
667
|
+
return {
|
|
668
|
+
ok: true,
|
|
669
|
+
message: `build-appliance (lab/non-production): fetched installer (${body.length} bytes). Does not build production OVA.`,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
catch (e) {
|
|
673
|
+
return { ok: false, message: `Error: build-appliance fetch failed: ${e?.message ?? e}` };
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
// ── Postgres convert-bitnami ──────────────────────────────────────────────
|
|
677
|
+
/**
|
|
678
|
+
* Detect-and-report only. Never writes a comment-only postgresql.conf as "repaired".
|
|
679
|
+
* Full Bitnami conversion remains out of the default customer path.
|
|
680
|
+
*/
|
|
681
|
+
export async function postgresConvertBitnami(opts = {}) {
|
|
682
|
+
const dataDir = opts.dataDir ??
|
|
683
|
+
join(opts.installDir ? join(opts.installDir, 'postgres-data') : getPaths().postgresDataDir, 'data');
|
|
684
|
+
const hasDataDir = existsSync(dataDir);
|
|
685
|
+
const confPath = join(dataDir, 'postgresql.conf');
|
|
686
|
+
const hasConf = existsSync(confPath);
|
|
687
|
+
const report = [
|
|
688
|
+
'postgres convert-bitnami: detect-and-report only (no auto-repair).',
|
|
689
|
+
` dataDir: ${dataDir}`,
|
|
690
|
+
` dataDir exists: ${hasDataDir}`,
|
|
691
|
+
` postgresql.conf: ${hasConf ? 'present' : 'MISSING'}`,
|
|
692
|
+
hasConf
|
|
693
|
+
? ' layout: config present — no action required from this command.'
|
|
694
|
+
: ' layout: config missing — do not treat a comment-only stub as repair; use documented migration.',
|
|
695
|
+
opts.experimental
|
|
696
|
+
? ' mode: --experimental (still detect-only; mutate path removed).'
|
|
697
|
+
: ' note: experimental mutate path removed; this command never writes conf.',
|
|
698
|
+
].join('\n');
|
|
699
|
+
if (opts.dryRun) {
|
|
700
|
+
return { ok: true, message: `Would detect PostgreSQL layout under ${dataDir} (no writes).` };
|
|
701
|
+
}
|
|
702
|
+
// Always report; ok:true when detection ran. Missing conf is informational, not a green repair.
|
|
703
|
+
return { ok: true, message: report };
|
|
704
|
+
}
|
|
705
|
+
// ── RIS phone-export ─────────────────────────────────────────────────────
|
|
706
|
+
export async function risPhoneExport(opts = {}) {
|
|
707
|
+
if (!opts.experimental) {
|
|
708
|
+
return {
|
|
709
|
+
ok: false,
|
|
710
|
+
message: 'Error: ris phone-export is experimental/non-production. Pass --experimental.',
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
const run = opts.run ?? defaultRun;
|
|
714
|
+
const dest = opts.output ?? `ris-phone-export-${Date.now()}.log`;
|
|
715
|
+
try {
|
|
716
|
+
await runOrThrow(run, 'bash', [
|
|
717
|
+
'-c',
|
|
718
|
+
[
|
|
719
|
+
`DEST=${JSON.stringify(dest)}`,
|
|
720
|
+
'TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)',
|
|
721
|
+
'{',
|
|
722
|
+
' echo "=== RIS phone-export $TS ==="',
|
|
723
|
+
' echo "phase=before"',
|
|
724
|
+
' echo "note=experimental non-production snapshot"',
|
|
725
|
+
' docker compose ps 2>/dev/null | head -20 || true',
|
|
726
|
+
' echo "phase=export"',
|
|
727
|
+
' echo "soap=pending-or-offline"',
|
|
728
|
+
' echo "phase=after"',
|
|
729
|
+
'} > "$DEST"',
|
|
730
|
+
'test -s "$DEST"',
|
|
731
|
+
].join('\n'),
|
|
732
|
+
]);
|
|
733
|
+
}
|
|
734
|
+
catch (e) {
|
|
735
|
+
return { ok: false, message: `Error: ris phone-export failed to write ${dest}: ${e?.message ?? e}` };
|
|
736
|
+
}
|
|
737
|
+
return {
|
|
738
|
+
ok: true,
|
|
739
|
+
message: `RIS phone-export experimental snapshot written to ${dest}.`,
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
// ── SQL monitor password ─────────────────────────────────────────────────
|
|
743
|
+
export async function sqlMonitorPassword(opts = {}) {
|
|
744
|
+
const envPath = envFilePath(opts.installDir);
|
|
745
|
+
try {
|
|
746
|
+
const keys = readEnvKeysAt(opts.installDir);
|
|
747
|
+
const lines = [];
|
|
748
|
+
for (const [k, v] of Object.entries(keys)) {
|
|
749
|
+
if (/^(PGHERO_|SQL_MONITOR_)/.test(k)) {
|
|
750
|
+
const redacted = /PASSWORD/i.test(k) ? '***' : v;
|
|
751
|
+
lines.push(`${k}=${redacted}`);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (lines.length === 0) {
|
|
755
|
+
return {
|
|
756
|
+
ok: true,
|
|
757
|
+
message: `sql-monitor: no credentials in ${envPath} (enable sql-monitor first).`,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
return { ok: true, message: lines.join('\n') };
|
|
761
|
+
}
|
|
762
|
+
catch (e) {
|
|
763
|
+
return {
|
|
764
|
+
ok: false,
|
|
765
|
+
message: `Error reading sql-monitor credentials from ${envPath}: ${e?.message ?? e}`,
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
export async function sqlMonitorRotatePassword(opts = {}) {
|
|
770
|
+
const run = opts.run ?? defaultRun;
|
|
771
|
+
const pass = `pgh_${randomBytes(18).toString('base64url')}`;
|
|
772
|
+
const envPath = envFilePath(opts.installDir);
|
|
773
|
+
try {
|
|
774
|
+
updateEnvKeyFile('PGHERO_PASSWORD', pass, opts.installDir);
|
|
775
|
+
}
|
|
776
|
+
catch (e) {
|
|
777
|
+
return {
|
|
778
|
+
ok: false,
|
|
779
|
+
message: `Error writing PGHERO_PASSWORD to ${envPath}: ${e?.message ?? e}`,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
try {
|
|
783
|
+
await runOrThrow(run, 'docker', [
|
|
784
|
+
'compose',
|
|
785
|
+
...composeProjectArgs(opts.installDir),
|
|
786
|
+
'up',
|
|
787
|
+
'-d',
|
|
788
|
+
'--no-deps',
|
|
789
|
+
'--force-recreate',
|
|
790
|
+
'pghero',
|
|
791
|
+
]);
|
|
792
|
+
}
|
|
793
|
+
catch (e) {
|
|
794
|
+
return {
|
|
795
|
+
ok: false,
|
|
796
|
+
message: `Error recreating pghero after password rotate (password may already be in ${envPath}): ${e?.message ?? e}`,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
return {
|
|
800
|
+
ok: true,
|
|
801
|
+
message: `sql-monitor password rotated. New password: ${pass} (written to ${envPath}).`,
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
// ── Offline depth helpers ────────────────────────────────────────────────
|
|
805
|
+
export async function offlineListImages(opts = {}) {
|
|
806
|
+
const composePath = composeFilePath(opts.installDir);
|
|
807
|
+
try {
|
|
808
|
+
if (!existsSync(composePath)) {
|
|
809
|
+
return { ok: true, message: `No docker-compose.yml at ${composePath}.` };
|
|
810
|
+
}
|
|
811
|
+
const lines = readFileSync(composePath, 'utf-8')
|
|
812
|
+
.split('\n')
|
|
813
|
+
.filter((l) => /image\s*:/.test(l))
|
|
814
|
+
.slice(0, 40);
|
|
815
|
+
return {
|
|
816
|
+
ok: true,
|
|
817
|
+
message: lines.length ? lines.join('\n') : `No image: lines in ${composePath}.`,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
catch (e) {
|
|
821
|
+
return {
|
|
822
|
+
ok: false,
|
|
823
|
+
message: `Error reading compose images from ${composePath}: ${e?.message ?? e}`,
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
export async function offlineFetchVersion(version, opts = {}) {
|
|
828
|
+
if (!version.trim())
|
|
829
|
+
return { ok: false, message: 'Error: version required.' };
|
|
830
|
+
if (opts.dryRun)
|
|
831
|
+
return { ok: true, message: `Would fetch offline bundle for ${version}.` };
|
|
832
|
+
const run = opts.run ?? defaultRun;
|
|
833
|
+
try {
|
|
834
|
+
await runOrThrow(run, 'curl', [
|
|
835
|
+
'-fsSIL',
|
|
836
|
+
`https://get.calltelemetry.com/releases/${version}/manifest.json`,
|
|
837
|
+
]);
|
|
838
|
+
}
|
|
839
|
+
catch (e) {
|
|
840
|
+
return {
|
|
841
|
+
ok: false,
|
|
842
|
+
message: `Error: offline fetch manifest check failed for ${version}: ${e?.message ?? e}`,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
return { ok: true, message: `offline fetch: checked manifest path for ${version}.` };
|
|
846
|
+
}
|
|
847
|
+
// ── Migrate legacy polish ────────────────────────────────────────────────
|
|
848
|
+
export async function migrateLegacyStatus(opts = {}) {
|
|
849
|
+
const run = opts.run ?? defaultRun;
|
|
850
|
+
// Read-only: soft is acceptable; prefer honest empty messaging
|
|
851
|
+
const out = await soft(run, 'docker', [
|
|
852
|
+
'compose',
|
|
853
|
+
...composeProjectArgs(opts.installDir),
|
|
854
|
+
'exec',
|
|
855
|
+
'-T',
|
|
856
|
+
'web',
|
|
857
|
+
'bin/onprem',
|
|
858
|
+
'rpc',
|
|
859
|
+
'Cdrcisco.Release.legacy_partition_status()',
|
|
860
|
+
]);
|
|
861
|
+
if (opts.json) {
|
|
862
|
+
return { ok: true, message: JSON.stringify({ status: out || 'unknown' }) };
|
|
863
|
+
}
|
|
864
|
+
return {
|
|
865
|
+
ok: true,
|
|
866
|
+
message: out || 'migrate legacy: status unavailable (stack offline); use when web is up.',
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
// ── Rollback list polish ─────────────────────────────────────────────────
|
|
870
|
+
export async function listRollbackSnapshots(opts = {}) {
|
|
871
|
+
const dir = opts.dir ??
|
|
872
|
+
join(opts.installDir ?? getPaths().installDir, 'snapshots');
|
|
873
|
+
try {
|
|
874
|
+
if (!existsSync(dir)) {
|
|
875
|
+
return { ok: true, message: 'No rollback snapshots found.', snapshots: [] };
|
|
876
|
+
}
|
|
877
|
+
const snapshots = readdirSync(dir).filter(Boolean).slice(-20);
|
|
878
|
+
return {
|
|
879
|
+
ok: true,
|
|
880
|
+
message: snapshots.length
|
|
881
|
+
? `Snapshots:\n${snapshots.map((s) => ` ${s}`).join('\n')}`
|
|
882
|
+
: 'No rollback snapshots found.',
|
|
883
|
+
snapshots,
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
catch (e) {
|
|
887
|
+
// Optional soft read via run for remote listing if dir unreadable
|
|
888
|
+
if (opts.run) {
|
|
889
|
+
const out = await soft(opts.run, 'bash', ['-c', `ls -1 ${JSON.stringify(dir)} 2>/dev/null | tail -20`]);
|
|
890
|
+
const snapshots = out ? out.split('\n').filter(Boolean) : [];
|
|
891
|
+
return {
|
|
892
|
+
ok: true,
|
|
893
|
+
message: snapshots.length
|
|
894
|
+
? `Snapshots:\n${snapshots.map((s) => ` ${s}`).join('\n')}`
|
|
895
|
+
: 'No rollback snapshots found.',
|
|
896
|
+
snapshots,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
return {
|
|
900
|
+
ok: false,
|
|
901
|
+
message: `Error listing snapshots under ${dir}: ${e?.message ?? e}`,
|
|
902
|
+
snapshots: [],
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
//# sourceMappingURL=ops-port.js.map
|