wip: more domain stuf

This commit is contained in:
Derock 2024-02-04 19:51:15 -05:00
parent fbbf1772a1
commit ddad271a55
No known key found for this signature in database
9 changed files with 463 additions and 214 deletions

View file

@ -73,6 +73,7 @@
"trpc-openapi": "^1.2.0",
"ts-permissions": "^1.0.0",
"ua-parser-js": "^1.0.37",
"uuidv7": "^0.6.3",
"winston": "^3.11.0",
"ws": "^8.16.0",
"zod": "^3.22.4"

View file

@ -173,6 +173,9 @@ dependencies:
ua-parser-js:
specifier: ^1.0.37
version: 1.0.37
uuidv7:
specifier: ^0.6.3
version: 0.6.3
winston:
specifier: ^3.11.0
version: 3.11.0
@ -8257,6 +8260,11 @@ packages:
/util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
/uuidv7@0.6.3:
resolution: {integrity: sha512-zV3eW2NlXTsun/aJ7AixxZjH/byQcH/r3J99MI0dDEkU2cJIBJxhEWUHDTpOaLPRNhebPZoeHuykYREkI9HafA==}
hasBin: true
dev: false
/validate-npm-package-license@3.0.4:
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
dependencies:

View file

@ -1,7 +1,9 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { PlusIcon } from "lucide-react";
import { useFieldArray } from "react-hook-form";
import { uuidv7 } from "uuidv7";
import { z } from "zod";
import { Button } from "~/components/ui/button";
import { Form } from "~/components/ui/form";
@ -10,11 +12,14 @@ import {
FormUnsavedChangesIndicator,
useForm,
} from "~/hooks/forms";
import { api } from "~/trpc/react";
import { useService } from "../_hooks/service";
import DomainEntry from "./_components/DomainEntry";
const formValidator = z.object({
domains: z.array(
z.object({
domainId: z.string(),
domain: z
.string()
.regex(
@ -23,73 +28,108 @@ const formValidator = z.object({
),
internalPort: z.coerce.number().int().min(1).max(65535).default(8080),
https: z.boolean().default(true),
forceSSL: z.boolean().default(true),
https: z.coerce.boolean().default(true),
forceSSL: z.coerce.boolean().default(true),
}),
),
});
export default function DomainsList(
{
// service,
}: {
// service: RouterOutputs["projects"]["services"]["get"];
},
) {
export default function DomainsList() {
const service = useService();
const updateDomain = api.projects.services.updateDomain.useMutation();
const form = useForm(formValidator, {
defaultValues: {
domains: [],
},
});
const domainsForm = useFieldArray({
control: form.control,
name: "domains",
});
// useEffect(() => {
// console.log("setting domains", service.data?.domains ?? []);
// form.setValue("domains", service.data?.domains ?? []);
// }, [service.data?.domains]);
console.log(
"Rendering fields with ids: ",
domainsForm.fields.map((field) => field.id),
);
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(async (data) => {
console.log(data);
// await Promise.all(
// data.domains.map((domain) => {
// if (service.data === undefined) return;
// return updateDomain.mutateAsync({
// projectId: service.data.projectId,
// serviceId: service.data.id,
// domain: domain.domain,
// forceSSL: domain.forceSSL,
// https: domain.https,
// internalPort: domain.internalPort,
// });
// }),
// );
// refetch service
await service.refetch();
})}
className="flex flex-col gap-4"
>
<h1 className="col-span-2">Domains</h1>
{domainsForm.fields.map((field, index) => (
<DomainEntry
form={form}
domains={domainsForm}
field={field}
index={index}
key={field.id}
/>
))}
<AnimatePresence mode="sync">
{domainsForm.fields.map((field, index) => (
<DomainEntry
form={form}
domains={domainsForm}
field={field}
index={index}
key={field.id}
/>
))}
<div className="flex flex-row flex-wrap items-center gap-4">
<FormSubmit
form={form}
className="col-span-2"
hideUnsavedChangesIndicator
/>
<Button
variant="secondary"
icon={PlusIcon}
onClick={() =>
domainsForm.append({
domain: "",
forceSSL: false,
https: false,
internalPort: 8080,
})
}
<motion.div
className="flex flex-row flex-wrap items-center gap-4"
layout
key={service.data?.id}
>
Add Domain
</Button>
<FormSubmit
form={form}
className="col-span-2"
hideUnsavedChangesIndicator
/>
<FormUnsavedChangesIndicator form={form} />
</div>
<Button
variant="secondary"
icon={PlusIcon}
onClick={() => {
const domain = {
domainId: new Date().toISOString(),
domain: uuidv7().split("-").at(-1) + ".example.com",
forceSSL: false,
https: false,
internalPort: 8080,
};
console.log("add domain: ", domain.domainId);
domainsForm.append(domain);
}}
>
Add Domain
</Button>
<FormUnsavedChangesIndicator form={form} />
</motion.div>
</AnimatePresence>
</form>
</Form>
);

View file

@ -1,7 +1,8 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { ArrowRight, CogIcon, TrashIcon } from "lucide-react";
import { useState } from "react";
import { forwardRef, useState } from "react";
import { type UseFieldArrayReturn, type UseFormReturn } from "react-hook-form";
import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
@ -9,78 +10,139 @@ import { Switch } from "~/components/ui/switch";
import { SimpleFormField } from "~/hooks/forms";
type FieldData = {
domainId: string;
domain: string;
internalPort: number;
https: boolean;
forceSSL: boolean;
};
export default function DomainEntry({
form,
domains,
field,
index,
}: {
form: UseFormReturn<
{
domains: FieldData[];
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
any,
undefined
>;
const DomainEntry = forwardRef<
HTMLDivElement,
{
form: UseFormReturn<
{
domains: FieldData[];
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
any,
undefined
>;
domains: UseFieldArrayReturn<
{
domains: FieldData[];
},
"domains",
"id"
>;
domains: UseFieldArrayReturn<
{
domains: FieldData[];
},
"domains",
"id"
>;
field: FieldData;
index: number;
}) {
const isOpen = useState(false);
field: FieldData;
index: number;
}
>(({ form, domains, field, index }, ref) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Card className="flex flex-row gap-4 p-4">
<SimpleFormField
control={form.control}
name={`domains.${index}.forceSSL`}
friendlyName="HTTPS"
render={({ field }) => (
<div className="pt-2">
<Switch {...field} className="mx-auto block" />
<motion.div
ref={ref}
layout
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{
duration: 0.4,
type: "spring",
}}
key={field.domainId}
>
<Card className="p-4">
<h1>
Rendering {field.domainId ?? "undefined???"} at index {index}
</h1>
<div className="flex flex-row gap-4">
<SimpleFormField
control={form.control}
name={`domains.${index}.forceSSL`}
friendlyName="HTTPS"
render={({ field }) => (
<div className="pt-2">
<Switch {...field} className="mx-auto block" />
</div>
)}
/>
<SimpleFormField
control={form.control}
name={`domains.${index}.domain`}
friendlyName="Domain"
className="flex-1"
/>
<ArrowRight className="mt-9 flex-shrink-0" />
<SimpleFormField
control={form.control}
name={`domains.${index}.internalPort`}
friendlyName="Internal Port"
className="w-60"
/>
<div className="flex flex-row gap-2 pt-8">
<Button
variant="secondary"
icon={CogIcon}
className="mr-2"
onClick={() => {
setIsOpen(!isOpen);
}}
/>
<Button
variant="destructive"
icon={TrashIcon}
onClick={() => {
domains.remove(index);
}}
/>
</div>
)}
/>
</div>
<SimpleFormField
control={form.control}
name={`domains.${index}.domain`}
friendlyName="Domain"
className="flex-1"
/>
<AnimatePresence>
{isOpen && (
<motion.div className="flex flex-col gap-4">
<SimpleFormField
control={form.control}
name={`domains.${index}.https`}
friendlyName="HTTPS"
render={({ field }) => (
<div className="pt-2">
<Switch {...field} className="mx-auto block" />
</div>
)}
/>
<ArrowRight className="mt-9 flex-shrink-0" />
<SimpleFormField
control={form.control}
name={`domains.${index}.domain`}
friendlyName="Domain"
className="flex-1"
/>
<SimpleFormField
control={form.control}
name={`domains.${index}.internalPort`}
friendlyName="Internal Port"
className="w-60"
/>
<div className="flex flex-row gap-2 pt-8">
<Button variant="secondary" icon={CogIcon} className="mr-2" />
<Button
variant="destructive"
icon={TrashIcon}
onClick={() => domains.remove(index)}
/>
</div>
</Card>
<SimpleFormField
control={form.control}
name={`domains.${index}.internalPort`}
friendlyName="Internal Port"
className="w-60"
/>
</motion.div>
)}
</AnimatePresence>
</Card>
</motion.div>
);
}
});
DomainEntry.displayName = "DomainEntry";
export default DomainEntry;

View file

@ -4,17 +4,23 @@ import { cn } from "~/utils/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"bg-card text-card-foreground rounded-xl border shadow",
className,
)}
{...props}
/>
));
React.HTMLAttributes<HTMLDivElement> & {
as?: React.ElementType;
}
>(({ className, as, ...props }, ref) => {
const Component = as ?? "div";
return (
<Component
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className,
)}
{...props}
/>
);
});
Card.displayName = "Card";
const CardHeader = React.forwardRef<
@ -47,7 +53,7 @@ const CardDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-muted-foreground text-sm", className)}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
@ -75,9 +81,9 @@ CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};

View file

@ -1,23 +1,26 @@
import assert from "assert";
import { randomBytes } from "crypto";
import { eq } from "drizzle-orm";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod";
import { env } from "~/env";
import { projectMiddleware } from "~/server/api/middleware/project";
import { serviceMiddleware } from "~/server/api/middleware/service";
import { authenticatedProcedure, createTRPCRouter } from "~/server/api/trpc";
import { service } from "~/server/db/schema";
import {
DOCKER_DEPLOY_MODE_MAP,
DockerDeployMode,
ServiceSource,
} from "~/server/db/types";
import { zDockerDuration, zDockerImage, zDockerName } from "~/server/utils/zod";
import { DOCKER_DEPLOY_MODE_MAP, ServiceSource } from "~/server/db/types";
import { zDockerName } from "~/server/utils/zod";
import { getServiceContainers } from "./containers";
import {
deleteServiceDomainsProcedure,
updateServiceDomainsProcedure,
updateServiceProcedure,
} from "./update";
export const serviceRouter = createTRPCRouter({
containers: getServiceContainers,
update: updateServiceProcedure,
updateDomain: updateServiceDomainsProcedure,
deleteDomain: deleteServiceDomainsProcedure,
get: authenticatedProcedure
.meta({
@ -46,94 +49,10 @@ export const serviceRouter = createTRPCRouter({
return {
...fullServiceData,
zeroDowntime: fullServiceData.zeroDowntime === 1,
deployMode: DOCKER_DEPLOY_MODE_MAP[fullServiceData.deployMode],
};
}),
update: authenticatedProcedure
.meta({
openapi: {
method: "PATCH",
path: "/api/projects/:projectId/services/:serviceId",
summary: "Update service",
},
})
.input(
// sometimes i forget how powerful zod is
z
.object({
projectId: z.string(),
serviceId: z.string(),
})
.merge(
createInsertSchema(service, {
dockerImage: zDockerImage,
gitUrl: (schema) =>
schema.gitUrl.regex(
// https://www.debuggex.com/r/fFggA8Uc4YYKjl34 from https://stackoverflow.com/a/22312124
// /(?P<host>(git@|https:\/\/)([\w\.@]+)(\/|:))(?P<owner>[\w,\-,\_]+)\/(?P<repo>[\w,\-,\_]+)(.git){0,1}((\/){0,1})/,
/(git@|https:\/\/)([\w\.@]+)(\/|:)([\w,\-,\_]+)\/([\w,\-,\_]+)(.git){0,1}(\/{0,1})/,
{
message: "Must be a valid git url. (Regex failed)",
},
),
deployMode: z
.nativeEnum(DockerDeployMode)
// or "replicated" | "global" and transform into 1 or 2
.or(
z
.enum(["replicated", "global"])
.transform((val) =>
val === "replicated"
? DockerDeployMode.Replicated
: DockerDeployMode.Global,
),
),
zeroDowntime: z.boolean().transform((val) => (val ? 1 : 0)),
restartDelay: zDockerDuration,
healthcheckEnabled: z.boolean().transform((val) => (val ? 1 : 0)),
healthcheckInterval: zDockerDuration,
healthcheckTimeout: zDockerDuration,
healthcheckStartPeriod: zDockerDuration,
loggingMaxSize: (schema) =>
schema.loggingMaxSize.regex(/^\d+[kmg]$/, {
message: "Must be an integer plus a modifier (k, m, or g).",
}),
loggingMaxFiles: (schema) => schema.loggingMaxFiles.positive(),
})
.omit({
id: true,
projectId: true,
name: true,
})
.partial(),
)
.strict(),
)
.use(projectMiddleware)
.use(serviceMiddleware)
.mutation(async ({ ctx, input }) => {
// gotta keep TS happy, can't delete properties from input directly
const queryUpdate: Omit<typeof input, "projectId" | "serviceId"> & {
projectId?: string;
serviceId?: string;
id?: string;
} = structuredClone(input);
delete queryUpdate.projectId;
delete queryUpdate.serviceId;
delete queryUpdate.id;
await ctx.db
.update(service)
.set(queryUpdate)
.where(eq(service.id, ctx.service.id))
.execute();
return true;
}),
create: authenticatedProcedure
.meta({
openapi: {

View file

@ -0,0 +1,202 @@
import { eq } from "drizzle-orm";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod";
import { projectMiddleware } from "~/server/api/middleware/project";
import { serviceMiddleware } from "~/server/api/middleware/service";
import { authenticatedProcedure } from "~/server/api/trpc";
import { service, serviceDomain } from "~/server/db/schema";
import { DockerDeployMode } from "~/server/db/types";
import { zDockerDuration, zDockerImage, zDomain } from "~/server/utils/zod";
/**
* Handles updating basic details of a service
*/
export const updateServiceProcedure = authenticatedProcedure
.meta({
openapi: {
method: "PATCH",
path: "/api/projects/:projectId/services/:serviceId",
summary: "Update service",
},
})
.input(
// sometimes i forget how powerful zod is
z
.object({
projectId: z.string(),
serviceId: z.string(),
})
.merge(
createInsertSchema(service, {
dockerImage: zDockerImage,
gitUrl: (schema) =>
schema.gitUrl.regex(
// https://www.debuggex.com/r/fFggA8Uc4YYKjl34 from https://stackoverflow.com/a/22312124
// /(?P<host>(git@|https:\/\/)([\w\.@]+)(\/|:))(?P<owner>[\w,\-,\_]+)\/(?P<repo>[\w,\-,\_]+)(.git){0,1}((\/){0,1})/,
/(git@|https:\/\/)([\w\.@]+)(\/|:)([\w,\-,\_]+)\/([\w,\-,\_]+)(.git){0,1}(\/{0,1})/,
{
message: "Must be a valid git url. (Regex failed)",
},
),
deployMode: z
.nativeEnum(DockerDeployMode)
// or "replicated" | "global" and transform into 1 or 2
.or(
z
.enum(["replicated", "global"])
.transform((val) =>
val === "replicated"
? DockerDeployMode.Replicated
: DockerDeployMode.Global,
),
),
zeroDowntime: z.boolean(),
restartDelay: zDockerDuration,
healthcheckEnabled: z.boolean(),
healthcheckInterval: zDockerDuration,
healthcheckTimeout: zDockerDuration,
healthcheckStartPeriod: zDockerDuration,
loggingMaxSize: (schema) =>
schema.loggingMaxSize.regex(/^\d+[kmg]$/, {
message: "Must be an integer plus a modifier (k, m, or g).",
}),
loggingMaxFiles: (schema) => schema.loggingMaxFiles.positive(),
})
.omit({
id: true,
projectId: true,
name: true,
})
.partial(),
)
.strict(),
)
.use(projectMiddleware)
.use(serviceMiddleware)
.mutation(async ({ ctx, input }) => {
// gotta keep TS happy, can't delete properties from input directly
const queryUpdate: Omit<typeof input, "projectId" | "serviceId"> & {
projectId?: string;
serviceId?: string;
id?: string;
} = structuredClone(input);
delete queryUpdate.projectId;
delete queryUpdate.serviceId;
delete queryUpdate.id;
await ctx.db
.update(service)
.set(queryUpdate)
.where(eq(service.id, ctx.service.id))
.execute();
return true;
});
export const updateServiceDomainsProcedure = authenticatedProcedure
.meta({
openapi: {
method: "PUT",
path: "/api/projects/:projectId/services/:serviceId/domains/:domainId",
summary: "Update or create a service domains",
},
})
.input(
z.object({
// meta
projectId: z.string(),
serviceId: z.string(),
domainId: z
.string()
.regex(
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
)
.optional(),
// details
domain: zDomain,
internalPort: z.coerce.number().int().min(1).max(65535),
https: z.boolean(),
forceSSL: z.boolean().default(true),
}),
)
.mutation(async ({ ctx, input }) => {
const baseQuery = ctx.db
.insert(serviceDomain)
.values({
id: input.domainId,
serviceId: input.serviceId,
domain: input.domain,
internalPort: input.internalPort,
https: input.https,
forceSSL: input.forceSSL,
})
.returning({
id: serviceDomain.id,
});
// track if we inserted or updated
let updated = false;
let domainId = input.domainId;
if (input.domainId) {
const result = await baseQuery
.onConflictDoUpdate({
set: {
internalPort: input.internalPort,
https: input.https,
forceSSL: input.forceSSL,
domain: input.domain,
},
target: serviceDomain.id,
})
.execute();
if (result[0]) {
updated = true;
domainId = result[0].id;
}
} else {
const result = await baseQuery.execute();
if (result[0]) {
updated = false;
domainId = result[0].id;
}
}
return {
updated,
domainId,
};
});
/**
* Deletes a service domain
*/
export const deleteServiceDomainsProcedure = authenticatedProcedure
.meta({
openapi: {
method: "DELETE",
path: "/api/projects/:projectId/services/:serviceId/domains/:domainId",
summary: "Delete a service domain",
},
})
.input(
z.object({
projectId: z.string(),
serviceId: z.string(),
domainId: z.string(),
}),
)
.use(projectMiddleware)
.use(serviceMiddleware)
.mutation(async ({ ctx, input }) => {
await ctx.db
.delete(serviceDomain)
.where(eq(serviceDomain.id, input.domainId))
.execute();
return true;
});

View file

@ -169,12 +169,14 @@ export const service = sqliteTable(
.$type<DockerDeployMode>()
.default(DockerDeployMode.Replicated)
.notNull(),
zeroDowntime: integer("zero_downtime").default(0).notNull(),
zeroDowntime: integer("zero_downtime", { mode: "boolean" })
.default(false)
.notNull(),
// deployment usage limits
max_cpu: real("max_cpu").default(0).notNull(),
max_memory: text("max_memory").default("0").notNull(),
max_pids: integer("max_pids").default(0).notNull(),
max_pids: integer("max_pids", { mode: "boolean" }).default(false).notNull(),
// restart policy
restart: integer("restart")
@ -187,7 +189,9 @@ export const service = sqliteTable(
restartMaxAttempts: integer("restart_max_attempts"),
// healthcheck
healthcheckEnabled: integer("healthcheck_enabled").default(0).notNull(),
healthcheckEnabled: integer("healthcheck_enabled", { mode: "boolean" })
.default(false)
.notNull(),
healthcheckCommand: text("healthcheck_command"),
healthcheckInterval: text("healthcheck_interval").default("30s").notNull(),
healthcheckTimeout: text("healthcheck_timeout").default("30s").notNull(),
@ -244,8 +248,8 @@ export const serviceDomain = sqliteTable("service_domain", {
domain: text("domain").notNull(),
internalPort: integer("internal_port").notNull(),
https: integer("https").default(0).notNull(),
forceSSL: integer("force_ssl").default(0).notNull(),
https: integer("https", { mode: "boolean" }).default(false).notNull(),
forceSSL: integer("force_ssl", { mode: "boolean" }).default(false).notNull(),
});
export const serviceDomainRelations = relations(serviceDomain, ({ one }) => ({

View file

@ -18,3 +18,10 @@ export const zDockerDuration = z
.regex(/(?:[\d.]+h)?(?:[\d.]+m)?(?:[\d.]+s)?/, {
message: "Must be a valid duration. (Regex failed)",
});
export const zDomain = z
.string()
.regex(
/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/,
{ message: "Invalid domain name. (Regex)" },
);