UNPKG

@acurast/cli

Version:

A cli to interact with the Acurast Cloud.

256 lines 11.8 kB
import { Option } from 'commander'; import { confirm, input, select } from '@inquirer/prompts'; import { parseByteSize, jobToNumber } from '@acurast/sdk/chain'; import { probeVpsReady } from '@acurast/vps'; import { buildVpsConfig, resolveVpsOptions, VPS_IMAGE_NAMES, DEFAULT_VPS_IMAGE, DEFAULT_VPS_DURATION, DEFAULT_VPS_NETWORK, } from '../util/buildVpsConfig.js'; import { persistVpsOptionsToEnv } from '../util/persistVpsEnv.js'; import { validateCliConfig } from '../util/validateCliConfig.js'; import { executeDeployFlow } from './deploy.js'; import { CLI_NETWORKS } from '../config.js'; import { parse as parseDuration } from '../util/parse-duration.js'; import { consoleOutput } from '../util/console-output.js'; import { acurastColor } from '../util.js'; import { humanTime } from '../util/humanTime.js'; import { filelogger } from '../util/fileLogger.js'; import * as ora from '../util/ora.js'; const IMAGE_LABELS = { ubuntu: 'Ubuntu 25.10 (questing)', }; const SSH_KEY_PREFIX = /^(ssh-(rsa|ed25519|dss)|ecdsa-sha2-\S+|sk-(ssh-ed25519|ecdsa-sha2-\S+))\s+\S+/; const validateSshKey = (value) => SSH_KEY_PREFIX.test(value.trim()) ? true : 'Expected an SSH public key (e.g. "ssh-ed25519 AAAA... user@host")'; const validateDurationInput = (value) => { const parsed = parseDuration(value) ?? 0; return Number.isFinite(parsed) && parsed > 0 ? true : 'Please enter a valid duration (e.g. 1h, 24h, 2d)'; }; const validateByteSizeInput = (value) => { if (value === '') return true; try { parseByteSize(value); return true; } catch { return 'Please enter a valid size (e.g. 2GB, 512MB) or leave empty'; } }; const validatePositiveIntInput = (value) => { if (value === '') return true; const n = Number(value); return Number.isInteger(n) && n > 0 ? true : 'Please enter a positive integer or leave empty'; }; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); /** * Interactively fill in every option that was not provided via flags or * `VPS_*` environment variables. */ const runVpsWizard = async (opts) => { const result = { ...opts }; if (result.network === undefined) { result.network = await select({ message: 'Which network should the VPS deploy to?', choices: CLI_NETWORKS.map((network) => ({ value: network })), default: DEFAULT_VPS_NETWORK, }); } if (result.image === undefined && VPS_IMAGE_NAMES.length > 1) { result.image = await select({ message: 'Which image should the VPS run?', choices: VPS_IMAGE_NAMES.map((alias) => ({ value: alias, name: IMAGE_LABELS[alias] ?? alias, })), default: DEFAULT_VPS_IMAGE, }); } if (result.duration === undefined) { result.duration = await input({ message: 'How long should the VPS run? (e.g. 1h, 24h, 2d):', default: DEFAULT_VPS_DURATION, validate: validateDurationInput, }); } if (result.minMemory === undefined && result.minStorage === undefined && result.minComputeScore === undefined && result.minCpuMultiScore === undefined) { const setRequirements = await confirm({ message: 'Set minimum hardware requirements for the processor?', default: false, }); if (setRequirements) { result.minMemory = (await input({ message: 'Minimum total RAM (e.g. 2GB, empty to skip):', validate: validateByteSizeInput, })) || undefined; result.minStorage = (await input({ message: 'Minimum available storage (e.g. 10GB, empty to skip):', validate: validateByteSizeInput, })) || undefined; result.minComputeScore = (await input({ message: 'Minimum CPU benchmark score (e.g. 100, empty to skip):', validate: validatePositiveIntInput, })) || undefined; } } if (result.authorizedSshKey === undefined) { result.authorizedSshKey = await input({ message: 'Authorized SSH public key (required, e.g. contents of ~/.ssh/id_ed25519.pub):', validate: validateSshKey, }); } if (result.callbackUrl === undefined) { result.callbackUrl = (await input({ message: 'Callback URL for tunnel lifecycle events (e.g. from https://webhook.watch, empty to skip):', validate: (value) => value === '' || /^https?:\/\/\S+$/.test(value) ? true : 'Please enter a valid URL or leave empty', })) || undefined; } return result; }; /** * Poll the precomputed tunnel domain until dropbear answers, then print the * SSH connect command. The processor boots the rootfs (image download + * apt-get) after the start time, so this can take a while. */ const waitForVpsReady = async (domain, sshCommand, log, deadlineMs, webUrl) => { const spinner = ora.default('Waiting for the VPS to boot (image download + setup can take a few minutes)...'); spinner.start(); const deadline = Date.now() + deadlineMs; while (Date.now() < deadline) { const result = await probeVpsReady(domain, { timeoutMs: 10000 }); if (result.ready) { spinner.stop(); log(`✅ VPS is up (${result.banner ?? 'SSH banner received'})`); log(''); log('Connect with:'); log(` ${sshCommand}`); if (webUrl) { log(''); log('Browser (HTTP from your --http-port):'); log(` ${webUrl}`); } log(''); return; } filelogger.debug(`VPS probe not ready yet: ${result.error}`); await sleep(15000); } spinner.stop(); log('⚠️ The VPS did not become reachable in time. It may still be booting — retry the SSH command in a few minutes:'); log(` ${sshCommand}`); log(''); }; export const addCommandDeployVps = (deployCmd) => { deployCmd .command('vps') .description('Deploy an SSH-able VPS on an Acurast processor, exposed via the Acurast tunnel.') .addOption(new Option('--image <alias>', `VPS image. One of: ${VPS_IMAGE_NAMES.join(', ')}`).choices(VPS_IMAGE_NAMES)) .addOption(new Option('--min-memory <size>', 'Minimum total RAM (e.g. 2GB, 512MiB)')) .addOption(new Option('--min-storage <size>', 'Minimum available storage capacity (e.g. 10GB)')) .addOption(new Option('--min-compute-score <n>', 'Minimum CPU single-core benchmark score')) .addOption(new Option('--min-cpu-multi-score <n>', 'Minimum CPU multi-core benchmark score')) .addOption(new Option('--authorized-ssh-key <key>', 'SSH public key added to /root/.ssh/authorized_keys on the VPS (required; the VPS is key-auth only)')) .addOption(new Option('--duration <duration>', `How long the VPS runs (e.g. 1h, 24h, 2d). Default: ${DEFAULT_VPS_DURATION}`)) .addOption(new Option('--callback-url <url>', 'Webhook that receives tunnel lifecycle events (log/started/error) as JSON')) .addOption(new Option('--http-port <port>', 'Also serve plain HTTP from this VPS port on the tunnel subdomain (>= 1024)')) .addOption(new Option('--network <network>', 'Target network').choices([ ...CLI_NETWORKS, ])) .addOption(new Option('--max-cost-per-execution <amount>', 'Maximum reward paid per execution (in the smallest token unit)')) .addOption(new Option('-d, --dry-run', 'Prepare everything without actually deploying.')) .addOption(new Option('-o, --output <format>', 'Output a json on each of the steps of the deployment process.') .choices(['text', 'json']) .default('text')) .addOption(new Option('-ee, --exit-early', 'Do not wait for the deployment to finish.')) .addOption(new Option('-n, --non-interactive', 'Do not ask for any input; missing options fall back to VPS_* env vars and defaults.')) .action(async (options) => { const log = consoleOutput(options.output); const toAcurastColor = (text) => options.output === 'json' ? text : acurastColor(text); const isInteractive = !options.nonInteractive && options.output === 'text'; // flags > VPS_* env vars > wizard/defaults let vpsOptions = resolveVpsOptions(options); if (isInteractive) { const beforeWizard = { ...vpsOptions }; vpsOptions = await runVpsWizard(vpsOptions); const wizardAnswered = Object.keys(vpsOptions).some((key) => vpsOptions[key] !== undefined && beforeWizard[key] === undefined); if (wizardAnswered) { const save = await confirm({ message: 'Save these settings to .env (as VPS_* variables) so future runs skip the questions?', default: true, }); if (save) { const written = persistVpsOptionsToEnv(vpsOptions); log(`Saved to .env: ${written.join(', ')}`); } } } let plan; try { plan = buildVpsConfig(vpsOptions); } catch (e) { log(e.message); return; } const { envVars, domain, sshCommand } = plan; const configResult = validateCliConfig(plan.config); if (!configResult.success) { log(''); log('⚠️ Generated VPS config is invalid:'); log(''); log(configResult.error); filelogger.error(`VPS config is invalid ${JSON.stringify(configResult.error)}`); return; } const config = configResult.data; log(''); log(`Deploying VPS "${config.projectName}"`); log(''); log(` Image: ${IMAGE_LABELS[vpsOptions.image ?? DEFAULT_VPS_IMAGE] ?? vpsOptions.image}`); log(` Duration: ${humanTime(config.execution.type === 'onetime' ? config.execution.maxExecutionTimeInMs : 0, true)}`); log(` Network: ${config.network}`); log(` Tunnel: ${toAcurastColor(`https://${domain}`)}`); if (config.benchmarkFilters) { const activeFilters = Object.fromEntries(Object.entries(config.benchmarkFilters).filter(([, value]) => value !== undefined)); if (Object.keys(activeFilters).length > 0) { log(` Requirements: ${JSON.stringify(activeFilters)}`); } } await executeDeployFlow(config, () => envVars, options, configResult.notes, async ({ jobId }) => { log(''); if (jobId && Array.isArray(jobId) && jobId[0]?.acurast) { log(`Hub: ${toAcurastColor(`https://hub.acurast.com/job-detail/acurast-${jobId[0].acurast}-${jobToNumber(jobId)}`)}`); } log(''); const webUrl = vpsOptions.httpPort !== undefined ? `https://${domain}` : undefined; if (options.exitEarly) { log('Once the VPS is up, connect with:'); log(` ${sshCommand}`); if (webUrl) { log(`It will also serve HTTP in the browser: ${webUrl}`); } log(''); return; } // Start delay (~3 min) + rootfs download and setup on the device. await waitForVpsReady(domain, sshCommand, log, 15 * 60000, webUrl); }); }); }; //# sourceMappingURL=deploy-vps.js.map