diff --git a/.env.example b/.env.example index 70c43f011..0c7baf914 100644 --- a/.env.example +++ b/.env.example @@ -229,7 +229,7 @@ export P2P_FILTER_ANNOUNCED_ADDRESSES= # Each environment references pool resources by id using lightweight refs {id, total?, min?, max?}. # Dual-gate tracking for fungible resources: per-env ceiling (Gate 1) + engine-wide pool (Gate 2). # Discrete resources (GPUs) are tracked globally — a GPU in use on envA shows as in-use on envB too. -# export DOCKER_COMPUTE_ENVIRONMENTS='[{"socketPath":"/var/run/docker.sock","resources":[{"id":"disk","total":500},{"id":"gpu0","kind":"discrete","type":"gpu","total":1,"description":"NVIDIA A100","platform":"nvidia","driverVersion":"570.195.03","init":{"deviceRequests":{"Driver":"nvidia","DeviceIDs":["GPU-uuid-a"],"Capabilities":[["gpu"]]}}}],"environments":[{"id":"envA","storageExpiry":604800,"maxJobDuration":3600,"minJobDuration":60,"resources":[{"id":"cpu"},{"id":"ram"},{"id":"disk","max":500},{"id":"gpu0"}],"fees":{"1":[{"feeToken":"0x123","prices":[{"id":"cpu","price":1},{"id":"ram","price":0.1},{"id":"disk","price":0.01},{"id":"gpu0","price":5}]}]}}]}]' +# export DOCKER_COMPUTE_ENVIRONMENTS='[{"socketPath":"/var/run/docker.sock","resources":[{"id":"disk","total":500},{"id":"gpu0","kind":"discrete","type":"gpu","total":1,"description":"NVIDIA A100","platform":"nvidia","driverVersion":"570.195.03","init":{"deviceRequests":{"Driver":"nvidia","DeviceIDs":["GPU-uuid-a"],"Capabilities":[["gpu"]]}}}],"serviceOnDemand":{"enabled":true,"nodeHost":"localhost","hostPortRange":[30000,32767],"minDurationSeconds":0,"maxDurationSeconds":86400,"allowImageBuild":false},"environments":[{"id":"envA","storageExpiry":604800,"maxJobDuration":3600,"minJobDuration":60,"minServiceDuration":600,"maxServiceDuration":7200,"resources":[{"id":"cpu"},{"id":"ram"},{"id":"disk","max":500},{"id":"gpu0"}],"fees":{"1":[{"feeToken":"0x123","prices":[{"id":"cpu","price":1},{"id":"ram","price":0.1},{"id":"disk","price":0.01},{"id":"gpu0","price":5}]}]}}]}]' export DOCKER_COMPUTE_ENVIRONMENTS= diff --git a/docs/API.md b/docs/API.md index 575c07a2f..6be6f7ad8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1672,6 +1672,8 @@ fetch all compute environments "storageExpiry": 604800, "maxJobDuration": 3600, "minJobDuration": 60, + "minServiceDuration": 60, + "maxServiceDuration": 86400, "resources": [ { "id": "cpu", "total": 16, "max": 16, "min": 1, "inUse": 0 }, { @@ -1698,6 +1700,32 @@ fetch all compute environments ] ``` +`maxJobDuration` / `minJobDuration` apply to **compute jobs**. Services have their own pair, +and SERVICE_START rejects a `duration` outside it: + +- `maxServiceDuration` — the ceiling. Defaults to the daemon's + `serviceOnDemand.maxDurationSeconds` and may be **lowered** per environment; a larger per-env + value is clamped to the daemon ceiling at startup. SERVICE_EXTEND also caps the resulting + remaining window to it. +- `minServiceDuration` — the floor. SERVICE_START rejects a shorter `duration`, and + SERVICE_EXTEND rejects a shorter `additionalDuration`: the floor is a minimum *purchase*, so a + smaller one is refused rather than silently billed at the floor. Everything accepted is then + priced by its actual duration, rounded up to whole minutes. Defaults to the environment's own + `minJobDuration`, and may be **raised** per environment; a value below the daemon's + `serviceOnDemand.minDurationSeconds` is clamped up at startup. + +Environments on the same engine may therefore report different values for both. + +Both fields are additive, and an older node omits them. Their fallbacks differ, so treat each +separately: + +- A missing `maxServiceDuration` means the 86400 s (24 h) default — **not** `maxJobDuration`, + which is a different limit and is often much larger, so using it would offer windows the node + rejects. +- A missing `minServiceDuration` means the environment's own `minJobDuration` (raised to the + daemon's `serviceOnDemand.minDurationSeconds`, which itself defaults to 0 — no daemon floor). + That is exactly what such a node already bills a service at. + ### `HTTP` POST /api/services/freeCompute ### `P2P` command: freeStartCompute diff --git a/docs/env.md b/docs/env.md index 02a549006..7d311e4fa 100644 --- a/docs/env.md +++ b/docs/env.md @@ -224,6 +224,15 @@ The config has a two-level structure: { "id": "disk", "total": 50 } ], + "serviceOnDemand": { + "enabled": true, + "nodeHost": "localhost", + "hostPortRange": [30000, 32767], + "minDurationSeconds": 0, + "maxDurationSeconds": 86400, + "allowImageBuild": false + }, + "environments": [ { "id": "default", @@ -231,6 +240,8 @@ The config has a two-level structure: "storageExpiry": 604800, "maxJobDuration": 3600, "minJobDuration": 60, + "minServiceDuration": 600, + "maxServiceDuration": 7200, "enableNetwork": false, "access": { "addresses": ["0x123", "0x456"], @@ -290,7 +301,9 @@ The config has a two-level structure: - **id** *(optional)*: Stable identifier for the environment. Used to compute the environment hash. - **description**: Human-readable description. - **storageExpiry**: Seconds before compute results expire. -- **maxJobDuration** / **minJobDuration**: Maximum/minimum job duration in seconds. +- **maxJobDuration** / **minJobDuration**: Maximum/minimum **compute job** duration in seconds. These do not apply to services. +- **minServiceDuration** *(optional)*: Minimum **service** duration in seconds, for service-on-demand. SERVICE_START rejects a shorter duration and SERVICE_EXTEND rejects a shorter top-up — it is a minimum purchase, not a rounding rule, so anything accepted is billed for its actual duration (rounded up to whole minutes). Must not exceed `maxServiceDuration`, or the node refuses to start. Omit to fall back to this environment's `minJobDuration` — which is what services were already priced at. A value below the daemon's `serviceOnDemand.minDurationSeconds` is raised to it at startup, with a warning. Advertised to clients as `minServiceDuration`. +- **maxServiceDuration** *(optional)*: Maximum **service** duration in seconds, for service-on-demand. Omit to inherit the daemon's `serviceOnDemand.maxDurationSeconds` (default 86400). That daemon value is a hard ceiling — an environment can only lower it, and a larger value is clamped at startup with a warning. Advertised to clients on every environment as `maxServiceDuration`. - **maxJobs**: Maximum simultaneous paid jobs. - **enableNetwork**: Whether algorithm containers can make outbound network connections. Default: `false` - **access**: Access control for paid jobs. diff --git a/docs/services.md b/docs/services.md index f2698b4d2..00e68036e 100644 --- a/docs/services.md +++ b/docs/services.md @@ -158,13 +158,39 @@ Service-on-demand is configured per Docker connection under `serviceOnDemand`: | `enabled` | Master switch for the feature on this connection. | | `nodeHost` | Externally reachable host used to build endpoint URLs. | | `hostPortRange` | `[start, end]` range the node allocates published host ports from. | -| `maxDurationSeconds` | Upper bound on a service's lifetime (default 86400). | +| `minDurationSeconds` | Hard floor under a service's duration for this daemon (default 0 — no floor). An environment may raise it with its own `minServiceDuration`; a smaller per-env value is clamped up to this one at startup, with a warning. | +| `maxDurationSeconds` | Hard ceiling on a service's lifetime for this daemon (default 86400). An environment may lower it with its own `maxServiceDuration`; a larger per-env value is clamped to this one at startup, with a warning. | | `allowImageBuild` | If true, consumers may submit an inline `dockerfile` to build. | Whether a given environment accepts services is gated by its `features.services` flag, and access can be restricted with the environment's `access` allow-list (`addresses` + on-chain `accessLists`). +Each environment resolves its own service bounds at startup and advertises them in +GET_COMPUTE_ENVIRONMENTS as `minServiceDuration` and `maxServiceDuration`. SERVICE_START +rejects a `duration` outside that range; SERVICE_EXTEND caps the resulting remaining window to +the maximum. Set them per environment to give, say, a cheap CPU env a 1 h limit while a GPU +env keeps the full 24 h: + +```json +{ "id": "cpu-small", "minServiceDuration": 600, "maxServiceDuration": 3600, "fees": { "1": [ ... ] } } +``` + +`minServiceDuration` is a minimum **purchase**, applied to a start and to an extension alike: +SERVICE_START rejects a shorter `duration` and SERVICE_EXTEND rejects a shorter +`additionalDuration`, rather than granting the smaller window and charging for the floor. +Anything accepted is priced by its actual duration, rounded up to whole minutes. Rejecting is +what keeps the two honest — billing a 100 s top-up as 600 s would let ten of them add 1000 s of +runtime while charging for 6000 s. It defaults to the environment's `minJobDuration`, which is +exactly what services were already billed at, so leaving both new fields unset changes nothing. + +Both are separate from `minJobDuration` / `maxJobDuration`, which are per-env too but apply +only to compute jobs. + +An environment whose resolved floor exceeds its resolved cap is a **fatal config error**: no +duration could satisfy both, so the node logs the offending environment and refuses to start +rather than advertising an environment that can never be booked. + **Templates are not shipped in the image.** The node reads them from a folder the operator mounts in, so a node without that mount advertises no templates at all. Point `serviceTemplatesPath` (env var `SERVICE_TEMPLATES_PATH`) at the mount: diff --git a/src/@types/C2D/C2D.ts b/src/@types/C2D/C2D.ts index 01d7ce1a1..6da7d0b4c 100644 --- a/src/@types/C2D/C2D.ts +++ b/src/@types/C2D/C2D.ts @@ -158,6 +158,8 @@ export interface ComputeEnvironmentBaseConfig { storageExpiry?: number // amount of seconds for storage minJobDuration?: number // min billable seconds for a paid job maxJobDuration?: number // max duration in seconds for a paid job + minServiceDuration?: number // min duration in seconds for a paid service + maxServiceDuration?: number // max duration in seconds for a paid service maxJobs?: number // maximum number of simultaneous paid jobs fees: ComputeEnvFeesStructure resources?: ComputeResource[] @@ -209,6 +211,14 @@ export interface C2DEnvironmentConfig { storageExpiry?: number minJobDuration?: number maxJobDuration?: number + // Optional per-env service floor. The daemon's serviceOnDemand.minDurationSeconds is a hard + // floor: an env may only raise it, and a smaller value is clamped up (with a warning) at + // startup. Omitted → the env falls back to its own minJobDuration. + minServiceDuration?: number + // Optional per-env service cap. The daemon's serviceOnDemand.maxDurationSeconds is a hard + // ceiling: an env may only lower it, and a larger value is clamped (with a warning) at + // startup. Omitted → the env inherits the daemon cap. + maxServiceDuration?: number maxJobs?: number fees?: ComputeEnvFeesStructure access?: ComputeAccessList diff --git a/src/@types/C2D/ServiceOnDemand.ts b/src/@types/C2D/ServiceOnDemand.ts index 6e45a2a87..644cedcfe 100644 --- a/src/@types/C2D/ServiceOnDemand.ts +++ b/src/@types/C2D/ServiceOnDemand.ts @@ -104,11 +104,23 @@ export interface ServiceTemplatePublic extends Omit // ── Operational config (per Docker daemon, not global) ──────────────── +// Service duration cap applied when a daemon carries no `serviceOnDemand` block, or one +// that omits `maxDurationSeconds`. Single source of truth for the config schema's default, +// the SERVICE_START / SERVICE_EXTEND checks and the `maxServiceDuration` every compute +// environment advertises — those three must never disagree. +export const DEFAULT_SERVICE_MAX_DURATION_SECONDS = 86400 // 24 h + +// Daemon-level service floor when `serviceOnDemand` omits `minDurationSeconds`. Zero means +// "no daemon floor", so an environment's own minServiceDuration (which itself falls back to +// minJobDuration) is what applies — keeping an unconfigured node billing exactly as before. +export const DEFAULT_SERVICE_MIN_DURATION_SECONDS = 0 + export interface ServiceOnDemandConfig { enabled: boolean nodeHost: string // host (or IP) clients use to reach forwarded service ports; e.g. 'localhost' hostPortRange?: [number, number] // e.g. [30000, 32767]; specific to this daemon's host - maxDurationSeconds?: number // default: 86400 (24 h) + minDurationSeconds?: number // default: DEFAULT_SERVICE_MIN_DURATION_SECONDS (no daemon floor) + maxDurationSeconds?: number // default: DEFAULT_SERVICE_MAX_DURATION_SECONDS (24 h) allowImageBuild?: boolean // default: false — gates Dockerfile-based services per daemon } diff --git a/src/components/c2d/compute_engine_base.ts b/src/components/c2d/compute_engine_base.ts index f63295634..de4f0b613 100644 --- a/src/components/c2d/compute_engine_base.ts +++ b/src/components/c2d/compute_engine_base.ts @@ -16,7 +16,11 @@ import type { DBComputeJobMetadata, ComputeEnvFees } from '../../@types/C2D/C2D.js' -import type { ServiceJob } from '../../@types/C2D/ServiceOnDemand.js' +import { + DEFAULT_SERVICE_MAX_DURATION_SECONDS, + DEFAULT_SERVICE_MIN_DURATION_SECONDS, + type ServiceJob +} from '../../@types/C2D/ServiceOnDemand.js' import { C2DClusterType, C2DStatusNumber } from '../../@types/C2D/C2D.js' import { C2DDatabase } from '../database/C2DDatabase.js' import { Escrow } from '../core/utils/escrow.js' @@ -80,6 +84,34 @@ export abstract class C2DEngine { return this.clusterConfig } + /** + * Hard cap, in seconds, on how long a service may run — what SERVICE_START validates the + * requested duration against, and what SERVICE_EXTEND caps the resulting remaining window + * to. Per Docker daemon rather than per environment, so every env on this engine reports + * the same value via `maxServiceDuration`. Falls back to the schema default when the + * cluster carries no `serviceOnDemand` block, so what is advertised is exactly what is + * enforced. + */ + getMaxServiceDuration(): number { + return ( + this.getC2DConfig().connection?.serviceOnDemand?.maxDurationSeconds ?? + DEFAULT_SERVICE_MAX_DURATION_SECONDS + ) + } + + /** + * Floor, in seconds, that this daemon puts under every service. An environment may raise it + * with its own `minServiceDuration` but never go below it. Defaults to 0 — no daemon floor — + * so an unconfigured node leaves each env's own floor (its minJobDuration) in charge and + * bills exactly as it did before this knob existed. + */ + getMinServiceDuration(): number { + return ( + this.getC2DConfig().connection?.serviceOnDemand?.minDurationSeconds ?? + DEFAULT_SERVICE_MIN_DURATION_SECONDS + ) + } + getC2DType(): C2DClusterType { /** Returns cluster type */ return this.clusterConfig.type @@ -1012,14 +1044,21 @@ export abstract class C2DEngine { return cost } + /** + * @param minDurationOverride - billing floor to apply instead of `env.minJobDuration`. + * Services pass their own `minServiceDuration` here so a service is never priced against the + * compute-job floor. Omitted (compute jobs) keeps the original behaviour. + */ public calculateResourcesCost( resourcesRequest: ComputeResourceRequest[], env: ComputeEnvironment, chainId: number, token: string, - maxJobDuration: number + maxJobDuration: number, + minDurationOverride?: number ): number | null { - if (maxJobDuration < env.minJobDuration) maxJobDuration = env.minJobDuration + const minDuration = minDurationOverride ?? env.minJobDuration + if (maxJobDuration < minDuration) maxJobDuration = minDuration const prices = this.getEnvPricesForToken(env, chainId, token) if (!prices) return null let cost: number = 0 diff --git a/src/components/c2d/compute_engine_docker.ts b/src/components/c2d/compute_engine_docker.ts index ae77ef676..fa858fbd8 100755 --- a/src/components/c2d/compute_engine_docker.ts +++ b/src/components/c2d/compute_engine_docker.ts @@ -537,6 +537,57 @@ export class C2DEngineDocker extends C2DEngine { const fees = this.processFeesForEnvironment(envDef.fees, supportedChains) const envResources = this.resolveEnvironmentResources(envDef, connectionPool) + // Service duration cap. The daemon's serviceOnDemand.maxDurationSeconds is a hard + // ceiling — an env may tighten it but never raise it, so a larger value is clamped + // with a warning (same treatment a resource max above the pool total gets). + const daemonServiceCap = this.getMaxServiceDuration() + const { maxServiceDuration: envServiceCap } = envDef + if (envServiceCap !== undefined && envServiceCap > daemonServiceCap) { + CORE_LOGGER.warn( + `Environment "${envDef.description || envDef.id || 'unknown'}": ` + + `maxServiceDuration (${envServiceCap}) is greater than the daemon's ` + + `serviceOnDemand.maxDurationSeconds (${daemonServiceCap}) — clamping to ` + + `${daemonServiceCap}. An environment can only lower the daemon cap.` + ) + } + const maxServiceDuration = Math.min( + envServiceCap ?? daemonServiceCap, + daemonServiceCap + ) + + // Service floor. Mirror image of the cap: the daemon's serviceOnDemand.minDurationSeconds + // is a hard floor an env may raise but not undercut, so a smaller per-env value is clamped + // up with a warning. Absent, an env falls back to its own minJobDuration, which is what + // services were already billed at — so an unconfigured node is unchanged. + const daemonServiceFloor = this.getMinServiceDuration() + const { minServiceDuration: envServiceFloor } = envDef + if (envServiceFloor !== undefined && envServiceFloor < daemonServiceFloor) { + CORE_LOGGER.warn( + `Environment "${envDef.description || envDef.id || 'unknown'}": ` + + `minServiceDuration (${envServiceFloor}) is below the daemon's ` + + `serviceOnDemand.minDurationSeconds (${daemonServiceFloor}) — raising to ` + + `${daemonServiceFloor}. An environment can only raise the daemon floor.` + ) + } + const minServiceDuration = Math.max( + envServiceFloor ?? envDef.minJobDuration ?? 0, + daemonServiceFloor + ) + // Fatal, unlike the clamps above: those correct a value into a working range, whereas an + // empty range leaves the env permanently unusable for services — every SERVICE_START would + // 400. Refuse to boot rather than advertise an environment that can never be booked. + if (minServiceDuration > maxServiceDuration) { + const envName = envDef.description || envDef.id || 'unknown' + const message = + `Environment "${envName}": minServiceDuration (${minServiceDuration}) exceeds ` + + `maxServiceDuration (${maxServiceDuration}) — no service duration can satisfy both, so ` + + `every SERVICE_START would be rejected. Fix the environment's minServiceDuration / ` + + `maxServiceDuration, or the daemon's serviceOnDemand.minDurationSeconds / ` + + `maxDurationSeconds.` + CORE_LOGGER.error(message) + throw new Error(message) + } + const env: ComputeEnvironment = { id: '', runningJobs: 0, @@ -555,7 +606,11 @@ export class C2DEngineDocker extends C2DEngine { features: { computeJobs: envDef.features?.computeJobs ?? true, services: envDef.features?.services ?? true - } + }, + // Always advertised, even where features.services is false, because they state what + // SERVICE_START would enforce — clients gate on features.services, not on absence. + minServiceDuration, + maxServiceDuration } if (envDef.storageExpiry !== undefined) env.storageExpiry = envDef.storageExpiry diff --git a/src/components/core/service/extendService.ts b/src/components/core/service/extendService.ts index fd73b149a..15c952efa 100644 --- a/src/components/core/service/extendService.ts +++ b/src/components/core/service/extendService.ts @@ -142,9 +142,9 @@ export class ServiceExtendHandler extends CommandHandler { ) ) - // Extension must not push total beyond maxDurationSeconds - const sod = engine.getC2DConfig().connection?.serviceOnDemand - const maxDuration = sod?.maxDurationSeconds ?? 86400 + // Extension must not push the remaining window beyond the cap of the env the + // service actually runs on (already clamped to the daemon ceiling at engine start). + const maxDuration = runEnv.maxServiceDuration ?? engine.getMaxServiceDuration() const newTotalDuration = remainingSeconds + task.additionalDuration if (newTotalDuration > maxDuration) return buildInvalidParametersResponse( @@ -153,6 +153,20 @@ export class ServiceExtendHandler extends CommandHandler { ) ) + // Reject a top-up below the floor rather than rounding its price up to it. Billing a + // 100s extension as 600s on a 600s-floor env would let ten such top-ups add 1000s of + // runtime while charging for 6000s; the floor is a minimum purchase, so it gates what + // may be bought instead of silently inflating what a smaller purchase costs. Same rule + // and same message shape as SERVICE_START. + const minDuration = + runEnv.minServiceDuration ?? + Math.max(runEnv.minJobDuration ?? 0, engine.getMinServiceDuration()) + if (task.additionalDuration < minDuration) + return buildInvalidParametersResponse( + buildInvalidRequestMessage( + `Additional duration ${task.additionalDuration}s is below minimum ${minDuration}s` + ) + ) // Cost — same price formula as the start, priced off the env the service runs // on. No fallback: pricing must use runEnv (resolved above); // calculateResourcesCost returns null if that env has no pricing for the token. @@ -161,7 +175,10 @@ export class ServiceExtendHandler extends CommandHandler { runEnv, task.payment.chainId, task.payment.token, - task.additionalDuration + task.additionalDuration, + // Guarded above to be >= minDuration, so this never rounds the price up; it is passed + // to keep a service off the compute-job floor (minJobDuration), which may be higher. + minDuration ) if (costExtend === null) return buildInvalidParametersResponse( diff --git a/src/components/core/service/startService.ts b/src/components/core/service/startService.ts index 430a49ead..49856d6d4 100644 --- a/src/components/core/service/startService.ts +++ b/src/components/core/service/startService.ts @@ -137,15 +137,27 @@ export class ServiceStartHandler extends CommandHandler { return outputBucketCheck } - // 4. Duration limit - const sod = engine.getC2DConfig().connection?.serviceOnDemand - const maxDuration = sod?.maxDurationSeconds ?? 86400 + // 4. Duration limits. Both are per-env, resolved and clamped against the daemon's + // serviceOnDemand bounds at engine start; the daemon values are the fallback for an + // env built without them. + const maxDuration = env.maxServiceDuration ?? engine.getMaxServiceDuration() if (task.duration > maxDuration) return buildInvalidParametersResponse( buildInvalidRequestMessage( `Duration ${task.duration}s exceeds maximum ${maxDuration}s` ) ) + // Reject rather than silently round up: below the floor the service would be billed for + // time it is not granted, and the caller would never learn why it cost what it did. + const minDuration = + env.minServiceDuration ?? + Math.max(env.minJobDuration ?? 0, engine.getMinServiceDuration()) + if (task.duration < minDuration) + return buildInvalidParametersResponse( + buildInvalidRequestMessage( + `Duration ${task.duration}s is below minimum ${minDuration}s` + ) + ) // 5. Resolve resources (fill cpu/ram/disk defaults the same way compute jobs do) let resources @@ -173,7 +185,9 @@ export class ServiceStartHandler extends CommandHandler { env, task.payment.chainId, task.payment.token, - task.duration + task.duration, + // Services bill against their own floor, never the compute-job one. + minDuration ) if (cost === null) return buildInvalidParametersResponse( diff --git a/src/test/integration/services.test.ts b/src/test/integration/services.test.ts index bbca9c93f..e04eec5a7 100644 --- a/src/test/integration/services.test.ts +++ b/src/test/integration/services.test.ts @@ -752,7 +752,7 @@ describe('********** Service on Demand', () => { expect(resp.status.httpStatus).to.equal(400) }) - it('(j) SERVICE_EXTEND advances expiresAt and records an extendPayment', async () => { + it('(j) SERVICE_EXTEND below the minimum duration → 400', async () => { const { consumerAddress: addr, nonce, @@ -768,12 +768,31 @@ describe('********** Service on Demand', () => { payment: { chainId: DEVELOPMENT_CHAIN_ID, token: paymentToken } } const resp = await new ServiceExtendHandler(oceanNode).handle(task) + expect(resp.status.httpStatus).to.equal(400) + }) + + it('(j2) SERVICE_EXTEND advances expiresAt and records an extendPayment', async () => { + const { + consumerAddress: addr, + nonce, + signature + } = await signFor(consumerAccount, PROTOCOL_COMMANDS.SERVICE_EXTEND) + const task: ServiceExtendCommand = { + command: PROTOCOL_COMMANDS.SERVICE_EXTEND, + consumerAddress: addr, + nonce, + signature, + serviceId, + additionalDuration: 60, + payment: { chainId: DEVELOPMENT_CHAIN_ID, token: paymentToken } + } + const resp = await new ServiceExtendHandler(oceanNode).handle(task) assert( resp.status.httpStatus === 200, `expected 200, got ${resp.status.httpStatus}: ${resp.status?.error ?? ''}` ) const [job] = (await streamToObject(resp.stream as Readable)) as ServiceJob[] - expect(job.expiresAt).to.equal(expiresAt + 30 * 1000) + expect(job.expiresAt).to.equal(expiresAt + 60 * 1000) expect(job.extendPayments?.length).to.equal(1) expiresAt = job.expiresAt }) diff --git a/src/test/unit/service/serviceHandlers.test.ts b/src/test/unit/service/serviceHandlers.test.ts index a18e48d64..fe18f2efd 100644 --- a/src/test/unit/service/serviceHandlers.test.ts +++ b/src/test/unit/service/serviceHandlers.test.ts @@ -58,6 +58,10 @@ const TEMPLATE = { interface FakeOpts { serviceEnabled?: boolean + // per-env service cap; omitted → the env inherits the daemon's 86400 + maxServiceDuration?: number + // per-env service floor; omitted → the env has none (no minJobDuration on the fake env) + minServiceDuration?: number serviceJobInDb?: ServiceJob | null cost?: number | null envId?: string @@ -71,7 +75,13 @@ function buildFakes(opts: FakeOpts = {}) { computeJobs: true, services: opts.serviceEnabled !== false }, - resources: [{ id: 'cpu', kind: 'fungible', total: 8, min: 1, max: 8 }] + resources: [{ id: 'cpu', kind: 'fungible', total: 8, min: 1, max: 8 }], + ...(opts.maxServiceDuration === undefined + ? {} + : { maxServiceDuration: opts.maxServiceDuration }), + ...(opts.minServiceDuration === undefined + ? {} + : { minServiceDuration: opts.minServiceDuration }) } const escrow = { @@ -109,6 +119,20 @@ function buildFakes(opts: FakeOpts = {}) { hash: 'hash-1', connection: { serviceOnDemand: { maxDurationSeconds: 86400 } } }), + // Mirrors C2DEngine.getMaxServiceDuration by deriving from getC2DConfig, so a test that + // re-stubs the cluster's serviceOnDemand block still steers the duration checks. + // Mirrors C2DEngine.getMinServiceDuration — the daemon floor, 0 unless a test sets one. + getMinServiceDuration: sinon + .stub() + .callsFake( + () => engine.getC2DConfig().connection?.serviceOnDemand?.minDurationSeconds ?? 0 + ), + getMaxServiceDuration: sinon + .stub() + .callsFake( + () => + engine.getC2DConfig().connection?.serviceOnDemand?.maxDurationSeconds ?? 86400 + ), calculateResourcesCost: sinon .stub() .returns(opts.cost === undefined ? 10 : opts.cost), @@ -614,6 +638,47 @@ describe('Service handlers', () => { expect(res.status.httpStatus).to.equal(400) }) + it("400 when the extension is below the env's own minServiceDuration", async () => { + const { node, engine } = buildFakes({ + minServiceDuration: 600, + serviceJobInDb: makeJob({ expiresAt: Date.now() + 500 * 1000 }) + }) + const res = await new ServiceExtendHandler(node).handle({ + ...baseTask, + additionalDuration: 100 + } as any) + expect(res.status.httpStatus).to.equal(400) + // Rejected outright, never priced — a short top-up must not be billed at the full floor. + expect(engine.calculateResourcesCost.called).to.equal(false) + }) + + it('prices an extension by its actual duration, never rounded up to the floor', async () => { + const { node, engine } = buildFakes({ + minServiceDuration: 600, + serviceJobInDb: makeJob({ expiresAt: Date.now() + 500 * 1000 }) + }) + await new ServiceExtendHandler(node).handle({ + ...baseTask, + additionalDuration: 900 + } as any) + const { args } = engine.calculateResourcesCost.firstCall + expect(args[4]).to.equal(900) // the duration actually priced + expect(args[5]).to.equal(600) // floor passed, but 900 > 600 so it cannot inflate + }) + + it("400 when the extension exceeds the env's own maxServiceDuration", async () => { + // ~500 s left + 400 s = 900 s: inside the daemon's 86400, outside the env's 600. + const { node } = buildFakes({ + maxServiceDuration: 600, + serviceJobInDb: makeJob({ expiresAt: Date.now() + 500 * 1000 }) + }) + const res = await new ServiceExtendHandler(node).handle({ + ...baseTask, + additionalDuration: 400 + } as any) + expect(res.status.httpStatus).to.equal(400) + }) + it('402 when escrow lock fails', async () => { const { node, escrow } = buildFakes({ serviceJobInDb: makeJob() }) escrow.createLock.resolves(null) @@ -983,6 +1048,50 @@ describe('Service handlers', () => { expect(res.status.httpStatus).to.equal(400) }) + it("400 when duration is below the env's own minServiceDuration", async () => { + const { node } = buildFakes({ minServiceDuration: 600 }) + const res = await new ServiceStartHandler(node).handle({ + ...baseTask, + duration: 300 + } as any) + expect(res.status.httpStatus).to.equal(400) + }) + + it("200 when duration exactly meets the env's own minServiceDuration", async () => { + const { node } = buildFakes({ minServiceDuration: 600 }) + const res = await new ServiceStartHandler(node).handle({ + ...baseTask, + duration: 600 + } as any) + expect(res.status.httpStatus).to.equal(200) + }) + + it('prices a service against its own floor, not the compute-job one', async () => { + const { node, engine } = buildFakes({ minServiceDuration: 600 }) + await new ServiceStartHandler(node).handle({ ...baseTask, duration: 900 } as any) + // 6th arg is the billing floor override handed to calculateResourcesCost. + expect(engine.calculateResourcesCost.firstCall.args[5]).to.equal(600) + }) + + it("400 when duration exceeds the env's own maxServiceDuration", async () => { + // Well under the daemon's 86400, so only the tighter per-env cap can reject this. + const { node } = buildFakes({ maxServiceDuration: 600 }) + const res = await new ServiceStartHandler(node).handle({ + ...baseTask, + duration: 1200 + } as any) + expect(res.status.httpStatus).to.equal(400) + }) + + it("200 when duration is within the env's own maxServiceDuration", async () => { + const { node } = buildFakes({ maxServiceDuration: 600 }) + const res = await new ServiceStartHandler(node).handle({ + ...baseTask, + duration: 600 + } as any) + expect(res.status.httpStatus).to.equal(200) + }) + it('400 when no pricing for the token (cost null)', async () => { const { node } = buildFakes({ cost: null }) const res = await new ServiceStartHandler(node).handle({ ...baseTask } as any) diff --git a/src/test/unit/service/serviceSchemas.test.ts b/src/test/unit/service/serviceSchemas.test.ts index 503da459e..3d97f2d36 100644 --- a/src/test/unit/service/serviceSchemas.test.ts +++ b/src/test/unit/service/serviceSchemas.test.ts @@ -4,6 +4,11 @@ import { ServiceOnDemandConfigSchema, C2DEnvironmentConfigSchema } from '../../../utils/config/schemas.js' +import { + DEFAULT_SERVICE_MAX_DURATION_SECONDS, + DEFAULT_SERVICE_MIN_DURATION_SECONDS +} from '../../../@types/C2D/ServiceOnDemand.js' +import { C2DEngine } from '../../../components/c2d/compute_engine_base.js' const baseTemplate = { id: 'jupyter-cpu', @@ -142,6 +147,25 @@ describe('ServiceOnDemandConfigSchema', () => { expect(parsed.maxDurationSeconds).to.equal(86400) expect(parsed.allowImageBuild).to.equal(false) }) + it('minDurationSeconds defaults to no daemon floor', () => { + const parsed = ServiceOnDemandConfigSchema.parse({ + enabled: true, + nodeHost: 'localhost' + }) + expect(parsed.minDurationSeconds).to.equal(DEFAULT_SERVICE_MIN_DURATION_SECONDS) + expect(parsed.minDurationSeconds).to.equal(0) + }) + it('default is the shared constant, not an independent literal', () => { + // The schema default, the SERVICE_START / SERVICE_EXTEND fallback and the + // maxServiceDuration every env advertises all read this one constant. Pinning it here + // means a change to it can never leave the advertised cap disagreeing with the + // enforced one. + const parsed = ServiceOnDemandConfigSchema.parse({ + enabled: true, + nodeHost: 'localhost' + }) + expect(parsed.maxDurationSeconds).to.equal(DEFAULT_SERVICE_MAX_DURATION_SECONDS) + }) it('requires nodeHost', () => { expect(ServiceOnDemandConfigSchema.safeParse({ enabled: true }).success).to.equal( false @@ -158,6 +182,105 @@ describe('ServiceOnDemandConfigSchema', () => { }) }) +describe('C2DEnvironmentConfigSchema maxServiceDuration', () => { + const baseEnv = { + fees: { '1': [{ feeToken: '0xabc', prices: [{ id: 'cpu', price: 1 }] }] } + } + + it('absent → undefined, so the env inherits the daemon cap at engine start', () => { + const parsed = C2DEnvironmentConfigSchema.parse({ ...baseEnv }) + expect(parsed.maxServiceDuration).to.equal(undefined) + }) + it('accepts a positive integer', () => { + const parsed = C2DEnvironmentConfigSchema.parse({ + ...baseEnv, + maxServiceDuration: 600 + }) + expect(parsed.maxServiceDuration).to.equal(600) + }) + it('rejects zero and negatives', () => { + for (const bad of [0, -1]) { + expect( + C2DEnvironmentConfigSchema.safeParse({ ...baseEnv, maxServiceDuration: bad }) + .success, + `maxServiceDuration=${bad}` + ).to.equal(false) + } + }) +}) + +describe('C2DEnvironmentConfigSchema minServiceDuration', () => { + const baseEnv = { + fees: { '1': [{ feeToken: '0xabc', prices: [{ id: 'cpu', price: 1 }] }] } + } + + it('absent → undefined, so the env falls back to its minJobDuration', () => { + const parsed = C2DEnvironmentConfigSchema.parse({ ...baseEnv }) + expect(parsed.minServiceDuration).to.equal(undefined) + }) + it('accepts 0 — an env may explicitly opt out of any floor', () => { + const parsed = C2DEnvironmentConfigSchema.parse({ ...baseEnv, minServiceDuration: 0 }) + expect(parsed.minServiceDuration).to.equal(0) + }) + it('rejects negatives', () => { + expect( + C2DEnvironmentConfigSchema.safeParse({ ...baseEnv, minServiceDuration: -1 }).success + ).to.equal(false) + }) +}) + +describe('C2DEngine.getMinServiceDuration', () => { + const engineWith = (connection: any): C2DEngine => { + const engine: any = Object.create(C2DEngine.prototype) + engine.clusterConfig = { hash: 'hash-1', connection } + return engine + } + + it("returns the daemon's configured floor", () => { + expect( + engineWith({ serviceOnDemand: { minDurationSeconds: 600 } }).getMinServiceDuration() + ).to.equal(600) + }) + it('falls back to no floor when the daemon does not set one', () => { + expect( + engineWith({ serviceOnDemand: { enabled: true } }).getMinServiceDuration() + ).to.equal(DEFAULT_SERVICE_MIN_DURATION_SECONDS) + expect(engineWith({}).getMinServiceDuration()).to.equal( + DEFAULT_SERVICE_MIN_DURATION_SECONDS + ) + expect(engineWith(undefined).getMinServiceDuration()).to.equal( + DEFAULT_SERVICE_MIN_DURATION_SECONDS + ) + }) +}) + +describe('C2DEngine.getMaxServiceDuration', () => { + // Object.create skips the abstract class's constructor (which wants a db, escrow and key + // manager) — the getter only ever reads clusterConfig. + const engineWith = (connection: any): C2DEngine => { + const engine: any = Object.create(C2DEngine.prototype) + engine.clusterConfig = { hash: 'hash-1', connection } + return engine + } + + it("returns the daemon's configured cap", () => { + const engine = engineWith({ serviceOnDemand: { maxDurationSeconds: 7200 } }) + expect(engine.getMaxServiceDuration()).to.equal(7200) + }) + it('falls back to the shared default when serviceOnDemand omits maxDurationSeconds', () => { + const engine = engineWith({ serviceOnDemand: { enabled: true } }) + expect(engine.getMaxServiceDuration()).to.equal(DEFAULT_SERVICE_MAX_DURATION_SECONDS) + }) + it('falls back to the shared default when there is no serviceOnDemand block at all', () => { + expect(engineWith({}).getMaxServiceDuration()).to.equal( + DEFAULT_SERVICE_MAX_DURATION_SECONDS + ) + expect(engineWith(undefined).getMaxServiceDuration()).to.equal( + DEFAULT_SERVICE_MAX_DURATION_SECONDS + ) + }) +}) + describe('C2DEnvironmentConfigSchema features', () => { const base: any = { fees: { '8996': [{ feeToken: '0x0', prices: [] as any[] }] }, diff --git a/src/utils/config/schemas.ts b/src/utils/config/schemas.ts index 54b4635a7..ae64b6910 100644 --- a/src/utils/config/schemas.ts +++ b/src/utils/config/schemas.ts @@ -2,6 +2,10 @@ import { z } from 'zod' import { getAddress } from 'ethers' import { dhtFilterMethod } from '../../@types/OceanNode.js' import { C2DClusterType } from '../../@types/C2D/C2D.js' +import { + DEFAULT_SERVICE_MAX_DURATION_SECONDS, + DEFAULT_SERVICE_MIN_DURATION_SECONDS +} from '../../@types/C2D/ServiceOnDemand.js' import { CONFIG_LOGGER } from '../logging/common.js' import { booleanFromString, jsonFromString } from './transforms.js' import { @@ -548,7 +552,18 @@ export const ServiceOnDemandConfigSchema = z message: 'hostPortRange[0] must be less than hostPortRange[1]' }) .optional(), - maxDurationSeconds: z.number().int().min(60).optional().default(86400), + minDurationSeconds: z + .number() + .int() + .min(0) + .optional() + .default(DEFAULT_SERVICE_MIN_DURATION_SECONDS), + maxDurationSeconds: z + .number() + .int() + .min(60) + .optional() + .default(DEFAULT_SERVICE_MAX_DURATION_SECONDS), allowImageBuild: z.boolean().optional().default(false) }) .strict() @@ -605,6 +620,12 @@ export const C2DEnvironmentConfigSchema = z storageExpiry: z.number().int().optional().default(604800), minJobDuration: z.number().int().optional().default(60), maxJobDuration: z.number().int().optional().default(3600), + // No default: absent means "fall back to this env's minJobDuration", resolved at engine + // start and clamped up there if it sits below the daemon's serviceOnDemand.minDurationSeconds. + minServiceDuration: z.number().int().min(0).optional(), + // No default: absent means "inherit the daemon's serviceOnDemand.maxDurationSeconds", + // which is resolved at engine start and clamped there if this exceeds it. + maxServiceDuration: z.number().int().min(1).optional(), maxJobs: z.number().int().optional(), fees: z.record(z.string(), z.array(ComputeEnvFeesSchema)).optional(), access: z