Pulse/tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs
2026-04-24 13:13:36 +01:00

306 lines
9.5 KiB
JavaScript

#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import {
restartHostedTenantRuntime,
shellQuote,
} from './hosted-tenant-runtime.mjs';
import { createHostedRelayMobileToken } from './hosted-mobile-token-runtime.mjs';
const DEFAULT_POLL_INTERVAL_MS = 500;
const DEFAULT_POLL_TIMEOUT_MS = 15_000;
const SCRIPT_PATH = fileURLToPath(import.meta.url);
function usage(message) {
if (message) {
console.error(`error: ${message}`);
console.error('');
}
console.error(
'usage: node ./tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs --tenant-id <id> --cloud-host <user@host> --control-plane-url <url> [--email <email>] [--poll-timeout-ms <ms>] [--poll-interval-ms <ms>]',
);
process.exit(1);
}
function parseArgs(argv) {
const parsed = {
cloudHost: '',
controlPlaneUrl: '',
email: '',
pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
pollTimeoutMs: DEFAULT_POLL_TIMEOUT_MS,
tenantId: '',
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case '--tenant-id':
parsed.tenantId = argv[index + 1] ?? usage('missing value for --tenant-id');
index += 1;
break;
case '--email':
parsed.email = argv[index + 1] ?? usage('missing value for --email');
index += 1;
break;
case '--cloud-host':
parsed.cloudHost = argv[index + 1] ?? usage('missing value for --cloud-host');
index += 1;
break;
case '--control-plane-url':
parsed.controlPlaneUrl = argv[index + 1] ?? usage('missing value for --control-plane-url');
index += 1;
break;
case '--poll-interval-ms':
parsed.pollIntervalMs = Number(argv[index + 1] ?? usage('missing value for --poll-interval-ms'));
index += 1;
break;
case '--poll-timeout-ms':
parsed.pollTimeoutMs = Number(argv[index + 1] ?? usage('missing value for --poll-timeout-ms'));
index += 1;
break;
case '--help':
case '-h':
usage();
break;
default:
usage(`unsupported flag ${arg}`);
}
}
parsed.controlPlaneUrl = String(parsed.controlPlaneUrl).trim().replace(/\/+$/, '');
parsed.email = String(parsed.email).trim().toLowerCase();
parsed.tenantId = String(parsed.tenantId).trim();
if (!parsed.tenantId) {
usage('--tenant-id is required');
}
if (!parsed.cloudHost) {
usage('--cloud-host is required');
}
if (!parsed.controlPlaneUrl) {
usage('--control-plane-url is required');
}
if (!Number.isFinite(parsed.pollIntervalMs) || parsed.pollIntervalMs < 1) {
usage('--poll-interval-ms must be a positive number');
}
if (!Number.isFinite(parsed.pollTimeoutMs) || parsed.pollTimeoutMs < 1) {
usage('--poll-timeout-ms must be a positive number');
}
return parsed;
}
export function deriveTenantBaseUrl(controlPlaneUrl, tenantId) {
const parsed = new URL(controlPlaneUrl);
parsed.hostname = `${tenantId}.${parsed.hostname}`;
parsed.pathname = '';
parsed.search = '';
parsed.hash = '';
return parsed.toString().replace(/\/$/, '');
}
export function redactBearerTokens(value) {
return String(value).replace(/Bearer\s+[A-Za-z0-9._~+/-]+/g, 'Bearer [REDACTED]');
}
export async function fetchOnboardingPayload({ fetchImpl = globalThis.fetch, rawToken, tenantBaseUrl }) {
if (typeof fetchImpl !== 'function') {
throw new Error('fetch is not available in this Node runtime');
}
const url = `${tenantBaseUrl}/api/onboarding/qr`;
let response;
try {
response = await fetchImpl(url, {
headers: {
Authorization: `Bearer ${rawToken}`,
},
});
} catch (error) {
throw new Error(`failed to fetch hosted onboarding payload from ${tenantBaseUrl}: ${redactBearerTokens(error instanceof Error ? error.message : String(error))}`);
}
const body = await response.text();
if (!response.ok) {
throw new Error(`hosted onboarding payload request returned HTTP ${response.status} from ${tenantBaseUrl}: ${redactBearerTokens(body.slice(0, 500))}`);
}
try {
return JSON.parse(body);
} catch (error) {
throw new Error(`hosted onboarding payload from ${tenantBaseUrl} was not valid JSON: ${redactBearerTokens(error instanceof Error ? error.message : String(error))}`);
}
}
const REMOTE_ONBOARDING_FETCH_PY = `
import json
import sys
import urllib.error
import urllib.request
base_url = sys.argv[1].rstrip("/")
token = sys.stdin.read().strip()
request = urllib.request.Request(
base_url + "/api/onboarding/qr",
headers={"Authorization": "Bearer " + token},
)
try:
with urllib.request.urlopen(request, timeout=20) as response:
sys.stdout.write(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")[:500]
sys.stderr.write(json.dumps({"status": exc.code, "body": body}))
sys.exit(22)
except Exception as exc:
sys.stderr.write(str(exc))
sys.exit(1)
`;
export function fetchOnboardingPayloadViaCloudHost({
cloudHost,
rawToken,
runner = execFileSync,
tenantBaseUrl,
}) {
const host = String(cloudHost ?? '').trim();
if (!host) {
throw new Error('cloud host is required for hosted onboarding remote fetch');
}
const command = `python3 -c ${shellQuote(REMOTE_ONBOARDING_FETCH_PY)} ${shellQuote(tenantBaseUrl)}`;
let output;
try {
output = runner('ssh', [host, command], {
encoding: 'utf8',
input: rawToken,
maxBuffer: 32 * 1024 * 1024,
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch (error) {
const detail = ['stderr', 'stdout']
.map((field) => error?.[field])
.filter((value) => value !== undefined && value !== null && String(value).trim() !== '')
.map((value) => String(value).trim())
.join('\n') || (error instanceof Error ? error.message : String(error));
throw new Error(`failed to fetch hosted onboarding payload via ${host}: ${redactBearerTokens(detail)}`);
}
try {
return JSON.parse(output);
} catch (error) {
throw new Error(`hosted onboarding payload fetched via ${host} was not valid JSON: ${redactBearerTokens(error instanceof Error ? error.message : String(error))}`);
}
}
function sleep(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
async function pollOnboardingPayload({
cloudHost,
fetchImpl,
pollIntervalMs,
pollTimeoutMs,
rawToken,
tenantBaseUrl,
}) {
const deadline = Date.now() + pollTimeoutMs;
let lastError = null;
while (Date.now() <= deadline) {
try {
return await fetchOnboardingPayload({ fetchImpl, rawToken, tenantBaseUrl });
} catch (error) {
lastError = error;
if (String(cloudHost ?? '').trim() !== '') {
try {
return fetchOnboardingPayloadViaCloudHost({ cloudHost, rawToken, tenantBaseUrl });
} catch (remoteError) {
lastError = remoteError;
}
}
if (Date.now() >= deadline) {
break;
}
sleep(pollIntervalMs);
}
}
throw new Error(`timed out waiting for hosted onboarding payload from ${tenantBaseUrl}: ${String(lastError instanceof Error ? lastError.message : lastError)}`);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-hosted-mobile-onboarding-'));
try {
const tokenPayload = createHostedRelayMobileToken({
cloudHost: args.cloudHost,
tenantId: args.tenantId,
tempDir,
});
const rawToken = String(tokenPayload?.token ?? '').trim();
if (!rawToken) {
throw new Error('hosted relay-mobile token helper did not return a raw token');
}
restartHostedTenantRuntime(args.cloudHost, args.tenantId);
const tenantBaseUrl = deriveTenantBaseUrl(args.controlPlaneUrl, args.tenantId);
const qrPayload = await pollOnboardingPayload({
cloudHost: args.cloudHost,
pollIntervalMs: args.pollIntervalMs,
pollTimeoutMs: args.pollTimeoutMs,
rawToken,
tenantBaseUrl,
});
const instanceId = String(qrPayload?.instance_id ?? '').trim();
const deepLink = String(qrPayload?.deep_link ?? '').trim();
const relayUrl = String(qrPayload?.relay?.url ?? '').trim();
if (!instanceId) {
throw new Error(`hosted tenant ${args.tenantId} does not currently expose a relay instance_id`);
}
if (!deepLink) {
throw new Error(`hosted tenant ${args.tenantId} did not return an onboarding deep link`);
}
if (qrPayload?.relay?.enabled !== true) {
throw new Error(`hosted tenant ${args.tenantId} relay is not enabled for mobile onboarding`);
}
process.stdout.write(`${JSON.stringify({
controlPlaneUrl: args.controlPlaneUrl,
deepLink,
email: args.email,
instanceId,
relayConnected: instanceId !== '',
relayStatus: {
diagnostics: Array.isArray(qrPayload?.diagnostics) ? qrPayload.diagnostics : [],
enabled: qrPayload?.relay?.enabled === true,
instanceId,
},
relayUrl,
tenantBaseUrl,
tenantId: args.tenantId,
tokenId: tokenPayload?.record?.id ?? null,
runtimeRestarted: true,
}, null, 2)}\n`);
} finally {
fs.rmSync(tempDir, { force: true, recursive: true });
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === SCRIPT_PATH) {
main().catch((error) => {
console.error(redactBearerTokens(error instanceof Error ? error.message : String(error)));
process.exit(1);
});
}