diff --git a/apps/webservice/src/app/api/v1/resource-providers/[providerId]/set/route.ts b/apps/webservice/src/app/api/v1/resource-providers/[providerId]/set/route.ts index 26167be01..c3bced6c6 100644 --- a/apps/webservice/src/app/api/v1/resource-providers/[providerId]/set/route.ts +++ b/apps/webservice/src/app/api/v1/resource-providers/[providerId]/set/route.ts @@ -1,3 +1,4 @@ +import type { ResourceToInsert } from "@ctrlplane/job-dispatch"; import { NextResponse } from "next/server"; import _ from "lodash"; import { z } from "zod"; @@ -11,36 +12,38 @@ import { } from "@ctrlplane/db/schema"; import { handleResourceProviderScan } from "@ctrlplane/job-dispatch"; import { Permission } from "@ctrlplane/validators/auth"; +import { partitionForSchemaErrors } from "@ctrlplane/validators/resources"; import { authn, authz } from "~/app/api/v1/auth"; import { parseBody } from "../../../body-parser"; import { request } from "../../../middleware"; +const bodyResource = createResource + .omit({ lockedAt: true, providerId: true, workspaceId: true }) + .extend({ + metadata: z.record(z.string()).optional(), + variables: z + .array( + z.object({ + key: z.string(), + value: z.union([z.string(), z.number(), z.boolean(), z.null()]), + sensitive: z.boolean(), + }), + ) + .optional() + .refine( + (vars) => + vars == null || new Set(vars.map((v) => v.key)).size === vars.length, + "Duplicate variable keys are not allowed", + ), + }); + const bodySchema = z.object({ - resources: z.array( - createResource - .omit({ lockedAt: true, providerId: true, workspaceId: true }) - .extend({ - metadata: z.record(z.string()).optional(), - variables: z - .array( - z.object({ - key: z.string(), - value: z.union([z.string(), z.number(), z.boolean(), z.null()]), - sensitive: z.boolean(), - }), - ) - .optional() - .refine( - (vars) => - vars == null || - new Set(vars.map((v) => v.key)).size === vars.length, - "Duplicate variable keys are not allowed", - ), - }), - ), + resources: z.array(bodyResource), }); +type BodySchema = z.infer; + export const PATCH = request() .use(authn) .use(parseBody(bodySchema)) @@ -51,42 +54,56 @@ export const PATCH = request() .on({ type: "resourceProvider", id: extra.params.providerId }), ), ) - .handle< - { body: z.infer }, - { params: { providerId: string } } - >(async (ctx, { params }) => { - const { body } = ctx; + .handle<{ body: BodySchema }, { params: { providerId: string } }>( + async (ctx, { params }) => { + const { body } = ctx; - const query = await db - .select() - .from(resourceProvider) - .innerJoin(workspace, eq(workspace.id, resourceProvider.workspaceId)) - .where(eq(resourceProvider.id, params.providerId)) - .then(takeFirstOrNull); + const query = await db + .select() + .from(resourceProvider) + .innerJoin(workspace, eq(workspace.id, resourceProvider.workspaceId)) + .where(eq(resourceProvider.id, params.providerId)) + .then(takeFirstOrNull); - const provider = query?.resource_provider; - if (!provider) - return NextResponse.json( - { error: "Provider not found" }, - { status: 404 }, - ); + const provider = query?.resource_provider; + if (!provider) { + return NextResponse.json( + { error: "Provider not found" }, + { status: 404 }, + ); + } - const resourcesToInsert = body.resources.map((r) => ({ - ...r, - providerId: provider.id, - workspaceId: provider.workspaceId, - })); - - const resources = await handleResourceProviderScan( - db, - resourcesToInsert.map((r) => ({ + const resourcesToInsert = body.resources.map((r) => ({ ...r, - variables: r.variables?.map((v) => ({ - ...v, - value: v.value ?? null, - })), - })), - ); + providerId: provider.id, + workspaceId: provider.workspaceId, + })); - return NextResponse.json({ resources }); - }); + const { valid, errors } = + partitionForSchemaErrors(resourcesToInsert); + + if (valid.length > 0) { + const resources = await handleResourceProviderScan( + db, + valid.map((r) => ({ + ...r, + variables: r.variables?.map((v) => ({ + ...v, + value: v.value ?? null, + })), + })), + ); + + return NextResponse.json({ resources }); + } + + if (errors.length > 0) { + return NextResponse.json( + { error: "Validation errors", issues: errors }, + { status: 400 }, + ); + } + + return NextResponse.json([]); + }, + ); diff --git a/packages/validators/src/resources/cloud-v1.ts b/packages/validators/src/resources/cloud-v1.ts index 021acadd3..afbdb936c 100644 --- a/packages/validators/src/resources/cloud-v1.ts +++ b/packages/validators/src/resources/cloud-v1.ts @@ -1,5 +1,9 @@ +import type { ZodError } from "zod"; import { z } from "zod"; +import type { Identifiable } from "./util"; +import { getSchemaParseError } from "./util.js"; + const subnet = z.object({ name: z.string(), region: z.string(), @@ -19,9 +23,12 @@ const subnet = z.object({ .optional(), }); +const kind = "VPC"; +const version = "cloud/v1"; + export const cloudVpcV1 = z.object({ - version: z.literal("cloud/v1"), - kind: z.literal("VPC"), + version: z.literal(version), + kind: z.literal(kind), identifier: z.string(), name: z.string(), config: z.object({ @@ -43,3 +50,13 @@ export const cloudVpcV1 = z.object({ export type CloudVPCV1 = z.infer; export type CloudSubnetV1 = z.infer; + +export const getCloudVpcV1SchemaParserError = ( + obj: object, +): ZodError | undefined => + getSchemaParseError( + obj, + (identifiable: Identifiable) => + identifiable.kind === kind && identifiable.version === version, + cloudVpcV1, + ); diff --git a/packages/validators/src/resources/index.ts b/packages/validators/src/resources/index.ts index 435b7cede..39799b82f 100644 --- a/packages/validators/src/resources/index.ts +++ b/packages/validators/src/resources/index.ts @@ -3,3 +3,4 @@ export * from "./conditions/index.js"; export * from "./cloud-v1.js"; export * from "./vm-v1.js"; export * from "./cloud-geo.js"; +export * from "./validate.js"; diff --git a/packages/validators/src/resources/kubernetes-v1.ts b/packages/validators/src/resources/kubernetes-v1.ts index 7d2edb442..66d7ccd6e 100644 --- a/packages/validators/src/resources/kubernetes-v1.ts +++ b/packages/validators/src/resources/kubernetes-v1.ts @@ -1,5 +1,9 @@ +import type { ZodError } from "zod"; import { z } from "zod"; +import type { Identifiable } from "./util"; +import { getSchemaParseError } from "./util.js"; + const clusterConfig = z.object({ name: z.string(), status: z.string().optional(), @@ -54,9 +58,12 @@ const clusterConfig = z.object({ ]), }); +const version = "kubernetes/v1"; +const kind = "ClusterAPI"; + export const kubernetesClusterApiV1 = z.object({ - version: z.literal("kubernetes/v1"), - kind: z.literal("ClusterAPI"), + version: z.literal(version), + kind: z.literal(kind), identifier: z.string(), name: z.string(), config: clusterConfig, @@ -92,3 +99,13 @@ export const kubernetesNamespaceV1 = z.object({ }); export type KubernetesNamespaceV1 = z.infer; + +export const getKubernetesClusterAPIV1SchemaParseError = ( + obj: object, +): ZodError | undefined => + getSchemaParseError( + obj, + (identifiable: Identifiable) => + identifiable.kind === kind && identifiable.version === version, + kubernetesClusterApiV1, + ); diff --git a/packages/validators/src/resources/util.ts b/packages/validators/src/resources/util.ts new file mode 100644 index 000000000..b580a2c07 --- /dev/null +++ b/packages/validators/src/resources/util.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +export const identifiable = z.object({ + version: z.string(), + kind: z.string(), +}); + +export type Identifiable = z.infer; + +export const isIdentifiable = (obj: object): obj is Identifiable => { + return identifiable.safeParse(obj).success; +}; + +export const getIdentifiableSchemaParseError = ( + obj: object, +): z.ZodError | undefined => { + return identifiable.safeParse(obj).error; +}; + +/** + * getSchemaParseError will return a ZodError if the object has expected kind and version + * @param obj incoming object to have it's schema validated, if identifiable based on its kind and version + * @param matcher impl to check the object's kind and version + * @param schema schema to validate the object against + * @returns ZodError if the object is has expected kind and version + */ +export const getSchemaParseError = ( + obj: object, + matcher: (identifiable: Identifiable) => boolean, + schema: S, +): z.ZodError | undefined => { + if (isIdentifiable(obj) && matcher(obj)) { + // If the object is identifiable and matches the kind and version, validate it against the schema + const parseResult = schema.safeParse(obj); + return parseResult.error; + } + return undefined; +}; diff --git a/packages/validators/src/resources/validate.ts b/packages/validators/src/resources/validate.ts new file mode 100644 index 000000000..19a27b3e5 --- /dev/null +++ b/packages/validators/src/resources/validate.ts @@ -0,0 +1,38 @@ +import type { ZodError } from "zod"; + +import { getCloudVpcV1SchemaParserError } from "./cloud-v1.js"; +import { getKubernetesClusterAPIV1SchemaParseError } from "./kubernetes-v1.js"; +import { getIdentifiableSchemaParseError } from "./util.js"; +import { getVmV1SchemaParseError } from "./vm-v1.js"; + +export const anySchemaError = (obj: object): ZodError | undefined => { + return ( + getIdentifiableSchemaParseError(obj) ?? + getCloudVpcV1SchemaParserError(obj) ?? + getKubernetesClusterAPIV1SchemaParseError(obj) ?? + getVmV1SchemaParseError(obj) + ); +}; + +interface ValidatedObjects { + valid: T[]; + errors: ZodError[]; +} + +export const partitionForSchemaErrors = ( + objs: T[], +): ValidatedObjects => { + const errors: ZodError[] = []; + const valid: T[] = []; + + for (const obj of objs) { + const error = anySchemaError(obj); + if (error) { + errors.push(error); + } else { + valid.push(obj); + } + } + + return { valid, errors }; +}; diff --git a/packages/validators/src/resources/vm-v1.ts b/packages/validators/src/resources/vm-v1.ts index 31f35914c..a1d5d1382 100644 --- a/packages/validators/src/resources/vm-v1.ts +++ b/packages/validators/src/resources/vm-v1.ts @@ -1,5 +1,9 @@ +import type { ZodError } from "zod"; import { z } from "zod"; +import type { Identifiable } from "./util"; +import { getSchemaParseError } from "./util.js"; + const diskV1 = z.object({ name: z.string(), size: z.number(), @@ -7,11 +11,14 @@ const diskV1 = z.object({ encrypted: z.boolean(), }); +const version = "vm/v1"; +const kind = "VM"; + export const vmV1 = z.object({ workspaceId: z.string(), providerId: z.string(), - version: z.literal("vm/v1"), - kind: z.literal("VM"), + version: z.literal(version), + kind: z.literal(kind), identifier: z.string(), name: z.string(), config: z @@ -34,3 +41,11 @@ export const vmV1 = z.object({ }); export type VmV1 = z.infer; + +export const getVmV1SchemaParseError = (obj: object): ZodError | undefined => + getSchemaParseError( + obj, + (identifiable: Identifiable) => + identifiable.kind === kind && identifiable.version === version, + vmV1, + );