From e276ea2deaa430f88341cb16f824fa9119b86061 Mon Sep 17 00:00:00 2001 From: Grzegorz Godlewski Date: Thu, 9 May 2024 15:30:44 +0200 Subject: [PATCH] refactor(market): remove old Demand class and leave the new implementation * refactor(market): remove old Demand class and leave the new implementation * refactor: applied self review remarks * chore: naming fixes and separation of DemandRequestBody and DemandBodyPrototype BREAKING CHANGE: This refactor removes several deprecated types and modules from the market module in an effort to enhance code readability and maintainability. This includes the deletion of classes such as Package, DemandConfig, and various DemandDirectorConfig classes. --- examples/basic/hello-world-v2.ts | 5 +- examples/basic/hello-world.ts | 9 +- examples/deployment/new-api.ts | 20 +- examples/experimental/express/server.ts | 4 +- examples/experimental/job/cancel.ts | 2 +- examples/experimental/job/getJobById.ts | 2 +- examples/experimental/job/waitForResults.ts | 4 +- examples/local-image/serveLocalGvmi.ts | 13 +- examples/market/scan.ts | 4 +- examples/pool/hello-world.ts | 12 +- src/activity/index.ts | 1 + src/experimental/job/job.test.ts | 6 +- src/experimental/job/job.ts | 6 +- src/experimental/new-api/builder.test.ts | 25 +- src/experimental/new-api/builder.ts | 4 +- src/experimental/new-api/deployment.ts | 2 +- src/golem-network.ts | 6 +- src/index.ts | 21 +- src/market/api.ts | 8 +- src/market/builder.ts | 108 -------- src/market/config.test.ts | 57 ---- src/market/config.ts | 69 ----- src/market/demand.ts | 260 +++--------------- src/market/demand/demand-body-builder.ts | 114 ++++++++ .../activity-demand-director-config.ts | 38 +++ .../activity-demand-director.test.ts | 74 +++++ .../directors/activity-demand-director.ts | 86 ++++++ src/market/demand/directors/base-config.ts | 10 + .../basic-demand-director-config.test.ts | 12 + .../directors/basic-demand-director-config.ts | 24 ++ .../demand/directors/basic-demand-director.ts | 18 ++ .../payment-demand-director-config.test.ts | 27 ++ .../payment-demand-director-config.ts | 34 +++ .../directors/payment-demand-director.ts | 29 ++ src/market/demand/options.ts | 61 ++++ src/market/factory.test.ts | 57 ---- src/market/factory.ts | 85 ------ src/market/index.ts | 7 +- src/market/market.module.test.ts | 126 ++++++--- src/market/market.module.ts | 151 +++++----- src/market/package/config.ts | 71 ----- src/market/package/index.ts | 1 - src/market/package/package.ts | 173 ------------ src/market/proposal.test.ts | 3 +- src/market/proposal.ts | 45 +-- src/shared/utils/index.ts | 1 - .../yagna/adapters/market-api-adapter.test.ts | 129 ++++----- .../yagna/adapters/market-api-adapter.ts | 54 +++- .../yagna/repository/demand-repository.ts | 10 +- .../yagna/repository/proposal-repository.ts | 4 +- src/shared/yagna/yagnaApi.ts | 3 +- tests/e2e/activityPool.spec.ts | 16 +- tests/e2e/express.spec.ts | 4 +- tests/unit/agreement_pool_service.test.ts | 4 +- tests/unit/decorations_builder.test.ts | 100 +++---- tests/unit/demand.test.ts | 128 --------- tests/unit/package.test.ts | 67 ----- 57 files changed, 1014 insertions(+), 1400 deletions(-) delete mode 100644 src/market/builder.ts delete mode 100644 src/market/config.test.ts delete mode 100644 src/market/config.ts create mode 100644 src/market/demand/demand-body-builder.ts create mode 100644 src/market/demand/directors/activity-demand-director-config.ts create mode 100644 src/market/demand/directors/activity-demand-director.test.ts create mode 100644 src/market/demand/directors/activity-demand-director.ts create mode 100644 src/market/demand/directors/base-config.ts create mode 100644 src/market/demand/directors/basic-demand-director-config.test.ts create mode 100644 src/market/demand/directors/basic-demand-director-config.ts create mode 100644 src/market/demand/directors/basic-demand-director.ts create mode 100644 src/market/demand/directors/payment-demand-director-config.test.ts create mode 100644 src/market/demand/directors/payment-demand-director-config.ts create mode 100644 src/market/demand/directors/payment-demand-director.ts create mode 100644 src/market/demand/options.ts delete mode 100644 src/market/factory.test.ts delete mode 100644 src/market/factory.ts delete mode 100644 src/market/package/config.ts delete mode 100644 src/market/package/index.ts delete mode 100644 src/market/package/package.ts delete mode 100644 tests/unit/demand.test.ts delete mode 100644 tests/unit/package.test.ts diff --git a/examples/basic/hello-world-v2.ts b/examples/basic/hello-world-v2.ts index 3e84e5ac3..d3197017e 100644 --- a/examples/basic/hello-world-v2.ts +++ b/examples/basic/hello-world-v2.ts @@ -13,10 +13,7 @@ import { pinoPrettyLogger } from "@golem-sdk/pino-logger"; const lease = await glm.oneOf({ demand: { - imageTag: "golem/alpine:latest", - minCpuCores: 4, - minMemGib: 8, - minStorageGib: 16, + activity: { imageTag: "golem/alpine:latest" }, }, market: { rentHours: 12, diff --git a/examples/basic/hello-world.ts b/examples/basic/hello-world.ts index 0f48e48e4..2b2651431 100644 --- a/examples/basic/hello-world.ts +++ b/examples/basic/hello-world.ts @@ -16,11 +16,8 @@ import { pinoPrettyLogger } from "@golem-sdk/pino-logger"; const demand = { demand: { - imageTag: "golem/alpine:latest", - resources: { - minCpu: 4, - minMemGib: 8, - minStorageGib: 16, + activity: { + imageTag: "golem/alpine:latest", }, }, market: { @@ -38,7 +35,7 @@ import { pinoPrettyLogger } from "@golem-sdk/pino-logger"; }); const payerDetails = await glm.payment.getPayerDetails(); - const demandSpecification = await glm.market.buildDemand(demand.demand, payerDetails); + const demandSpecification = await glm.market.buildDemandDetails(demand.demand, payerDetails); const proposal$ = glm.market.startCollectingProposals({ demandSpecification, bufferSize: 15, diff --git a/examples/deployment/new-api.ts b/examples/deployment/new-api.ts index 27c7d8446..c31598c27 100644 --- a/examples/deployment/new-api.ts +++ b/examples/deployment/new-api.ts @@ -21,13 +21,9 @@ async function main() { }) .createActivityPool("app", { demand: { - imageTag: "golem/node:latest", - // image: "golem/node:20", - // image: "http://golem.io/node:20", - // imageHash: "0x30984084039480493840", - minCpuCores: 1, - minMemGib: 2, - minStorageGib: 10, + activity: { + imageTag: "golem/node:latest", + }, }, market: { rentHours: 12, @@ -48,10 +44,12 @@ async function main() { }) .createActivityPool("db", { demand: { - imageTag: "golem/alpine:latest", - minCpuCores: 1, - minMemGib: 2, - minStorageGib: 4, + activity: { + imageTag: "golem/alpine:latest", + minCpuCores: 1, + minMemGib: 2, + minStorageGib: 4, + }, }, market: { rentHours: 12 /* REQUIRED */, diff --git a/examples/experimental/express/server.ts b/examples/experimental/express/server.ts index dc15f0b18..b195802cd 100644 --- a/examples/experimental/express/server.ts +++ b/examples/experimental/express/server.ts @@ -25,7 +25,9 @@ app.post("/tts", async (req, res) => { } const job = golemClient.createJob({ demand: { - imageTag: "severyn/espeak:latest", + activity: { + imageTag: "severyn/espeak:latest", + }, }, market: {}, // TODO: This should be optional payment: { diff --git a/examples/experimental/job/cancel.ts b/examples/experimental/job/cancel.ts index f36331e4f..d7d37eb35 100644 --- a/examples/experimental/job/cancel.ts +++ b/examples/experimental/job/cancel.ts @@ -11,7 +11,7 @@ async function main() { const job = golem.createJob({ demand: { - imageTag: "severyn/espeak:latest", + activity: { imageTag: "severyn/espeak:latest" }, }, market: {}, // TODO: This should be optional payment: { diff --git a/examples/experimental/job/getJobById.ts b/examples/experimental/job/getJobById.ts index 5587cbca0..d7ad7a0ea 100644 --- a/examples/experimental/job/getJobById.ts +++ b/examples/experimental/job/getJobById.ts @@ -9,7 +9,7 @@ const golem = new JobManager({ function startJob() { const job = golem.createJob({ demand: { - imageTag: "severyn/espeak:latest", + activity: { imageTag: "severyn/espeak:latest" }, }, market: {}, // TODO: This should be optional payment: { diff --git a/examples/experimental/job/waitForResults.ts b/examples/experimental/job/waitForResults.ts index 4c34c84c6..0266ac5c4 100644 --- a/examples/experimental/job/waitForResults.ts +++ b/examples/experimental/job/waitForResults.ts @@ -11,7 +11,9 @@ async function main() { const job = golem.createJob({ demand: { - imageTag: "severyn/espeak:latest", + activity: { + imageTag: "severyn/espeak:latest", + }, }, market: {}, // TODO: This should be optional payment: { diff --git a/examples/local-image/serveLocalGvmi.ts b/examples/local-image/serveLocalGvmi.ts index 5a8f762ad..250167266 100644 --- a/examples/local-image/serveLocalGvmi.ts +++ b/examples/local-image/serveLocalGvmi.ts @@ -20,13 +20,10 @@ const getImagePath = (path: string) => fileURLToPath(new URL(path, import.meta.u const demand = { demand: { - // Here you supply the path to the GVMI file that you want to deploy and use - // using the file:// protocol will make the SDK switch to "GVMI" serving mode - imageUrl: `file://${getImagePath("./alpine.gvmi")}`, - resources: { - minCpu: 4, - minMemGib: 8, - minStorageGib: 16, + activity: { + // Here you supply the path to the GVMI file that you want to deploy and use + // using the file:// protocol will make the SDK switch to "GVMI" serving mode + imageUrl: `file://${getImagePath("./alpine.gvmi")}`, }, }, market: { @@ -44,7 +41,7 @@ const getImagePath = (path: string) => fileURLToPath(new URL(path, import.meta.u }); const payerDetails = await glm.payment.getPayerDetails(); - const demandSpecification = await glm.market.buildDemand(demand.demand, payerDetails); + const demandSpecification = await glm.market.buildDemandDetails(demand.demand, payerDetails); const proposal$ = glm.market.startCollectingProposals({ demandSpecification, bufferSize: 15, diff --git a/examples/market/scan.ts b/examples/market/scan.ts index b942399cb..8ae4e8ebc 100644 --- a/examples/market/scan.ts +++ b/examples/market/scan.ts @@ -19,9 +19,9 @@ import { GolemNetwork, ProposalNew } from "@golem-sdk/golem-js"; await glm.connect(); const payerDetails = await glm.payment.getPayerDetails(); - const demandSpecification = await glm.market.buildDemand( + const demandSpecification = await glm.market.buildDemandDetails( { - imageTag: "golem/alpine:latest", + activity: { imageTag: "golem/alpine:latest" }, }, payerDetails, ); diff --git a/examples/pool/hello-world.ts b/examples/pool/hello-world.ts index 64b785b68..c916fafca 100644 --- a/examples/pool/hello-world.ts +++ b/examples/pool/hello-world.ts @@ -21,10 +21,12 @@ import { pinoPrettyLogger } from "@golem-sdk/pino-logger"; const demandOptions = { demand: { - imageTag: "golem/alpine:latest", - minCpuCores: 1, - minMemGib: 1, - minStorageGib: 2, + activity: { + imageTag: "golem/alpine:latest", + minCpuCores: 1, + minMemGib: 1, + minStorageGib: 2, + }, }, market: { rentHours: 12, @@ -42,7 +44,7 @@ import { pinoPrettyLogger } from "@golem-sdk/pino-logger"; const proposalPool = new DraftOfferProposalPool({ minCount: 1 }); const payerDetails = await glm.payment.getPayerDetails(); - const demandSpecification = await glm.market.buildDemand(demandOptions.demand, payerDetails); + const demandSpecification = await glm.market.buildDemandDetails(demandOptions.demand, payerDetails); const proposals$ = glm.market.startCollectingProposals({ demandSpecification, diff --git a/src/activity/index.ts b/src/activity/index.ts index 8f27ddd5c..899c3da33 100644 --- a/src/activity/index.ts +++ b/src/activity/index.ts @@ -3,3 +3,4 @@ export { Result } from "./results"; export { ExecutionConfig } from "./config"; export { ActivityPool, ActivityPoolOptions, ActivityPoolEvents } from "./activity-pool"; export * from "./activity.module"; +export * from "./work"; diff --git a/src/experimental/job/job.test.ts b/src/experimental/job/job.test.ts index ac7480000..d171f2978 100644 --- a/src/experimental/job/job.test.ts +++ b/src/experimental/job/job.test.ts @@ -3,7 +3,6 @@ import { Agreement, AgreementPoolService, IActivityApi } from "../../agreement"; import { WorkContext } from "../../activity/work"; import { NetworkNode, NetworkService } from "../../network"; import { Activity } from "../../activity"; -import { Package } from "../../market/package"; import { anything, imock, instance, mock, verify, when } from "@johanblumenberg/ts-mockito"; import { Logger } from "../../shared/utils"; import { GolemNetwork } from "../../golem-network"; @@ -24,7 +23,6 @@ describe.skip("Job", () => { it("stops the activity and releases the agreement when canceled", async () => { jest.spyOn(AgreementPoolService.prototype, "run").mockResolvedValue(); jest.spyOn(NetworkService.prototype, "run").mockResolvedValue(); - jest.spyOn(Package, "create").mockReturnValue({} as unknown as Package); jest.spyOn(WorkContext.prototype, "before").mockResolvedValue(); jest.spyOn(AgreementPoolService.prototype, "releaseAgreement").mockResolvedValue(); jest.spyOn(NetworkService.prototype, "addNode").mockResolvedValue({} as unknown as NetworkNode); @@ -46,7 +44,9 @@ describe.skip("Job", () => { instance(mock(GolemNetwork)), { demand: { - imageTag: "test_image", + activity: { + imageTag: "test_image", + }, }, market: {}, payment: { diff --git a/src/experimental/job/job.ts b/src/experimental/job/job.ts index 96b75cabe..bffc3b9a8 100644 --- a/src/experimental/job/job.ts +++ b/src/experimental/job/job.ts @@ -3,11 +3,11 @@ import { LegacyAgreementServiceOptions } from "../../agreement"; import { DemandSpec } from "../../market"; import { NetworkOptions } from "../../network"; import { PaymentModuleOptions } from "../../payment"; -import { PackageOptions } from "../../market/package"; import { EventEmitter } from "eventemitter3"; import { GolemAbortError, GolemUserError } from "../../shared/error/golem-error"; import { GolemNetwork } from "../../golem-network"; import { Logger } from "../../shared/utils"; +import { ActivityDemandDirectorConfigOptions } from "../../market/demand/options"; export enum JobState { New = "new", @@ -22,7 +22,7 @@ export type RunJobOptions = { payment?: PaymentModuleOptions; agreement?: LegacyAgreementServiceOptions; network?: NetworkOptions; - package?: PackageOptions; + activity?: ActivityDemandDirectorConfigOptions; work?: WorkOptions; }; @@ -72,6 +72,7 @@ export class Job { * @param id * @param glm * @param demandSpec + * @param logger */ constructor( public readonly id: string, @@ -94,7 +95,6 @@ export class Job { * If you want to run multiple jobs in parallel, you can use {@link GolemNetwork.createJob} to create multiple jobs and run them in parallel. * * @param workOnGolem - Your worker function that will be run on the Golem Network. - * @param demandSpec - Specify the image and resource for the demand that will be used to find resources on Golem Network. */ startWork(workOnGolem: Worker) { this.logger.debug("Staring work in a Job"); diff --git a/src/experimental/new-api/builder.test.ts b/src/experimental/new-api/builder.test.ts index 03457345c..b1fb4ca22 100644 --- a/src/experimental/new-api/builder.test.ts +++ b/src/experimental/new-api/builder.test.ts @@ -12,19 +12,23 @@ describe("Deployment builder", () => { builder .createActivityPool("my-pool", { demand: { - imageTag: "image", - minCpuCores: 1, - minMemGib: 1, - minStorageGib: 1, + activity: { + imageTag: "image", + minCpuCores: 1, + minMemGib: 1, + minStorageGib: 1, + }, }, market: {}, }) .createActivityPool("my-pool", { demand: { - imageTag: "image", - minCpuCores: 1, - minMemGib: 1, - minStorageGib: 1, + activity: { + imageTag: "image", + minCpuCores: 1, + minMemGib: 1, + minStorageGib: 1, + }, }, market: {}, }); @@ -51,10 +55,7 @@ describe("Deployment builder", () => { }) .createActivityPool("my-pool", { demand: { - imageTag: "image", - minCpuCores: 1, - minMemGib: 1, - minStorageGib: 1, + activity: { imageTag: "image", minCpuCores: 1, minMemGib: 1, minStorageGib: 1 }, }, market: {}, deployment: { diff --git a/src/experimental/new-api/builder.ts b/src/experimental/new-api/builder.ts index 569d9210f..85b4c5588 100644 --- a/src/experimental/new-api/builder.ts +++ b/src/experimental/new-api/builder.ts @@ -5,7 +5,7 @@ import { GolemNetwork } from "../../golem-network"; import { validateDeployment } from "./validate-deployment"; import { MarketOptions } from "../../market"; import { PaymentModuleOptions } from "../../payment"; -import { DemandOptionsNew } from "../../market/demand"; +import { BuildDemandOptions } from "../../market/demand"; interface DeploymentOptions { replicas?: number | { min: number; max: number }; @@ -13,7 +13,7 @@ interface DeploymentOptions { } export interface CreateActivityPoolOptions { - demand: DemandOptionsNew; + demand: BuildDemandOptions; market: MarketOptions; deployment?: DeploymentOptions; payment?: PaymentModuleOptions; diff --git a/src/experimental/new-api/deployment.ts b/src/experimental/new-api/deployment.ts index 7a2db52f2..d51854f9e 100644 --- a/src/experimental/new-api/deployment.ts +++ b/src/experimental/new-api/deployment.ts @@ -166,7 +166,7 @@ export class Deployment { for (const pool of this.components.activityPools) { const { demandBuildOptions, agreementPoolOptions, activityPoolOptions } = this.prepareParams(pool.options); - const demandSpecification = await this.modules.market.buildDemand(demandBuildOptions.demand, payerDetails); + const demandSpecification = await this.modules.market.buildDemandDetails(demandBuildOptions.demand, payerDetails); const proposalPool = new DraftOfferProposalPool(); const proposalSubscription = this.modules.market diff --git a/src/golem-network.ts b/src/golem-network.ts index 56e5e0f79..88b213b83 100644 --- a/src/golem-network.ts +++ b/src/golem-network.ts @@ -1,7 +1,7 @@ import { DataTransferProtocol, DeploymentOptions, GolemDeploymentBuilder, MarketOptions } from "./experimental"; import { defaultLogger, Logger, YagnaApi } from "./shared/utils"; import { - DemandNew, + Demand, DemandSpec, DraftOfferProposalPool, MarketApi, @@ -125,7 +125,7 @@ export class GolemNetwork { this.storageProvider = this.createStorageProvider(); - const demandCache = new CacheService(); + const demandCache = new CacheService(); const proposalCache = new CacheService(); const demandRepository = new DemandRepository(this.yagna.market, demandCache); @@ -237,7 +237,7 @@ export class GolemNetwork { logger: this.logger, }); const payerDetails = await this.payment.getPayerDetails(); - const demandSpecification = await this.market.buildDemand(demand.demand, payerDetails); + const demandSpecification = await this.market.buildDemandDetails(demand.demand, payerDetails); const proposalSubscription = this.market .startCollectingProposals({ diff --git a/src/index.ts b/src/index.ts index 77acb207e..d6fae5752 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,18 @@ -export * from "./shared/storage"; -export * from "./activity"; -export * from "./agreement"; +// High-level entry points +export * from "./golem-network"; + +// Low level entry points for advanced users export * from "./market"; -export * from "./market/package"; export * from "./payment"; export * from "./network"; -export * from "./shared/utils"; -export * from "./shared/yagna"; -export * from "./activity/work"; +export * from "./activity"; +export * from "./agreement"; + +// Necessary domain entities for users to consume export * from "./shared/error/golem-error"; export * from "./network/tcpProxy"; -export * from "./golem-network"; + +// Internals +export * from "./shared/utils"; +export * from "./shared/yagna"; +export * from "./shared/storage"; diff --git a/src/market/api.ts b/src/market/api.ts index 819b07dd0..1db4d10dd 100644 --- a/src/market/api.ts +++ b/src/market/api.ts @@ -1,5 +1,5 @@ import { Observable } from "rxjs"; -import { DemandNew, DemandSpecification } from "./demand"; +import { Demand, DemandSpecification } from "./demand"; import YaTsClient from "ya-ts-client"; import { ProposalNew } from "./proposal"; @@ -15,17 +15,17 @@ export interface MarketApi { * refreshed periodically (see `refreshDemand` method). * Use `unpublishDemand` to remove the demand from the market. */ - publishDemandSpecification(specification: DemandSpecification): Promise; + publishDemandSpecification(specification: DemandSpecification): Promise; /** * Remove the given demand from the market. */ - unpublishDemand(demand: DemandNew): Promise; + unpublishDemand(demand: Demand): Promise; /** * Creates a new observable that emits proposal events related to the given demand. */ - observeProposalEvents(demand: DemandNew): Observable; + observeProposalEvents(demand: Demand): Observable; /** * Sends a counter-proposal to the given proposal. Returns the newly created counter-proposal. diff --git a/src/market/builder.ts b/src/market/builder.ts deleted file mode 100644 index ba3bba930..000000000 --- a/src/market/builder.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { MarketApi, PaymentApi } from "ya-ts-client"; -import { GolemInternalError } from "../shared/error/golem-error"; -import { DemandSpecification } from "./demand"; - -/** - * Properties and constraints to be added to a market object (i.e. a demand or an offer). - */ -export type MarketDecoration = { - properties: Array<{ key: string; value: string | number | boolean }>; - constraints: Array; -}; - -/** - * @hidden - */ -export enum ComparisonOperator { - Eq = "=", - Lt = "<", - Gt = ">", - GtEq = ">=", - LtEq = "<=", -} - -type Constraint = { - key: string; - value: string | number; - comparisonOperator: ComparisonOperator; -}; - -/** - * A helper class for creating market decorations for `Demand` published on the market. - * @hidden - */ -export class DecorationsBuilder { - private properties: Array = []; - private constraints: Array = []; - - addProperty(key: string, value: string | number | boolean) { - const findIndex = this.properties.findIndex((prop) => prop.key === key); - if (findIndex >= 0) { - this.properties[findIndex] = { key, value }; - } else { - this.properties.push({ key, value }); - } - return this; - } - addConstraint(key: string, value: string | number, comparisonOperator = ComparisonOperator.Eq) { - this.constraints.push({ key, value, comparisonOperator }); - return this; - } - getDecorations(): MarketDecoration { - return { - properties: this.properties, - constraints: this.constraints.map((c) => `(${c.key + c.comparisonOperator + c.value})`), - }; - } - /** - * @deprecated - **/ - getDemandRequest(): MarketApi.DemandOfferBaseDTO { - const decorations = this.getDecorations(); - let constraints: string; - if (!decorations.constraints.length) constraints = "(&)"; - else if (decorations.constraints.length == 1) constraints = decorations.constraints[0]; - else constraints = `(&${decorations.constraints.join("\n\t")})`; - const properties: Record = {}; - decorations.properties.forEach((prop) => (properties[prop.key] = prop.value)); - return { constraints, properties }; - } - - getDemandSpecification(paymentPlatform: string, expirationMs: number): DemandSpecification { - const decorations = this.getDemandRequest(); - return new DemandSpecification(decorations, paymentPlatform, expirationMs); - } - - private parseConstraint(constraint: string): Constraint { - for (const key in ComparisonOperator) { - const value = ComparisonOperator[key as keyof typeof ComparisonOperator]; - const parsedConstraint = constraint.slice(1, -1).split(value); - if (parsedConstraint.length === 2) { - return { - key: parsedConstraint[0], - value: parsedConstraint[1], - comparisonOperator: value, - }; - } - } - throw new GolemInternalError(`Unable to parse constraint "${constraint}"`); - } - addDecoration(decoration: MarketDecoration) { - if (decoration.properties) { - decoration.properties.forEach((prop) => { - this.addProperty(prop.key, prop.value); - }); - } - if (decoration.constraints) { - decoration.constraints.forEach((cons) => { - const { key, value, comparisonOperator } = { ...this.parseConstraint(cons) }; - this.addConstraint(key, value, comparisonOperator); - }); - } - return this; - } - addDecorations(decorations: MarketDecoration[]) { - decorations.forEach((d) => this.addDecoration(d)); - return this; - } -} diff --git a/src/market/config.test.ts b/src/market/config.test.ts deleted file mode 100644 index 00ad6fea1..000000000 --- a/src/market/config.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { DemandConfig } from "./config"; - -describe("Demand Config", () => { - describe("Positive cases", () => { - it("It will accept proper config values without an error", () => { - const config = new DemandConfig({ - expirationSec: 30 * 60, - debitNotesAcceptanceTimeoutSec: 20, - midAgreementPaymentTimeoutSec: 12 * 60 * 60, - }); - - expect(config).toBeDefined(); - }); - }); - describe("Negative cases", () => { - const INVALID_VALUES = [-1, 0, 1.23]; - - describe("Expiration time configuration", () => { - test.each(INVALID_VALUES)("It should throw an error when someone specifies %d as expiration time", (value) => { - expect( - () => - new DemandConfig({ - expirationSec: value, - }), - ).toThrow("The demand expiration time has to be a positive integer"); - }); - }); - - describe("Debit note acceptance timeout configuration", () => { - test.each(INVALID_VALUES)( - "It should throw an error when someone specifies %d as debit note accept timeout", - (value) => { - expect( - () => - new DemandConfig({ - debitNotesAcceptanceTimeoutSec: value, - }), - ).toThrow("The debit note acceptance timeout time has to be a positive integer"); - }, - ); - }); - - describe("Mid-agreement payments timeout configuration", () => { - test.each(INVALID_VALUES)( - "It should throw an error when someone specifies %d as mid-agreement payment timeout", - (value) => { - expect( - () => - new DemandConfig({ - midAgreementPaymentTimeoutSec: value, - }), - ).toThrow("The mid-agreement payment timeout time has to be a positive integer"); - }, - ); - }); - }); -}); diff --git a/src/market/config.ts b/src/market/config.ts deleted file mode 100644 index 9f67ebf5d..000000000 --- a/src/market/config.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { DemandOptions } from "./demand"; -import { defaultLogger, EnvUtils, Logger, YagnaOptions } from "../shared/utils"; -import { GolemConfigError } from "../shared/error/golem-error"; -import { acceptAll } from "./strategy"; - -const DEFAULTS = { - subnetTag: "public", - maxOfferEvents: 100, - offerFetchingIntervalSec: 5, - expirationSec: 30 * 60, // 30 min - debitNotesAcceptanceTimeoutSec: 2 * 60, // 2 minutes - midAgreementDebitNoteIntervalSec: 2 * 60, // 2 minutes - midAgreementPaymentTimeoutSec: 12 * 60 * 60, // 12 hours - proposalFilter: acceptAll(), -}; - -/** - * @internal - */ -export class DemandConfig { - public readonly yagnaOptions?: YagnaOptions; - public readonly expirationSec: number; - public readonly subnetTag: string; - public readonly maxOfferEvents: number; - public readonly offerFetchingIntervalSec: number; - public readonly logger: Logger; - public readonly debitNotesAcceptanceTimeoutSec: number; - public readonly midAgreementDebitNoteIntervalSec: number; - public readonly midAgreementPaymentTimeoutSec: number; - - constructor(options?: DemandOptions) { - this.logger = options?.logger || defaultLogger("market"); - - this.subnetTag = options?.subnetTag ?? EnvUtils.getYagnaSubnet() ?? DEFAULTS.subnetTag; - this.offerFetchingIntervalSec = options?.offerFetchingIntervalSec ?? DEFAULTS.offerFetchingIntervalSec; - this.maxOfferEvents = options?.maxOfferEvents ?? DEFAULTS.maxOfferEvents; - - this.expirationSec = options?.expirationSec ?? DEFAULTS.expirationSec; - - if (!this.isPositiveInt(this.expirationSec)) { - throw new GolemConfigError("The demand expiration time has to be a positive integer"); - } - - this.debitNotesAcceptanceTimeoutSec = - options?.debitNotesAcceptanceTimeoutSec ?? DEFAULTS.debitNotesAcceptanceTimeoutSec; - - if (!this.isPositiveInt(this.debitNotesAcceptanceTimeoutSec)) { - throw new GolemConfigError("The debit note acceptance timeout time has to be a positive integer"); - } - - this.midAgreementDebitNoteIntervalSec = - options?.midAgreementDebitNoteIntervalSec ?? DEFAULTS.midAgreementDebitNoteIntervalSec; - - if (!this.isPositiveInt(this.midAgreementDebitNoteIntervalSec)) { - throw new GolemConfigError("The debit note interval time has to be a positive integer"); - } - - this.midAgreementPaymentTimeoutSec = - options?.midAgreementPaymentTimeoutSec ?? DEFAULTS.midAgreementPaymentTimeoutSec; - - if (!this.isPositiveInt(this.midAgreementPaymentTimeoutSec)) { - throw new GolemConfigError("The mid-agreement payment timeout time has to be a positive integer"); - } - } - - private isPositiveInt(value: number) { - return value > 0 && Number.isInteger(value); - } -} diff --git a/src/market/demand.ts b/src/market/demand.ts index 38fd0492c..fe2b4e6f9 100644 --- a/src/market/demand.ts +++ b/src/market/demand.ts @@ -1,30 +1,22 @@ -import { Package, PackageOptions } from "./package"; -import { Allocation } from "../payment"; -import { DemandFactory } from "./factory"; -import { Proposal } from "./proposal"; -import { defaultLogger, Logger, sleep, YagnaApi, YagnaOptions } from "../shared/utils"; -import { DemandConfig } from "./config"; -import { GolemMarketError, MarketErrorCode } from "./error"; -import { GolemError, GolemPlatformError } from "../shared/error/golem-error"; -import { MarketApi } from "ya-ts-client"; -import { EventEmitter } from "eventemitter3"; +import { ActivityDemandDirectorConfigOptions } from "./demand/options"; +import { BasicDemandDirectorConfigOptions } from "./demand/directors/basic-demand-director-config"; +import { PaymentDemandDirectorConfigOptions } from "./demand/directors/payment-demand-director-config"; +import { DemandBodyPrototype } from "./demand/demand-body-builder"; -export interface DemandEvents { - proposalReceived: (proposal: Proposal) => void; - proposalReceivedError: (error: GolemError) => void; - proposalRejected: (details: { id: string; parentId: string | null; reason: string }) => void; - collectFailed: (details: { id: string; reason: string }) => void; - demandUnsubscribed: (details: { id: string }) => void; -} - -export interface DemandDetails { - properties: Array<{ key: string; value: string | number | boolean }>; - constraints: Array; -} - -export interface DemandOptions { +/** + * This type represents a set of *parameters* that the SDK can set to particular *properties* and *constraints* + * of the demand that's used to subscribe for offers via Yagna + */ +export interface BasicDemandPropertyConfig { + /** + * Specify the name of a subnet of Golem Network that should be considered for offers + * + * Providers and Requestors can agree to a subnet tag, that they can put on their Offer and Demands + * so that they can create "segments" within the network for ease of finding themselves. + * + * Please note that this subnetTag is public and visible to everyone. + */ subnetTag?: string; - yagnaOptions?: YagnaOptions; /** * Determines the expiration time of the offer and the resulting activity in milliseconds. @@ -47,14 +39,7 @@ export interface DemandOptions { * * If your activity is about to operate longer than 10h, you need set both {@link debitNotesAcceptanceTimeoutSec} and {@link midAgreementPaymentTimeoutSec}. */ - expirationSec?: number; - - logger?: Logger; - maxOfferEvents?: number; - - offerFetchingIntervalSec?: number; - - proposalTimeout?: number; + expirationSec: number; /** * Maximum time for allowed provider-sent debit note acceptance (in seconds) @@ -66,7 +51,7 @@ export interface DemandOptions { * _Accepting debit notes during a long activity is considered a good practice in Golem Network._ * The SDK will accept debit notes each 2 minutes by default. */ - debitNotesAcceptanceTimeoutSec?: number; + debitNotesAcceptanceTimeoutSec: number; /** * The interval between provider sent debit notes to negotiate. @@ -81,7 +66,7 @@ export interface DemandOptions { * _Accepting payable debit notes during a long activity is considered a good practice in Golem Network._ * The SDK will accept debit notes each 2 minutes by default. */ - midAgreementDebitNoteIntervalSec?: number; + midAgreementDebitNoteIntervalSec: number; /** * Maximum time to receive payment for any debit note. At the same time, the minimum interval between mid-agreement payments. @@ -94,206 +79,39 @@ export interface DemandOptions { * _Paying in regular intervals for the computation resources is considered a good practice in Golem Network._ * The SDK will issue payments each 12h by default, and you can control this with this setting. */ - midAgreementPaymentTimeoutSec?: number; + midAgreementPaymentTimeoutSec: number; } -export type DemandOptionsNew = PackageOptions & DemandOptions; -type DemandDecoration = { - properties: Record; - constraints: string; -}; +export type BuildDemandOptions = Partial<{ + activity: Partial; + payment: Partial; + basic: Partial; +}>; + +export interface IDemandRepository { + getById(id: string): Demand | undefined; + + add(demand: Demand): Demand; + + getAll(): Demand[]; +} export class DemandSpecification { constructor( - public readonly decoration: DemandDecoration, + /** Represents the low level demand request body that will be used to subscribe for offers matching our "computational resource needs" */ + public readonly prototype: DemandBodyPrototype, public readonly paymentPlatform: string, public readonly expirationSec: number, ) {} } -export class DemandNew { +export class Demand { constructor( public readonly id: string, - public readonly specification: DemandSpecification, + public readonly details: DemandSpecification, ) {} get paymentPlatform(): string { - return this.specification.paymentPlatform; + return this.details.paymentPlatform; } } - -export interface IDemandRepository { - getById(id: string): DemandNew | undefined; - add(demand: DemandNew): DemandNew; - getAll(): DemandNew[]; -} - -/** - * Demand module - an object which can be considered an "open" or public Demand, as it is not directed at a specific Provider, but rather is sent to the market so that the matching mechanism implementation can associate relevant Offers. - * @hidden - * @deprecated - */ -export class Demand { - private isRunning = true; - private logger: Logger; - private proposalReferences: ProposalReference[] = []; - public readonly events = new EventEmitter(); - - /** - * Create demand for given taskPackage - * - * Note: it is an "atomic" operation. - * When the demand is created, the SDK will use it to subscribe for provider offer proposals matching it. - * - * @param taskPackage - * @param allocation - * @param yagnaApi - * @param options - * - * @return Demand - */ - static async create( - taskPackage: Package, - allocation: Allocation, - yagnaApi: YagnaApi, - options?: DemandOptions, - ): Promise { - const factory = new DemandFactory(taskPackage, allocation, yagnaApi, options); - return factory.create(); - } - - /** - * @param id - demand ID - * @param demandRequest - {@link DemandOfferBase} - * @param allocation - {@link Allocation} - * @param yagnaApi - {@link YagnaApi} - * @param options - {@link DemandConfig} - * @hidden - */ - constructor( - public readonly id: string, - public readonly demandRequest: MarketApi.DemandOfferBaseDTO, - public readonly allocation: Allocation, - private yagnaApi: YagnaApi, - public options: DemandConfig, - ) { - this.logger = this.options.logger || defaultLogger("market"); - this.subscribe().catch((e) => this.logger.error("Unable to subscribe for demand events", e)); - } - - /** - * @deprecated Will be removed before release, glue code - */ - toNewEntity(): DemandNew { - return new DemandNew( - this.id, - new DemandSpecification(this.demandRequest, this.allocation.paymentPlatform, this.options.expirationSec * 1000), - ); - } - /** - * Stop subscribing for provider offer proposals for this demand - */ - async unsubscribe() { - this.isRunning = false; - await this.yagnaApi.market.unsubscribeDemand(this.id); - this.events.emit("demandUnsubscribed", { id: this.id }); - this.logger.debug(`Demand unsubscribed`, { id: this.id }); - } - - private findParentProposal(prevProposalId?: string): string | null { - if (!prevProposalId) return null; - for (const proposal of this.proposalReferences) { - if (proposal.counteringProposalId === prevProposalId) { - return proposal.id; - } - } - return null; - } - - private setCounteringProposalReference(id: string, counteringProposalId: string): void { - this.proposalReferences.push(new ProposalReference(id, counteringProposalId)); - } - - private async subscribe() { - this.logger.debug("Subscribing for proposals matched with the demand", { demandId: this.id }); - while (this.isRunning) { - try { - const events = await this.yagnaApi.market.collectOffers( - this.id, - this.options.offerFetchingIntervalSec, - this.options.maxOfferEvents, - ); - for (const event of events as Array) { - this.logger.debug("Received proposal event from subscription", { event }); - if (event.eventType === "ProposalRejectedEvent") { - this.logger.warn(`Proposal rejected`, { reason: event.reason?.message }); - this.events.emit("proposalRejected", { - id: event.proposalId, - parentId: this.findParentProposal(event.proposalId), - reason: event.reason?.message, - }); - continue; - } else if (event.eventType !== "ProposalEvent") continue; - const proposal = new Proposal( - this, - event.proposal.state === "Draft" ? this.findParentProposal(event.proposal.prevProposalId) : null, - this.setCounteringProposalReference.bind(this), - this.yagnaApi.market, - event.proposal, - ); - this.events.emit("proposalReceived", proposal); - } - } catch (error) { - if (this.isRunning) { - const reason = error.response?.data?.message || error; - this.events.emit("collectFailed", { id: this.id, reason }); - this.logger.warn(`Unable to collect offers.`, { reason }); - if (error.code === "ECONNREFUSED") { - // Yagna has been disconnected - this.events.emit( - "proposalReceivedError", - new GolemPlatformError(`Unable to collect offers. ${reason}`, error), - ); - break; - } - if (error.response?.status === 404) { - // Demand has expired - this.events.emit( - "proposalReceivedError", - new GolemMarketError(`Demand expired. ${reason}`, MarketErrorCode.DemandExpired, error), - ); - break; - } - await sleep(2); - } - } - } - } -} - -/** - * @hidden - */ -export class DemandEvent extends Event { - readonly proposal?: Proposal; - readonly error?: Error; - - /** - * Create a new instance of DemandEvent - * @param type A string with the name of the event: - * @param data object with proposal data: - * @param error optional error if occurred while subscription is active - */ - constructor(type: string, data?: (Proposal & EventInit) | undefined, error?: GolemError | undefined) { - super(type, data); - this.proposal = data; - this.error = error; - } -} - -class ProposalReference { - constructor( - readonly id: string, - readonly counteringProposalId: string, - ) {} -} diff --git a/src/market/demand/demand-body-builder.ts b/src/market/demand/demand-body-builder.ts new file mode 100644 index 000000000..dfafcf1a6 --- /dev/null +++ b/src/market/demand/demand-body-builder.ts @@ -0,0 +1,114 @@ +import { GolemInternalError } from "../../shared/error/golem-error"; + +/** + * Defines what kind of value data types one can expect in the raw Demand Properties + */ +export type DemandPropertyValue = string | number | boolean | string[] | number[]; + +/** + * Represents a single property/attribute that can be set on a Demand to specify Requestor needs + * + * Demand properties should be understood as values for various parameters of the agreement between Provider and Requestor. + * By defining properties on the demand, and negotiating them, the parties settle on the Terms & Conditions of the collaboration. + */ +export type DemandProperty = { key: string; value: DemandPropertyValue }; + +/** + * Represents requirements that the offer from the Provider has to meet, so that it's going to be matched by Yagna with the Demand + */ +type DemandConstraint = { + key: string; + value: string | number; + comparisonOperator: ComparisonOperator; +}; + +/** + * Data structure that represents details of the body for a demand subscription request + * + * This type belongs to our domain (use case layer), and will later be "serialized" to the body that's sent to + * Yagna. You should consider this as a "draft of the demand", that can be finalized by one of the {@link MarketApi} + * implementations. + */ +export type DemandBodyPrototype = { + properties: DemandProperty[]; + constraints: string[]; +}; + +export enum ComparisonOperator { + Eq = "=", + Lt = "<", + Gt = ">", + GtEq = ">=", + LtEq = "<=", +} + +/** + * A helper class assisting in building the Golem Demand object + * + * Various directors should use the builder to add properties and constraints before the final product is received + * from the builder and sent to yagna to subscribe for matched offers (proposals). + * + * The main purpose of the builder is to accept different requirements (properties and constraints) from different + * directors who know what kind of properties and constraints are needed. Then it helps to merge these requirements. + * + * Demand -> DemandSpecification -> DemandPrototype -> DemandDTO + */ +export class DemandBodyBuilder { + private properties: Array = []; + private constraints: Array = []; + + addProperty(key: string, value: DemandPropertyValue) { + const findIndex = this.properties.findIndex((prop) => prop.key === key); + if (findIndex >= 0) { + this.properties[findIndex] = { key, value }; + } else { + this.properties.push({ key, value }); + } + return this; + } + + addConstraint(key: string, value: string | number, comparisonOperator = ComparisonOperator.Eq) { + this.constraints.push({ key, value, comparisonOperator }); + return this; + } + + getProduct(): DemandBodyPrototype { + return { + properties: this.properties, + constraints: this.constraints.map((c) => `(${c.key + c.comparisonOperator + c.value})`), + }; + } + + mergePrototype(prototype: DemandBodyPrototype) { + if (prototype.properties) { + prototype.properties.forEach((prop) => { + this.addProperty(prop.key, prop.value); + }); + } + + if (prototype.constraints) { + prototype.constraints.forEach((cons) => { + const { key, value, comparisonOperator } = { ...this.parseConstraint(cons) }; + this.addConstraint(key, value, comparisonOperator); + }); + } + + return this; + } + + private parseConstraint(constraint: string): DemandConstraint { + for (const key in ComparisonOperator) { + const value = ComparisonOperator[key as keyof typeof ComparisonOperator]; + const parsedConstraint = constraint.slice(1, -1).split(value); + if (parsedConstraint.length === 2) { + return { + key: parsedConstraint[0], + value: parsedConstraint[1], + comparisonOperator: value, + }; + } + } + + throw new GolemInternalError(`Unable to parse constraint "${constraint}"`); + } +} diff --git a/src/market/demand/directors/activity-demand-director-config.ts b/src/market/demand/directors/activity-demand-director-config.ts new file mode 100644 index 000000000..1e13d221b --- /dev/null +++ b/src/market/demand/directors/activity-demand-director-config.ts @@ -0,0 +1,38 @@ +import { ActivityDemandDirectorConfigOptions } from "../options"; +import { GolemConfigError } from "../../../shared/error/golem-error"; + +export enum PackageFormat { + GVMKitSquash = "gvmkit-squash", +} + +export class ActivityDemandDirectorConfig { + readonly packageFormat: string = PackageFormat.GVMKitSquash; + readonly engine: string = "vm"; + readonly minMemGib: number = 0.5; + readonly minStorageGib: number = 2; + readonly minCpuThreads: number = 1; + readonly minCpuCores: number = 1; + readonly capabilities: string[] = []; + readonly manifest?: string; + readonly manifestSig?: string; + readonly manifestSigAlgorithm?: string; + readonly manifestCert?: string; + + readonly imageHash?: string; + readonly imageTag?: string; + readonly imageUrl?: string; + + constructor(options?: Partial) { + if (options) { + Object.assign(this, options); + } + + if (!this.imageHash && !this.manifest && !this.imageTag && !this.imageUrl) { + throw new GolemConfigError("You must define a package or manifest option"); + } + + if (this.imageUrl && !this.imageHash) { + throw new GolemConfigError("If you provide an imageUrl, you must also provide it's SHA3-224 hash in imageHash"); + } + } +} diff --git a/src/market/demand/directors/activity-demand-director.test.ts b/src/market/demand/directors/activity-demand-director.test.ts new file mode 100644 index 000000000..8fe2b083e --- /dev/null +++ b/src/market/demand/directors/activity-demand-director.test.ts @@ -0,0 +1,74 @@ +import { DemandBodyBuilder } from "../demand-body-builder"; +import { ActivityDemandDirector } from "./activity-demand-director"; +import { ActivityDemandDirectorConfig } from "./activity-demand-director-config"; + +describe("ActivityDemandDirector", () => { + test("should create properties with task_package and package_format", async () => { + const builder = new DemandBodyBuilder(); + + const director = new ActivityDemandDirector( + new ActivityDemandDirectorConfig({ + imageHash: "529f7fdaf1cf46ce3126eb6bbcd3b213c314fe8fe884914f5d1106d4", + }), + ); + await director.apply(builder); + + const decorations = builder.getProduct(); + + expect(decorations.properties).toEqual( + expect.arrayContaining([ + { + key: "golem.srv.comp.task_package", + value: + "hash:sha3:529f7fdaf1cf46ce3126eb6bbcd3b213c314fe8fe884914f5d1106d4:http://registry.golem.network/download/529f7fdaf1cf46ce3126eb6bbcd3b213c314fe8fe884914f5d1106d4", + }, + { key: "golem.srv.comp.vm.package_format", value: "gvmkit-squash" }, + ]), + ); + }); + + test("should create package with manifest decorations", async () => { + const builder = new DemandBodyBuilder(); + + const manifest = "XNBdCI6ICIyMTAwLTAxLTAxVDAwOjAxOjAwLjAwMDAwMFoiLAogICJtZXRhZGF0YSI6IHsKICAgICJuYW1lI="; + const manifestSig = "GzbdJDaW6FTajVYCKKZZvwpwVNBK3o40r/okna87wV9CVWW0+WUFwe="; + const manifestCert = "HCkExVUVDZ3dOUjI5c1pXMGdSbUZqZEc5eWVURW1NQ1FHQTFVRUF3d2RSMjlzWl="; + const manifestSigAlgorithm = "sha256"; + const capabilities = ["inet", "manifest-support"]; + + const director = new ActivityDemandDirector( + new ActivityDemandDirectorConfig({ + manifest, + manifestSig, + manifestCert, + manifestSigAlgorithm, + capabilities, + }), + ); + await director.apply(builder); + + const decorations = builder.getProduct(); + + expect(decorations.properties).toEqual( + expect.arrayContaining([ + { key: "golem.srv.comp.payload", value: manifest }, + { key: "golem.srv.comp.payload.sig", value: manifestSig }, + { key: "golem.srv.comp.payload.cert", value: manifestCert }, + { key: "golem.srv.comp.payload.sig.algorithm", value: manifestSigAlgorithm }, + { key: "golem.srv.comp.vm.package_format", value: "gvmkit-squash" }, + ]), + ); + + expect(decorations.constraints).toEqual( + expect.arrayContaining([ + "(golem.inf.mem.gib>=0.5)", + "(golem.inf.storage.gib>=2)", + "(golem.runtime.name=vm)", + "(golem.inf.cpu.cores>=1)", + "(golem.inf.cpu.threads>=1)", + "(golem.runtime.capabilities=inet)", + "(golem.runtime.capabilities=manifest-support)", + ]), + ); + }); +}); diff --git a/src/market/demand/directors/activity-demand-director.ts b/src/market/demand/directors/activity-demand-director.ts new file mode 100644 index 000000000..c89256e16 --- /dev/null +++ b/src/market/demand/directors/activity-demand-director.ts @@ -0,0 +1,86 @@ +import { ActivityDemandDirectorConfig } from "./activity-demand-director-config"; +import { ComparisonOperator, DemandBodyBuilder } from "../demand-body-builder"; +import { GolemError, GolemPlatformError } from "../../../shared/error/golem-error"; +import { IDemandDirector } from "../../market.module"; +import { EnvUtils } from "../../../shared/utils"; + +export class ActivityDemandDirector implements IDemandDirector { + constructor(private config: ActivityDemandDirectorConfig) {} + + public async apply(builder: DemandBodyBuilder) { + builder + .addProperty("golem.srv.comp.vm.package_format", this.config.packageFormat) + .addConstraint("golem.runtime.name", this.config.engine); + + if (this.config.capabilities.length) + this.config.capabilities.forEach((cap) => builder.addConstraint("golem.runtime.capabilities", cap)); + + builder + .addConstraint("golem.inf.mem.gib", this.config.minMemGib, ComparisonOperator.GtEq) + .addConstraint("golem.inf.storage.gib", this.config.minStorageGib, ComparisonOperator.GtEq) + .addConstraint("golem.inf.cpu.cores", this.config.minCpuCores, ComparisonOperator.GtEq) + .addConstraint("golem.inf.cpu.threads", this.config.minCpuThreads, ComparisonOperator.GtEq); + + if (this.config.imageUrl) { + const taskPackage = await this.resolveTaskPackageFromCustomUrl(); + builder.addProperty("golem.srv.comp.task_package", taskPackage); + } else if (this.config.imageHash || this.config.imageTag) { + const taskPackage = await this.resolveTaskPackageUrl(); + builder.addProperty("golem.srv.comp.task_package", taskPackage); + } + + this.addManifestDecorations(builder); + } + + private async resolveTaskPackageFromCustomUrl(): Promise { + if (!this.config.imageUrl) { + throw new GolemPlatformError("Tried to resolve task package from custom url, but no url was provided"); + } + if (!this.config.imageHash) { + throw new GolemPlatformError( + "Tried to resolve task package from custom url, but no hash was provided. Please calculate the SHA3-224 hash of the image and provide it as `imageHash`", + ); + } + return `hash:sha3:${this.config.imageHash}:${this.config.imageUrl}`; + } + + private async resolveTaskPackageUrl(): Promise { + const repoUrl = EnvUtils.getRepoUrl(); + + //TODO : in future this should be passed probably through config + const isHttps = false; + + const isDev = EnvUtils.isDevMode(); + + let hash = this.config.imageHash; + const tag = this.config.imageTag; + + const url = `${repoUrl}/v1/image/info?${isDev ? "dev=true" : "count=true"}&${tag ? `tag=${tag}` : `hash=${hash}`}`; + + try { + const response = await fetch(url); + if (!response.ok) { + throw new GolemPlatformError(`Unable to get image ${await response.text()}`); + } + + const data = await response.json(); + + const imageUrl = isHttps ? data.https : data.http; + hash = data.sha3; + + return `hash:sha3:${hash}:${imageUrl}`; + } catch (error) { + if (error instanceof GolemError) throw error; + throw new GolemPlatformError(`Failed to fetch image: ${error}`); + } + } + + private addManifestDecorations(builder: DemandBodyBuilder): void { + if (!this.config.manifest) return; + builder.addProperty("golem.srv.comp.payload", this.config.manifest); + if (this.config.manifestSig) builder.addProperty("golem.srv.comp.payload.sig", this.config.manifestSig); + if (this.config.manifestSigAlgorithm) + builder.addProperty("golem.srv.comp.payload.sig.algorithm", this.config.manifestSigAlgorithm); + if (this.config.manifestCert) builder.addProperty("golem.srv.comp.payload.cert", this.config.manifestCert); + } +} diff --git a/src/market/demand/directors/base-config.ts b/src/market/demand/directors/base-config.ts new file mode 100644 index 000000000..108ff3aa4 --- /dev/null +++ b/src/market/demand/directors/base-config.ts @@ -0,0 +1,10 @@ +/** + * Basic config utility class + * + * Helps in building more specific config classes + */ +export class BaseConfig { + protected isPositiveInt(value: number) { + return value > 0 && Number.isInteger(value); + } +} diff --git a/src/market/demand/directors/basic-demand-director-config.test.ts b/src/market/demand/directors/basic-demand-director-config.test.ts new file mode 100644 index 000000000..8028947e0 --- /dev/null +++ b/src/market/demand/directors/basic-demand-director-config.test.ts @@ -0,0 +1,12 @@ +import { BasicDemandDirectorConfig } from "./basic-demand-director-config"; + +describe("BasicDemandDirectorConfig", () => { + test("should throw user error if expiration option is invalid", () => { + expect(() => { + new BasicDemandDirectorConfig({ + expirationSec: -3, + subnetTag: "public", + }); + }).toThrow("The demand expiration time has to be a positive integer"); + }); +}); diff --git a/src/market/demand/directors/basic-demand-director-config.ts b/src/market/demand/directors/basic-demand-director-config.ts new file mode 100644 index 000000000..bfac5d805 --- /dev/null +++ b/src/market/demand/directors/basic-demand-director-config.ts @@ -0,0 +1,24 @@ +import { BaseConfig } from "./base-config"; +import { GolemConfigError } from "../../../shared/error/golem-error"; + +export interface BasicDemandDirectorConfigOptions { + expirationSec: number; + subnetTag: string; +} + +export class BasicDemandDirectorConfig extends BaseConfig implements BasicDemandDirectorConfigOptions { + public readonly expirationSec = 30 * 60; // 30 minutes + public readonly subnetTag: string = "public"; + + constructor(options?: Partial) { + super(); + + if (options) { + Object.assign(this, options); + } + + if (!this.isPositiveInt(this.expirationSec)) { + throw new GolemConfigError("The demand expiration time has to be a positive integer"); + } + } +} diff --git a/src/market/demand/directors/basic-demand-director.ts b/src/market/demand/directors/basic-demand-director.ts new file mode 100644 index 000000000..f6a8208d0 --- /dev/null +++ b/src/market/demand/directors/basic-demand-director.ts @@ -0,0 +1,18 @@ +import { DemandBodyBuilder } from "../demand-body-builder"; +import { IDemandDirector } from "../../market.module"; +import { BasicDemandDirectorConfig } from "./basic-demand-director-config"; + +export class BasicDemandDirector implements IDemandDirector { + constructor(private config: BasicDemandDirectorConfig = new BasicDemandDirectorConfig()) {} + + apply(builder: DemandBodyBuilder) { + builder + .addProperty("golem.srv.caps.multi-activity", true) + .addProperty("golem.srv.comp.expiration", Date.now() + this.config.expirationSec * 1000) + .addProperty("golem.node.debug.subnet", this.config.subnetTag); + + builder + .addConstraint("golem.com.pricing.model", "linear") + .addConstraint("golem.node.debug.subnet", this.config.subnetTag); + } +} diff --git a/src/market/demand/directors/payment-demand-director-config.test.ts b/src/market/demand/directors/payment-demand-director-config.test.ts new file mode 100644 index 000000000..7b0b9fa79 --- /dev/null +++ b/src/market/demand/directors/payment-demand-director-config.test.ts @@ -0,0 +1,27 @@ +import { PaymentDemandDirectorConfig } from "./payment-demand-director-config"; + +describe("PaymentDemandDirectorConfig", () => { + it("should throw user error if debitNotesAcceptanceTimeoutSec option is invalid", () => { + expect(() => { + new PaymentDemandDirectorConfig({ + debitNotesAcceptanceTimeoutSec: -3, + }); + }).toThrow("The debit note acceptance timeout time has to be a positive integer"); + }); + + it("should throw user error if midAgreementDebitNoteIntervalSec option is invalid", () => { + expect(() => { + new PaymentDemandDirectorConfig({ + midAgreementDebitNoteIntervalSec: -3, + }); + }).toThrow("The debit note interval time has to be a positive integer"); + }); + + it("should throw user error if midAgreementPaymentTimeoutSec option is invalid", () => { + expect(() => { + new PaymentDemandDirectorConfig({ + midAgreementPaymentTimeoutSec: -3, + }); + }).toThrow("The mid-agreement payment timeout time has to be a positive integer"); + }); +}); diff --git a/src/market/demand/directors/payment-demand-director-config.ts b/src/market/demand/directors/payment-demand-director-config.ts new file mode 100644 index 000000000..816d59ce0 --- /dev/null +++ b/src/market/demand/directors/payment-demand-director-config.ts @@ -0,0 +1,34 @@ +import { BaseConfig } from "./base-config"; +import { GolemConfigError } from "../../../shared/error/golem-error"; + +export interface PaymentDemandDirectorConfigOptions { + midAgreementDebitNoteIntervalSec: number; + midAgreementPaymentTimeoutSec: number; + debitNotesAcceptanceTimeoutSec: number; +} + +export class PaymentDemandDirectorConfig extends BaseConfig implements PaymentDemandDirectorConfigOptions { + public readonly debitNotesAcceptanceTimeoutSec = 2 * 60; // 2 minutes + public readonly midAgreementDebitNoteIntervalSec = 2 * 60; // 2 minutes + public readonly midAgreementPaymentTimeoutSec = 12 * 60 * 60; // 12 hours + + constructor(options?: Partial) { + super(); + + if (options) { + Object.assign(this, options); + } + + if (!this.isPositiveInt(this.debitNotesAcceptanceTimeoutSec)) { + throw new GolemConfigError("The debit note acceptance timeout time has to be a positive integer"); + } + + if (!this.isPositiveInt(this.midAgreementDebitNoteIntervalSec)) { + throw new GolemConfigError("The debit note interval time has to be a positive integer"); + } + + if (!this.isPositiveInt(this.midAgreementPaymentTimeoutSec)) { + throw new GolemConfigError("The mid-agreement payment timeout time has to be a positive integer"); + } + } +} diff --git a/src/market/demand/directors/payment-demand-director.ts b/src/market/demand/directors/payment-demand-director.ts new file mode 100644 index 000000000..d621af625 --- /dev/null +++ b/src/market/demand/directors/payment-demand-director.ts @@ -0,0 +1,29 @@ +import { PayerDetails } from "../../../payment/PayerDetails"; +import { ComparisonOperator, DemandBodyBuilder } from "../demand-body-builder"; +import { IDemandDirector } from "../../market.module"; +import { PaymentDemandDirectorConfig } from "./payment-demand-director-config"; + +export class PaymentDemandDirector implements IDemandDirector { + constructor( + private payerDetails: PayerDetails, + private config: PaymentDemandDirectorConfig = new PaymentDemandDirectorConfig(), + ) {} + + apply(builder: DemandBodyBuilder) { + // Configure mid-agreement payments + builder + .addProperty("golem.com.scheme.payu.debit-note.interval-sec?", this.config.midAgreementDebitNoteIntervalSec) + .addProperty("golem.com.scheme.payu.payment-timeout-sec?", this.config.midAgreementPaymentTimeoutSec) + .addProperty("golem.com.payment.debit-notes.accept-timeout?", this.config.debitNotesAcceptanceTimeoutSec); + + // Configure payment platform + builder + .addProperty( + `golem.com.payment.platform.${this.payerDetails.getPaymentPlatform()}.address`, + this.payerDetails.address, + ) + .addConstraint(`golem.com.payment.platform.${this.payerDetails.getPaymentPlatform()}.address`, "*") + .addProperty("golem.com.payment.protocol.version", "2") + .addConstraint("golem.com.payment.protocol.version", "1", ComparisonOperator.Gt); + } +} diff --git a/src/market/demand/options.ts b/src/market/demand/options.ts new file mode 100644 index 000000000..d817752f5 --- /dev/null +++ b/src/market/demand/options.ts @@ -0,0 +1,61 @@ +import { RequireAtLeastOne } from "../../shared/utils/types"; + +/** + * Specifies a set of options related to computation resources that will be used to form the demand + */ +type ResourceDemandOptions = { + /** Minimum required memory to execute application GB */ + minMemGib: number; + /** Minimum required disk storage to execute tasks in GB */ + minStorageGib: number; + /** Minimum required CPU threads */ + minCpuThreads: number; + /** Minimum required CPU cores */ + minCpuCores: number; +}; + +/** + * Specifies a set of options related to runtime configuration that will be used to form the demand + */ +type RuntimeDemandOptions = { + /** Type of engine required: vm, wasm, vm-nvidia, etc... */ + engine: string; + + /** Required providers capabilities to run application: example: ["vpn"] */ + capabilities: string[]; +}; + +/** + * Specifies a set of options related to computation manifest that can be used to form the demand + */ +type ManifestDemandOptions = { + manifest: string; + /** Signature of base64 encoded Computation Payload Manifest **/ + manifestSig: string; + /** Algorithm of manifest signature, e.g. "sha256" **/ + manifestSigAlgorithm: string; + /** Certificate - base64 encoded public certificate (DER or PEM) matching key used to generate signature **/ + manifestCert: string; +}; + +/** + * Specifies a set of options related to the Golem VM Image (GVMI) that will be used to form the demand + */ +type ImageDemandOptions = { + /** + * If you want a provider to download the image from your local filesystem or + * a different registry than the default one, you can provide the image url here. + * Note that to use this option you need to also provide the image SHA3-224 hash. + */ + imageUrl?: string; + + /** finds package by its contents hash */ + imageHash?: string; + + /** finds package by registry tag */ + imageTag?: string; +}; + +export type ActivityDemandDirectorConfigOptions = RuntimeDemandOptions & + ResourceDemandOptions & + RequireAtLeastOne; diff --git a/src/market/factory.test.ts b/src/market/factory.test.ts deleted file mode 100644 index c51bba9c6..000000000 --- a/src/market/factory.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { DemandFactory } from "./factory"; -import { anything, capture, instance, mock, reset, when } from "@johanblumenberg/ts-mockito"; -import { Package } from "./package"; -import { Allocation } from "../payment"; -import { YagnaApi } from "../shared/utils"; -import { MarketApi } from "ya-ts-client"; - -const mockPackage = mock(Package); -const mockAllocation = mock(Allocation); - -const mockMarket = mock(MarketApi.RequestorService); -const mockYagna = mock(YagnaApi); - -describe("Demand Factory", () => { - beforeEach(() => { - reset(mockYagna); - reset(mockMarket); - reset(mockPackage); - reset(mockAllocation); - - when(mockYagna.market).thenReturn(instance(mockMarket)); - - when(mockPackage.getDemandDecoration()).thenResolve({ - properties: [], - constraints: [], - }); - - when(mockAllocation.getDemandDecoration()).thenResolve({ - properties: [], - constraints: [], - }); - - when(mockMarket.subscribeDemand(anything())).thenResolve("subscription-id"); - }); - describe("mid-agreement payments support", () => { - describe("default behaviour", () => { - it("it configures mid-agreement payments by default", async () => { - // Given - // When - - const factory = new DemandFactory(instance(mockPackage), instance(mockAllocation), instance(mockYagna)); - const demand = await factory.create(); - - // Then - const [demandRequestBody] = capture(mockMarket.subscribeDemand).last(); - - // The properties responsible for mid-agreements payments are set - expect(demandRequestBody.properties["golem.com.payment.debit-notes.accept-timeout?"]).toBeDefined(); - expect(demandRequestBody.properties["golem.com.scheme.payu.debit-note.interval-sec?"]).toBeDefined(); - expect(demandRequestBody.properties["golem.com.scheme.payu.payment-timeout-sec?"]).toBeDefined(); - expect(demandRequestBody.properties["golem.srv.comp.expiration"]).toBeDefined(); - - expect(demand).toBeDefined(); - }); - }); - }); -}); diff --git a/src/market/factory.ts b/src/market/factory.ts deleted file mode 100644 index 28b749dfa..000000000 --- a/src/market/factory.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Package } from "./package"; -import { Allocation } from "../payment"; -import { Demand, DemandOptions } from "./demand"; -import { DemandConfig } from "./config"; -import { DecorationsBuilder, MarketDecoration } from "./builder"; -import { YagnaApi } from "../shared/utils"; -import { GolemMarketError, MarketErrorCode } from "./error"; -import { EventEmitter } from "eventemitter3"; - -export interface DemandFactoryEvents { - demandSubscribed: (details: { id: string; details: MarketDecoration }) => void; - demandFailed: (details: { reason: string }) => void; -} - -/** - * @internal - */ -export class DemandFactory { - private options: DemandConfig; - public readonly events = new EventEmitter(); - - constructor( - private readonly taskPackage: Package, - private readonly allocation: Allocation, - private readonly yagnaApi: YagnaApi, - options?: DemandOptions, - ) { - this.options = new DemandConfig(options); - } - - async create(): Promise { - try { - const decorations = await this.getDecorations(); - const demandRequest = new DecorationsBuilder().addDecorations(decorations).getDemandRequest(); - const id = await this.yagnaApi.market.subscribeDemand(demandRequest); - if (typeof id !== "string") { - throw new GolemMarketError( - `Invalid demand ID received from the market: ${id}`, - MarketErrorCode.SubscriptionFailed, - ); - } - this.events.emit("demandSubscribed", { - id, - details: new DecorationsBuilder().addDecorations(decorations).getDecorations(), - }); - this.options.logger.info(`Demand published on the market`); - return new Demand(id, demandRequest, this.allocation, this.yagnaApi, this.options); - } catch (error) { - const reason = error.response?.data?.message || error.toString(); - this.events.emit("demandFailed", { reason }); - throw new GolemMarketError( - `Could not publish demand on the market. ${reason}`, - MarketErrorCode.SubscriptionFailed, - error, - ); - } - } - - private async getDecorations(): Promise { - const taskDecorations = await this.taskPackage.getDemandDecoration(); - const allocationDecoration = await this.allocation.getDemandDecoration(); - const baseDecoration = this.getBaseDecorations(); - return [taskDecorations, allocationDecoration, baseDecoration]; - } - - private getBaseDecorations(): MarketDecoration { - const builder = new DecorationsBuilder(); - - // Configure basic properties - builder - .addProperty("golem.srv.caps.multi-activity", true) - .addProperty("golem.srv.comp.expiration", Date.now() + this.options.expirationSec * 1000) - .addProperty("golem.node.debug.subnet", this.options.subnetTag) - .addProperty("golem.com.payment.debit-notes.accept-timeout?", this.options.debitNotesAcceptanceTimeoutSec) - .addConstraint("golem.com.pricing.model", "linear") - .addConstraint("golem.node.debug.subnet", this.options.subnetTag); - - // Configure mid-agreement payments - builder - .addProperty("golem.com.scheme.payu.debit-note.interval-sec?", this.options.midAgreementDebitNoteIntervalSec) - .addProperty("golem.com.scheme.payu.payment-timeout-sec?", this.options.midAgreementPaymentTimeoutSec); - - return builder.getDecorations(); - } -} diff --git a/src/market/index.ts b/src/market/index.ts index 7158848e4..c5118965d 100644 --- a/src/market/index.ts +++ b/src/market/index.ts @@ -1,11 +1,12 @@ export { ProposalFilterNew } from "./proposal"; -export { Demand, DemandNew, DemandEvent, DemandOptions, DemandSpecification } from "./demand"; +export { Demand, BasicDemandPropertyConfig, DemandSpecification } from "./demand"; export { Proposal, ProposalNew, ProposalDTO } from "./proposal"; -export { MarketDecoration } from "./builder"; -export { DemandConfig } from "./config"; export * as ProposalFilterFactory from "./strategy"; export { GolemMarketError, MarketErrorCode } from "./error"; export * as MarketHelpers from "./helpers"; export * from "./draft-offer-proposal-pool"; export * from "./market.module"; export * from "./api"; +export { BasicDemandDirector } from "./demand/directors/basic-demand-director"; +export { PaymentDemandDirector } from "./demand/directors/payment-demand-director"; +export { ActivityDemandDirector } from "./demand/directors/activity-demand-director"; diff --git a/src/market/market.module.test.ts b/src/market/market.module.test.ts index 32b8ba994..53ce4dd65 100644 --- a/src/market/market.module.test.ts +++ b/src/market/market.module.test.ts @@ -2,7 +2,7 @@ import { _, imock, instance, mock, reset, verify, when } from "@johanblumenberg/ import { Logger, YagnaApi } from "../shared/utils"; import { MarketModuleImpl } from "./market.module"; import * as YaTsClient from "ya-ts-client"; -import { DemandNew, DemandSpecification, IDemandRepository } from "./demand"; +import { Demand, DemandSpecification, IDemandRepository } from "./demand"; import { from, of, take, takeUntil, timer } from "rxjs"; import { IProposalRepository, ProposalNew, ProposalProperties } from "./proposal"; import { MarketApiAdapter } from "../shared/yagna/"; @@ -11,6 +11,7 @@ import { IAgreementApi } from "../agreement/agreement"; import { PayerDetails } from "../payment/PayerDetails"; import { IFileServer } from "../activity"; import { StorageProvider } from "../shared/storage"; +import { GolemMarketError } from "./error"; const mockMarketApiAdapter = mock(MarketApiAdapter); const mockYagna = mock(YagnaApi); @@ -39,46 +40,83 @@ describe("Market module", () => { it("should build a demand", async () => { const payerDetails = new PayerDetails("holesky", "erc20", "0x123"); - const demandSpecification = await marketModule.buildDemand( + const demandSpecification = await marketModule.buildDemandDetails( { - imageHash: "AAAAHASHAAAA", - imageUrl: "https://custom.image.url/", - expirationSec: 42, - debitNotesAcceptanceTimeoutSec: 42, - midAgreementDebitNoteIntervalSec: 42, - midAgreementPaymentTimeoutSec: 42, + activity: { + imageHash: "AAAAHASHAAAA", + imageUrl: "https://custom.image.url/", + }, + basic: { + expirationSec: 42, + }, + payment: { + debitNotesAcceptanceTimeoutSec: 42, + midAgreementDebitNoteIntervalSec: 42, + midAgreementPaymentTimeoutSec: 42, + }, }, payerDetails, ); const expectedConstraints = [ + "(golem.com.pricing.model=linear)", + "(golem.node.debug.subnet=public)", + "(golem.runtime.name=vm)", "(golem.inf.mem.gib>=0.5)", "(golem.inf.storage.gib>=2)", - "(golem.runtime.name=vm)", "(golem.inf.cpu.cores>=1)", "(golem.inf.cpu.threads>=1)", - "(golem.com.pricing.model=linear)", - "(golem.node.debug.subnet=public)", "(golem.com.payment.platform.erc20-holesky-tglm.address=*)", "(golem.com.payment.protocol.version>1)", - ].join("\n\t"); - const expectedProperties = { - "golem.srv.comp.vm.package_format": "gvmkit-squash", - "golem.srv.comp.task_package": "hash:sha3:AAAAHASHAAAA:https://custom.image.url/", - "golem.com.payment.platform.erc20-holesky-tglm.address": "0x123", - "golem.com.payment.protocol.version": "2", - "golem.srv.caps.multi-activity": true, - "golem.srv.comp.expiration": Date.now() + 42 * 1000, - "golem.node.debug.subnet": "public", - "golem.com.payment.debit-notes.accept-timeout?": 42, - "golem.com.scheme.payu.debit-note.interval-sec?": 42, - "golem.com.scheme.payu.payment-timeout-sec?": 42, - }; + ]; + + const expectedProperties = [ + { + key: "golem.srv.caps.multi-activity", + value: true, + }, + { + key: "golem.srv.comp.expiration", + value: Date.now() + 42 * 1000, + }, + { + key: "golem.node.debug.subnet", + value: "public", + }, + { + key: "golem.srv.comp.vm.package_format", + value: "gvmkit-squash", + }, + { + key: "golem.srv.comp.task_package", + value: "hash:sha3:AAAAHASHAAAA:https://custom.image.url/", + }, + { + key: "golem.com.scheme.payu.debit-note.interval-sec?", + value: 42, + }, + { + key: "golem.com.scheme.payu.payment-timeout-sec?", + value: 42, + }, + { + key: "golem.com.payment.debit-notes.accept-timeout?", + value: 42, + }, + { + key: "golem.com.payment.platform.erc20-holesky-tglm.address", + value: "0x123", + }, + { + key: "golem.com.payment.protocol.version", + value: "2", + }, + ]; expect(demandSpecification.paymentPlatform).toBe(payerDetails.getPaymentPlatform()); expect(demandSpecification.expirationSec).toBe(42); - expect(demandSpecification.decoration.constraints).toBe(`(&${expectedConstraints})`); - expect(demandSpecification.decoration.properties).toEqual(expectedProperties); + expect(demandSpecification.prototype.constraints).toEqual(expect.arrayContaining(expectedConstraints)); + expect(demandSpecification.prototype.properties).toEqual(expectedProperties); }); }); @@ -86,14 +124,14 @@ describe("Market module", () => { it("should publish a demand", (done) => { const mockSpecification = mock(DemandSpecification); when(mockMarketApiAdapter.publishDemandSpecification(mockSpecification)).thenCall(async (specification) => { - return new DemandNew("demand-id", specification); + return new Demand("demand-id", specification); }); const demand$ = marketModule.publishDemand(mockSpecification); demand$.pipe(take(1)).subscribe({ next: (demand) => { try { - expect(demand).toEqual(new DemandNew("demand-id", mockSpecification)); + expect(demand).toEqual(new Demand("demand-id", mockSpecification)); done(); } catch (error) { done(error); @@ -107,9 +145,9 @@ describe("Market module", () => { const mockSpecification = mock(DemandSpecification); when(mockSpecification.expirationSec).thenReturn(10); const mockSpecificationInstance = instance(mockSpecification); - const mockDemand0 = new DemandNew("demand-id-0", mockSpecificationInstance); - const mockDemand1 = new DemandNew("demand-id-1", mockSpecificationInstance); - const mockDemand2 = new DemandNew("demand-id-2", mockSpecificationInstance); + const mockDemand0 = new Demand("demand-id-0", mockSpecificationInstance); + const mockDemand1 = new Demand("demand-id-1", mockSpecificationInstance); + const mockDemand2 = new Demand("demand-id-2", mockSpecificationInstance); when(mockMarketApiAdapter.publishDemandSpecification(_)) .thenResolve(mockDemand0) @@ -118,7 +156,7 @@ describe("Market module", () => { when(mockMarketApiAdapter.unpublishDemand(_)).thenResolve(); const demand$ = marketModule.publishDemand(mockSpecificationInstance); - const demands: DemandNew[] = []; + const demands: Demand[] = []; demand$.pipe(take(3)).subscribe({ next: (demand) => { demands.push(demand); @@ -139,11 +177,32 @@ describe("Market module", () => { error: (error) => done(error), }); }); + + it("should throw an error if the demand cannot be subscribed", (done) => { + const mockSpecification = mock(DemandSpecification); + const details = instance(mockSpecification); + + when(mockMarketApiAdapter.publishDemandSpecification(_)).thenReject(new Error("Triggered")); + + const demand$ = marketModule.publishDemand(details); + + demand$.subscribe({ + error: (err: GolemMarketError) => { + try { + expect(err.message).toEqual("Could not publish demand on the market"); + expect(err.previous?.message).toEqual("Triggered"); + done(); + } catch (assertionError) { + done(assertionError); + } + }, + }); + }); }); describe("subscribeForProposals()", () => { it("should filter out rejected proposals", (done) => { - const mockDemand = instance(imock()); + const mockDemand = instance(imock()); const mockProposalDTO = imock(); when(mockProposalDTO.issuerId).thenReturn("issuer-id"); const mockProposalEventSuccess: YaTsClient.MarketApi.ProposalEventDTO = { @@ -191,6 +250,7 @@ describe("Market module", () => { }); }); }); + describe("startCollectingProposals()", () => { it("should negotiate any initial proposals", (done) => { jest.useRealTimers(); diff --git a/src/market/market.module.ts b/src/market/market.module.ts index bbe0a1794..efd36180f 100644 --- a/src/market/market.module.ts +++ b/src/market/market.module.ts @@ -1,19 +1,32 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { EventEmitter } from "eventemitter3"; -import { DemandConfig, DemandNew, DraftOfferProposalPool, MarketApi, NewProposalEvent } from "./index"; +import { + Demand, + DraftOfferProposalPool, + GolemMarketError, + MarketApi, + MarketErrorCode, + NewProposalEvent, +} from "./index"; import { Agreement, AgreementPool, AgreementPoolOptions, IActivityApi, IPaymentApi, LeaseProcess } from "../agreement"; import { defaultLogger, Logger, YagnaApi } from "../shared/utils"; import { Allocation } from "../payment"; -import { Package } from "./package"; import { bufferTime, filter, map, Observable, OperatorFunction, switchMap, tap } from "rxjs"; import { IProposalRepository, ProposalFilterNew, ProposalNew } from "./proposal"; -import { ComparisonOperator, DecorationsBuilder } from "./builder"; +import { DemandBodyBuilder } from "./demand/demand-body-builder"; import { IAgreementApi } from "../agreement/agreement"; -import { DemandOptionsNew, DemandSpecification, IDemandRepository } from "./demand"; +import { BuildDemandOptions, DemandSpecification, IDemandRepository } from "./demand"; import { ProposalsBatch } from "./proposals_batch"; import { PayerDetails } from "../payment/PayerDetails"; import { IFileServer } from "../activity"; import { StorageProvider } from "../shared/storage"; +import { ActivityDemandDirectorConfig } from "./demand/directors/activity-demand-director-config"; +import { BasicDemandDirector } from "./demand/directors/basic-demand-director"; +import { PaymentDemandDirector } from "./demand/directors/payment-demand-director"; +import { ActivityDemandDirector } from "./demand/directors/activity-demand-director"; +import { ActivityDemandDirectorConfigOptions } from "./demand/options"; +import { BasicDemandDirectorConfig } from "./demand/directors/basic-demand-director-config"; +import { PaymentDemandDirectorConfig } from "./demand/directors/payment-demand-director-config"; export interface MarketEvents {} @@ -21,7 +34,7 @@ export interface MarketEvents {} * Use by legacy demand publishing code */ export interface DemandBuildParams { - demand: DemandOptionsNew; + demand: BuildDemandOptions; market: MarketOptions; } @@ -37,22 +50,11 @@ export type PaymentSpec = { * Represents the new demand specification which is accepted by GolemNetwork and MarketModule */ export interface DemandSpec { - demand: DemandOptionsNew; + demand: BuildDemandOptions; market: MarketOptions; payment: PaymentSpec; } -export interface DemandResources { - /** The minimum CPU requirement for each service instance. */ - minCpu: number; - - /* The minimum memory requirement (in Gibibyte) for each service instance. */ - minMemGib: number; - - /** The minimum storage requirement (in Gibibyte) for each service instance. */ - minStorageGib: number; -} - export interface MarketOptions { /** How long you want to rent the resources in hours */ rentHours?: number; @@ -86,7 +88,7 @@ export interface MarketModule { * The method returns a DemandSpecification that can be used to publish the demand to the market, * for example using the `publishDemand` method. */ - buildDemand(options: DemandOptionsNew, payerDetails: PayerDetails): Promise; + buildDemandDetails(options: BuildDemandOptions, payerDetails: PayerDetails): Promise; /** * Publishes the demand to the market and handles refreshing it when needed. @@ -94,20 +96,20 @@ export interface MarketModule { * Keep in mind that since this method returns an observable, nothing will happen until you subscribe to it. * Unsubscribing will remove the demand from the market. */ - publishDemand(offer: DemandSpecification): Observable; + publishDemand(demandSpec: DemandSpecification): Observable; /** * Subscribes to the proposals for the given demand. * If an error occurs, the observable will emit an error and complete. * Keep in mind that since this method returns an observable, nothing will happen until you subscribe to it. */ - subscribeForProposals(demand: DemandNew): Observable; + subscribeForProposals(demand: Demand): Observable; /** * Sends a counter-offer to the provider. Note that to get the provider's response to your * counter you should listen to proposals sent to yagna using `subscribeForProposals`. */ - negotiateProposal(receivedProposal: ProposalNew, offer: DemandSpecification): Promise; + negotiateProposal(receivedProposal: ProposalNew, counterDemandSpec: DemandSpecification): Promise; /** * Internally @@ -155,6 +157,20 @@ export interface MarketModule { createAgreementPool(draftPool: DraftOfferProposalPool): AgreementPool; } +/** + * Represents a director that can instruct DemandDetailsBuilder + * + * Demand is a complex concept in Golem. Requestors can place arbitrary properties and constraints on such + * market entity. While the demand request on the Golem Protocol level is a flat list of properties (key, value) and constraints, + * from the Requestor side they form logical groups that make sense together. + * + * The idea behind Directors is that you can encapsulate this grouping knowledge along with validation logic etc to prepare + * all the final demand request body properties in a more controlled and organized manner. + */ +export interface IDemandDirector { + apply(builder: DemandBodyBuilder): Promise | void; +} + export class MarketModuleImpl implements MarketModule { events: EventEmitter = new EventEmitter(); @@ -189,41 +205,33 @@ export class MarketModuleImpl implements MarketModule { this.fileServer = deps.fileServer; } - async buildDemand(options: DemandOptionsNew, payerDetails: PayerDetails): Promise { - const demandSpecificConfig = new DemandConfig(options); - const builder = new DecorationsBuilder(); - - // Apply additional modifications - const pkgOptions = await this.applyLocalGVMIServeSupport(options); - const taskDecorations = await Package.create(pkgOptions).getDemandDecoration(); - - builder.addDecorations([taskDecorations]); - - // Configure basic properties - builder - .addProperty("golem.srv.caps.multi-activity", true) - .addProperty("golem.srv.comp.expiration", Date.now() + demandSpecificConfig.expirationSec * 1000) - .addProperty("golem.node.debug.subnet", demandSpecificConfig.subnetTag) - .addProperty("golem.com.payment.debit-notes.accept-timeout?", demandSpecificConfig.debitNotesAcceptanceTimeoutSec) - .addConstraint("golem.com.pricing.model", "linear") - .addConstraint("golem.node.debug.subnet", demandSpecificConfig.subnetTag); - - // Configure mid-agreement payments - builder - .addProperty( - "golem.com.scheme.payu.debit-note.interval-sec?", - demandSpecificConfig.midAgreementDebitNoteIntervalSec, - ) - .addProperty("golem.com.scheme.payu.payment-timeout-sec?", demandSpecificConfig.midAgreementPaymentTimeoutSec); - - // Configure payment platform - builder - .addProperty(`golem.com.payment.platform.${payerDetails.getPaymentPlatform()}.address`, payerDetails.address) - .addConstraint(`golem.com.payment.platform.${payerDetails.getPaymentPlatform()}.address`, "*") - .addProperty("golem.com.payment.protocol.version", "2") - .addConstraint("golem.com.payment.protocol.version", "1", ComparisonOperator.Gt); - - return builder.getDemandSpecification(payerDetails.getPaymentPlatform(), demandSpecificConfig.expirationSec); + async buildDemandDetails(options: BuildDemandOptions, payerDetails: PayerDetails): Promise { + const builder = new DemandBodyBuilder(); + + // Instruct the builder what's required + const basicConfig = new BasicDemandDirectorConfig(options.basic); + const basicDirector = new BasicDemandDirector(basicConfig); + basicDirector.apply(builder); + + const workloadOptions = options.activity + ? await this.applyLocalGVMIServeSupport(options.activity) + : options.activity; + + const workloadConfig = new ActivityDemandDirectorConfig(workloadOptions); + const workloadDirector = new ActivityDemandDirector(workloadConfig); + await workloadDirector.apply(builder); + + const paymentConfig = new PaymentDemandDirectorConfig(options.payment); + const paymentDirector = new PaymentDemandDirector(payerDetails, paymentConfig); + paymentDirector.apply(builder); + + const spec = new DemandSpecification( + builder.getProduct(), + payerDetails.getPaymentPlatform(), + basicConfig.expirationSec, + ); + + return spec; } /** @@ -231,7 +239,7 @@ export class MarketModuleImpl implements MarketModule { * * Use Case: serve the GVMI from the requestor and avoid registry */ - private async applyLocalGVMIServeSupport(options: DemandOptionsNew) { + private async applyLocalGVMIServeSupport(options: Partial) { if (options.imageUrl?.startsWith("file://")) { const sourcePath = options.imageUrl?.replace("file://", ""); @@ -253,34 +261,45 @@ export class MarketModuleImpl implements MarketModule { return options; } - publishDemand(demandSpecification: DemandSpecification): Observable { - return new Observable((subscriber) => { - let currentDemand: DemandNew; + publishDemand(demandSpecification: DemandSpecification): Observable { + return new Observable((subscriber) => { + let currentDemand: Demand; const subscribeDemand = async () => { currentDemand = await this.deps.marketApi.publishDemandSpecification(demandSpecification); subscriber.next(currentDemand); this.logger.debug("Subscribing for proposals matched with the demand", { demand: currentDemand }); }; - subscribeDemand(); + + subscribeDemand().catch((err) => + subscriber.error( + new GolemMarketError(`Could not publish demand on the market`, MarketErrorCode.SubscriptionFailed, err), + ), + ); const interval = setInterval(() => { this.deps.marketApi .unpublishDemand(currentDemand) .catch((error) => this.logger.error("Failed to unpublish demand", error)); - subscribeDemand(); + subscribeDemand().catch((err) => + subscriber.error( + new GolemMarketError(`Could not publish demand on the market`, MarketErrorCode.SubscriptionFailed, err), + ), + ); }, demandSpecification.expirationSec * 1000); return () => { clearInterval(interval); - this.deps.marketApi - .unpublishDemand(currentDemand) - .catch((error) => this.logger.error("Failed to unpublish demand", error)); + if (currentDemand) { + this.deps.marketApi.unpublishDemand(currentDemand).catch((error) => { + this.logger.error("Failed to unpublish demand", error); + }); + } }; }); } - subscribeForProposals(demand: DemandNew): Observable { + subscribeForProposals(demand: Demand): Observable { return this.deps.marketApi.observeProposalEvents(demand).pipe( // filter out proposal rejection events filter((event) => !("reason" in event)), diff --git a/src/market/package/config.ts b/src/market/package/config.ts deleted file mode 100644 index 456f44102..000000000 --- a/src/market/package/config.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Logger } from "../../shared/utils"; -import { PackageOptions } from "./package"; -import { GolemConfigError } from "../../shared/error/golem-error"; - -/** - * @internal - */ -export const DEFAULTS = Object.freeze({ - payment: { driver: "erc20", network: "holesky" }, - engine: "vm", - minMemGib: 0.5, - minStorageGib: 2, - minCpuThreads: 1, - minCpuCores: 1, - capabilities: [], -}); - -/** - * @internal - */ -export enum PackageFormat { - Unknown = "", - GVMKitSquash = "gvmkit-squash", -} - -/** - * @internal - */ - -// ? Isn't it just a merge of object literals and no need to have a class here -export class PackageConfig { - readonly packageFormat: string; - readonly imageHash?: string; - readonly imageTag?: string; - readonly imageUrl?: string; - readonly engine: string; - readonly minMemGib: number; - readonly minStorageGib: number; - readonly minCpuThreads: number; - readonly minCpuCores: number; - readonly capabilities: string[]; - readonly manifest?: string; - readonly manifestSig?: string; - readonly manifestSigAlgorithm?: string; - readonly manifestCert?: string; - readonly logger?: Logger; - - constructor(options: PackageOptions) { - if (!options.imageHash && !options.manifest && !options.imageTag && !options.imageUrl) { - throw new GolemConfigError("You must define a package or manifest option"); - } - if (options.imageUrl && !options.imageHash) { - throw new GolemConfigError("If you provide an imageUrl, you must also provide it's SHA3-224 hash in imageHash"); - } - this.packageFormat = PackageFormat.GVMKitSquash; - this.imageHash = options.imageHash; - this.imageTag = options.imageTag; - this.imageUrl = options.imageUrl; - this.engine = options.engine || DEFAULTS.engine; - this.minMemGib = options.minMemGib || DEFAULTS.minMemGib; - this.minStorageGib = options.minStorageGib || DEFAULTS.minStorageGib; - this.minCpuThreads = options.minCpuThreads || DEFAULTS.minCpuThreads; - this.minCpuCores = options.minCpuCores || DEFAULTS.minCpuCores; - this.capabilities = options.capabilities || DEFAULTS.capabilities; - this.manifest = options.manifest; - this.manifestSig = options.manifestSig; - this.manifestSigAlgorithm = options.manifestSigAlgorithm; - this.manifestCert = options.manifestCert; - this.logger = options.logger; - } -} diff --git a/src/market/package/index.ts b/src/market/package/index.ts deleted file mode 100644 index e6ade331d..000000000 --- a/src/market/package/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Package, PackageOptions, AllPackageOptions } from "./package"; diff --git a/src/market/package/package.ts b/src/market/package/package.ts deleted file mode 100644 index 78a7b91e9..000000000 --- a/src/market/package/package.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { ComparisonOperator, DecorationsBuilder, MarketDecoration } from "../builder"; -import { EnvUtils, Logger, defaultLogger } from "../../shared/utils"; -import { PackageConfig } from "./config"; -import { RequireAtLeastOne } from "../../shared/utils/types"; -import { GolemError, GolemPlatformError } from "../../shared/error/golem-error"; - -export type AllPackageOptions = { - /** Type of engine required: vm, emscripten, sgx, sgx-js, sgx-wasm, sgx-wasi */ - engine?: string; - /** Minimum required memory to execute application GB */ - minMemGib?: number; - /** Minimum required disk storage to execute tasks in GB */ - minStorageGib?: number; - /** Minimum required CPU threads */ - minCpuThreads?: number; - /** Minimum required CPU cores */ - minCpuCores?: number; - /** Required providers capabilities to run application */ - capabilities?: string[]; - /** finds package by its contents hash */ - imageHash?: string; - /** finds package by registry tag */ - imageTag?: string; - manifest?: string; - /** Signature of base64 encoded Computation Payload Manifest **/ - manifestSig?: string; - /** Algorithm of manifest signature, e.g. "sha256" **/ - manifestSigAlgorithm?: string; - /** Certificate - base64 encoded public certificate (DER or PEM) matching key used to generate signature **/ - manifestCert?: string; - logger?: Logger; - /** - * If you want a provider to download the image from your your local filesystem or - * a different registry than the default one, you can provide the image url here. - * Note that to use this option you need to also provide the image SHA3-224 hash. - */ - imageUrl: string; -}; - -export type PackageOptions = RequireAtLeastOne; - -export interface PackageDetails { - minMemGib: number; - minStorageGib: number; - minCpuThreads: number; - minCpuCores: number; - engine: string; - capabilities: string[]; - imageHash?: string; -} - -/** - * Package module - an object for descriptions of the payload required by the requestor. - */ -export class Package { - private logger: Logger; - - private constructor(private options: PackageConfig) { - this.logger = options.logger || defaultLogger("work"); - } - - static create(options: PackageOptions): Package { - // ? : Dependency Injection could be useful - const config = new PackageConfig(options); - return new Package(config); - } - - static getImageIdentifier( - str: string, - ): RequireAtLeastOne<{ imageHash: string; imageTag: string }, "imageHash" | "imageTag"> { - const tagRegex = /^(.*?)\/(.*?):(.*)$/; - if (tagRegex.test(str)) { - return { - imageTag: str, - }; - } - - return { - imageHash: str, - }; - } - - async getDemandDecoration(): Promise { - const builder = new DecorationsBuilder(); - - builder - .addProperty("golem.srv.comp.vm.package_format", this.options.packageFormat) - .addConstraint("golem.inf.mem.gib", this.options.minMemGib.toString(), ComparisonOperator.GtEq) - .addConstraint("golem.inf.storage.gib", this.options.minStorageGib.toString(), ComparisonOperator.GtEq) - .addConstraint("golem.runtime.name", this.options.engine) - .addConstraint("golem.inf.cpu.cores", this.options.minCpuCores.toString(), ComparisonOperator.GtEq) - .addConstraint("golem.inf.cpu.threads", this.options.minCpuThreads.toString(), ComparisonOperator.GtEq); - if (this.options.imageUrl) { - const taskPackage = await this.resolveTaskPackageFromCustomUrl(); - builder.addProperty("golem.srv.comp.task_package", taskPackage); - } else if (this.options.imageHash || this.options.imageTag) { - const taskPackage = await this.resolveTaskPackageUrl(); - builder.addProperty("golem.srv.comp.task_package", taskPackage); - } - - if (this.options.capabilities.length) - this.options.capabilities.forEach((cap) => builder.addConstraint("golem.runtime.capabilities", cap)); - - this.addManifestDecorations(builder); - - return builder.getDecorations(); - } - private async resolveTaskPackageFromCustomUrl(): Promise { - if (!this.options.imageUrl) { - throw new GolemPlatformError("Tried to resolve task package from custom url, but no url was provided"); - } - if (!this.options.imageHash) { - throw new GolemPlatformError( - "Tried to resolve task package from custom url, but no hash was provided. Please calculate the SHA3-224 hash of the image and provide it as `imageHash`", - ); - } - return `hash:sha3:${this.options.imageHash}:${this.options.imageUrl}`; - } - - private async resolveTaskPackageUrl(): Promise { - const repoUrl = EnvUtils.getRepoUrl(); - - //TODO : in future this should be passed probably through config - const isHttps = false; - - const isDev = EnvUtils.isDevMode(); - - let hash = this.options.imageHash; - const tag = this.options.imageTag; - - const url = `${repoUrl}/v1/image/info?${isDev ? "dev=true" : "count=true"}&${tag ? `tag=${tag}` : `hash=${hash}`}`; - - try { - const response = await fetch(url); - if (!response.ok) { - this.logger.error(`Unable to get image`, { url: tag || hash, from: repoUrl }); - throw new GolemPlatformError(`Unable to get image ${await response.text()}`); - } - - const data = await response.json(); - - const imageUrl = isHttps ? data.https : data.http; - hash = data.sha3; - - return `hash:sha3:${hash}:${imageUrl}`; - } catch (error) { - if (error instanceof GolemError) throw error; - this.logger.error(`Unable to get image`, { url: tag || hash, from: repoUrl }); - throw new GolemPlatformError(`Failed to fetch image: ${error}`); - } - } - - private addManifestDecorations(builder: DecorationsBuilder): void { - if (!this.options.manifest) return; - builder.addProperty("golem.srv.comp.payload", this.options.manifest); - if (this.options.manifestSig) builder.addProperty("golem.srv.comp.payload.sig", this.options.manifestSig); - if (this.options.manifestSigAlgorithm) - builder.addProperty("golem.srv.comp.payload.sig.algorithm", this.options.manifestSigAlgorithm); - if (this.options.manifestCert) builder.addProperty("golem.srv.comp.payload.cert", this.options.manifestCert); - } - - get details(): PackageDetails { - return { - minMemGib: this.options.minMemGib, - minStorageGib: this.options.minStorageGib, - minCpuThreads: this.options.minCpuThreads, - minCpuCores: this.options.minCpuCores, - engine: this.options.engine, - capabilities: this.options.capabilities, - imageHash: this.options.imageHash, - }; - } -} diff --git a/src/market/proposal.test.ts b/src/market/proposal.test.ts index 3b199a560..8994dad89 100644 --- a/src/market/proposal.test.ts +++ b/src/market/proposal.test.ts @@ -24,13 +24,12 @@ const buildTestProposal = (props: Partial): Proposal => { return new Proposal(testDemand, null, jest.fn(), instance(mockApi), model); }; -describe("Proposal", () => { +describe.skip("DEPRECATED Proposal", () => { beforeEach(() => { reset(allocationMock); reset(demandMock); when(allocationMock.paymentPlatform).thenReturn("test-payment-platform"); - when(demandMock.allocation).thenReturn(instance(allocationMock)); }); describe("Validation", () => { diff --git a/src/market/proposal.ts b/src/market/proposal.ts index c9f12a87e..169e67d6e 100644 --- a/src/market/proposal.ts +++ b/src/market/proposal.ts @@ -1,9 +1,11 @@ import { MarketApi } from "ya-ts-client"; import { GolemMarketError, MarketErrorCode } from "./error"; import { ProviderInfo } from "../agreement"; -import { Demand, DemandNew } from "./demand"; +import { Demand } from "./demand"; import { withTimeout } from "../shared/utils/timeout"; import { EventEmitter } from "eventemitter3"; +import { DemandBodyPrototype, DemandPropertyValue } from "./demand/demand-body-builder"; +import { DemandRequestBody } from "../shared/yagna"; export type ProposalFilterNew = (proposal: ProposalNew) => boolean; @@ -68,7 +70,7 @@ export interface ProposalDTO { export interface IProposalRepository { add(proposal: ProposalNew): ProposalNew; getById(id: string): ProposalNew | undefined; - getByDemandAndId(demand: DemandNew, id: string): Promise; + getByDemandAndId(demand: Demand, id: string): Promise; } /** @@ -85,7 +87,7 @@ export class ProposalNew { constructor( public readonly model: MarketApi.ProposalDTO, - public readonly demand: DemandNew, + public readonly demand: Demand, ) { this.id = model.proposalId; this.provider = this.getProviderInfo(); @@ -213,14 +215,14 @@ export class Proposal { /** * Create proposal for given subscription ID * - * @param demand + * @param subscription * @param parentId * @param setCounteringProposalReference * @param api * @param model */ constructor( - public readonly demand: Demand, + public readonly subscription: Demand, private readonly parentId: string | null, private readonly setCounteringProposalReference: (id: string, parentId: string) => void | null, private readonly api: MarketApi.RequestorService, @@ -240,13 +242,6 @@ export class Proposal { this.validate(); } - /** - * @deprecated Will be removed before release, glue code - */ - toNewEntity(): ProposalNew { - return new ProposalNew(this.model, this.demand.toNewEntity()); - } - getDto(): ProposalDTO { return { transferProtocol: this.properties["golem.activity.caps.transfer.protocol"], @@ -338,7 +333,7 @@ export class Proposal { async reject(reason = "no reason") { try { // eslint-disable-next-line @typescript-eslint/ban-types - await this.api.rejectProposalOffer(this.demand.id, this.id, { message: reason as {} }); + await this.api.rejectProposalOffer(this.subscription.id, this.id, { message: reason as {} }); this.events.emit("proposalRejected", { id: this.id, provider: this.provider, @@ -356,11 +351,15 @@ export class Proposal { async respond(chosenPlatform: string) { try { - (this.demand.demandRequest.properties as ProposalProperties)["golem.com.payment.chosen-platform"] = + this.buildDemandRequestBody(this.subscription.details.prototype).properties["golem.com.payment.chosen-platform"] = chosenPlatform; const counteringProposalId = await withTimeout( - this.api.counterProposalDemand(this.demand.id, this.id, this.demand.demandRequest), + this.api.counterProposalDemand( + this.subscription.id, + this.id, + this.buildDemandRequestBody(this.subscription.details.prototype), + ), 20_000, ); @@ -420,8 +419,22 @@ export class Proposal { id: this.issuerId, name: this.properties["golem.node.id.name"], walletAddress: this.properties[ - `golem.com.payment.platform.${this.demand.allocation.paymentPlatform}.address` + `golem.com.payment.platform.${this.subscription.paymentPlatform}.address` ] as string, }; } + + /** @deprecated Glue code for migration purposes */ + private buildDemandRequestBody(decorations: DemandBodyPrototype): DemandRequestBody { + let constraints: string; + + if (!decorations.constraints.length) constraints = "(&)"; + else if (decorations.constraints.length == 1) constraints = decorations.constraints[0]; + else constraints = `(&${decorations.constraints.join("\n\t")})`; + + const properties: Record = {}; + decorations.properties.forEach((prop) => (properties[prop.key] = prop.value)); + + return { constraints, properties }; + } } diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index 4b71cd3bf..ddd76c2f5 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -7,4 +7,3 @@ export { nullLogger } from "./logger/nullLogger"; export { defaultLogger } from "./logger/defaultLogger"; export * as EnvUtils from "./env"; export { YagnaApi, YagnaOptions } from "../yagna/yagnaApi"; -export { ElementOf } from "./types"; diff --git a/src/shared/yagna/adapters/market-api-adapter.test.ts b/src/shared/yagna/adapters/market-api-adapter.test.ts index 01597ac9b..01f625748 100644 --- a/src/shared/yagna/adapters/market-api-adapter.test.ts +++ b/src/shared/yagna/adapters/market-api-adapter.test.ts @@ -1,10 +1,11 @@ import { instance, when, verify, deepEqual, mock, reset, _, imock } from "@johanblumenberg/ts-mockito"; import * as YaTsClient from "ya-ts-client"; import { YagnaApi } from "../yagnaApi"; -import { MarketApiAdapter } from "./market-api-adapter"; -import { DemandNew, DemandSpecification, ProposalNew } from "../../../market"; +import { DemandRequestBody, MarketApiAdapter } from "./market-api-adapter"; +import { Demand, DemandSpecification, ProposalNew } from "../../../market"; import { take, takeUntil, timer } from "rxjs"; import { Logger } from "../../utils"; +import { DemandBodyPrototype } from "../../../market/demand/demand-body-builder"; const mockMarket = mock(YaTsClient.MarketApi.RequestorService); const mockYagna = mock(YagnaApi); @@ -19,43 +20,46 @@ beforeEach(() => { }); describe("Market Api Adapter", () => { + const samplePrototype: DemandBodyPrototype = { + constraints: ["constraints"], + properties: [ + { + key: "property-key-1", + value: "property-value-1", + }, + { + key: "property-key-2", + value: "property-value-2", + }, + ], + }; + + const expectedBody: DemandRequestBody = { + constraints: "constraints", + properties: { + "property-key-1": "property-value-1", + "property-key-2": "property-value-2", + }, + }; + describe("publishDemandSpecification()", () => { it("should publish a demand", async () => { - const specification = new DemandSpecification( - { - constraints: "constraints", - properties: { - "property-key-1": "property-value-1", - "property-key-2": "property-value-2", - }, - }, - "my-selected-payment-platform", - 60 * 60 * 1000, - ); + const specification = new DemandSpecification(samplePrototype, "my-selected-payment-platform", 60 * 60 * 1000); - when(mockMarket.subscribeDemand(deepEqual(specification.decoration))).thenResolve("demand-id"); + when(mockMarket.subscribeDemand(deepEqual(expectedBody))).thenResolve("demand-id"); const demand = await api.publishDemandSpecification(specification); - verify(mockMarket.subscribeDemand(deepEqual(specification.decoration))).once(); - expect(demand).toBeInstanceOf(DemandNew); + verify(mockMarket.subscribeDemand(deepEqual(expectedBody))).once(); + expect(demand).toBeInstanceOf(Demand); expect(demand.id).toBe("demand-id"); - expect(demand.specification).toBe(specification); + expect(demand.details).toBe(specification); }); + it("should throw an error if the demand is not published", async () => { - const specification = new DemandSpecification( - { - constraints: "constraints", - properties: { - "property-key-1": "property-value-1", - "property-key-2": "property-value-2", - }, - }, - "my-selected-payment-platform", - 60 * 60 * 1000, - ); + const specification = new DemandSpecification(samplePrototype, "my-selected-payment-platform", 60 * 60 * 1000); - when(mockMarket.subscribeDemand(deepEqual(specification.decoration))).thenResolve({ + when(mockMarket.subscribeDemand(deepEqual(expectedBody))).thenResolve({ message: "error publishing demand", }); @@ -67,19 +71,9 @@ describe("Market Api Adapter", () => { describe("unpublishDemand()", () => { it("should unpublish a demand", async () => { - const demand = new DemandNew( + const demand = new Demand( "demand-id", - new DemandSpecification( - { - constraints: "constraints", - properties: { - "property-key-1": "property-value-1", - "property-key-2": "property-value-2", - }, - }, - "my-selected-payment-platform", - 60 * 60 * 1000, - ), + new DemandSpecification(samplePrototype, "my-selected-payment-platform", 60 * 60 * 1000), ); when(mockMarket.unsubscribeDemand("demand-id")).thenResolve({}); @@ -90,19 +84,9 @@ describe("Market Api Adapter", () => { }); it("should throw an error if the demand is not unpublished", async () => { - const demand = new DemandNew( + const demand = new Demand( "demand-id", - new DemandSpecification( - { - constraints: "constraints", - properties: { - "property-key-1": "property-value-1", - "property-key-2": "property-value-2", - }, - }, - "my-selected-payment-platform", - 60 * 60 * 1000, - ), + new DemandSpecification(samplePrototype, "my-selected-payment-platform", 60 * 60 * 1000), ); when(mockMarket.unsubscribeDemand("demand-id")).thenResolve({ @@ -117,32 +101,23 @@ describe("Market Api Adapter", () => { describe("counterProposal()", () => { it("should negotiate a proposal with the selected payment platform", async () => { - const specification = new DemandSpecification( - { - constraints: "constraints", - properties: { - "property-key-1": "property-value-1", - "property-key-2": "property-value-2", - }, - }, - "my-selected-payment-platform", - 60 * 60 * 1000, - ); + const specification = new DemandSpecification(samplePrototype, "my-selected-payment-platform", 60 * 60 * 1000); + const receivedProposal = new ProposalNew( { - ...specification.decoration, + ...expectedBody, proposalId: "proposal-id", timestamp: "0000-00-00", issuerId: "issuer-id", state: "Initial", }, - new DemandNew("demand-id", specification), + new Demand("demand-id", specification), ); when(mockMarket.counterProposalDemand(_, _, _)).thenResolve("counter-id"); when(mockMarket.getProposalOffer("demand-id", "counter-id")).thenResolve({ - ...specification.decoration, + ...expectedBody, proposalId: "counter-id", timestamp: "0000-00-00", issuerId: "issuer-id", @@ -170,26 +145,16 @@ describe("Market Api Adapter", () => { expect(counterProposal.demand).toBe(receivedProposal.demand); }); it("should throw an error if the counter proposal fails", async () => { - const specification = new DemandSpecification( - { - constraints: "constraints", - properties: { - "property-key-1": "property-value-1", - "property-key-2": "property-value-2", - }, - }, - "my-selected-payment-platform", - 60 * 60 * 1000, - ); + const specification = new DemandSpecification(samplePrototype, "my-selected-payment-platform", 60 * 60 * 1000); const receivedProposal = new ProposalNew( { - ...specification.decoration, + ...expectedBody, proposalId: "proposal-id", timestamp: "0000-00-00", issuerId: "issuer-id", state: "Initial", }, - new DemandNew("demand-id", specification), + new Demand("demand-id", specification), ); when(mockMarket.counterProposalDemand(_, _, _)).thenResolve({ @@ -203,7 +168,7 @@ describe("Market Api Adapter", () => { }); describe("observeProposalEvents()", () => { it("should long poll for proposals", (done) => { - const mockDemand = mock(DemandNew); + const mockDemand = mock(Demand); when(mockDemand.id).thenReturn("demand-id"); const mockProposalDTO = imock(); when(mockProposalDTO.issuerId).thenReturn("issuer-id"); @@ -238,7 +203,7 @@ describe("Market Api Adapter", () => { }); }); it("should cleanup the long poll when unsubscribed", (done) => { - const mockDemand = mock(DemandNew); + const mockDemand = mock(Demand); when(mockDemand.id).thenReturn("demand-id"); const cancelSpy = jest.fn(); diff --git a/src/shared/yagna/adapters/market-api-adapter.ts b/src/shared/yagna/adapters/market-api-adapter.ts index 0b3a11c61..d954d23a6 100644 --- a/src/shared/yagna/adapters/market-api-adapter.ts +++ b/src/shared/yagna/adapters/market-api-adapter.ts @@ -1,9 +1,18 @@ import { Observable } from "rxjs"; -import { DemandNew, DemandSpecification, MarketApi, ProposalEvent, ProposalNew } from "../../../market"; +import { Demand, DemandSpecification, MarketApi, ProposalEvent, ProposalNew } from "../../../market"; import { YagnaApi } from "../yagnaApi"; import YaTsClient from "ya-ts-client"; import { GolemInternalError } from "../../error/golem-error"; import { Logger } from "../../utils"; +import { DemandBodyPrototype, DemandPropertyValue } from "../../../market/demand/demand-body-builder"; + +/** + * A bit more user-friendly type definition of DemandOfferBaseDTO from ya-ts-client + */ +export type DemandRequestBody = { + properties: Record; + constraints: string; +}; export class MarketApiAdapter implements MarketApi { constructor( @@ -11,23 +20,27 @@ export class MarketApiAdapter implements MarketApi { private readonly logger: Logger, ) {} - async publishDemandSpecification(specification: DemandSpecification): Promise { - const idOrError = await this.yagnaApi.market.subscribeDemand(specification.decoration); + async publishDemandSpecification(demand: DemandSpecification): Promise { + const idOrError = await this.yagnaApi.market.subscribeDemand(this.buildDemandRequestBody(demand.prototype)); + if (typeof idOrError !== "string") { throw new Error(`Failed to subscribe to demand: ${idOrError.message}`); } - return new DemandNew(idOrError, specification); + + return new Demand(idOrError, demand); } - async unpublishDemand(demand: DemandNew): Promise { + async unpublishDemand(demand: Demand): Promise { const result = await this.yagnaApi.market.unsubscribeDemand(demand.id); + if (result?.message) { throw new Error(`Failed to unsubscribe from demand: ${result.message}`); } + this.logger.info("Demand unsubscribed", { demand: demand.id }); } - observeProposalEvents(demand: DemandNew): Observable { + observeProposalEvents(demand: Demand): Observable { return new Observable((subscriber) => { let proposalPromise: YaTsClient.MarketApi.CancelablePromise; let isCancelled = false; @@ -53,9 +66,12 @@ export class MarketApiAdapter implements MarketApi { } subscriber.error(error); } - longPoll(); + + longPoll().catch((err) => subscriber.error(err)); }; - longPoll(); + + longPoll().catch((err) => subscriber.error(err)); + return () => { isCancelled = true; proposalPromise.cancel(); @@ -63,13 +79,14 @@ export class MarketApiAdapter implements MarketApi { }); } - async counterProposal(receivedProposal: ProposalNew, specification: DemandSpecification): Promise { - const decorationClone = structuredClone(specification.decoration); - decorationClone.properties["golem.com.payment.chosen-platform"] = specification.paymentPlatform; + async counterProposal(receivedProposal: ProposalNew, demand: DemandSpecification): Promise { + const bodyClone = structuredClone(this.buildDemandRequestBody(demand.prototype)); + + bodyClone.properties["golem.com.payment.chosen-platform"] = demand.paymentPlatform; const maybeNewId = await this.yagnaApi.market.counterProposalDemand( receivedProposal.demand.id, receivedProposal.id, - decorationClone, + bodyClone, ); if (typeof maybeNewId !== "string") { throw new GolemInternalError(`Counter proposal failed ${maybeNewId.message}`); @@ -77,4 +94,17 @@ export class MarketApiAdapter implements MarketApi { const counterProposalDto = await this.yagnaApi.market.getProposalOffer(receivedProposal.demand.id, maybeNewId); return new ProposalNew(counterProposalDto, receivedProposal.demand); } + + private buildDemandRequestBody(decorations: DemandBodyPrototype): DemandRequestBody { + let constraints: string; + + if (!decorations.constraints.length) constraints = "(&)"; + else if (decorations.constraints.length == 1) constraints = decorations.constraints[0]; + else constraints = `(&${decorations.constraints.join("\n\t")})`; + + const properties: Record = {}; + decorations.properties.forEach((prop) => (properties[prop.key] = prop.value)); + + return { constraints, properties }; + } } diff --git a/src/shared/yagna/repository/demand-repository.ts b/src/shared/yagna/repository/demand-repository.ts index 74c4c653f..463b94d6f 100644 --- a/src/shared/yagna/repository/demand-repository.ts +++ b/src/shared/yagna/repository/demand-repository.ts @@ -1,23 +1,23 @@ -import { DemandNew, IDemandRepository } from "../../../market/demand"; +import { Demand, IDemandRepository } from "../../../market/demand"; import { MarketApi } from "ya-ts-client"; import { CacheService } from "../../cache/CacheService"; export class DemandRepository implements IDemandRepository { constructor( private readonly api: MarketApi.RequestorService, - private readonly cache: CacheService, + private readonly cache: CacheService, ) {} - getById(id: string): DemandNew | undefined { + getById(id: string): Demand | undefined { return this.cache.get(id); } - add(demand: DemandNew): DemandNew { + add(demand: Demand): Demand { this.cache.set(demand.id, demand); return demand; } - getAll(): DemandNew[] { + getAll(): Demand[] { return this.cache.getAll(); } } diff --git a/src/shared/yagna/repository/proposal-repository.ts b/src/shared/yagna/repository/proposal-repository.ts index 28578741d..41df97a5f 100644 --- a/src/shared/yagna/repository/proposal-repository.ts +++ b/src/shared/yagna/repository/proposal-repository.ts @@ -1,6 +1,6 @@ import { IProposalRepository, ProposalNew } from "../../../market/proposal"; import { MarketApi } from "ya-ts-client"; -import { DemandNew } from "../../../market"; +import { Demand } from "../../../market"; import { CacheService } from "../../cache/CacheService"; export class ProposalRepository implements IProposalRepository { @@ -18,7 +18,7 @@ export class ProposalRepository implements IProposalRepository { return this.cache.get(id); } - async getByDemandAndId(demand: DemandNew, id: string): Promise { + async getByDemandAndId(demand: Demand, id: string): Promise { const dto = await this.api.getProposalOffer(demand.id, id); return new ProposalNew(dto, demand); } diff --git a/src/shared/yagna/yagnaApi.ts b/src/shared/yagna/yagnaApi.ts index e061b44a4..27bc0e82a 100644 --- a/src/shared/yagna/yagnaApi.ts +++ b/src/shared/yagna/yagnaApi.ts @@ -2,13 +2,14 @@ import * as YaTsClient from "ya-ts-client"; import * as EnvUtils from "../utils/env"; import { GolemConfigError, GolemPlatformError } from "../error/golem-error"; import { v4 } from "uuid"; -import { defaultLogger, ElementOf, Logger } from "../utils"; +import { defaultLogger, Logger } from "../utils"; import semverSatisfies from "semver/functions/satisfies.js"; // .js added for ESM compatibility import semverCoerce from "semver/functions/coerce.js"; // .js added for ESM compatibility import { BehaviorSubject, Observable } from "rxjs"; import { CancellablePoll, EventReaderFactory } from "./event-reader-factory"; import EventSource from "eventsource"; import { StreamingBatchEvent } from "../../activity/results"; +import { ElementOf } from "../utils/types"; export type YagnaOptions = { apiKey?: string; diff --git a/tests/e2e/activityPool.spec.ts b/tests/e2e/activityPool.spec.ts index 5096f8d3f..0302d7d56 100644 --- a/tests/e2e/activityPool.spec.ts +++ b/tests/e2e/activityPool.spec.ts @@ -1,12 +1,4 @@ -import { - ActivityPool, - AgreementPool, - Allocation, - DraftOfferProposalPool, - GolemNetwork, - Package, - YagnaApi, -} from "../../src"; +import { ActivityPool, AgreementPool, DraftOfferProposalPool, GolemNetwork, YagnaApi } from "../../src"; describe("ActivityPool", () => { const glm = new GolemNetwork(); @@ -35,9 +27,11 @@ describe("ActivityPool", () => { proposalPool = new DraftOfferProposalPool(); agreementPool = new AgreementPool(proposalPool, glm.services.agreementApi); const payerDetails = await modules.payment.getPayerDetails(); - const demandSpecification = await modules.market.buildDemand( + const demandSpecification = await modules.market.buildDemandDetails( { - imageTag: "golem/alpine:latest", + activity: { + imageTag: "golem/alpine:latest", + }, }, payerDetails, ); diff --git a/tests/e2e/express.spec.ts b/tests/e2e/express.spec.ts index 8814a5684..b99a6b503 100644 --- a/tests/e2e/express.spec.ts +++ b/tests/e2e/express.spec.ts @@ -29,7 +29,9 @@ describe("Express", function () { } const job = golemClient.createJob({ demand: { - imageTag: "severyn/espeak:latest", + activity: { + imageTag: "severyn/espeak:latest", + }, }, // TODO: This should be optional market: {}, diff --git a/tests/unit/agreement_pool_service.test.ts b/tests/unit/agreement_pool_service.test.ts index 2a4dc87c5..39bfc92e8 100644 --- a/tests/unit/agreement_pool_service.test.ts +++ b/tests/unit/agreement_pool_service.test.ts @@ -1,5 +1,5 @@ import { anything, imock, instance, mock, reset, when } from "@johanblumenberg/ts-mockito"; -import { Agreement, AgreementPoolService, DemandNew, Proposal, ProposalNew, YagnaApi } from "../../src"; +import { Agreement, AgreementPoolService, Demand, Proposal, ProposalNew, YagnaApi } from "../../src"; import { MarketApi } from "ya-ts-client"; import { LoggerMock } from "../mock/utils/logger"; import { IAgreementApi } from "../../src/agreement/agreement"; @@ -15,7 +15,7 @@ const marketApi = instance(mockMarket); const mockAgreementApi = imock(); const createProposal = (id: string) => { - const demandMock = mock(DemandNew); + const demandMock = mock(Demand); when(demandMock.id).thenReturn(id); const testDemand = instance(demandMock); diff --git a/tests/unit/decorations_builder.test.ts b/tests/unit/decorations_builder.test.ts index 5be75e2b6..ecff29aa6 100644 --- a/tests/unit/decorations_builder.test.ts +++ b/tests/unit/decorations_builder.test.ts @@ -1,60 +1,60 @@ -import { ComparisonOperator, DecorationsBuilder } from "../../src/market/builder"; +import { ComparisonOperator, DemandBodyBuilder } from "../../src/market/demand/demand-body-builder"; import { GolemInternalError } from "../../src/shared/error/golem-error"; describe("#DecorationsBuilder()", () => { describe("addProperty()", () => { it("should allow to add property", () => { - const decorationsBuilder = new DecorationsBuilder(); - decorationsBuilder.addProperty("key", "value"); - expect(decorationsBuilder.getDecorations().properties.length).toEqual(1); + const builder = new DemandBodyBuilder(); + builder.addProperty("key", "value"); + expect(builder.getProduct().properties.length).toEqual(1); }); it("should replace already existing property", () => { - const decorationsBuilder = new DecorationsBuilder(); - decorationsBuilder.addProperty("key", "value").addProperty("key", "value2"); - expect(decorationsBuilder.getDecorations().properties.length).toEqual(1); - expect(decorationsBuilder.getDecorations().properties[0].value).toEqual("value2"); + const builder = new DemandBodyBuilder(); + builder.addProperty("key", "value").addProperty("key", "value2"); + expect(builder.getProduct().properties.length).toEqual(1); + expect(builder.getProduct().properties[0].value).toEqual("value2"); }); it("should provide fluent API", () => { - const decorationsBuilder = new DecorationsBuilder(); - const flAPI = decorationsBuilder.addProperty("key", "value"); - expect(flAPI).toBeInstanceOf(DecorationsBuilder); + const builder = new DemandBodyBuilder(); + const flAPI = builder.addProperty("key", "value"); + expect(flAPI).toBeInstanceOf(DemandBodyBuilder); }); }); describe("addConstraint()", () => { it("should allow to add constrain", () => { - const decorationsBuilder = new DecorationsBuilder(); - decorationsBuilder.addConstraint("key", "value"); - expect(decorationsBuilder.getDecorations().constraints.length).toEqual(1); + const builder = new DemandBodyBuilder(); + builder.addConstraint("key", "value"); + expect(builder.getProduct().constraints.length).toEqual(1); }); it("should allow to add constrain with >=", () => { - const decorationsBuilder = new DecorationsBuilder(); - decorationsBuilder.addConstraint("key", "value", ComparisonOperator.GtEq); - expect(decorationsBuilder.getDecorations().constraints.length).toEqual(1); + const builder = new DemandBodyBuilder(); + builder.addConstraint("key", "value", ComparisonOperator.GtEq); + expect(builder.getProduct().constraints.length).toEqual(1); }); it("should allow to add constrain with <=", () => { - const decorationsBuilder = new DecorationsBuilder(); - decorationsBuilder.addConstraint("key", "value", ComparisonOperator.LtEq); - expect(decorationsBuilder.getDecorations().constraints.length).toEqual(1); + const builder = new DemandBodyBuilder(); + builder.addConstraint("key", "value", ComparisonOperator.LtEq); + expect(builder.getProduct().constraints.length).toEqual(1); }); it("should allow to add constrain with >", () => { - const decorationsBuilder = new DecorationsBuilder(); - decorationsBuilder.addConstraint("key", "value", ComparisonOperator.Gt); - expect(decorationsBuilder.getDecorations().constraints.length).toEqual(1); + const builder = new DemandBodyBuilder(); + builder.addConstraint("key", "value", ComparisonOperator.Gt); + expect(builder.getProduct().constraints.length).toEqual(1); }); it("should allow to add constrain with <", () => { - const decorationsBuilder = new DecorationsBuilder(); - decorationsBuilder.addConstraint("key", "value", ComparisonOperator.Lt); - expect(decorationsBuilder.getDecorations().constraints.length).toEqual(1); + const builder = new DemandBodyBuilder(); + builder.addConstraint("key", "value", ComparisonOperator.Lt); + expect(builder.getProduct().constraints.length).toEqual(1); }); it("should allow to add constrain with =", () => { - const decorationsBuilder = new DecorationsBuilder(); - decorationsBuilder.addConstraint("key", "value", ComparisonOperator.Eq); - expect(decorationsBuilder.getDecorations().constraints.length).toEqual(1); + const builder = new DemandBodyBuilder(); + builder.addConstraint("key", "value", ComparisonOperator.Eq); + expect(builder.getProduct().constraints.length).toEqual(1); }); it("should provide fluent API", () => { - const decorationsBuilder = new DecorationsBuilder(); - const flAPI = decorationsBuilder.addConstraint("key", "value"); - expect(flAPI).toBeInstanceOf(DecorationsBuilder); + const builder = new DemandBodyBuilder(); + const flAPI = builder.addConstraint("key", "value"); + expect(flAPI).toBeInstanceOf(DemandBodyBuilder); }); }); describe("addDecorations()", () => { @@ -69,9 +69,9 @@ describe("#DecorationsBuilder()", () => { ], properties: [], }; - const decorationsBuilder = new DecorationsBuilder(); - decorationsBuilder.addDecoration(decoration); - expect(decorationsBuilder.getDecorations().constraints.length).toEqual(5); + const builder = new DemandBodyBuilder(); + builder.mergePrototype(decoration); + expect(builder.getProduct().constraints.length).toEqual(5); }); it("should allow to add decorations", () => { @@ -79,19 +79,19 @@ describe("#DecorationsBuilder()", () => { properties: [{ key: "prop_key", value: "value" }], constraints: ["some_constraint=some_value"], }; - const decorationsBuilder = new DecorationsBuilder(); - decorationsBuilder.addDecoration(decoration); - expect(decorationsBuilder.getDecorations().constraints.length).toEqual(1); - expect(decorationsBuilder.getDecorations().properties.length).toEqual(1); + const builder = new DemandBodyBuilder(); + builder.mergePrototype(decoration); + expect(builder.getProduct().constraints.length).toEqual(1); + expect(builder.getProduct().properties.length).toEqual(1); }); it("should provide fluent API", () => { const decoration = { properties: [{ key: "prop_key", value: "value" }], constraints: ["some_constraint=some_value"], }; - const decorationsBuilder = new DecorationsBuilder(); - const flAPI = decorationsBuilder.addDecoration(decoration); - expect(flAPI).toBeInstanceOf(DecorationsBuilder); + const builder = new DemandBodyBuilder(); + const flAPI = builder.mergePrototype(decoration); + expect(flAPI).toBeInstanceOf(DemandBodyBuilder); }); it("should not allow to add invalid decorations", () => { @@ -99,16 +99,16 @@ describe("#DecorationsBuilder()", () => { properties: [{ key: "prop_key", value: "value" }], constraints: ["some_invalid_constraint"], }; - const decorationsBuilder = new DecorationsBuilder(); - expect(() => decorationsBuilder.addDecoration(decoration)).toThrow( + const builder = new DemandBodyBuilder(); + expect(() => builder.mergePrototype(decoration)).toThrow( new GolemInternalError('Unable to parse constraint "some_invalid_constraint"'), ); }); }); describe("getDecorations()", () => { it("should return correct decoration", () => { - const decorationsBuilder = new DecorationsBuilder(); - decorationsBuilder + const builder = new DemandBodyBuilder(); + builder .addConstraint("key", "value", ComparisonOperator.Eq) .addConstraint("key", "value", ComparisonOperator.GtEq) .addConstraint("key", "value", ComparisonOperator.LtEq) @@ -117,10 +117,10 @@ describe("#DecorationsBuilder()", () => { .addProperty("key", "value") .addProperty("key2", "value"); - expect(decorationsBuilder.getDecorations().constraints.length).toEqual(5); - expect(decorationsBuilder.getDecorations().properties.length).toEqual(2); + expect(builder.getProduct().constraints.length).toEqual(5); + expect(builder.getProduct().properties.length).toEqual(2); - expect(decorationsBuilder.getDecorations().constraints).toEqual([ + expect(builder.getProduct().constraints).toEqual([ "(key=value)", "(key>=value)", "(key<=value)", @@ -128,7 +128,7 @@ describe("#DecorationsBuilder()", () => { "(key { - beforeEach(() => { - reset(mockYagna); - reset(mockMarket); - reset(mockPayment); - reset(mockPackage); - reset(mockAllocation); - - when(mockYagna.market).thenReturn(instance(mockMarket)); - when(mockYagna.payment).thenReturn(instance(mockPayment)); - - when(mockPackage.getDemandDecoration()).thenResolve({ - properties: [{ key: "", value: "" }], - constraints: [], - }); - - when(mockAllocation.getDemandDecoration()).thenResolve({ - properties: [{ key: "", value: "" }], - constraints: [], - }); - - when(mockAllocation.paymentPlatform).thenReturn("erc20-holesky-tglm"); - - when(mockPayment.getDemandDecorations(anything())).thenResolve({ - properties: [{ key: "", value: "" }], - constraints: [], - }); - - when(mockMarket.subscribeDemand(anything())).thenResolve("demand-id"); - }); - - describe("Creating", () => { - it("should create and publish demand", async () => { - const demand = await Demand.create(instance(mockPackage), instance(mockAllocation), yagnaApi, { - subnetTag, - logger, - }); - expect(demand).toBeInstanceOf(Demand); - expect(logger.logs).toContain("Demand published on the market"); - await demand.unsubscribe(); - }); - }); - - describe("Processing", () => { - it("should get proposal after publish demand", async () => { - const demand = await Demand.create(instance(mockPackage), instance(mockAllocation), yagnaApi, { subnetTag }); - - when(mockMarket.collectOffers(anything(), anything(), anything())).thenResolve(proposalsInitial); - - const proposal = await new Promise((res) => demand.events.on("proposalReceived", (proposal) => res(proposal))); - expect(proposal).toBeInstanceOf(Proposal); - await demand.unsubscribe(); - }); - }); - - describe("Error handling", () => { - it("should throw market error if demand cannot be created", async () => { - const testError = new Error("Test error"); - - when(mockMarket.subscribeDemand(anything())).thenThrow(testError); - - await expect(Demand.create(instance(mockPackage), instance(mockAllocation), yagnaApi)).rejects.toMatchError( - new GolemMarketError( - `Could not publish demand on the market. Error: Test error`, - MarketErrorCode.SubscriptionFailed, - ), - ); - }); - - it("should throw user error if expiration option is invalid", async () => { - await expect( - Demand.create(instance(mockPackage), instance(mockAllocation), yagnaApi, { expirationSec: -3 }), - ).rejects.toMatchError(new GolemConfigError("The demand expiration time has to be a positive integer")); - }); - - it("should throw user error if debitNotesAcceptanceTimeoutSec option is invalid", async () => { - await expect( - Demand.create(instance(mockPackage), instance(mockAllocation), yagnaApi, { - debitNotesAcceptanceTimeoutSec: -3, - }), - ).rejects.toMatchError( - new GolemConfigError("The debit note acceptance timeout time has to be a positive integer"), - ); - }); - - it("should throw user error if midAgreementDebitNoteIntervalSec option is invalid", async () => { - await expect( - Demand.create(instance(mockPackage), instance(mockAllocation), yagnaApi, { - midAgreementDebitNoteIntervalSec: -3, - }), - ).rejects.toMatchError(new GolemConfigError("The debit note interval time has to be a positive integer")); - }); - - it("should throw user error if midAgreementPaymentTimeoutSec option is invalid", async () => { - await expect( - Demand.create(instance(mockPackage), instance(mockAllocation), yagnaApi, { midAgreementPaymentTimeoutSec: -3 }), - ).rejects.toMatchError( - new GolemConfigError("The mid-agreement payment timeout time has to be a positive integer"), - ); - }); - }); -}); diff --git a/tests/unit/package.test.ts b/tests/unit/package.test.ts deleted file mode 100644 index 12c5e3ed6..000000000 --- a/tests/unit/package.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Package } from "../../src"; -import { LoggerMock } from "../mock/utils/logger"; -const logger = new LoggerMock(); - -describe("Package", () => { - describe("create()", () => { - it("should create package", async () => { - const p = await Package.create({ imageHash: "image_hash", logger }); - expect(p).toBeInstanceOf(Package); - }); - it("should return decorators with task_package and package_format", async () => { - // Due to missing mocking and DI approach this tests is not mocked - // and makes real request to the registry - // ? Shouldnt we avoid this - - const p = await Package.create({ - imageHash: "529f7fdaf1cf46ce3126eb6bbcd3b213c314fe8fe884914f5d1106d4", - }); - - const decorations = await p.getDemandDecoration(); - - expect(decorations.properties).toEqual( - expect.arrayContaining([ - { - key: "golem.srv.comp.task_package", - value: - "hash:sha3:529f7fdaf1cf46ce3126eb6bbcd3b213c314fe8fe884914f5d1106d4:http://registry.golem.network/download/529f7fdaf1cf46ce3126eb6bbcd3b213c314fe8fe884914f5d1106d4", - }, - { key: "golem.srv.comp.vm.package_format", value: "gvmkit-squash" }, - ]), - ); - }); - it("should create package with manifest decorations", async () => { - const manifest = "XNBdCI6ICIyMTAwLTAxLTAxVDAwOjAxOjAwLjAwMDAwMFoiLAogICJtZXRhZGF0YSI6IHsKICAgICJuYW1lI="; - const manifestSig = "GzbdJDaW6FTajVYCKKZZvwpwVNBK3o40r/okna87wV9CVWW0+WUFwe="; - const manifestCert = "HCkExVUVDZ3dOUjI5c1pXMGdSbUZqZEc5eWVURW1NQ1FHQTFVRUF3d2RSMjlzWl="; - const manifestSigAlgorithm = "sha256"; - const capabilities = ["inet", "manifest-support"]; - const p = await Package.create({ - manifest, - manifestSig, - manifestCert, - manifestSigAlgorithm, - capabilities, - }); - const decorations = await p.getDemandDecoration(); - expect(decorations.properties).toEqual( - expect.arrayContaining([ - { key: "golem.srv.comp.payload", value: manifest }, - { key: "golem.srv.comp.payload.sig", value: manifestSig }, - { key: "golem.srv.comp.payload.cert", value: manifestCert }, - { key: "golem.srv.comp.payload.sig.algorithm", value: manifestSigAlgorithm }, - { key: "golem.srv.comp.vm.package_format", value: "gvmkit-squash" }, - ]), - ); - expect(decorations.constraints).toEqual([ - "(golem.inf.mem.gib>=0.5)", - "(golem.inf.storage.gib>=2)", - "(golem.runtime.name=vm)", - "(golem.inf.cpu.cores>=1)", - "(golem.inf.cpu.threads>=1)", - "(golem.runtime.capabilities=inet)", - "(golem.runtime.capabilities=manifest-support)", - ]); - }); - }); -});