feat: compose app

This commit is contained in:
Lorenzo Migliorero 2024-07-25 22:10:35 +02:00
parent 1f81ebd4fe
commit c681aa2e9f
No known key found for this signature in database
GPG Key ID: 9A9F1AD60C05DFE2
10 changed files with 3122 additions and 208 deletions

View File

@ -1,4 +1,3 @@
import { AddSSHKey } from "@/components/dashboard/settings/ssh-keys/add-ssh-key";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Form, Form,
@ -20,13 +19,10 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { SelectSeparator } from "@radix-ui/react-select";
import { KeyRoundIcon, LockIcon } from "lucide-react"; import { KeyRoundIcon, LockIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect } from "react"; import { useEffect } from "react";
import { flushSync } from "react-dom";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
@ -53,11 +49,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
const { mutateAsync, isLoading } = const { mutateAsync, isLoading } =
api.application.saveGitProdiver.useMutation(); api.application.saveGitProdiver.useMutation();
// const { mutateAsync: generateSSHKey, isLoading: isGeneratingSSHKey } =
// api.application.generateSSHKey.useMutation();
// const { mutateAsync: removeSSHKey, isLoading: isRemovingSSHKey } =
// api.application.removeSSHKey.useMutation();
const form = useForm<GitProvider>({ const form = useForm<GitProvider>({
defaultValues: { defaultValues: {
branch: "", branch: "",
@ -152,7 +144,6 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
<SelectItem value="none">None</SelectItem> <SelectItem value="none">None</SelectItem>
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel> <SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
</SelectGroup> </SelectGroup>
<SelectSeparator />
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>

View File

@ -1,13 +1,4 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { import {
Form, Form,
FormControl, FormControl,
@ -17,11 +8,19 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard"; import { KeyRoundIcon, LockIcon } from "lucide-react";
import { CopyIcon, LockIcon } from "lucide-react"; import { useRouter } from "next/router";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@ -33,6 +32,7 @@ const GitProviderSchema = z.object({
message: "Repository URL is required", message: "Repository URL is required",
}), }),
branch: z.string().min(1, "Branch required"), branch: z.string().min(1, "Branch required"),
sshKey: z.string().optional(),
}); });
type GitProvider = z.infer<typeof GitProviderSchema>; type GitProvider = z.infer<typeof GitProviderSchema>;
@ -43,19 +43,17 @@ interface Props {
export const SaveGitProviderCompose = ({ composeId }: Props) => { export const SaveGitProviderCompose = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery({ composeId }); const { data, refetch } = api.compose.one.useQuery({ composeId });
const { data: sshKeys } = api.sshKey.all.useQuery();
const router = useRouter();
const { mutateAsync, isLoading } = api.compose.update.useMutation(); const { mutateAsync, isLoading } = api.compose.update.useMutation();
const { mutateAsync: generateSSHKey, isLoading: isGeneratingSSHKey } =
api.compose.generateSSHKey.useMutation();
const { mutateAsync: removeSSHKey, isLoading: isRemovingSSHKey } =
api.compose.removeSSHKey.useMutation();
const form = useForm<GitProvider>({ const form = useForm<GitProvider>({
defaultValues: { defaultValues: {
branch: "", branch: "",
repositoryURL: "", repositoryURL: "",
composePath: "./docker-compose.yml", composePath: "./docker-compose.yml",
sshKey: undefined,
}, },
resolver: zodResolver(GitProviderSchema), resolver: zodResolver(GitProviderSchema),
}); });
@ -63,6 +61,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
useEffect(() => { useEffect(() => {
if (data) { if (data) {
form.reset({ form.reset({
sshKey: data.customGitSSHKeyId || undefined,
branch: data.customGitBranch || "", branch: data.customGitBranch || "",
repositoryURL: data.customGitUrl || "", repositoryURL: data.customGitUrl || "",
composePath: data.composePath, composePath: data.composePath,
@ -74,6 +73,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
await mutateAsync({ await mutateAsync({
customGitBranch: values.branch, customGitBranch: values.branch,
customGitUrl: values.repositoryURL, customGitUrl: values.repositoryURL,
customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey,
composeId, composeId,
sourceType: "git", sourceType: "git",
composePath: values.composePath, composePath: values.composePath,
@ -94,123 +94,72 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
className="flex flex-col gap-4" className="flex flex-col gap-4"
> >
<div className="grid md:grid-cols-2 gap-4 "> <div className="grid md:grid-cols-2 gap-4 ">
<div className="md:col-span-2 space-y-4"> <div className="flex items-end col-span-2 gap-4">
<FormField <div className="grow">
control={form.control} <FormField
name="repositoryURL" control={form.control}
render={({ field }) => ( name="repositoryURL"
<FormItem> render={({ field }) => (
<FormLabel className="flex flex-row justify-between"> <FormItem>
Repository URL <FormLabel className="flex flex-row justify-between">
<div className="flex gap-2"> Repository URL
<Dialog> </FormLabel>
<DialogTrigger className="flex flex-row gap-2"> <FormControl>
<LockIcon className="size-4 text-muted-foreground" />? <Input placeholder="git@bitbucket.org" {...field} />
</DialogTrigger> </FormControl>
<DialogContent className="sm:max-w-[425px]"> <FormMessage />
<DialogHeader> </FormItem>
<DialogTitle>Private Repository</DialogTitle> )}
<DialogDescription> />
If your repository is private is necessary to </div>
generate SSH Keys to add to your git provider. {sshKeys && sshKeys.length > 0 ? (
</DialogDescription> <FormField
</DialogHeader> control={form.control}
<div className="grid gap-4 py-4"> name="sshKey"
<div className="relative"> render={({ field }) => (
<Textarea <FormItem className="basis-40">
placeholder="Please click on Generate SSH Key" <FormLabel className="w-full inline-flex justify-between">
className="no-scrollbar h-64 text-muted-foreground" SSH Key
disabled={!data?.customGitSSHKey} <LockIcon className="size-4 text-muted-foreground" />
contentEditable={false} </FormLabel>
value={ <FormControl>
data?.customGitSSHKey || <Select
"Please click on Generate SSH Key" key={field.value}
} onValueChange={field.onChange}
/> defaultValue={field.value}
<button value={field.value}
type="button" >
className="absolute right-2 top-2" <SelectTrigger>
onClick={() => { <SelectValue placeholder="Select a key" />
copy( </SelectTrigger>
data?.customGitSSHKey || <SelectContent>
"Generate a SSH Key", <SelectGroup>
); {sshKeys?.map((sshKey) => (
toast.success("SSH Copied to clipboard"); <SelectItem
}} key={sshKey.sshKeyId}
value={sshKey.sshKeyId}
> >
<CopyIcon className="size-4" /> {sshKey.name}
</button> </SelectItem>
</div> ))}
</div> <SelectItem value="none">None</SelectItem>
<DialogFooter className="flex sm:justify-between gap-3.5 flex-col sm:flex-col w-full"> <SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
<div className="flex flex-row gap-2 w-full justify-between flex-wrap"> </SelectGroup>
{data?.customGitSSHKey && ( </SelectContent>
<Button </Select>
variant="destructive" </FormControl>
isLoading={ </FormItem>
isGeneratingSSHKey || isRemovingSSHKey )}
} />
className="max-sm:w-full" ) : (
onClick={async () => { <Button
await removeSSHKey({ variant="secondary"
composeId, onClick={() => router.push("/dashboard/settings/ssh-keys")}
}) type="button"
.then(async () => { >
toast.success("SSH Key Removed"); <KeyRoundIcon className="size-4" /> Add SSH Key
await refetch(); </Button>
}) )}
.catch(() => {
toast.error(
"Error to remove the SSH Key",
);
});
}}
type="button"
>
Remove SSH Key
</Button>
)}
<Button
isLoading={
isGeneratingSSHKey || isRemovingSSHKey
}
className="max-sm:w-full"
onClick={async () => {
await generateSSHKey({
composeId,
})
.then(async () => {
toast.success("SSH Key Generated");
await refetch();
})
.catch(() => {
toast.error(
"Error to generate the SSH Key",
);
});
}}
type="button"
>
Generate SSH Key
</Button>
</div>
<span className="text-sm text-muted-foreground">
Is recommended to remove the SSH Key if you want
to deploy a public repository.
</span>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</FormLabel>
<FormControl>
<Input placeholder="git@bitbucket.org" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<FormField <FormField

View File

@ -0,0 +1,8 @@
ALTER TABLE "compose" ADD COLUMN "customGitSSHKeyId" text;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "compose" ADD CONSTRAINT "compose_customGitSSHKeyId_ssh-key_sshKeyId_fk" FOREIGN KEY ("customGitSSHKeyId") REFERENCES "public"."ssh-key"("sshKeyId") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
ALTER TABLE "compose" DROP COLUMN IF EXISTS "customGitSSHKey";

File diff suppressed because it is too large Load Diff

View File

@ -190,6 +190,13 @@
"when": 1721928706816, "when": 1721928706816,
"tag": "0026_yielding_king_cobra", "tag": "0026_yielding_king_cobra",
"breakpoints": true "breakpoints": true
},
{
"idx": 27,
"version": "6",
"when": 1721937297064,
"tag": "0027_fantastic_squadron_sinister",
"breakpoints": true
} }
] ]
} }

View File

@ -31,11 +31,6 @@ import {
removeDirectoryCode, removeDirectoryCode,
removeMonitoringDirectory, removeMonitoringDirectory,
} from "@/server/utils/filesystem/directory"; } from "@/server/utils/filesystem/directory";
import {
generateSSHKey,
readSSHPublicKey,
removeSSHKey,
} from "@/server/utils/filesystem/ssh";
import { import {
readConfig, readConfig,
removeTraefikConfig, removeTraefikConfig,
@ -130,7 +125,6 @@ export const applicationRouter = createTRPCRouter({
async () => await removeMonitoringDirectory(application?.appName), async () => await removeMonitoringDirectory(application?.appName),
async () => await removeTraefikConfig(application?.appName), async () => await removeTraefikConfig(application?.appName),
async () => await removeService(application?.appName), async () => await removeService(application?.appName),
async () => await removeSSHKey(application?.appName),
]; ];
for (const operation of cleanupOperations) { for (const operation of cleanupOperations) {
@ -240,32 +234,6 @@ export const applicationRouter = createTRPCRouter({
applicationStatus: "idle", applicationStatus: "idle",
}); });
return true;
}),
generateSSHKey: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input }) => {
const application = await findApplicationById(input.applicationId);
try {
await generateSSHKey(application.appName);
const file = await readSSHPublicKey(application.appName);
// await updateApplication(input.applicationId, {
// customGitSSHKey: file,
// });
} catch (error) {}
return true;
}),
removeSSHKey: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input }) => {
const application = await findApplicationById(input.applicationId);
await removeSSHKey(application.appName);
// await updateApplication(input.applicationId, {
// customGitSSHKey: null,
// });
return true; return true;
}), }),
markRunning: protectedProcedure markRunning: protectedProcedure

View File

@ -16,11 +16,6 @@ import { myQueue } from "@/server/queues/queueSetup";
import { createCommand } from "@/server/utils/builders/compose"; import { createCommand } from "@/server/utils/builders/compose";
import { randomizeComposeFile } from "@/server/utils/docker/compose"; import { randomizeComposeFile } from "@/server/utils/docker/compose";
import { removeComposeDirectory } from "@/server/utils/filesystem/directory"; import { removeComposeDirectory } from "@/server/utils/filesystem/directory";
import {
generateSSHKey,
readSSHPublicKey,
removeSSHKey,
} from "@/server/utils/filesystem/ssh";
import { templates } from "@/templates/templates"; import { templates } from "@/templates/templates";
import type { TemplatesKeys } from "@/templates/types/templates-data.type"; import type { TemplatesKeys } from "@/templates/types/templates-data.type";
import { import {
@ -102,7 +97,6 @@ export const composeRouter = createTRPCRouter({
async () => await removeCompose(composeResult), async () => await removeCompose(composeResult),
async () => await removeDeploymentsByComposeId(composeResult), async () => await removeDeploymentsByComposeId(composeResult),
async () => await removeComposeDirectory(composeResult.appName), async () => await removeComposeDirectory(composeResult.appName),
async () => await removeSSHKey(composeResult.appName),
]; ];
for (const operation of cleanupOperations) { for (const operation of cleanupOperations) {
@ -181,38 +175,12 @@ export const composeRouter = createTRPCRouter({
const command = createCommand(compose); const command = createCommand(compose);
return `docker ${command}`; return `docker ${command}`;
}), }),
generateSSHKey: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input }) => {
const compose = await findComposeById(input.composeId);
try {
await generateSSHKey(compose.appName);
const file = await readSSHPublicKey(compose.appName);
await updateCompose(input.composeId, {
customGitSSHKey: file,
});
} catch (error) {}
return true;
}),
refreshToken: protectedProcedure refreshToken: protectedProcedure
.input(apiFindCompose) .input(apiFindCompose)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
await updateCompose(input.composeId, { await updateCompose(input.composeId, {
refreshToken: nanoid(), refreshToken: nanoid(),
}); });
return true;
}),
removeSSHKey: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input }) => {
const compose = await findComposeById(input.composeId);
await removeSSHKey(compose.appName);
await updateCompose(input.composeId, {
customGitSSHKey: null,
});
return true; return true;
}), }),
deployTemplate: protectedProcedure deployTemplate: protectedProcedure

View File

@ -1,3 +1,4 @@
import { sshKeys } from "@/server/db/schema/ssh-key";
import { generatePassword } from "@/templates/utils"; import { generatePassword } from "@/templates/utils";
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { boolean, pgEnum, pgTable, text } from "drizzle-orm/pg-core"; import { boolean, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
@ -41,7 +42,12 @@ export const compose = pgTable("compose", {
// Git // Git
customGitUrl: text("customGitUrl"), customGitUrl: text("customGitUrl"),
customGitBranch: text("customGitBranch"), customGitBranch: text("customGitBranch"),
customGitSSHKey: text("customGitSSHKey"), customGitSSHKeyId: text("customGitSSHKeyId").references(
() => sshKeys.sshKeyId,
{
onDelete: "set null",
},
),
// //
command: text("command").notNull().default(""), command: text("command").notNull().default(""),
// //
@ -62,6 +68,10 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
}), }),
deployments: many(deployments), deployments: many(deployments),
mounts: many(mounts), mounts: many(mounts),
customGitSSHKey: one(sshKeys, {
fields: [compose.customGitSSHKeyId],
references: [sshKeys.sshKeyId],
}),
})); }));
const createSchema = createInsertSchema(compose, { const createSchema = createInsertSchema(compose, {
@ -70,6 +80,7 @@ const createSchema = createInsertSchema(compose, {
env: z.string().optional(), env: z.string().optional(),
composeFile: z.string().min(1), composeFile: z.string().min(1),
projectId: z.string(), projectId: z.string(),
customGitSSHKeyId: z.string().optional(),
command: z.string().optional(), command: z.string().optional(),
composePath: z.string().min(1), composePath: z.string().min(1),
composeType: z.enum(["docker-compose", "stack"]).optional(), composeType: z.enum(["docker-compose", "stack"]).optional(),

View File

@ -1,4 +1,5 @@
import { applications } from "@/server/db/schema/application"; import { applications } from "@/server/db/schema/application";
import { compose } from "@/server/db/schema/compose";
import { sshKeyCreate } from "@/server/db/validations"; import { sshKeyCreate } from "@/server/db/validations";
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { pgTable, text, time } from "drizzle-orm/pg-core"; import { pgTable, text, time } from "drizzle-orm/pg-core";
@ -21,6 +22,7 @@ export const sshKeys = pgTable("ssh-key", {
export const sshKeysRelations = relations(sshKeys, ({ many }) => ({ export const sshKeysRelations = relations(sshKeys, ({ many }) => ({
applications: many(applications), applications: many(applications),
compose: many(compose),
})); }));
const createSchema = createInsertSchema( const createSchema = createInsertSchema(

View File

@ -39,7 +39,7 @@ export const cloneGitRepository = async (
writeStream.write( writeStream.write(
`\nCloning Repo Custom ${customGitUrl} to ${outputPath}: ✅\n`, `\nCloning Repo Custom ${customGitUrl} to ${outputPath}: ✅\n`,
); );
console.log(customGitSSHKeyId);
await spawnAsync( await spawnAsync(
"git", "git",
[ [