From 2524d7ea578a292e6af74a63998a7b99ecea94ab Mon Sep 17 00:00:00 2001 From: giurgiur99 Date: Mon, 8 Jun 2026 10:28:24 +0300 Subject: [PATCH] Output bucket id --- docs/API.md | 1 + docs/Storage.md | 2 + docs/persistentStorage.md | 44 +++++ src/@types/C2D/C2D.ts | 1 + src/@types/commands.ts | 1 + src/components/c2d/compute_engine_base.ts | 3 +- src/components/c2d/compute_engine_docker.ts | 67 ++++--- src/components/c2d/index.ts | 3 +- src/components/core/compute/startCompute.ts | 27 ++- src/components/core/compute/utils.ts | 51 ++++++ src/components/database/sqliteCompute.ts | 1 + src/components/httpRoutes/compute.ts | 6 + .../PersistentStorageFactory.ts | 5 + .../PersistentStorageLocalFS.ts | 18 ++ .../persistentStorage/PersistentStorageS3.ts | 8 + src/test/integration/compute.test.ts | 167 ++++++++++++++++++ 16 files changed, 378 insertions(+), 27 deletions(-) diff --git a/docs/API.md b/docs/API.md index f2ace4dce..dd5475852 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1519,6 +1519,7 @@ starts a free compute job and returns jobId if succesfull | queueMaxWaitTime | number | | optional max time in seconds a job can wait in the queue before being started | | encryptedDockerRegistryAuth | string | | Ecies encrypted docker auth schema for image (see [Private Docker Registries with Per-Job Authentication](../env.md#private-docker-registries-with-per-job-authentication)) | | output | string | | Ecies encrypted with instructions for uploading compute results (see [C2D result upload to remote storage](../Storage.md#c2d-result-upload-to-remote-storage)) | +| outputBucketId | string | | persistent-storage bucket id; the bucket is mounted at /data/outputs and results are stored there as individual files. Mutually exclusive with `output` (see [persistent storage](../persistentStorage.md#using-a-bucket-for-compute-job-outputs)) | #### Request diff --git a/docs/Storage.md b/docs/Storage.md index 24c7ba7bf..c6fb02865 100644 --- a/docs/Storage.md +++ b/docs/Storage.md @@ -211,6 +211,8 @@ FTPStorage supports `upload(filename, stream)`. If the file object’s `url` end Compute-to-Data jobs can upload their output archive to a remote backend instead of keeping it only on local node disk. +Alternatively, results can be stored as individual files in a node persistent-storage bucket via the `outputBucketId` start parameter — see [persistent storage](./persistentStorage.md#using-a-bucket-for-compute-job-outputs). The two options are mutually exclusive. + ### How it works 1. You build a `ComputeOutput` JSON object with: diff --git a/docs/persistentStorage.md b/docs/persistentStorage.md index 0b78c0f63..923557445 100644 --- a/docs/persistentStorage.md +++ b/docs/persistentStorage.md @@ -186,6 +186,50 @@ Upload uses the raw request body as bytes and forwards it to the handler as a st --- +## Using a bucket for compute job outputs + +Compute jobs (free and paid) can store their results directly in a persistent storage bucket instead of the default `outputs.tar` archive. Pass the bucket id as `outputBucketId` in the start compute command: + +```json +{ + "command": "freeStartCompute", + "...": "...", + "outputBucketId": "a4ad237d-dfd8-404c-a5d6-b8fc3a1f66d3" +} +``` + +How it works: + +- The bucket directory is bind-mounted **read-write** at `/data/outputs` inside the job container, so everything the algorithm writes there lands directly in the bucket as **individual files** (no archive, no copy step). Files appear in the bucket as the job writes them. +- No local `outputs.tar` is produced and the job's results index contains no `output` entry; logs (`imageLog`, `configurationLog`, `algorithmLog`) behave as usual. Results are retrieved via the persistent storage list/get APIs. +- The consumer starting the job must be the bucket owner or on the bucket access list, otherwise the start request is rejected with `403`. +- `outputBucketId` is **mutually exclusive** with the `output` (remote storage upload) parameter — sending both returns `400`. +- Files keep the names the algorithm gives them; writing an existing name **overwrites** it, so pipelines can re-run jobs with stable filenames. +- Nested directories created by the algorithm under `/data/outputs` are not visible through the bucket API (bucket filenames are flat); algorithms should write top-level files. + +### Chaining jobs + +Because results are regular bucket files, they can feed the next compute job without any intermediate download — use the standard `nodePersistentStorage` file object as a dataset: + +```json +{ + "command": "freeStartCompute", + "...": "...", + "datasets": [ + { + "fileObject": { + "type": "nodePersistentStorage", + "bucketId": "a4ad237d-dfd8-404c-a5d6-b8fc3a1f66d3", + "fileName": "result-from-previous-job.csv" + } + } + ], + "outputBucketId": "a4ad237d-dfd8-404c-a5d6-b8fc3a1f66d3" +} +``` + +--- + ## Limitations and notes - The bucket registry is local to the node (SQLite file). If you run multiple nodes, each node’s registry is independent unless you externalize/replicate it. diff --git a/src/@types/C2D/C2D.ts b/src/@types/C2D/C2D.ts index fb764c6c6..9fe929356 100644 --- a/src/@types/C2D/C2D.ts +++ b/src/@types/C2D/C2D.ts @@ -291,6 +291,7 @@ export interface DBComputeJob extends ComputeJob { algoDuration: number // duration of the job in seconds encryptedDockerRegistryAuth?: string output?: string // this is always an ECIES encrypted string, that decodes to ComputeOutput interface + outputBucketId?: string jobIdHash: string buildStartTimestamp?: string buildStopTimestamp?: string diff --git a/src/@types/commands.ts b/src/@types/commands.ts index a203bfed3..0e6d5e841 100644 --- a/src/@types/commands.ts +++ b/src/@types/commands.ts @@ -255,6 +255,7 @@ export interface FreeComputeStartCommand extends Command { algorithm: ComputeAlgorithm datasets?: ComputeAsset[] output?: string // this is always an ECIES encrypted string, that decodes to ComputeOutput interface + outputBucketId?: string resources?: ComputeResourceRequest[] maxJobDuration?: number policyServer?: any // object to pass to policy server diff --git a/src/components/c2d/compute_engine_base.ts b/src/components/c2d/compute_engine_base.ts index 1fb7bea4e..361599798 100644 --- a/src/components/c2d/compute_engine_base.ts +++ b/src/components/c2d/compute_engine_base.ts @@ -99,7 +99,8 @@ export abstract class C2DEngine { metadata?: DBComputeJobMetadata, additionalViewers?: string[], queueMaxWaitTime?: number, - encryptedDockerRegistryAuth?: string + encryptedDockerRegistryAuth?: string, + outputBucketId?: string ): Promise public abstract stopComputeJob( diff --git a/src/components/c2d/compute_engine_docker.ts b/src/components/c2d/compute_engine_docker.ts index a9a02b7c6..95f188e9c 100755 --- a/src/components/c2d/compute_engine_docker.ts +++ b/src/components/c2d/compute_engine_docker.ts @@ -1207,7 +1207,8 @@ export class C2DEngineDocker extends C2DEngine { metadata?: DBComputeJobMetadata, additionalViewers?: string[], queueMaxWaitTime?: number, - encryptedDockerRegistryAuth?: string + encryptedDockerRegistryAuth?: string, + outputBucketId?: string ): Promise { if (!this.docker) return [] // TO DO - iterate over resources and get default runtime @@ -1304,6 +1305,7 @@ export class C2DEngineDocker extends C2DEngine { queueMaxWaitTime: queueMaxWaitTime || 0, encryptedDockerRegistryAuth, // we store the encrypted docker registry auth in the job output, + outputBucketId, buildStartTimestamp: '0', buildStopTimestamp: '0' } @@ -1427,7 +1429,7 @@ export class C2DEngineDocker extends C2DEngine { try { // check if we have an output request. const jobDb = await this.db.getJob(jobId) - if (jobDb.length < 1 || !jobDb[0].output) { + if (jobDb.length < 1 || (!jobDb[0].output && !jobDb[0].outputBucketId)) { const outputStat = statSync( this.getStoragePath() + '/' + jobId + '/data/outputs/outputs.tar' ) @@ -1968,12 +1970,7 @@ export class C2DEngineDocker extends C2DEngine { CORE_LOGGER.error( `Job ${job.jobId} asset ${i}: nodePersistentStorage requires bucketId and fileName` ) - job.status = C2DStatusNumber.DataProvisioningFailed - job.statusText = C2DStatusText.DataProvisioningFailed - job.isRunning = false - job.dateFinished = String(Date.now() / 1000) - await this.db.updateJob(job) - await this.cleanupJob(job) + await this.failJobDataProvisioning(job) return } const ps = OceanNode.getInstance().getPersistentStorage() @@ -1981,12 +1978,7 @@ export class C2DEngineDocker extends C2DEngine { CORE_LOGGER.error( `Job ${job.jobId} asset ${i}: persistent storage is not configured on this node` ) - job.status = C2DStatusNumber.DataProvisioningFailed - job.statusText = C2DStatusText.DataProvisioningFailed - job.isRunning = false - job.dateFinished = String(Date.now() / 1000) - await this.db.updateJob(job) - await this.cleanupJob(job) + await this.failJobDataProvisioning(job) return } try { @@ -2005,12 +1997,31 @@ export class C2DEngineDocker extends C2DEngine { CORE_LOGGER.error( `Job ${job.jobId} asset ${i}: failed to resolve persistent storage bind: ${errMsg}` ) - job.status = C2DStatusNumber.DataProvisioningFailed - job.statusText = C2DStatusText.DataProvisioningFailed - job.isRunning = false - job.dateFinished = String(Date.now() / 1000) - await this.db.updateJob(job) - await this.cleanupJob(job) + await this.failJobDataProvisioning(job) + return + } + } + if (job.outputBucketId) { + try { + const ps = OceanNode.getInstance().getPersistentStorage() + if (!ps) { + throw new Error('Persistent storage is not configured on this node') + } + const outputMount = await ps.getDockerOutputMountObject( + job.outputBucketId, + job.owner + ) + CORE_LOGGER.debug( + `Mounting output bucket ${job.outputBucketId} to folder ${outputMount.Target}` + ) + hostConfig.Mounts.push(outputMount) + mountVols[outputMount.Target] = {} + } catch (e) { + const errMsg = e instanceof Error ? e.message : String(e) + CORE_LOGGER.error( + `Job ${job.jobId}: failed to mount output bucket ${job.outputBucketId}: ${errMsg}` + ) + await this.failJobDataProvisioning(job) return } } @@ -2190,8 +2201,11 @@ export class C2DEngineDocker extends C2DEngine { try { if (container) { - // if we have an output request, stream to remote storage; otherwise write to local file - if (job.output) { + if (job.outputBucketId) { + CORE_LOGGER.info( + `Job ${job.jobId}: results stored in bucket ${job.outputBucketId}` + ) + } else if (job.output) { const decryptedOutput = await this.keyManager.decrypt( Uint8Array.from(Buffer.from(job.output, 'hex')), EncryptMethod.ECIES @@ -2251,6 +2265,15 @@ export class C2DEngineDocker extends C2DEngine { } } + private async failJobDataProvisioning(job: DBComputeJob): Promise { + job.status = C2DStatusNumber.DataProvisioningFailed + job.statusText = C2DStatusText.DataProvisioningFailed + job.isRunning = false + job.dateFinished = String(Date.now() / 1000) + await this.db.updateJob(job) + await this.cleanupJob(job) + } + // eslint-disable-next-line require-await private parseCpusetString(cpuset: string): number[] { const cores: number[] = [] diff --git a/src/components/c2d/index.ts b/src/components/c2d/index.ts index 745036b21..70ed3e235 100644 --- a/src/components/c2d/index.ts +++ b/src/components/c2d/index.ts @@ -44,7 +44,8 @@ export function omitDBComputeFieldsFromComputeJob(dbCompute: DBComputeJob): Comp 'isStarted', 'containerImage', 'encryptedDockerRegistryAuth', - 'output' + 'output', + 'outputBucketId' ]) as ComputeJob return job } diff --git a/src/components/core/compute/startCompute.ts b/src/components/core/compute/startCompute.ts index e5fe0b65a..69a16bf1e 100644 --- a/src/components/core/compute/startCompute.ts +++ b/src/components/core/compute/startCompute.ts @@ -11,7 +11,8 @@ import { generateUniqueID, getAlgoChecksums, validateAlgoForDataset, - validateOutput + validateOutput, + validateOutputBucket } from './utils.js' import { ValidateParams, @@ -607,6 +608,15 @@ export class PaidComputeStartHandler extends CommonComputeHandler { } } } + const isValidOutputBucket = await validateOutputBucket( + node, + task.outputBucketId, + task.output, + task.consumerAddress + ) + if (isValidOutputBucket.status.httpStatus !== 200) { + return isValidOutputBucket + } const isValidOutput = await validateOutput(node, task.output, node.getConfig()) if (isValidOutput.status.httpStatus !== 200) { return isValidOutput @@ -632,7 +642,8 @@ export class PaidComputeStartHandler extends CommonComputeHandler { task.metadata, task.additionalViewers, task.queueMaxWaitTime, - task.encryptedDockerRegistryAuth + task.encryptedDockerRegistryAuth, + task.outputBucketId ) CORE_LOGGER.logMessage( 'ComputeStartCommand Response: ' + JSON.stringify(response, null, 2), @@ -762,6 +773,15 @@ export class FreeComputeStartHandler extends CommonComputeHandler { } } const node = this.getOceanNode() + const isValidOutputBucket = await validateOutputBucket( + node, + task.outputBucketId, + task.output, + task.consumerAddress + ) + if (isValidOutputBucket.status.httpStatus !== 200) { + return isValidOutputBucket + } const isValidOutput = await validateOutput(node, task.output, node.getConfig()) if (isValidOutput.status.httpStatus !== 200) { return isValidOutput @@ -1016,7 +1036,8 @@ export class FreeComputeStartHandler extends CommonComputeHandler { task.metadata, task.additionalViewers, task.queueMaxWaitTime, - task.encryptedDockerRegistryAuth + task.encryptedDockerRegistryAuth, + task.outputBucketId ) CORE_LOGGER.logMessage( diff --git a/src/components/core/compute/utils.ts b/src/components/core/compute/utils.ts index 603368fa7..5f92fda04 100644 --- a/src/components/core/compute/utils.ts +++ b/src/components/core/compute/utils.ts @@ -253,3 +253,54 @@ export async function validateOutput( } } } + +export async function validateOutputBucket( + node: OceanNode, + outputBucketId: string, + output: string, + consumerAddress: string +): Promise { + const success: P2PCommandResponse = { + status: { + httpStatus: 200, + error: null, + headers: null + }, + stream: null + } + const failure = (httpStatus: number, error: string): P2PCommandResponse => ({ + status: { + httpStatus, + error, + headers: null + }, + stream: null + }) + + if (!outputBucketId) { + return success + } + if (output) { + return failure(400, 'output and outputBucketId are mutually exclusive') + } + const persistentStorage = node.getPersistentStorage() + if (!persistentStorage) { + return failure(400, 'Persistent storage is not enabled on this node') + } + try { + persistentStorage.validateBucket(outputBucketId) + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + return failure(400, message) + } + try { + await persistentStorage.assertConsumerAllowedForBucket( + consumerAddress, + outputBucketId + ) + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + return failure(403, message) + } + return success +} diff --git a/src/components/database/sqliteCompute.ts b/src/components/database/sqliteCompute.ts index 1c2462d5f..5a1006636 100644 --- a/src/components/database/sqliteCompute.ts +++ b/src/components/database/sqliteCompute.ts @@ -48,6 +48,7 @@ function getInternalStructure(job: DBComputeJob): any { algoDuration: job.algoDuration, queueMaxWaitTime: job.queueMaxWaitTime, output: job.output, + outputBucketId: job.outputBucketId, jobIdHash: job.jobIdHash, buildStartTimestamp: job.buildStartTimestamp, buildStopTimestamp: job.buildStopTimestamp diff --git a/src/components/httpRoutes/compute.ts b/src/components/httpRoutes/compute.ts index 3411253d0..1885678df 100644 --- a/src/components/httpRoutes/compute.ts +++ b/src/components/httpRoutes/compute.ts @@ -89,6 +89,9 @@ computeRoutes.post(`${SERVICES_API_BASE_PATH}/compute`, async (req, res) => { if (req.body.output) { startComputeTask.output = req.body.output } + if (req.body.outputBucketId) { + startComputeTask.outputBucketId = req.body.outputBucketId + } const response = await new PaidComputeStartHandler(req.oceanNode).handle( startComputeTask @@ -138,6 +141,9 @@ computeRoutes.post(`${SERVICES_API_BASE_PATH}/freeCompute`, async (req, res) => if (req.body.output) { startComputeTask.output = req.body.output } + if (req.body.outputBucketId) { + startComputeTask.outputBucketId = req.body.outputBucketId + } const response = await new FreeComputeStartHandler(req.oceanNode).handle( startComputeTask diff --git a/src/components/persistentStorage/PersistentStorageFactory.ts b/src/components/persistentStorage/PersistentStorageFactory.ts index 571524563..b653c56f3 100644 --- a/src/components/persistentStorage/PersistentStorageFactory.ts +++ b/src/components/persistentStorage/PersistentStorageFactory.ts @@ -181,6 +181,11 @@ export abstract class PersistentStorageFactory { consumerAddress?: string ): Promise + public abstract getDockerOutputMountObject( + bucketId: string, + consumerAddress: string + ): Promise + // common functions async getBucketAccessList(bucketId: string): Promise { try { diff --git a/src/components/persistentStorage/PersistentStorageLocalFS.ts b/src/components/persistentStorage/PersistentStorageLocalFS.ts index 65017465c..5bf515450 100644 --- a/src/components/persistentStorage/PersistentStorageLocalFS.ts +++ b/src/components/persistentStorage/PersistentStorageLocalFS.ts @@ -224,5 +224,23 @@ export class PersistentStorageLocalFS extends PersistentStorageFactory { ReadOnly: true } } + + async getDockerOutputMountObject( + bucketId: string, + consumerAddress: string + ): Promise { + await this.ensureBucketExists(bucketId) + await this.assertConsumerAllowedForBucket(consumerAddress, bucketId) + + const source = path.resolve(this.bucketPath(bucketId)) + await fsp.chmod(source, 0o777) + + return { + Type: 'bind', + Source: source, + Target: '/data/outputs', + ReadOnly: false + } + } } /* eslint-enable security/detect-non-literal-fs-filename */ diff --git a/src/components/persistentStorage/PersistentStorageS3.ts b/src/components/persistentStorage/PersistentStorageS3.ts index d0bf4ed99..a823cff2c 100644 --- a/src/components/persistentStorage/PersistentStorageS3.ts +++ b/src/components/persistentStorage/PersistentStorageS3.ts @@ -84,4 +84,12 @@ export class PersistentStorageS3 extends PersistentStorageFactory { ): Promise { throw new Error('PersistentStorageS3 is not implemented yet') } + + // eslint-disable-next-line require-await + async getDockerOutputMountObject( + _bucketId: string, + _consumerAddress: string + ): Promise { + throw new Error('PersistentStorageS3 is not implemented yet') + } } diff --git a/src/test/integration/compute.test.ts b/src/test/integration/compute.test.ts index bd61f9c54..e3577e6b6 100644 --- a/src/test/integration/compute.test.ts +++ b/src/test/integration/compute.test.ts @@ -2602,6 +2602,173 @@ describe('********** Compute', () => { 'expected access-denied style message' ) }) + + describe('Compute output in bucket (outputBucketId)', function () { + let outputBucketId: string + const seedFileName = 'seed.txt' + const resultFileName = 'bucket-result.txt' + const seedContent = 'OUTPUT_BUCKET_SEED\n' + + const bucketResultPath = () => + path.join(psRoot, 'buckets', outputBucketId, resultFileName) + + const copyRawcode = (inputFileName: string, appendSuffix = '') => + [ + "const fs = require('fs');", + `const c = fs.readFileSync('/data/persistentStorage/${outputBucketId}/${inputFileName}', 'utf8');`, + `fs.writeFileSync('/data/outputs/${resultFileName}', c + '${appendSuffix}', 'utf8');` + ].join('\n') + + const startFreeJob = async ( + inputFileName: string, + rawcode: string, + opts: { account?: typeof consumerAccount; output?: string } = {} + ) => { + const account = opts.account ?? consumerAccount + const consumerAddress = await account.getAddress() + const nonce = Date.now().toString() + const signature = await safeSign( + account, + createHashForSignature( + consumerAddress, + nonce, + PROTOCOL_COMMANDS.FREE_COMPUTE_START + ) + ) + const startTask: FreeComputeStartCommand = { + command: PROTOCOL_COMMANDS.FREE_COMPUTE_START, + consumerAddress, + signature, + nonce, + environment: firstEnv.id, + queueMaxWaitTime: 0, + datasets: [ + { + fileObject: { + type: 'nodePersistentStorage', + bucketId: outputBucketId, + fileName: inputFileName + } as any + } + ], + algorithm: { + meta: { ...publishedAlgoDataset.ddo.metadata.algorithm, rawcode } + }, + output: opts.output ?? null, + outputBucketId + } + return new FreeComputeStartHandler(oceanNode).handle(startTask) + } + + const startFreeJobAndWait = async (inputFileName: string, rawcode: string) => { + const startRes = await startFreeJob(inputFileName, rawcode) + assert.equal(startRes.status.httpStatus, 200, String(startRes.status.error)) + const started = await streamToObject(startRes.stream as Readable) + const fullJobId = started[0].jobId as string + const job = await waitForComputeJobFinished(oceanNode, fullJobId, 180_000) + return { job, innerJobId: fullJobId.slice(fullJobId.indexOf('-') + 1) } + } + + before(async function () { + const consumerAddress = await consumerAccount.getAddress() + let nonce = Date.now().toString() + let signature = await safeSign( + consumerAccount, + createHashForSignature( + consumerAddress, + nonce, + PROTOCOL_COMMANDS.PERSISTENT_STORAGE_CREATE_BUCKET + ) + ) + const createRes = await new PersistentStorageCreateBucketHandler( + oceanNode + ).handle({ + command: PROTOCOL_COMMANDS.PERSISTENT_STORAGE_CREATE_BUCKET, + consumerAddress, + signature, + nonce, + accessLists: [], + authorization: undefined + } as any) + assert.equal(createRes.status.httpStatus, 200) + outputBucketId = (await streamToObject(createRes.stream as Readable)).bucketId + + nonce = Date.now().toString() + signature = await safeSign( + consumerAccount, + createHashForSignature( + consumerAddress, + nonce, + PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPLOAD_FILE + ) + ) + const uploadRes = await new PersistentStorageUploadFileHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPLOAD_FILE, + consumerAddress, + signature, + nonce, + bucketId: outputBucketId, + fileName: seedFileName, + stream: Readable.from(Buffer.from(seedContent)) + } as any) + assert.equal(uploadRes.status.httpStatus, 200) + }) + + /* eslint-disable security/detect-non-literal-fs-filename -- test paths */ + it('stores job results directly in the bucket as individual files (no outputs.tar)', async function () { + this.timeout(300_000) + const { job, innerJobId } = await startFreeJobAndWait( + seedFileName, + copyRawcode(seedFileName) + ) + + assert.equal(await fsp.readFile(bucketResultPath(), 'utf8'), seedContent) + const files = await oceanNode + .getPersistentStorage() + .listFiles(outputBucketId, await consumerAccount.getAddress()) + assert( + files.some((f) => f.name === resultFileName), + 'result file should be listed in the bucket' + ) + + const base = (psDockerEngine as any).getStoragePath() as string + const outputsTarPath = path.join(base, innerJobId, 'data/outputs/outputs.tar') + assert(!existsSync(outputsTarPath), 'outputs.tar should not exist') + assert( + !(job.results || []).some((r: any) => r.type === 'output'), + 'no output entry expected in results' + ) + }) + + it('chains a bucket output file as input of a next job and overwrites on collision', async function () { + this.timeout(300_000) + await startFreeJobAndWait(resultFileName, copyRawcode(resultFileName, 'CHAINED')) + + assert.equal( + await fsp.readFile(bucketResultPath(), 'utf8'), + seedContent + 'CHAINED' + ) + const entries = await fsp.readdir(path.join(psRoot, 'buckets', outputBucketId)) + assert.deepEqual(entries.sort(), [resultFileName, seedFileName].sort()) + }) + /* eslint-enable security/detect-non-literal-fs-filename */ + + it('rejects a start request with both output and outputBucketId', async function () { + const res = await startFreeJob(seedFileName, "console.log('noop');", { + output: '0xdeadbeef' + }) + assert.equal(res.status.httpStatus, 400, String(res.status.error)) + assert.include(String(res.status.error), 'mutually exclusive') + }) + + it('denies compute start when consumer is not allowed on the output bucket', async function () { + const res = await startFreeJob(seedFileName, "console.log('noop');", { + account: nonAllowedAccount + }) + assert.equal(res.status.httpStatus, 403, String(res.status.error)) + assert.include((res.status.error || '').toLowerCase(), 'allow') + }) + }) }) })