Skip to content

enforce resource schema on WS upsert API #473

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ResourceToInsert } from "@ctrlplane/job-dispatch";
import { NextResponse } from "next/server";
import _ from "lodash";
import { z } from "zod";
Expand All @@ -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<typeof bodySchema>;

export const PATCH = request()
.use(authn)
.use(parseBody(bodySchema))
Expand All @@ -51,42 +54,56 @@ export const PATCH = request()
.on({ type: "resourceProvider", id: extra.params.providerId }),
),
)
.handle<
{ body: z.infer<typeof bodySchema> },
{ 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<ResourceToInsert>(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([]);
},
);
21 changes: 19 additions & 2 deletions packages/validators/src/resources/cloud-v1.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -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({
Expand All @@ -43,3 +50,13 @@ export const cloudVpcV1 = z.object({

export type CloudVPCV1 = z.infer<typeof cloudVpcV1>;
export type CloudSubnetV1 = z.infer<typeof subnet>;

export const getCloudVpcV1SchemaParserError = (
obj: object,
): ZodError | undefined =>
getSchemaParseError(
obj,
(identifiable: Identifiable) =>
identifiable.kind === kind && identifiable.version === version,
cloudVpcV1,
);
1 change: 1 addition & 0 deletions packages/validators/src/resources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
21 changes: 19 additions & 2 deletions packages/validators/src/resources/kubernetes-v1.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -92,3 +99,13 @@ export const kubernetesNamespaceV1 = z.object({
});

export type KubernetesNamespaceV1 = z.infer<typeof kubernetesNamespaceV1>;

export const getKubernetesClusterAPIV1SchemaParseError = (
obj: object,
): ZodError | undefined =>
getSchemaParseError(
obj,
(identifiable: Identifiable) =>
identifiable.kind === kind && identifiable.version === version,
kubernetesClusterApiV1,
);
38 changes: 38 additions & 0 deletions packages/validators/src/resources/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { z } from "zod";

export const identifiable = z.object({
version: z.string(),
kind: z.string(),
});

export type Identifiable = z.infer<typeof identifiable>;

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 = <S extends z.ZodSchema>(
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;
};
38 changes: 38 additions & 0 deletions packages/validators/src/resources/validate.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
valid: T[];
errors: ZodError[];
}

export const partitionForSchemaErrors = <T extends object>(
objs: T[],
): ValidatedObjects<T> => {
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 };
};
19 changes: 17 additions & 2 deletions packages/validators/src/resources/vm-v1.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
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(),
type: z.string(),
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
Expand All @@ -34,3 +41,11 @@ export const vmV1 = z.object({
});

export type VmV1 = z.infer<typeof vmV1>;

export const getVmV1SchemaParseError = (obj: object): ZodError | undefined =>
getSchemaParseError(
obj,
(identifiable: Identifiable) =>
identifiable.kind === kind && identifiable.version === version,
vmV1,
);
Loading