From f4bd3d9c7cb74c1b7cb8694eb6dbf7532159ef02 Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Wed, 22 Jul 2026 15:16:57 +0300 Subject: [PATCH 1/3] services --- README.md | 117 ++++++ package-lock.json | 3 +- package.json | 2 +- src/cli.ts | 328 +++++++++++++++- src/commands.ts | 798 +++++++++++++++++++++++++++++++++++++++ src/serviceHelpers.ts | 461 ++++++++++++++++++++++ test/serviceFlow.test.ts | 239 ++++++++++++ 7 files changed, 1945 insertions(+), 3 deletions(-) create mode 100644 src/serviceHelpers.ts create mode 100644 test/serviceFlow.test.ts diff --git a/README.md b/README.md index 0015916..209986f 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ With the Ocean CLI tool you can: - **Edit** existing assets. - **Consume** data services, ordering datatokens and downloading data. - **Compute to data** on public available datasets using a published algorithm. +- **Run on-demand services**: launch long-running containers (JupyterLab, inference servers, …) on compute environments, paid via escrow. - **Manage access control** with access lists for restricting dataset/algorithm access. - **Handle escrow payments** for compute jobs with deposit, withdrawal, and authorization. - **Manage authentication** with token generation and invalidation. @@ -307,6 +308,71 @@ Instead of a DID, you can pass a full `ComputeAsset` (datasets) or `ComputeAlgor --- +### Service-on-Demand (long-running containers) + +Launch a long-running container (JupyterLab, an inference server, nginx, …) on a +compute environment. Unlike a compute job — which runs an algorithm to completion +and exits — an on-demand service stays up until it **expires**, is **stopped**, or +is **extended**, and is reachable through a forwarded port (`http://:`). + +Full happy path: + +```bash +# 1. Inspect the node's templates and which environments can run them +npm run cli getServiceTemplates + +# 2. Fund escrow and authorize the environment's consumer address as payee +# (maxLockSeconds must be at least duration + 3600) +npm run cli depositEscrow 0x 100 +npm run cli authorizeEscrow 0x 0x 100 90000 100 + +# 3a. Start from an operator template +npm run cli -- startService 3600 0x --template jupyter-cpu \ + --user-data '{"JUPYTER_TOKEN":"secret"}' + +# 3b. …or bring your own image (pass the tag via --tag, NOT inside --image) +npm run cli -- startService 3600 0x \ + --image nginxinc/nginx-unprivileged --tag alpine --ports 8080 + +# 4. Inspect / manage +npm run cli getServiceStatus # YOUR services, full detail +npm run cli -- getServices --status 40 # ALL owners' running services on the node (SERVICES_LIST) +npm run cli -- serviceLogs --since 10m +npm run cli -- extendService 1800 --accept true +npm run cli -- restartService --cmd '["python","app.py"]' # optional cmd/entrypoint override +npm run cli stopService +``` + +Notes: + +- **Duration is in seconds.** The CLI prints an **estimated** cost for the payment + prompt; the **authoritative** cost is computed by the node and shown as + `Node-computed cost:` right after start. +- **Start is asynchronous.** `startService` returns immediately with a `serviceId` + in status `Starting (10)`; the CLI then polls until `Running (40)` (unless + `--wait false`) and prints the endpoint URL. If polling is interrupted, resume + with `getServiceStatus `. +- **Escrow must be funded and authorized before start.** Escrow shortfalls do not + fail the HTTP call — they surface as the job ending in `Error`/`*Failed` — so the + CLI pre-verifies funds/authorization client-side and aborts early with the exact + remediation commands. +- **Image spec:** provide at most one of `--tag`, `--checksum`, `--dockerfile`, and + keep the tag in `--tag` (an image reference that already contains a tag makes the + node build an invalid `image:tag:latest` reference). +- **Ports:** containers run with `CapDrop ALL` and cannot bind ports below 1024 — + services must listen on a high container port (e.g. 8080). +- **`--user-data`** is a plain JSON object of container env vars; ocean.js encrypts + it to the node's key. The CLI **never logs its values** (keys only). +- **`getServiceStatus` vs `getServices`:** `getServiceStatus` shows *your* services + with full detail; `getServices` (alias `listServices`, the SERVICES_LIST command) + lists services across *all* owners on the node, with the docker image spec + stripped, and supports `--status` / `--include-all` / `--from` filters. +- **`restartService --cmd/--entrypoint`** replace the stored command/entrypoint on + the recreated container (an empty array clears them); omit to reuse the stored + configuration. + +--- + **Mint Ocean:** - **Positional:** @@ -581,6 +647,57 @@ Instead of a DID, you can pass a full `ComputeAsset` (datasets) or `ComputeAlgor `-i, --index ` `-f, --folder [destinationFolder]` +- **getServiceTemplates:** (alias `serviceTemplates`) + `[node]` (Optional positional. Ocean Node URL or peer id to query; defaults to `NODE_URL`) + `-n, --node ` (Optional. Same as the positional) + +- **startService:** + `` `` (seconds) `` (required positionals) + `--template ` (Start from an operator-published template) + `-i, --image ` (Container image — alternative to `--template`; keep the tag in `--tag`) + `--tag ` / `--checksum ` / `--dockerfile ` (image spec — provide at most one) + `--additional-docker-files ` (JSON file of `{filename: content}`, used with `--dockerfile`) + `--cmd ` / `--entrypoint ` (Docker CMD/ENTRYPOINT override as JSON arrays) + `-p, --ports ` (Comma-separated container ports, e.g. `8888,8080`) + `-r, --resources ` (Stringified JSON `[{"id":"cpu","amount":1},…]`; defaults to template requirements) + `-u, --user-data ` / `--user-data-file ` (Container env vars; encrypted to the node, never logged) + `--accept [boolean]` (Auto-confirm payment) + `--wait [boolean]` (Poll until Running/failure; default `true`) + `--timeout ` (Max seconds to wait for Running; default 600) + +- **getServiceStatus:** (alias `myServices`) + `[serviceId]` (Optional; omit to list all your services) + `-s, --service ` + `-v, --verbose [boolean]` (Dump full job objects) + +- **getServices:** (alias `listServices` — the SERVICES_LIST command, all owners) + `[node]` / `-n, --node ` (Optional Ocean Node URL or peer id; defaults to `NODE_URL`) + `--status ` (Filter by a single status number, e.g. `40` for Running) + `--include-all [boolean]` (Include all statuses, not just active reservations) + `--from ` (Only services created at/after this ISO string or Unix timestamp) + `-v, --verbose [boolean]` (Dump full job objects) + +- **serviceLogs:** (alias `computeServiceLogs`) + `` / `-s, --service ` + `--since ` (Unix seconds or a relative duration like `30s` / `2h`) + +- **extendService:** + `` `` (seconds) `[paymentToken]` + `-s, --service ` + `--duration ` + `-t, --token [paymentToken]` (defaults to the token used at start) + `--accept [boolean]` (Auto-confirm payment) + +- **restartService:** + `` + `-u, --user-data ` / `--user-data-file ` (REPLACE stored env vars) + `--cmd ` / `--entrypoint ` (REPLACE stored Docker CMD/ENTRYPOINT; empty array clears) + `--wait [boolean]` (Poll until Running; default `true`) + `--timeout ` (Max seconds to wait; default 600) + +- **stopService:** + `` / `-s, --service ` + - **mintOcean:** No options/arguments required. diff --git a/package-lock.json b/package-lock.json index 2e2795d..a682219 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@oasisprotocol/sapphire-paratime": "^1.3.2", "@oceanprotocol/contracts": "^2.5.0", "@oceanprotocol/ddo-js": "^0.3.0", - "@oceanprotocol/lib": "^9.0.0-next.1", + "@oceanprotocol/lib": "^9.0.0-next.4", "axios": "^1.11.0", "commander": "^13.1.0", "cross-fetch": "^3.1.5", @@ -3191,6 +3191,7 @@ "version": "9.0.0-next.4", "resolved": "https://registry.npmjs.org/@oceanprotocol/lib/-/lib-9.0.0-next.4.tgz", "integrity": "sha512-V494YS6skdR+WxN5TDXC2ZfryOU2etxOVgy1wtPBtqThbaaXPCoUfUjrVstyu6XsIt09lRROmuDJF4M+Xa8VAg==", + "license": "Apache-2.0", "dependencies": { "@oasisprotocol/sapphire-paratime": "^1.3.2", "@oceanprotocol/ddo-js": "^0.3.0", diff --git a/package.json b/package.json index 8077a47..c183d96 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "@oasisprotocol/sapphire-paratime": "^1.3.2", "@oceanprotocol/contracts": "^2.5.0", "@oceanprotocol/ddo-js": "^0.3.0", - "@oceanprotocol/lib": "^9.0.0-next.1", + "@oceanprotocol/lib": "^9.0.0-next.4", "axios": "^1.11.0", "commander": "^13.1.0", "cross-fetch": "^3.1.5", diff --git a/src/cli.ts b/src/cli.ts index 89d9878..65f848a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,12 +1,48 @@ import { Command } from "commander"; import { Commands } from "./commands.js"; import { JsonRpcProvider, Signer, ethers } from "ethers"; +import fs from "fs"; import chalk from "chalk"; import { stdin as input, stdout } from "node:process"; import { createInterface } from "readline/promises"; -import { unitsToAmount, ProviderInstance, isP2pUri } from "@oceanprotocol/lib"; +import { + unitsToAmount, + ProviderInstance, + isP2pUri, + ServiceStatusNumber, +} from "@oceanprotocol/lib"; import { toBoolean } from "./helpers.js"; +// Parse a CLI JSON array-of-strings option (e.g. --cmd '["python","app.py"]'). +// Returns the array, or throws with a clear message for the action to surface. +function parseJsonStringArray(name: string, value: string): string[] { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new Error(`${name} must be a JSON array, e.g. '["a","b"]'`); + } + if (!Array.isArray(parsed) || !parsed.every((x) => typeof x === "string")) { + throw new Error(`${name} must be a JSON array of strings`); + } + return parsed as string[]; +} + +// Parse a comma-separated port list, validating each is an integer 1-65535. +function parsePorts(value: string): number[] { + return value + .split(",") + .map((p) => p.trim()) + .filter(Boolean) + .map((p) => { + const n = parseInt(p, 10); + if (!Number.isInteger(n) || n < 1 || n > 65535) { + throw new Error(`Invalid port "${p}" (must be an integer 1-65535)`); + } + return n; + }); +} + async function initializeSigner() { const provider = new JsonRpcProvider(process.env.RPC); let signer: Signer; @@ -561,6 +597,296 @@ export async function createCLI() { ]); }); + // ========================================================================= + // Service-on-Demand commands + // ========================================================================= + + // getServiceTemplates command + program + .command("getServiceTemplates") + .alias("serviceTemplates") + .description( + "Lists the node's Service-on-Demand templates and compatible environments" + ) + .argument( + "[node]", + "Optional Ocean Node URL or peer id to query (defaults to NODE_URL)" + ) + .option("-n, --node ", "Ocean Node URL or peer id to query") + .action(async (node, options) => { + const { signer, chainId } = await initializeSigner(); + const commands = new Commands(signer, chainId); + await commands.getServiceTemplates(options.node || node); + }); + + // startService command + program + .command("startService") + .description( + "Starts an on-demand service (long-running container) on a compute environment, paid via escrow" + ) + .argument("", "Compute environment ID (must have services enabled)") + .argument("", "Requested duration in seconds", parseInt) + .argument("", "Payment token address") + .option("--template ", "Start from an operator-published template") + .option("-i, --image ", "Container image (alternative to --template)") + .option("--tag ", "Image tag (mutually exclusive with --checksum/--dockerfile)") + .option("--checksum ", "Image digest, e.g. sha256:<64 hex>") + .option("--dockerfile ", "Path to a local Dockerfile (node must allow image builds)") + .option( + "--additional-docker-files ", + "Path to JSON file of {filename: content} used with --dockerfile" + ) + .option("--cmd ", 'Docker CMD override as JSON array, e.g. \'["python","app.py"]\'') + .option("--entrypoint ", "Docker ENTRYPOINT override as JSON array") + .option("-p, --ports ", "Comma-separated container ports to expose, e.g. 8888,8080") + .option( + "-r, --resources ", + 'Stringified JSON [{"id":"cpu","amount":1},...]; defaults to template requirements' + ) + .option( + "-u, --user-data ", + "JSON object of container env vars (encrypted to the node; never logged)" + ) + .option("--user-data-file ", "Path to JSON file with container env vars") + .option("--accept [boolean]", "Auto-confirm payment (true/false)", toBoolean) + .option("--wait [boolean]", "Poll until Running or failure (default true)", toBoolean, true) + .option("--timeout ", "Max seconds to wait for Running (default 600)", parseInt) + .action(async (computeEnvId, duration, paymentToken, options) => { + const envId = options.env || computeEnvId; + const token = paymentToken; + if (!envId || !duration || !token) { + console.error(chalk.red("Missing required arguments: ")); + return; + } + if (!Number.isInteger(duration) || duration <= 0) { + console.error(chalk.red("Duration must be a positive integer number of seconds.")); + return; + } + if (options.template && options.image) { + console.error(chalk.red("Provide either --template or --image, not both.")); + return; + } + + let ports: number[] | undefined; + let cmd: string[] | undefined; + let entrypoint: string[] | undefined; + try { + if (options.ports) ports = parsePorts(options.ports); + if (options.cmd) cmd = parseJsonStringArray("--cmd", options.cmd); + if (options.entrypoint) + entrypoint = parseJsonStringArray("--entrypoint", options.entrypoint); + } catch (e) { + console.error(chalk.red((e as Error).message)); + return; + } + + const { signer, chainId } = await initializeSigner(); + const commands = new Commands(signer, chainId); + await commands.startService({ + envId, + duration, + paymentToken: token, + templateId: options.template, + image: options.image, + tag: options.tag, + checksum: options.checksum, + dockerfilePath: options.dockerfile, + additionalDockerFilesPath: options.additionalDockerFiles, + cmd, + entrypoint, + ports, + resources: options.resources, + userDataInline: options.userData, + userDataFilePath: options.userDataFile, + accept: options.accept, + wait: options.wait, + timeout: options.timeout, + }); + }); + + // getServiceStatus command (caller-owned, full detail) + program + .command("getServiceStatus") + .alias("myServices") + .description("Shows status + endpoints of YOUR on-demand service(s)") + .argument("[serviceId]", "Service ID; omit to list all your services") + .option("-s, --service ", "Service ID") + .option("-v, --verbose [boolean]", "Dump full job objects", toBoolean) + .action(async (serviceId, options) => { + const { signer, chainId } = await initializeSigner(); + const commands = new Commands(signer, chainId); + await commands.getServiceStatus(options.service || serviceId, options.verbose); + }); + + // getServices command (SERVICES_LIST — node-wide, all owners) + program + .command("getServices") + .alias("listServices") + .description( + "Lists on-demand services across ALL owners on the node (docker spec hidden)" + ) + .argument( + "[node]", + "Optional Ocean Node URL or peer id to query (defaults to NODE_URL)" + ) + .option("-n, --node ", "Ocean Node URL or peer id to query") + .option( + "--status ", + "Filter by a single service status number (e.g. 40 for Running)", + parseInt + ) + .option("--include-all [boolean]", "Include all statuses, not just active reservations", toBoolean) + .option("--from ", "Only services created at/after this time (ISO string or Unix timestamp)") + .option("-v, --verbose [boolean]", "Dump full job objects", toBoolean) + .action(async (node, options) => { + if ( + options.status !== undefined && + ServiceStatusNumber[options.status] === undefined + ) { + console.error( + chalk.red( + `Unknown --status ${options.status}. Valid values: 10,11,12,13,14,15,20,30,40,50,70,75,99` + ) + ); + return; + } + const filters: { + status?: number; + includeAllStatuses?: boolean; + fromTimestamp?: string; + } = {}; + if (options.status !== undefined) filters.status = options.status; + if (options.includeAll !== undefined) + filters.includeAllStatuses = options.includeAll; + if (options.from !== undefined) filters.fromTimestamp = options.from; + + const { signer, chainId } = await initializeSigner(); + const commands = new Commands(signer, chainId); + await commands.getServices(options.node || node, filters, options.verbose); + }); + + // serviceLogs command (streamable logs) + program + .command("serviceLogs") + .alias("computeServiceLogs") + .description("Streams live logs from an on-demand service's container") + .argument("", "Service ID") + .option("-s, --service ", "Service ID") + .option( + "--since ", + "Only logs since this time — Unix seconds or a relative duration like 30s / 2h" + ) + .action(async (serviceId, options) => { + const id = options.service || serviceId; + if (!id) { + console.error(chalk.red("Missing required argument: ")); + return; + } + const { signer, chainId } = await initializeSigner(); + const commands = new Commands(signer, chainId); + await commands.serviceLogs(id, options.since); + }); + + // extendService command + program + .command("extendService") + .description("Extends a running on-demand service's expiry (paid via escrow)") + .argument("", "Service ID") + .argument("", "Additional duration in seconds", parseInt) + .argument("[paymentToken]", "Payment token (defaults to the token used at start)") + .option("-s, --service ", "Service ID") + .option("--duration ", "Additional duration in seconds", parseInt) + .option("-t, --token [paymentToken]", "Payment token") + .option("--accept [boolean]", "Auto-confirm payment (true/false)", toBoolean) + .action(async (serviceId, additionalDuration, paymentToken, options) => { + const id = options.service || serviceId; + const addl = options.duration || additionalDuration; + const token = options.token || paymentToken; + if (!id || !addl) { + console.error(chalk.red("Missing required arguments: ")); + return; + } + if (!Number.isInteger(addl) || addl <= 0) { + console.error(chalk.red("additionalDuration must be a positive integer number of seconds.")); + return; + } + const { signer, chainId } = await initializeSigner(); + const commands = new Commands(signer, chainId); + await commands.extendService(id, addl, token, options.accept); + }); + + // restartService command + program + .command("restartService") + .description( + "Recreates the container of a running service (same ports & expiry; no extra charge)" + ) + .argument("", "Service ID") + .option("-u, --user-data ", "REPLACE stored container env vars (JSON object)") + .option("--user-data-file ", "Path to JSON file with replacement env vars") + .option("--cmd ", "REPLACE stored Docker CMD as JSON array (#2114); empty array clears it") + .option("--entrypoint ", "REPLACE stored Docker ENTRYPOINT as JSON array (#2114)") + .option("--wait [boolean]", "Poll until Running (default true)", toBoolean, true) + .option("--timeout ", "Max seconds to wait (default 600)", parseInt) + .action(async (serviceId, options) => { + if (!serviceId) { + console.error(chalk.red("Missing required argument: ")); + return; + } + let userData: Record | undefined; + let cmd: string[] | undefined; + let entrypoint: string[] | undefined; + try { + if (options.userData) { + const parsed = JSON.parse(options.userData); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("--user-data must be a JSON object"); + } + userData = parsed; + } else if (options.userDataFile) { + const parsed = JSON.parse(fs.readFileSync(options.userDataFile, "utf8")); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("--user-data-file must contain a JSON object"); + } + userData = parsed; + } + if (options.cmd !== undefined) cmd = parseJsonStringArray("--cmd", options.cmd); + if (options.entrypoint !== undefined) + entrypoint = parseJsonStringArray("--entrypoint", options.entrypoint); + } catch (e) { + console.error(chalk.red((e as Error).message)); + return; + } + const { signer, chainId } = await initializeSigner(); + const commands = new Commands(signer, chainId); + await commands.restartService( + serviceId, + userData, + cmd, + entrypoint, + options.wait, + options.timeout + ); + }); + + // stopService command + program + .command("stopService") + .description("Stops an on-demand service and releases its resources") + .argument("", "Service ID") + .option("-s, --service ", "Service ID") + .action(async (serviceId, options) => { + const id = options.service || serviceId; + if (!id) { + console.error(chalk.red("Missing required argument: ")); + return; + } + const { signer, chainId } = await initializeSigner(); + const commands = new Commands(signer, chainId); + await commands.stopService(id); + }); + // mintOcean command program .command("mintOcean") diff --git a/src/commands.ts b/src/commands.ts index 06a513e..4444c30 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -30,13 +30,34 @@ import { getTokenDecimals, AccesslistFactory, AccessListContract, + ComputeResourceRequest, + ServiceJob, + ServiceJobListed, + ServiceStartParams, + ServiceStatusNumber, + ServiceTemplatePublic, } from "@oceanprotocol/lib"; import { Asset, DDOManager } from '@oceanprotocol/ddo-js'; import { Signer, ethers, getAddress } from "ethers"; +import { stdin as input, stdout as output } from "node:process"; +import { createInterface } from "readline/promises"; import { interactiveFlow } from "./interactiveFlow.js"; import { publishAsset } from "./publishAsset.js"; import chalk from 'chalk'; import { getPolicyServerOBJ, getPolicyServerOBJs, isVersionGte } from "./policyServerHelper.js"; +import { + findServiceEnvironments, + templateMismatchReason, + resolveServiceResources, + estimateServiceCost, + parseUserData, + describeUserDataKeys, + verifyServiceEscrow, + pollServiceStatus, + printServiceJob, + statusLabel, + isTerminal, +} from "./serviceHelpers.js"; const UPLOAD_TIMEOUT_MS = 30 * 60_000; @@ -1181,6 +1202,783 @@ export class Commands { console.log(text); } + // ========================================================================= + // Service-on-Demand (long-running containers on a compute environment) + // ========================================================================= + + // Interactive payment confirmation, mirroring the startCompute prompt. + // Returns true to proceed, false to abort. REPL-safe (no process.exit). + private async confirmServicePayment( + costHuman: number, + token: string, + durationSeconds: number, + accept?: boolean + ): Promise { + console.log( + chalk.yellow( + `\n--- Payment Details ---\n` + + ` estimated cost: ${costHuman} (token ${token})\n` + + ` duration: ${durationSeconds}s\n` + + ` Note: the final cost is computed by the node and shown after start.` + ) + ); + if (accept) { + console.log(chalk.cyan("Auto-confirm enabled with --accept.")); + return true; + } + if (!process.stdin.isTTY) { + console.error( + chalk.red( + 'Cannot prompt for confirmation (non-TTY). Use "--accept true" to skip.' + ) + ); + return false; + } + const rl = createInterface({ input, output }); + const confirmation = await rl.question( + `\nProceed with payment of estimated ${costHuman} ${token} for ${durationSeconds}s? (y/n): ` + ); + rl.close(); + const answer = confirmation.trim().toLowerCase(); + if (answer !== "y" && answer !== "yes") { + console.log(chalk.red("Service start canceled by user.")); + return false; + } + return true; + } + + public async getServiceTemplates(nodeUrlOverride?: string): Promise { + const nodeUrl = nodeUrlOverride || this.oceanNodeUrl; + try { + const templates = await ProviderInstance.getServiceTemplates(nodeUrl); + if (!templates || templates.length < 1) { + console.log( + chalk.yellow("Node has no Service-on-Demand templates configured.") + ); + return; + } + + let envs = []; + try { + envs = await ProviderInstance.getComputeEnvironments(nodeUrl); + } catch { + envs = []; + } + + for (const t of templates) { + const imageSpec = t.tag + ? `${t.image}:${t.tag}` + : t.checksum + ? `${t.image}@${t.checksum}` + : t.dockerfile + ? `${t.image} (dockerfile)` + : t.image; + console.log(`\n${chalk.bold(t.id)}${t.name ? ` — ${t.name}` : ""}`); + if (t.description) console.log(` ${t.description}`); + console.log(` image: ${imageSpec}`); + console.log(` exposedPorts: ${JSON.stringify(t.exposedPorts ?? [])}`); + if (t.userConfigurableEnvVars?.length) { + console.log(" userConfigurableEnvVars:"); + for (const v of t.userConfigurableEnvVars) { + console.log( + ` - ${v.key}${v.sensitive ? " (sensitive)" : ""}${ + v.validation ? ` [validation: ${v.validation}]` : "" + }` + ); + } + } + if (t.requiredResources?.length) { + console.log( + ` requiredResources: ${JSON.stringify(t.requiredResources)}` + ); + } + if (t.recommendedResources?.length) { + console.log( + ` recommendedResources: ${JSON.stringify(t.recommendedResources)}` + ); + } + const compatible = findServiceEnvironments(envs, t).map((e) => e.id); + if (compatible.length) { + console.log(` compatible environments: ${compatible.join(", ")}`); + } else { + console.log( + chalk.red( + " compatible environments: none — insufficient free resources or services disabled" + ) + ); + } + } + + // Stable, machine-parseable line (mirrors getComputeEnvironments). + console.log("Service templates: " + JSON.stringify(templates)); + } catch (error) { + console.error(chalk.red("Error fetching service templates:"), error); + } + } + + public async startService(opts: { + envId: string; + duration: number; + paymentToken: string; + templateId?: string; + image?: string; + tag?: string; + checksum?: string; + dockerfilePath?: string; + additionalDockerFilesPath?: string; + cmd?: string[]; + entrypoint?: string[]; + ports?: number[]; + resources?: string; + userDataInline?: string; + userDataFilePath?: string; + accept?: boolean; + wait?: boolean; + timeout?: number; + }): Promise { + try { + const { chainId } = await this.signer.provider.getNetwork(); + const chainIdNum = Number(chainId); + + // 1. Resolve env + const envs = await ProviderInstance.getComputeEnvironments( + this.oceanNodeUrl + ); + if (!envs || envs.length < 1) { + console.error(chalk.red("No compute environments available.")); + return; + } + const env = envs.find((e) => e.id === opts.envId); + if (!env) { + console.error( + chalk.red(`No compute environment matches id: ${opts.envId}`) + ); + return; + } + if (env.features?.services === false) { + console.error( + chalk.red(`Environment ${env.id} has services disabled.`) + ); + return; + } + + // 2. Resolve container spec (template and/or explicit flags) + let template: ServiceTemplatePublic | undefined; + let image: string | undefined; + let tag: string | undefined; + let checksum: string | undefined; + let dockerfile: string | undefined; + let additionalDockerFiles: Record | undefined; + let exposedPorts: number[] | undefined; + let dockerCmd: string[] | undefined; + let dockerEntrypoint: string[] | undefined; + + if (opts.templateId) { + const templates = await ProviderInstance.getServiceTemplates( + this.oceanNodeUrl + ); + template = (templates ?? []).find((t) => t.id === opts.templateId); + if (!template) { + console.error( + chalk.red(`Template "${opts.templateId}" not found on the node.`) + ); + return; + } + image = template.image; + tag = template.tag; + checksum = template.checksum; + dockerfile = template.dockerfile; + additionalDockerFiles = template.additionalDockerFiles; + exposedPorts = template.exposedPorts; + // NOTE the field rename: template.command -> dockerCmd, .entrypoint -> dockerEntrypoint + dockerCmd = template.command; + dockerEntrypoint = template.entrypoint; + + const reason = templateMismatchReason(env, template); + if (reason) { + console.error( + chalk.red( + `Environment ${env.id} does not satisfy template "${template.id}": ${reason}` + ) + ); + return; + } + } + + // Explicit flags override template values. + image = opts.image || image; + if (!image) { + console.error( + chalk.red("An image is required: pass --template or --image .") + ); + return; + } + if (opts.tag !== undefined) tag = opts.tag; + if (opts.checksum !== undefined) checksum = opts.checksum; + if (opts.dockerfilePath) { + try { + dockerfile = fs.readFileSync(opts.dockerfilePath, "utf8"); + } catch (e) { + console.error( + chalk.red(`Cannot read Dockerfile at ${opts.dockerfilePath}`), + e + ); + return; + } + } + if (opts.additionalDockerFilesPath) { + try { + additionalDockerFiles = JSON.parse( + fs.readFileSync(opts.additionalDockerFilesPath, "utf8") + ); + } catch (e) { + console.error( + chalk.red( + `Cannot read additional docker files JSON at ${opts.additionalDockerFilesPath}` + ), + e + ); + return; + } + } + + const specCount = [tag, checksum, dockerfile].filter(Boolean).length; + if (specCount > 1) { + console.error( + chalk.red( + "Provide at most one of --tag, --checksum or --dockerfile." + ) + ); + return; + } + + if (opts.ports) exposedPorts = opts.ports; + if (opts.cmd) dockerCmd = opts.cmd; + if (opts.entrypoint) dockerEntrypoint = opts.entrypoint; + + // 3. Resolve resources + let resources: ComputeResourceRequest[]; + if (opts.resources) { + try { + const parsed = JSON.parse(opts.resources); + if ( + !Array.isArray(parsed) || + !parsed.every( + (r) => + r && + typeof r.id === "string" && + typeof r.amount === "number" + ) + ) { + throw new Error("must be an array of {id, amount}"); + } + resources = parsed; + } catch (e) { + console.error( + chalk.red(`Invalid --resources JSON: ${(e as Error).message}`) + ); + return; + } + } else { + resources = resolveServiceResources(template, env); + } + console.log(`Requested resources: ${JSON.stringify(resources)}`); + + // 4. Duration + if (!Number.isInteger(opts.duration) || opts.duration <= 0) { + console.error( + chalk.red("Duration must be a positive integer number of seconds.") + ); + return; + } + if (opts.duration > 86400) { + console.log( + chalk.yellow( + `Warning: duration ${opts.duration}s exceeds the node's typical maxDurationSeconds (86400) — the node may clamp or reject it.` + ) + ); + } + + // 5. userData (never logged — keys only) + let userDataFromFile: Record | undefined; + if (opts.userDataFilePath) { + try { + userDataFromFile = JSON.parse( + fs.readFileSync(opts.userDataFilePath, "utf8") + ); + } catch (e) { + console.error( + chalk.red(`Cannot read user-data file at ${opts.userDataFilePath}`), + e + ); + return; + } + } + let userData: Record | undefined; + try { + userData = parseUserData( + opts.userDataInline, + userDataFromFile, + template + ); + } catch (e) { + console.error(chalk.red((e as Error).message)); + return; + } + const userDataKeys = describeUserDataKeys(userData); + if (userDataKeys) console.log(`userData keys: ${userDataKeys}`); + + // 6. Cost + escrow gate + const cost = estimateServiceCost( + env, + chainIdNum, + opts.paymentToken, + resources, + opts.duration + ); + if (cost === null) { + console.error( + chalk.red( + `Environment ${env.id} has no pricing for token ${opts.paymentToken} on chain ${chainIdNum}.` + ) + ); + return; + } + const escrowOk = await verifyServiceEscrow( + this.signer, + chainIdNum, + opts.paymentToken, + env.consumerAddress, + cost, + opts.duration + ); + if (!escrowOk) return; + + // 7. Confirmation prompt + const proceed = await this.confirmServicePayment( + cost, + opts.paymentToken, + opts.duration, + opts.accept + ); + if (!proceed) return; + + // 8. Start (async on the node — returns immediately in status Starting) + const params: ServiceStartParams = { + environment: env.id, + image, + tag, + checksum, + dockerfile, + additionalDockerFiles, + dockerCmd, + dockerEntrypoint, + exposedPorts, + resources, + duration: opts.duration, + userData, // plain object; ocean.js encrypts it to the node + payment: { chainId: chainIdNum, token: opts.paymentToken }, + }; + + const jobs = await ProviderInstance.serviceStart( + this.oceanNodeUrl, + this.signer, + params, + AbortSignal.timeout(120_000) + ); + const job = jobs?.[0]; + if (!job) { + console.error(chalk.red("Service start returned no job."), jobs); + return; + } + + // Always print the id first — polling may die but the user needs it. + console.log(chalk.green(`Service started. ServiceID: ${job.serviceId}`)); + if (job.payment?.cost !== undefined) { + console.log(`Node-computed cost: ${job.payment.cost}`); + } + + // 9. Wait for Running (unless --wait false) + if (opts.wait === false) { + console.log( + `Check later with: npm run cli getServiceStatus ${job.serviceId}` + ); + return job; + } + + try { + const running = await pollServiceStatus( + this.oceanNodeUrl, + this.signer, + job.serviceId, + ServiceStatusNumber.Running, + (opts.timeout ?? 600) * 1000 + ); + printServiceJob(running); + return running; + } catch (e) { + console.error(chalk.red((e as Error).message)); + console.log( + `Check later with: npm run cli getServiceStatus ${job.serviceId}` + ); + return job; + } + } catch (error) { + console.error(chalk.red("Error starting service:"), error); + } + } + + public async getServiceStatus( + serviceId?: string, + verbose?: boolean + ): Promise { + try { + const jobs = await ProviderInstance.getServiceStatus( + this.oceanNodeUrl, + this.signer, + serviceId + ); + if (!jobs || jobs.length < 1) { + const who = await this.signer.getAddress(); + console.log( + chalk.yellow( + `No services found for ${who}${ + serviceId ? ` with id ${serviceId}` : "" + }` + ) + ); + return []; + } + for (const job of jobs) printServiceJob(job, { verbose }); + return jobs; + } catch (error) { + console.error(chalk.red("Error fetching service status:"), error); + return []; + } + } + + public async getServices( + nodeUrlOverride?: string, + filters?: { + status?: number; + includeAllStatuses?: boolean; + fromTimestamp?: string; + }, + verbose?: boolean + ): Promise { + const nodeUrl = nodeUrlOverride || this.oceanNodeUrl; + try { + const jobs = await ProviderInstance.getServices( + nodeUrl, + this.signer, + filters as any + ); + if (!jobs || jobs.length < 1) { + const filterDesc = + filters && Object.keys(filters).length + ? ` (filters: ${JSON.stringify(filters)})` + : ""; + console.log(chalk.yellow(`No services found on ${nodeUrl}${filterDesc}`)); + console.log("Services list: " + JSON.stringify(jobs ?? [])); + return []; + } + for (const job of jobs) printServiceJob(job as ServiceJob, { verbose }); + // Stable, machine-parseable line for tests/scripts. + console.log("Services list: " + JSON.stringify(jobs)); + return jobs; + } catch (error) { + console.error(chalk.red("Error listing services:"), error); + return []; + } + } + + public async serviceLogs(serviceId: string, since?: string): Promise { + try { + const stream = await ProviderInstance.serviceGetStreamableLogs( + this.oceanNodeUrl, + this.signer, + serviceId, + since + ); + if (!stream) { + console.log( + chalk.yellow(`No logs available for service ${serviceId}`) + ); + return; + } + let text: string; + if (stream[Symbol.asyncIterator]) { + const chunks: Uint8Array[] = []; + for await (const chunk of stream) chunks.push(chunk); + text = Buffer.concat(chunks).toString("utf-8"); + } else { + text = await new Response(stream).text(); + } + console.log("Service Logs:"); + console.log(text); + } catch (error) { + console.error(chalk.red("Error fetching service logs:"), error); + } + } + + public async extendService( + serviceId: string, + additionalDuration: number, + paymentToken?: string, + accept?: boolean + ): Promise { + try { + if (!Number.isInteger(additionalDuration) || additionalDuration <= 0) { + console.error( + chalk.red("additionalDuration must be a positive integer (seconds).") + ); + return; + } + + // 1. Fetch the job + const jobs = await ProviderInstance.getServiceStatus( + this.oceanNodeUrl, + this.signer, + serviceId + ); + const job = (jobs ?? []).find((j) => j.serviceId === serviceId); + if (!job) { + console.error(chalk.red(`Service ${serviceId} not found.`)); + return; + } + if (isTerminal(job.status)) { + console.error( + chalk.red( + `Service ${serviceId} is ${statusLabel( + job.status, + job.statusText + )} (${job.status}) — nothing to extend.` + ) + ); + return; + } + + const { chainId } = await this.signer.provider.getNetwork(); + const chainIdNum = Number(chainId); + const token = paymentToken || job.payment?.token; + if (!token) { + console.error( + chalk.red( + "No payment token: pass one explicitly (the job has no stored token)." + ) + ); + return; + } + if ( + job.payment?.chainId !== undefined && + Number(job.payment.chainId) !== chainIdNum + ) { + console.log( + chalk.yellow( + `Warning: job was paid on chain ${job.payment.chainId} but the signer is on ${chainIdNum}; the environment must price on this chain.` + ) + ); + } + + // Resolve the env once — we need its consumerAddress (escrow payee) and, + // as a fallback, its fee schedule for the cost estimate. + const envs = await ProviderInstance.getComputeEnvironments( + this.oceanNodeUrl + ); + const env = (envs ?? []).find((e) => e.id === job.environment); + if (!env) { + console.error( + chalk.red( + `Environment ${job.environment} for this service was not found on the node.` + ) + ); + return; + } + + // 2/3. Estimate cost: prefer the running job's priced resources when the + // token matches; otherwise fall back to the env fee schedule. + let cost: number | null = null; + const sameToken = + job.payment?.token && + job.payment.token.toLowerCase() === token.toLowerCase(); + const pricedResources = + Array.isArray(job.resources) && + job.resources.length > 0 && + job.resources.every((r) => typeof r?.price === "number"); + if (sameToken && pricedResources) { + const minutes = Math.ceil(additionalDuration / 60); + cost = job.resources.reduce( + (sum: number, r: any) => + sum + Number(r.price ?? 0) * Number(r.amount ?? 0) * minutes, + 0 + ); + } else { + const resources = (job.resources ?? []).map((r: any) => ({ + id: r.id, + amount: r.amount, + })); + cost = estimateServiceCost( + env, + chainIdNum, + token, + resources, + additionalDuration + ); + } + if (cost === null) { + console.error( + chalk.red( + `Could not estimate extend cost for token ${token} on chain ${chainIdNum}.` + ) + ); + return; + } + + const escrowOk = await verifyServiceEscrow( + this.signer, + chainIdNum, + token, + env.consumerAddress, + cost, + additionalDuration + ); + if (!escrowOk) return; + + // 4. Confirmation prompt + const proceed = await this.confirmServicePayment( + cost, + token, + additionalDuration, + accept + ); + if (!proceed) return; + + // 5. Extend + const oldExpiry = job.expiresAt; + const extended = await ProviderInstance.serviceExtend( + this.oceanNodeUrl, + this.signer, + serviceId, + additionalDuration, + { chainId: chainIdNum, token }, + AbortSignal.timeout(120_000) + ); + const newJob = extended?.[0]; + if (!newJob) { + console.error(chalk.red("Extend returned no job."), extended); + return; + } + + // 6. Report + console.log(chalk.green(`Service ${serviceId} extended.`)); + console.log( + ` expiry: ${new Date(oldExpiry).toISOString()} → ${new Date( + newJob.expiresAt + ).toISOString()}` + ); + console.log(` extendPayments: ${newJob.extendPayments?.length ?? 0}`); + return newJob; + } catch (error) { + console.error(chalk.red("Error extending service:"), error); + } + } + + public async restartService( + serviceId: string, + userData?: Record, + dockerCmd?: string[], + dockerEntrypoint?: string[], + wait?: boolean, + timeout?: number + ): Promise { + try { + // 1. Fetch current job to learn the old containerId (poll for the new one) + const jobs = await ProviderInstance.getServiceStatus( + this.oceanNodeUrl, + this.signer, + serviceId + ); + const job = (jobs ?? []).find((j) => j.serviceId === serviceId); + if (!job) { + console.error(chalk.red(`Service ${serviceId} not found.`)); + return; + } + const oldContainerId = job.containerId; + + // 2. Restart. dockerCmd/dockerEntrypoint sit BEFORE the signal (#2114). + const restarted = await ProviderInstance.serviceRestart( + this.oceanNodeUrl, + this.signer, + serviceId, + userData, + dockerCmd, + dockerEntrypoint, + AbortSignal.timeout(120_000) + ); + const newJob = restarted?.[0]; + if (!newJob) { + console.error(chalk.red("Restart returned no job."), restarted); + return; + } + console.log(chalk.green(`Service ${serviceId} restarting...`)); + + // 3. Wait for the NEW container to reach Running + if (wait === false) { + console.log( + `Check later with: npm run cli getServiceStatus ${serviceId}` + ); + return newJob; + } + try { + const running = await pollServiceStatus( + this.oceanNodeUrl, + this.signer, + serviceId, + ServiceStatusNumber.Running, + (timeout ?? 600) * 1000, + oldContainerId + ); + printServiceJob(running); + return running; + } catch (e) { + console.error(chalk.red((e as Error).message)); + console.log( + `Check later with: npm run cli getServiceStatus ${serviceId}` + ); + return newJob; + } + } catch (error) { + console.error(chalk.red("Error restarting service:"), error); + } + } + + public async stopService(serviceId: string): Promise { + try { + const jobs = await ProviderInstance.serviceStop( + this.oceanNodeUrl, + this.signer, + serviceId, + AbortSignal.timeout(120_000) + ); + const job = jobs?.[0]; + if (!job) { + console.error(chalk.red("Stop returned no job."), jobs); + return; + } + console.log( + chalk.green( + `Service ${serviceId} stopped — status ${statusLabel( + job.status, + job.statusText + )} (${job.status})` + ) + ); + return job; + } catch (error) { + console.error(chalk.red("Error stopping service:"), error); + } + } + public async allowAlgo(args: string[]) { const asset = await this.aquarius.waitForIndexer( args[1], diff --git a/src/serviceHelpers.ts b/src/serviceHelpers.ts new file mode 100644 index 0000000..67fb717 --- /dev/null +++ b/src/serviceHelpers.ts @@ -0,0 +1,461 @@ +import util from "util"; +import chalk from "chalk"; +import { Signer, getAddress } from "ethers"; +import { + ProviderInstance, + EscrowContract, + amountToUnits, + unitsToAmount, + getTokenDecimals, + ComputeEnvironment, + ComputeResource, + ComputeResourceRequest, + ServiceJob, + ServiceStatusNumber, + ServiceTemplatePublic, + TemplateResourceRequirement, +} from "@oceanprotocol/lib"; +import { getConfigByChainId } from "./helpers.js"; + +// --------------------------------------------------------------------------- +// 4.1 Status labels +// --------------------------------------------------------------------------- + +export const SERVICE_STATUS_LABELS: Record = { + 10: "Starting", + 11: "Pulling image", + 12: "Image pull FAILED", + 13: "Building image", + 14: "Image build FAILED", + 15: "Image VULNERABLE", + 20: "Locking escrow", + 30: "Claiming payment", + 40: "Running", + 50: "Stopping", + 70: "Stopped", + 75: "Expired", + 99: "Error", +}; + +// Statuses that mean the service failed and will never reach Running. +export const TERMINAL_FAILURE_STATUSES = [12, 14, 15, 99]; + +// Any status the poller should stop on (failure or benign end state). +export function isTerminal(status: number): boolean { + return TERMINAL_FAILURE_STATUSES.includes(status) || [70, 75].includes(status); +} + +export function statusLabel(status: number, statusText?: string): string { + // Prefer the node-provided statusText; fall back to the local map. + return statusText || SERVICE_STATUS_LABELS[status] || `status ${status}`; +} + +// Colorize a status string: green for Running, red for failures, yellow otherwise. +function colorForStatus(status: number, text: string): string { + if (status === ServiceStatusNumber.Running) return chalk.green(text); + if (TERMINAL_FAILURE_STATUSES.includes(status)) return chalk.red(text); + return chalk.yellow(text); +} + +// --------------------------------------------------------------------------- +// 4.2 Environment <-> template resource matching +// (ported from ocean.js test/integration/Services.test.ts) +// --------------------------------------------------------------------------- + +export function availableFor( + env: ComputeEnvironment, + req: TemplateResourceRequirement +): number { + const resources: ComputeResource[] = env.resources ?? []; + if (req.id) { + const r = resources.find((x) => x.id === req.id); + return r ? (r.total ?? 0) - (r.inUse ?? 0) : 0; + } + return resources + .filter((x) => x.kind === req.kind && (!req.type || x.type === req.type)) + .reduce((sum, x) => sum + ((x.total ?? 0) - (x.inUse ?? 0)), 0); +} + +export function envSatisfiesTemplate( + env: ComputeEnvironment, + reqs?: TemplateResourceRequirement[] +): boolean { + return (reqs ?? []).every((req) => availableFor(env, req) >= req.min); +} + +// Human-readable reason a template does not fit an env (or null when it fits). +export function templateMismatchReason( + env: ComputeEnvironment, + template?: ServiceTemplatePublic +): string | null { + if (!template) return null; + for (const req of template.requiredResources ?? []) { + const have = availableFor(env, req); + if (have < req.min) { + const what = req.id ?? `${req.kind ?? "resource"}${req.type ? `/${req.type}` : ""}`; + return `${what}: need ${req.min}, have ${have}`; + } + } + return null; +} + +export function findServiceEnvironments( + envs: ComputeEnvironment[], + template?: ServiceTemplatePublic +): ComputeEnvironment[] { + return (envs ?? []).filter( + (e) => + e.features?.services !== false && + (!template || envSatisfiesTemplate(e, template.requiredResources)) + ); +} + +// --------------------------------------------------------------------------- +// 4.3 Default resources from a template +// --------------------------------------------------------------------------- + +export function resolveServiceResources( + template: ServiceTemplatePublic | undefined, + env: ComputeEnvironment +): ComputeResourceRequest[] { + const requiredById = (template?.requiredResources ?? []).filter( + (r) => typeof r.id === "string" + ); + if (requiredById.length) { + return requiredById.map((r) => ({ id: r.id as string, amount: r.min })); + } + return (env.resources ?? []) + .filter((r) => r.id === "cpu" || r.id === "ram") + .map((r) => ({ id: r.id, amount: 1 })); +} + +// --------------------------------------------------------------------------- +// 4.4 Cost estimation (same formula the node uses) +// --------------------------------------------------------------------------- + +// Returns the estimated cost in HUMAN token amount, or null when the env has no +// fee schedule for (chainId, token) — the caller must abort in that case. +export function estimateServiceCost( + env: ComputeEnvironment, + chainId: number, + token: string, + resources: { id: string; amount: number }[], + durationSeconds: number +): number | null { + const schedules = env.fees?.[String(chainId)]; + const schedule = schedules?.find( + (f) => f.feeToken.toLowerCase() === token.toLowerCase() + ); + if (!schedule) return null; + const priceFor = (id: string) => + Number(schedule.prices?.find((p) => p.id === id)?.price ?? 0); + const minutes = Math.ceil(durationSeconds / 60); + return resources.reduce( + (sum, r) => sum + priceFor(r.id) * r.amount * minutes, + 0 + ); +} + +// --------------------------------------------------------------------------- +// 4.5 userData parsing + validation +// --------------------------------------------------------------------------- + +// inlineJson wins over filePath. Returns undefined when neither is given. +// `template` (optional) is used only to validate/warn about keys. +export function parseUserData( + inlineJson?: string, + parsedFromFile?: Record, + template?: ServiceTemplatePublic +): Record | undefined { + let data: Record | undefined; + if (typeof inlineJson === "string" && inlineJson.trim().length > 0) { + let parsed: unknown; + try { + parsed = JSON.parse(inlineJson); + } catch { + throw new Error("--user-data must be a valid JSON object"); + } + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + throw new Error("--user-data must be a JSON object (not an array or primitive)"); + } + data = parsed as Record; + } else if (parsedFromFile) { + if ( + typeof parsedFromFile !== "object" || + parsedFromFile === null || + Array.isArray(parsedFromFile) + ) { + throw new Error("--user-data-file must contain a JSON object"); + } + data = parsedFromFile; + } + + if (!data) return undefined; + + if (template) { + const configurable = template.userConfigurableEnvVars ?? []; + const byKey = new Map(configurable.map((v) => [v.key, v])); + for (const key of Object.keys(data)) { + const spec = byKey.get(key); + if (!spec) { + // Warn (don't fail) about keys the template does not advertise. + console.log( + chalk.yellow( + `Warning: userData key "${key}" is not listed in the template's userConfigurableEnvVars.` + ) + ); + continue; + } + if (spec.validation) { + let re: RegExp | undefined; + try { + re = new RegExp(spec.validation); + } catch { + re = undefined; + } + // Only validate string values; never print the value itself. + const val = data[key]; + if (re && typeof val === "string" && !re.test(val)) { + throw new Error( + `userData value for "${key}" does not match the template's validation pattern` + ); + } + } + } + } + + return data; +} + +// Safe echo of userData: keys only, never values (may contain secrets). +export function describeUserDataKeys( + data?: Record +): string | undefined { + if (!data) return undefined; + const keys = Object.keys(data); + if (keys.length === 0) return undefined; + return keys.join(", "); +} + +// --------------------------------------------------------------------------- +// 4.6 Escrow pre-verification +// --------------------------------------------------------------------------- + +// Prints actionable errors and returns false when escrow is not ready. +export async function verifyServiceEscrow( + signer: Signer, + chainId: number, + token: string, + payee: string, // env.consumerAddress + costHuman: number, // from estimateServiceCost + durationSeconds: number +): Promise { + try { + const config = await getConfigByChainId(chainId); + if (!config?.Escrow) { + console.error( + chalk.red( + `Escrow contract address not found for chain ${chainId} in the address file.` + ) + ); + return false; + } + const escrow = new EscrowContract( + getAddress(config.Escrow), + signer, + chainId + ); + const decimals = await getTokenDecimals(signer, token); + const amountUnits = await amountToUnits( + signer, + token, + String(costHuman), + decimals + ); + const availableHuman = await unitsToAmount( + signer, + token, + amountUnits.toString(), + decimals + ); + const minLockSeconds = durationSeconds + 3600; // node getMinLockTime margin + + const validation = await escrow.verifyFundsForEscrowPayment( + token, + payee, + availableHuman, + amountUnits.toString(), + String(minLockSeconds), + "10" + ); + + if (validation.isValid === false) { + console.error(chalk.red(`Escrow check failed: ${validation.message}`)); + console.error( + chalk.yellow( + ` → deposit funds: npm run cli depositEscrow ${token} \n` + + ` → authorize node: npm run cli authorizeEscrow ${token} ${payee} \n` + + ` (maxLockSeconds must be at least ${minLockSeconds} = duration + 3600)` + ) + ); + return false; + } + return true; + } catch (error) { + console.error(chalk.red("Error verifying escrow funds:"), error); + return false; + } +} + +// --------------------------------------------------------------------------- +// 4.7 Status polling +// --------------------------------------------------------------------------- + +export async function pollServiceStatus( + nodeUrl: string, + signer: Signer, + serviceId: string, + target: ServiceStatusNumber, + timeoutMs = 600_000, + notContainerId?: string +): Promise { + const started = Date.now(); + let lastStatus: number | undefined; + + // eslint-disable-next-line no-constant-condition + while (true) { + let jobs: ServiceJob[] = []; + try { + jobs = await ProviderInstance.getServiceStatus( + nodeUrl, + signer, + serviceId + ); + } catch (error) { + // Transient errors while polling should not abort the whole wait. + console.log( + chalk.yellow( + ` (temporary error fetching status: ${ + (error as Error)?.message ?? error + })` + ) + ); + } + + const job = (jobs ?? []).find((j) => j.serviceId === serviceId); + if (job) { + if (job.status !== lastStatus) { + lastStatus = job.status; + console.log( + ` Status: ${colorForStatus( + job.status, + statusLabel(job.status, job.statusText) + )} (${job.status})` + ); + } + + const matchesContainer = + !notContainerId || job.containerId !== notContainerId; + + if (job.status === target && matchesContainer) { + return job; + } + + if (TERMINAL_FAILURE_STATUSES.includes(job.status)) { + throw new Error( + `Service ${serviceId} failed: ${statusLabel( + job.status, + job.statusText + )} (${job.status})` + ); + } + } + + if (Date.now() - started > timeoutMs) { + throw new Error( + `Timed out after ${Math.round( + timeoutMs / 1000 + )}s waiting for service ${serviceId} to reach ${statusLabel(target)}` + ); + } + + await new Promise((resolve) => setTimeout(resolve, 5000)); + } +} + +// --------------------------------------------------------------------------- +// 4.8 Job pretty-printer +// --------------------------------------------------------------------------- + +function relativeTime(ms: number): string { + const diff = ms - Date.now(); + const abs = Math.abs(diff); + const mins = Math.round(abs / 60000); + if (mins < 60) return diff >= 0 ? `in ${mins}m` : `${mins}m ago`; + const hours = Math.floor(mins / 60); + const rem = mins % 60; + const label = `${hours}h${rem ? ` ${rem}m` : ""}`; + return diff >= 0 ? `in ${label}` : `${label} ago`; +} + +export function printServiceJob( + job: ServiceJob, + opts?: { verbose?: boolean } +): void { + const header = colorForStatus( + job.status, + statusLabel(job.status, job.statusText) + ); + console.log(`\nService ${chalk.bold(job.serviceId)} [${header}]`); + console.log( + ` environment: ${job.environment} owner: ${job.owner}` + ); + + const imageSpec = job.tag + ? `${job.image}:${job.tag}` + : job.checksum + ? `${job.image}@${job.checksum}` + : job.image; + console.log(` image: ${imageSpec}`); + + const expires = + typeof job.expiresAt === "number" && job.expiresAt > 0 + ? `${new Date(job.expiresAt).toISOString()} (${relativeTime( + job.expiresAt + )})` + : "n/a"; + console.log(` created: ${job.dateCreated} expires: ${expires}`); + + if (job.endpoints?.length) { + console.log(" endpoints:"); + for (const ep of job.endpoints) { + console.log( + ` → ${chalk.green(ep.url)} (container port ${ep.containerPort})` + ); + } + } else { + console.log(" endpoints: (not yet assigned — poll getServiceStatus)"); + } + + const p = job.payment ?? {}; + const paymentBits = [ + p.cost !== undefined ? `cost ${p.cost}` : null, + p.token ? `token ${p.token}` : null, + p.lockTx ? `lockTx ${p.lockTx}` : null, + p.claimTx ? `claimTx ${p.claimTx}` : null, + ].filter(Boolean); + const extendCount = job.extendPayments?.length ?? 0; + console.log( + ` payment: ${paymentBits.join(" ") || "n/a"}${ + extendCount ? ` extends: ${extendCount}` : "" + }` + ); + + if (opts?.verbose) { + console.log(util.inspect(job, false, null, true)); + } +} diff --git a/test/serviceFlow.test.ts b/test/serviceFlow.test.ts new file mode 100644 index 0000000..91d59c5 --- /dev/null +++ b/test/serviceFlow.test.ts @@ -0,0 +1,239 @@ +import { expect } from "chai"; +import fs from "fs"; +import { homedir } from "os"; +import { runCommand } from "./util.js"; + +/** + * Service-on-Demand (Service-on-Demand) end-to-end flow. + * + * Requires a running Ocean stack (barge) whose node has the services feature + * enabled (ocean-node v4+ / PR #1402): at least one service template and a + * compute environment with `features.services !== false`. On a stock node with + * no services support the whole lifecycle skips cleanly (`skipLifecycle`). + * + * Deviation from the plan: rather than launching a heavy model template (the + * bundled templates download multi-GB models from Hugging Face — minutes long + * and flaky in CI), the lifecycle uses a tiny, cache-friendly custom image + * (nginx-unprivileged:alpine, listening on the high port 8080 — services can't + * bind ports < 1024 because of `CapDrop ALL`). `getServiceTemplates` is still + * asserted separately so template parsing is covered. + */ +describe("Ocean CLI Service-on-Demand", function () { + this.timeout(600000); + + process.env.AVOID_LOOP_RUN = "true"; + + // Lightweight image split into image + tag (the node builds `image:tag`; a tag + // baked into the image field yields a Docker "invalid reference format"). + const IMAGE = "nginxinc/nginx-unprivileged"; + const TAG = "alpine"; + const CONTAINER_PORT = 8080; + const START_DURATION = 300; // seconds + const EXTEND_DURATION = 60; // seconds + + let skipLifecycle = false; + let template: any; + let servicesEnv: any; + let oceanToken: string; + let serviceId: string; + + const getAddresses = () => { + const data = JSON.parse( + fs.readFileSync( + process.env.ADDRESS_FILE || + `${homedir}/.ocean/ocean-contracts/artifacts/address.json`, + "utf8" + ) + ); + return data.development; + }; + + const parseTrailingArray = (output: string, prefix: string): any[] | null => { + const re = new RegExp(`${prefix}\\s*(\\[[\\s\\S]*\\])`); + const m = output.match(re); + if (!m) return null; + try { + return JSON.parse(m[1]); + } catch { + return null; + } + }; + + before(function () { + process.env.PRIVATE_KEY = + process.env.PRIVATE_KEY || + "0x1d751ded5a32226054cd2e71261039b65afb9ee1c746d055dd699b1150a5befc"; + process.env.RPC = process.env.RPC || "http://localhost:8545"; + process.env.NODE_URL = process.env.NODE_URL || "http://localhost:8001"; + process.env.ADDRESS_FILE = + process.env.ADDRESS_FILE || + `${homedir}/.ocean/ocean-contracts/artifacts/address.json`; + oceanToken = getAddresses().Ocean; + }); + + it("lists service templates with 'getServiceTemplates'", async function () { + const output = await runCommand(`npm run cli getServiceTemplates`); + + if (output.includes("no Service-on-Demand templates")) { + console.log("Node has no service templates — skipping lifecycle."); + skipLifecycle = true; + this.skip(); + return; + } + + const templates = parseTrailingArray(output, "Service templates:"); + expect(templates, "could not parse 'Service templates:' output").to.be.an( + "array" + ).that.is.not.empty; + template = templates[0]; + expect(template).to.have.property("id").that.is.a("string"); + // Operator secrets must never leak: only env-var KEYS are exposed. + expect(template).to.not.have.property("envVars"); + expect(output).to.not.match(/JUPYTER_TOKEN\s*[:=]/i); + }); + + it("finds a compute environment with services enabled", async function () { + if (skipLifecycle) this.skip(); + const output = await runCommand(`npm run cli getComputeEnvironments`); + const envs = parseTrailingArray(output, "Existing compute environments:"); + expect(envs, "could not parse compute environments").to.be.an("array").that + .is.not.empty; + servicesEnv = (envs || []).find((e: any) => e?.features?.services !== false); + if (!servicesEnv) { + console.log("No services-enabled environment — skipping lifecycle."); + skipLifecycle = true; + this.skip(); + return; + } + expect(servicesEnv).to.have.property("consumerAddress").that.is.a("string"); + console.log(`Using services env: ${servicesEnv.id}`); + }); + + it("funds escrow (deposit + authorize the env consumer)", async function () { + if (skipLifecycle) this.skip(); + + // Best-effort mint — the well-known key may not be the token minter, in + // which case the account is expected to be pre-funded on barge. + try { + await runCommand(`npm run cli mintOcean`); + } catch { + /* tolerate: account may already hold Ocean */ + } + + const deposit = await runCommand( + `npm run cli depositEscrow ${oceanToken} 500` + ); + expect(deposit.toLowerCase()).to.match(/deposit/); + + // Authorize the env's consumerAddress as payee. maxLockSeconds must exceed + // duration + 3600; maxLockCounts covers start + extends. Tolerate the SDK's + // "authorization already exists" no-op on reruns. + try { + const auth = await runCommand( + `npm run cli authorizeEscrow ${oceanToken} ${servicesEnv.consumerAddress} 500 90000 100` + ); + expect(auth.toLowerCase()).to.match(/authoriz/); + } catch (e) { + console.log("authorizeEscrow non-fatal (may already be authorized):", e); + } + }); + + it("starts a service and reaches Running with an endpoint", async function () { + if (skipLifecycle) this.skip(); + const output = await runCommand( + `npm run cli -- startService ${servicesEnv.id} ${START_DURATION} ${oceanToken} ` + + `--image ${IMAGE} --tag ${TAG} --ports ${CONTAINER_PORT} ` + + `--accept true --wait true --timeout 480` + ); + + const idMatch = output.match(/ServiceID:\s*([^\s]+)/); + expect(idMatch, "could not find 'ServiceID:' in output").to.not.be.null; + serviceId = idMatch![1]; + expect(serviceId).to.be.a("string").with.length.greaterThan(0); + + expect(output, "service never reached Running").to.match(/\[Running\]|Running \(40\)/); + expect(output, "no endpoint URL printed").to.match(/http:\/\//); + console.log(`Service running: ${serviceId}`); + }); + + it("shows the service via getServiceStatus (single + list)", async function () { + if (skipLifecycle) this.skip(); + + const single = await runCommand(`npm run cli getServiceStatus ${serviceId}`); + expect(single).to.contain(serviceId); + expect(single).to.match(/http:\/\//); + expect(single).to.not.contain("userData"); + + const all = await runCommand(`npm run cli getServiceStatus`); + expect(all).to.contain(serviceId); + }); + + it("lists the service via getServices (SERVICES_LIST) without docker spec", async function () { + if (skipLifecycle) this.skip(); + + const output = await runCommand(`npm run cli getServices`); + const jobs = parseTrailingArray(output, "Services list:"); + expect(jobs, "could not parse 'Services list:'").to.be.an("array"); + const ours = (jobs || []).find((j: any) => j.serviceId === serviceId); + expect(ours, "our service not present in getServices").to.exist; + // ServiceJobListed strips the sensitive image-spec fields. + for (const j of jobs || []) { + expect(j).to.not.have.property("dockerCmd"); + expect(j).to.not.have.property("dockerEntrypoint"); + expect(j).to.not.have.property("dockerfile"); + } + + const filtered = await runCommand(`npm run cli -- getServices --status 40`); + const running = parseTrailingArray(filtered, "Services list:"); + expect(running, "could not parse filtered 'Services list:'").to.be.an( + "array" + ); + expect( + (running || []).some((j: any) => j.serviceId === serviceId) + ).to.equal(true); + }); + + it("fetches service logs (lenient)", async function () { + if (skipLifecycle) this.skip(); + // Logs may be empty or unavailable for a freshly started container; only + // assert the command runs and produces a recognizable line. + const output = await runCommand( + `npm run cli -- serviceLogs ${serviceId} --since 10m` + ); + expect(output).to.match(/Service Logs:|No logs available/); + }); + + it("extends the service expiry with extendService", async function () { + if (skipLifecycle) this.skip(); + const output = await runCommand( + `npm run cli -- extendService ${serviceId} ${EXTEND_DURATION} --accept true` + ); + expect(output).to.match(/extended/i); + expect(output).to.match(/extendPayments:\s*[1-9]/); + }); + + it("restarts the container with restartService", async function () { + if (skipLifecycle) this.skip(); + const output = await runCommand( + `npm run cli -- restartService ${serviceId} --wait true --timeout 300` + ); + expect(output).to.match(/restarting/i); + expect(output).to.match(/\[Running\]|Running \(40\)/); + }); + + it("stops the service with stopService", async function () { + if (skipLifecycle) this.skip(); + const output = await runCommand(`npm run cli stopService ${serviceId}`); + expect(output).to.match(/Stopped \(70\)|stopped/i); + }); + + after(async function () { + // Best-effort teardown if a mid-flow failure left the service running. + if (skipLifecycle || !serviceId) return; + try { + await runCommand(`npm run cli stopService ${serviceId}`); + } catch { + /* already stopped or gone */ + } + }); +}); From 8461865764e0f59585537d1fa51015856881bbdf Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Wed, 22 Jul 2026 15:22:43 +0300 Subject: [PATCH 2/3] run tests on evert PR --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adf738d..007fd6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,6 @@ name: Test Flow on: pull_request: - branches: - - main jobs: build: From 0bbe0c190b1630099b1effacd3a11feea65a1607 Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Wed, 22 Jul 2026 22:31:18 +0300 Subject: [PATCH 3/3] fix bugs --- src/commands.ts | 5 ++--- src/serviceHelpers.ts | 9 ++++++--- test/serviceFlow.test.ts | 4 ++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 4444c30..d481786 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -55,6 +55,7 @@ import { verifyServiceEscrow, pollServiceStatus, printServiceJob, + formatExpiry, statusLabel, isTerminal, } from "./serviceHelpers.js"; @@ -1872,9 +1873,7 @@ export class Commands { // 6. Report console.log(chalk.green(`Service ${serviceId} extended.`)); console.log( - ` expiry: ${new Date(oldExpiry).toISOString()} → ${new Date( - newJob.expiresAt - ).toISOString()}` + ` expiry: ${formatExpiry(oldExpiry)} → ${formatExpiry(newJob.expiresAt)}` ); console.log(` extendPayments: ${newJob.extendPayments?.length ?? 0}`); return newJob; diff --git a/src/serviceHelpers.ts b/src/serviceHelpers.ts index 67fb717..377f821 100644 --- a/src/serviceHelpers.ts +++ b/src/serviceHelpers.ts @@ -391,6 +391,11 @@ export async function pollServiceStatus( // 4.8 Job pretty-printer // --------------------------------------------------------------------------- +// Safe ISO expiry rendering: never throws on undefined/zero/invalid values. +export function formatExpiry(ms?: number): string { + return typeof ms === "number" && ms > 0 ? new Date(ms).toISOString() : "n/a"; +} + function relativeTime(ms: number): string { const diff = ms - Date.now(); const abs = Math.abs(diff); @@ -424,9 +429,7 @@ export function printServiceJob( const expires = typeof job.expiresAt === "number" && job.expiresAt > 0 - ? `${new Date(job.expiresAt).toISOString()} (${relativeTime( - job.expiresAt - )})` + ? `${formatExpiry(job.expiresAt)} (${relativeTime(job.expiresAt)})` : "n/a"; console.log(` created: ${job.dateCreated} expires: ${expires}`); diff --git a/test/serviceFlow.test.ts b/test/serviceFlow.test.ts index 91d59c5..b07977e 100644 --- a/test/serviceFlow.test.ts +++ b/test/serviceFlow.test.ts @@ -41,7 +41,7 @@ describe("Ocean CLI Service-on-Demand", function () { const data = JSON.parse( fs.readFileSync( process.env.ADDRESS_FILE || - `${homedir}/.ocean/ocean-contracts/artifacts/address.json`, + `${homedir()}/.ocean/ocean-contracts/artifacts/address.json`, "utf8" ) ); @@ -67,7 +67,7 @@ describe("Ocean CLI Service-on-Demand", function () { process.env.NODE_URL = process.env.NODE_URL || "http://localhost:8001"; process.env.ADDRESS_FILE = process.env.ADDRESS_FILE || - `${homedir}/.ocean/ocean-contracts/artifacts/address.json`; + `${homedir()}/.ocean/ocean-contracts/artifacts/address.json`; oceanToken = getAddresses().Ocean; });