Skip to content

Commit

Permalink
feat(sdl): add storage and gpu validation
Browse files Browse the repository at this point in the history
  • Loading branch information
ygrishajev committed May 16, 2024
1 parent e7aa7f2 commit d0744f5
Show file tree
Hide file tree
Showing 4 changed files with 316 additions and 33 deletions.
183 changes: 182 additions & 1 deletion src/sdl/SDL/SDL.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { faker } from "@faker-js/faker";
import omit from "lodash/omit";

import { createGroups, createManifest, createSdlYml } from "../../../test/templates";
import { createGroups, createManifest, createSdlJson, createSdlYml } from "../../../test/templates";
import { AKT_DENOM, SANDBOX_ID, USDC_IBC_DENOMS } from "../../config/network";
import { SdlValidationError } from "../../error";
import { v2ServiceImageCredentials } from "../types";
Expand Down Expand Up @@ -225,4 +225,185 @@ describe("SDL", () => {
});
});
});

describe("deployment validation", () => {
it("should throw an error when a service is not defined in deployment", () => {
const yml = createSdlYml({
deployment: { $unset: ["web"] }
});

expect(() => SDL.fromString(yml, "beta3", "sandbox")).toThrowError(new SdlValidationError('Service "web" is not defined in the "deployment" section.'));
});

it("should throw an error when deployment is not defined in profile placement", () => {
const yml = createSdlYml({
"profiles.placement": { $unset: ["dcloud"] }
});

expect(() => SDL.fromString(yml, "beta3", "sandbox")).toThrowError(
new SdlValidationError('The placement "dcloud" is not defined in the "placement" section.')
);
});

it("should throw an error when a service is not defined in deployment", () => {
const yml = createSdlYml({
"profiles.compute": { $unset: ["web"] }
});

expect(() => SDL.fromString(yml, "beta3", "sandbox")).toThrowError(
new SdlValidationError('The compute requirements for the "web" profile are not defined in the "compute" section.')
);
});
});

describe("storage validation", () => {
it("should throw an error when a service references a non-existing volume", () => {
const yml = createSdlYml({
"services.web.params": { $set: { storage: { data: { mount: "/mnt/data", readOnly: false } } } }
});

expect(() => SDL.fromString(yml, "beta3", "sandbox")).toThrowError(
new SdlValidationError('Service "web" references to non-existing compute volume names "data".')
);
});

it("should throw an error when a service volume mount is a non-absolute path", () => {
const yml = createSdlYml({
"services.web.params": { $set: { storage: { data: { mount: "./mnt/data", readOnly: false } } } },
"profiles.compute.web.resources.storage": { $set: { name: "data", size: "1Gi" } }
});

expect(() => SDL.fromString(yml, "beta3", "sandbox")).toThrowError(
new SdlValidationError('Invalid value for "service.web.params.data.mount" parameter. expected absolute path.')
);
});

it("should throw an error when multiple ephemeral storages are provided", () => {
const yml = createSdlYml({
"services.web.params": {
$set: {
storage: {
data: {},
db: {}
}
}
},
"profiles.compute.web.resources.storage": {
$set: [
{ name: "data", size: "1Gi" },
{ name: "db", size: "1Gi" }
]
}
});

expect(() => SDL.fromString(yml, "beta3", "sandbox")).toThrowError(new SdlValidationError("Multiple root ephemeral storages are not allowed"));
});

it("should throw an error when mount is used by multiple volumes", () => {
const yml = createSdlYml({
"services.web.params": { $set: { storage: { data: { mount: "/", readOnly: false }, db: { mount: "/", readOnly: false } } } },
"profiles.compute.web.resources.storage": {
$set: [
{ name: "data", size: "1Gi" },
{ name: "db", size: "1Gi" }
]
}
});

expect(() => SDL.fromString(yml, "beta3", "sandbox")).toThrowError(new SdlValidationError("Mount / already in use by volume data."));
});

it("should require a service storage mount if volume is persistent", () => {
const yml = createSdlYml({
"services.web.params": {
$set: { storage: { data: { readOnly: false } } }
},
"profiles.compute.web.resources.storage": { $set: { name: "data", size: "1Gi", attributes: { persistent: true } } }
});

expect(() => SDL.fromString(yml, "beta3", "sandbox")).toThrowError(
new SdlValidationError("compute.storage.data has persistent=true which requires service.web.params.storage.data to have mount.")
);
});

it("should throw an error when gpu units > 0 and attributes vendor is not supported", () => {
const yml = createSdlJson({
"profiles.compute.web.resources.storage": { $set: { name: "data", size: "1Gi", attributes: { class: "ram", persistent: true } } }
});
expect(() => new SDL(yml, "beta3", "sandbox")).toThrowError(
new SdlValidationError(`Storage attribute "ram" must have "persistent" set to "false" or not defined for service "web".`)
);
});

it("should throw an error if storage size in not defined", () => {
const yml = createSdlJson({
"profiles.compute.web.resources.storage": { $set: { name: "data" } }
});
expect(() => new SDL(yml, "beta3", "sandbox")).toThrowError(new SdlValidationError('Storage size is required for service "web".'));
});
});

describe("gpu validation", () => {
it("should throw an error when gpu units is not defined", () => {
const sdlJson = createSdlJson({
"profiles.compute.web.resources.gpu": { $set: {} }
});

expect(() => new SDL(sdlJson, "beta3", "sandbox")).toThrowError(new SdlValidationError('GPU units must be specified for profile "web".'));
});

it("should throw an error when gpu units > 0 and attributes is not defined", () => {
const sdlJson = createSdlJson({
"profiles.compute.web.resources.gpu": { $set: { units: 1 } }
});

expect(() => new SDL(sdlJson, "beta3", "sandbox")).toThrowError(new SdlValidationError("GPU must have attributes if units is not 0"));
});

it("should throw an error when gpu units is 0 and attributes is defined", () => {
const sdlJson = createSdlJson({
"profiles.compute.web.resources.gpu": { $set: { units: 0, attributes: {} } }
});

expect(() => new SDL(sdlJson, "beta3", "sandbox")).toThrowError(new SdlValidationError("GPU must not have attributes if units is 0"));
});

it("should throw an error when gpu units > 0 and attributes vendor is not supported", () => {
const sdlJson = createSdlJson({
"profiles.compute.web.resources.gpu": {
$set: {
units: 1,
attributes: {
vendor: {
nvidia: { model: "rtxa6000" }
}
}
}
}
});

expect(() => new SDL(sdlJson, "beta3", "sandbox")).toThrowError(
new SdlValidationError("GPU configuration must be an array of GPU models with optional ram.")
);
});

it("should throw an error when gpu units > 0 and attributes vendor is not supported", () => {
const sdlJson = createSdlJson({
"profiles.compute.web.resources.gpu": {
$set: {
units: 1,
attributes: {
vendor: {
nvidia: [{ model: "rtxa6000", interface: "foo" }]
}
}
}
}
});

expect(() => new SDL(sdlJson, "beta3", "sandbox")).toThrowError(
new SdlValidationError("GPU interface must be one of the supported interfaces (pcie,sxm).")
);
});
});
});
149 changes: 128 additions & 21 deletions src/sdl/SDL/SDL.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import path from "path";
import YAML from "js-yaml";
import castArray from "lodash/castArray";
import mapKeys from "lodash/mapKeys";

import {
v2Manifest,
v3Manifest,
Expand Down Expand Up @@ -30,8 +34,8 @@ import {
v3ManifestServiceParams,
v2StorageAttributes,
v2ServiceImageCredentials
} from "./../types";
import { convertCpuResourceString, convertResourceString } from "./../sizes";
} from "../types";
import { convertCpuResourceString, convertResourceString } from "../sizes";
import { default as stableStringify } from "json-stable-stringify";
import crypto from "node:crypto";
import { AKT_DENOM, MAINNET_ID, USDC_IBC_DENOMS } from "../../config/network";
Expand Down Expand Up @@ -156,14 +160,6 @@ export class SDL {
private validate() {
// 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 }]) => {
if ("gpu" in resources) {
SDL.validateGPU(name, resources.gpu);
}
SDL.validateStorage(name, resources.storage);
});

this.validateEndpoints();

Object.keys(this.data.services).forEach(serviceName => {
Expand Down Expand Up @@ -215,30 +211,141 @@ export class SDL {
SdlValidationError.assert(deployment, `Service "${serviceName}" is not defined in the "deployment" section.`);

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];
this.validateDeploymentRelations(serviceName, deploymentName);
this.validateServiceStorages(serviceName, deploymentName);
this.validateStorages(serviceName, deploymentName);
this.validateGPU(serviceName, deploymentName);
});
}

private validateDeploymentRelations(serviceName: string, deploymentName: string) {
const serviceDeployment = this.data.deployment[serviceName][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],
`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.`);
}

private validateServiceStorages(serviceName: string, deploymentName: string) {
const service = this.data.services[serviceName];
const mounts: Record<string, string> = {};
const serviceDeployment = this.data.deployment[serviceName][deploymentName];
const compute = this.data.profiles.compute[serviceDeployment.profile];
const storages = castArray(compute.resources.storage);

if (!service.params?.storage) {
return;
}

mapKeys(service.params.storage, (storage, storageName) => {
const storageNameExists = storages.some(({ name }) => name === storageName);
SdlValidationError.assert(storageNameExists, `Service "${serviceName}" references to non-existing compute volume names "${storageName}".`);

SdlValidationError.assert(
!storage.mount || path.isAbsolute(storage.mount),
`Invalid value for "service.${serviceName}.params.${storageName}.mount" parameter. expected absolute path.`
);

const mount = storage.mount;
const volumeName = mounts[mount];

SdlValidationError.assert(!volumeName || mount, "Multiple root ephemeral storages are not allowed");
SdlValidationError.assert(!volumeName || !mount, `Mount ${mount} already in use by volume ${volumeName}.`);

mounts[mount] = storageName;
});
}

private validateStorages(serviceName: string, deploymentName: string) {
const service = this.data.services[serviceName];
const serviceDeployment = this.data.deployment[serviceName][deploymentName];
const compute = this.data.profiles.compute[serviceDeployment.profile];
const storages = castArray(compute.resources.storage);

SdlValidationError.assert(infra, `The placement "${deploymentName}" is not defined in the "placement" section.`);
storages.forEach(storage => {
const isRam = storage.attributes?.class === "ram";
const persistent = this.stringToBoolean(storage.attributes?.persistent || false);

SdlValidationError.assert(storage.size, `Storage size is required for service "${serviceName}".`);
SdlValidationError.assert(
!isRam || !persistent,
`Storage attribute "ram" must have "persistent" set to "false" or not defined for service "${serviceName}".`
);

const mount = service.params?.storage?.[storage.name]?.mount;
SdlValidationError.assert(
infra.pricing?.[serviceDeployment.profile],
`The pricing for the "${serviceDeployment.profile}" profile is not defined in the "${deploymentName}" "placement" definition.`
!persistent || mount,
`compute.storage.${storage.name} has persistent=true which requires service.${serviceName}.params.storage.${storage.name} to have mount.`
);
SdlValidationError.assert(compute, `The compute requirements for the "${serviceDeployment.profile}" profile are not defined in the "compute" section.`);
});
}

private stringToBoolean(str: string | boolean) {
if (typeof str === "boolean") {
return str;
}

switch (str.toLowerCase()) {
case "false":
case "no":
case "0":
case "":
return false;
default:
return true;
}
}

private validateGPU(serviceName: string, deploymentName: string) {
const deployment = this.data.deployment[serviceName];
const compute = this.data.profiles.compute[deployment[deploymentName].profile];
const gpu = (compute.resources as v3ComputeResources).gpu;

if (!gpu) {
return;
}
const hasUnits = gpu.units !== 0;
const hasAttributes = typeof gpu.attributes !== "undefined";
const hasVendor = hasAttributes && typeof gpu.attributes?.vendor !== "undefined";

SdlValidationError.assert(typeof gpu.units === "number", `GPU units must be specified for profile "${serviceName}".`);
SdlValidationError.assert(hasUnits || !hasAttributes, "GPU must not have attributes if units is 0");
SdlValidationError.assert(!hasUnits || hasAttributes, "GPU must have attributes if units is not 0");
SdlValidationError.assert(!hasUnits || hasVendor, "GPU must specify a vendor if units is not 0");
const hasUnsupportedVendor = hasVendor && GPU_SUPPORTED_VENDORS.some(vendor => vendor in (gpu.attributes?.vendor || {}));
SdlValidationError.assert(!hasUnits || hasUnsupportedVendor, `GPU must be one of the supported vendors (${GPU_SUPPORTED_VENDORS.join(",")}).`);

const vendor: string = Object.keys(gpu.attributes?.vendor || {})[0];

SdlValidationError.assert(
!hasUnits || !gpu.attributes?.vendor[vendor] || Array.isArray(gpu.attributes.vendor[vendor]),
`GPU configuration must be an array of GPU models with optional ram.`
);
SdlValidationError.assert(
!hasUnits ||
!Object.values(gpu.attributes?.vendor || {}).some(models =>
models?.some(model => model.interface && !GPU_SUPPORTED_INTERFACES.includes(model.interface))
),
`GPU interface must be one of the supported interfaces (${GPU_SUPPORTED_INTERFACES.join(",")}).`
);
}

private validateLeaseIP(serviceName: string) {
this.data.services[serviceName].expose?.forEach(expose => {
const proto = this.parseServiceProto(expose.proto);

expose.to?.forEach(to => {
if (to.ip?.length > 0) {
SdlValidationError.assert(to.global, `Error on "${serviceName}", if an IP is declared, the directive must be declared as global.`);

if (!this.data.endpoints?.[to.ip]) {
throw new SdlValidationError(`Unknown endpoint "${to.ip}" in service "${serviceName}". Add to the list of endpoints in the "endpoints" section.`);
}
SdlValidationError.assert(
this.data.endpoints?.[to.ip],
`Unknown endpoint "${to.ip}" in service "${serviceName}". Add to the list of endpoints in the "endpoints" section.`
);

this.endpointsUsed.add(to.ip);

Expand Down
Loading

0 comments on commit d0744f5

Please sign in to comment.