diff --git a/src/sdl/SDL/SDL.spec.ts b/src/sdl/SDL/SDL.spec.ts index bae9275..33d3465 100644 --- a/src/sdl/SDL/SDL.spec.ts +++ b/src/sdl/SDL/SDL.spec.ts @@ -37,6 +37,107 @@ describe("SDL", () => { }); }); + describe("endpoints", () => { + it("should resolve with valid endpoints", () => { + const endpointName = faker.lorem.word(); + const endpoint = { + [endpointName]: { + kind: "ip" + } + }; + const yml = readBasicSdl({ endpoint }); + const sdl = SDL.fromString(yml, "beta3", "sandbox"); + + expect(sdl.manifest()).toMatchObject([ + { + services: [ + { + resources: { + endpoints: { + 1: { + kind: 2, + sequence_number: 1 + } + } + }, + expose: [ + { + ip: endpointName, + endpointSequenceNumber: 1 + } + ] + } + ] + } + ]); + expect(sdl.groups()).toMatchObject([ + { + resources: [ + { + resource: { + endpoints: { + 1: { + kind: 2, + sequence_number: 1 + } + } + } + } + ] + } + ]); + }); + + it("should throw provided an invalid endpoint name", () => { + const endpointName = faker.number.int().toString(); + const endpoint = { + [endpointName]: { + kind: "ip" + } + }; + const yml = readBasicSdl({ endpoint }); + + expect(() => SDL.fromString(yml, "beta3", "sandbox")).toThrowError(new SdlValidationError(`Endpoint named "${endpointName}" is not a valid name.`)); + }); + + it("should throw provided no endpoint kind", () => { + const endpointName = faker.lorem.word(); + const endpoint = { + [endpointName]: {} + }; + const yml = readBasicSdl({ endpoint }); + + expect(() => SDL.fromString(yml, "beta3", "sandbox")).toThrowError(new SdlValidationError(`Endpoint named "${endpointName}" has no kind.`)); + }); + + it("should throw provided invalid endpoint kind", () => { + const endpointName = faker.lorem.word(); + const endpointKind = faker.lorem.word(); + const endpoint = { + [endpointName]: { + kind: endpointKind + } + }; + const yml = readBasicSdl({ endpoint }); + + expect(() => SDL.fromString(yml, "beta3", "sandbox")).toThrowError( + new SdlValidationError(`Endpoint named "${endpointName}" has an unknown kind "${endpointKind}".`) + ); + }); + + it("should throw when endpoint is unused", () => { + const endpointName = faker.lorem.word(); + const endpoint = { + [endpointName]: { + kind: "ip" + } + }; + const yml = readBasicSdl({ endpoint, endpointRef: undefined }); + + expect(() => SDL.fromString(yml, "beta3", "sandbox")).toThrowError(new SdlValidationError(`Endpoint ${endpointName} declared but never used.`)); + }); + }); + describe("service image credentials", () => { it("should resolve a service with valid credentials", () => { const credentials = { diff --git a/src/sdl/SDL/SDL.ts b/src/sdl/SDL/SDL.ts index c707752..cbf9584 100644 --- a/src/sdl/SDL/SDL.ts +++ b/src/sdl/SDL/SDL.ts @@ -66,7 +66,7 @@ export class SDL { // to v2 or v3 SDL only after being validated const data = YAML.load(yaml) as v3Sdl; - for (const [name, profile] of Object.entries(data.profiles.compute)) { + for (const [name, profile] of Object.entries(data.profiles.compute || {})) { this.validateGPU(name, profile.resources.gpu); this.validateStorage(name, profile.resources.storage); } @@ -157,7 +157,7 @@ export class SDL { // TODO: this should really be cast to unknown, then assigned // to v2 or v3 SDL only after being validated const v3data = this.data as v3Sdl; - Object.entries(v3data.profiles.compute).forEach(([name, { resources }]) => { + Object.entries(v3data.profiles.compute || {}).forEach(([name, { resources }]) => { if ("gpu" in resources) { SDL.validateGPU(name, resources.gpu); } @@ -173,6 +173,7 @@ export class SDL { }); this.validateDenom(); + this.validateEndpointsUtility(); } private validateDenom() { @@ -215,12 +216,12 @@ export class SDL { Object.keys(this.data.deployment[serviceName]).forEach(deploymentName => { const serviceDeployment = this.data.deployment[serviceName][deploymentName]; - const compute = this.data.profiles.compute[serviceDeployment.profile]; - const infra = this.data.profiles.placement[deploymentName]; + const compute = this.data.profiles.compute?.[serviceDeployment.profile]; + const infra = this.data.profiles.placement?.[deploymentName]; SdlValidationError.assert(infra, `The placement "${deploymentName}" is not defined in the "placement" section.`); SdlValidationError.assert( - infra.pricing[serviceDeployment.profile], + infra.pricing?.[serviceDeployment.profile], `The pricing for the "${serviceDeployment.profile}" profile is not defined in the "${deploymentName}" "placement" definition.` ); SdlValidationError.assert(compute, `The compute requirements for the "${serviceDeployment.profile}" profile are not defined in the "compute" section.`); @@ -253,6 +254,14 @@ export class SDL { }); } + private validateEndpointsUtility() { + if (this.data.endpoints) { + Object.keys(this.data.endpoints).forEach(endpoint => { + SdlValidationError.assert(this.endpointsUsed.has(endpoint), `Endpoint ${endpoint} declared but never used.`); + }); + } + } + services() { if (this.data) { return this.data.services; @@ -849,7 +858,7 @@ export class SDL { const pricing = infra.pricing[svcdepl.profile]; const price = { ...pricing, - amount: pricing.amount.toString() + amount: pricing.amount?.toString() }; let group = groups.get(placementName); diff --git a/test/fixtures/sdl-basic.yml b/test/fixtures/sdl-basic.yml index 2282629..b61a6ac 100644 --- a/test/fixtures/sdl-basic.yml +++ b/test/fixtures/sdl-basic.yml @@ -8,6 +8,7 @@ services: as: 80 to: - global: true + ${endpointRef} ${credentials} profiles: @@ -33,3 +34,5 @@ deployment: dcloud: profile: web count: 1 + +${endpoints} \ No newline at end of file diff --git a/test/yml.ts b/test/yml.ts index bb836a4..5818812 100644 --- a/test/yml.ts +++ b/test/yml.ts @@ -13,11 +13,11 @@ export const readYml = (name: string): string => { }; type YmlInputObject = { - [key: string]: string | number | boolean | null | YmlInputObject; + [key: string]: string | number | boolean | null | YmlInputObject | YmlInputObject[]; }; export const toYmlFragment = (object: YmlInputObject, options?: { nestingLevel: number }): string => { - const yamlString = dump(object); + const yamlString = dump(object, { forceQuotes: true, quotingType: '"' }); if (!options) { return yamlString; @@ -37,15 +37,23 @@ interface BasicSdlTemplateVariables { username?: string; password?: string; }; + endpoint?: Record; + endpointRef?: { ip: string }; } const readBasicSdlTemplate = memoize(() => template(readYml("sdl-basic"))); export const readBasicSdl = (variables: BasicSdlTemplateVariables = {}): string => { const createYML = readBasicSdlTemplate(); - const ymlVars: Record = { + const endpointName = Object.keys(variables.endpoint || {})[0]; + const ymlVars: Record | "endpoints" | "endpointRef", string> = { denom: variables.denom || faker.helpers.arrayElement([AKT_DENOM, USDC_IBC_DENOMS[SANDBOX_ID]]), - credentials: variables.credentials ? toYmlFragment(pick(variables, "credentials"), { nestingLevel: 2 }) : "" + credentials: variables.credentials ? toYmlFragment(pick(variables, "credentials"), { nestingLevel: 2 }) : "", + endpoints: variables.endpoint ? toYmlFragment({ endpoints: variables.endpoint }) : "", + endpointRef: + ("endpointRef" in variables && toYmlFragment(pick(variables, "endpointRef"), { nestingLevel: 3 })) || + (endpointName && toYmlFragment({ ip: endpointName })) || + "" }; return createYML(ymlVars); diff --git a/tsconfig.json b/tsconfig.json index 3153954..48cf78f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -69,5 +69,5 @@ "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ }, "include": ["./src/**/*"], - "exclude": ["./examples", "./tests"] + "exclude": ["./examples", "./tests", "./test", "./src/**/*.spec.ts"] }