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)", - ]); - }); - }); -});