Skip to content

Commit

Permalink
feat: add form to deploy new stack
Browse files Browse the repository at this point in the history
  • Loading branch information
Trugamr committed Jan 17, 2024
1 parent 971a199 commit fb07670
Show file tree
Hide file tree
Showing 12 changed files with 373 additions and 14 deletions.
2 changes: 1 addition & 1 deletion app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;

--destructive: 0 62.8% 30.6%;
--destructive: 0 73.71% 41.76%;
--destructive-foreground: 0 0% 98%;

--border: 240 3.7% 15.9%;
Expand Down
52 changes: 52 additions & 0 deletions app/lib/deployment.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Deployment } from '~/routes/deploy/schema'
import {
convertComposeConfigToYaml,
convertDeploymentToComposeConfig,
} from '~/routes/deploy/utils'
import { getManagedStacksDirectory } from './stack.server'
import path from 'node:path'
import fs from 'node:fs/promises'

export async function createNewDeployment(deployment: Deployment) {
let composeConfig: ReturnType<typeof convertDeploymentToComposeConfig>
try {
composeConfig = convertDeploymentToComposeConfig(deployment)
} catch (error) {
// eslint-disable-next-line no-console
console.error(error)
throw new Error('Failed to convert deployment to compose config')
}

let composeYaml: ReturnType<typeof convertComposeConfigToYaml>
try {
composeYaml = convertComposeConfigToYaml(composeConfig)
} catch (error) {
// eslint-disable-next-line no-console
console.error(error)
throw new Error('Failed to convert compose config to yaml')
}

// Create a new directory for the stack
const stackDirectory = path.resolve(getManagedStacksDirectory(), deployment.name)

try {
await fs.mkdir(stackDirectory)
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'EEXIST') {
throw new Error(`Stack with name "${deployment.name}" already exists`)
}
// eslint-disable-next-line no-console
console.error(error)
throw new Error('Failed to create stack directory')
}

// Create a new docker-compose.yml file
const composeYamlPath = path.resolve(stackDirectory, 'docker-compose.yml')
try {
await fs.writeFile(composeYamlPath, composeYaml)
} catch (error) {
// eslint-disable-next-line no-console
console.error(error)
throw new Error('Failed to create docker-compose.yml file')
}
}
18 changes: 18 additions & 0 deletions app/lib/stack.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,3 +407,21 @@ export async function getStackDetails(stack: Pick<Stack, 'directory' | 'path'>)

return merged
}

/**
* @returns `true` if managed stack with given name doesn't exist
*/
export async function isStackNameUnique(name: string) {
const stacksDirectory = getManagedStacksDirectory()
const stackDirectory = path.join(stacksDirectory, name)

try {
await fs.stat(stackDirectory)
return false
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
return true
}
throw error
}
}
2 changes: 1 addition & 1 deletion app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export default function App() {
</div>
</nav>
</header>
<div className="h-0 flex-grow">
<div className="h-0 grow">
<Outlet />
</div>
</div>
Expand Down
9 changes: 0 additions & 9 deletions app/routes/deploy.tsx

This file was deleted.

201 changes: 201 additions & 0 deletions app/routes/deploy/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { Form, useActionData } from '@remix-run/react'
import { z } from 'zod'
import {
FieldConfig,
conform,
useFieldList,
useFieldset,
useForm,
list,
} from '@conform-to/react'
import { parse, getFieldsetConstraint } from '@conform-to/zod'
import { Input } from '~/components/ui/input'
import { ActionFunctionArgs, json, redirect } from '@remix-run/node'
import { useRef } from 'react'
import { Button } from '~/components/ui/button'
import { XIcon } from 'lucide-react'
import { cn } from '~/lib/utils'
import { DeploymentSchema, DeploymentServiceSchema } from './schema'
import { DeploymentSchemaServer } from './schema.server'
import { createNewDeployment } from '~/lib/deployment.server'

export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
const submission = await parse(formData, {
async: true,
schema: intent =>
DeploymentSchemaServer.transform(async (values, ctx) => {
if (intent !== 'submit') {
return { ...values, deployment: null } as const
}

try {
await createNewDeployment(values)
} catch (error) {
const message =
error instanceof Error
? error.message
: 'Something went wrong while creating your deployment'

ctx.addIssue({
code: 'custom',
message,
fatal: true,
})

return z.NEVER
}

return {
...values,
deployment: {
name: values.name,
},
} as const
}),
})

if (submission.intent !== 'submit') {
return json({ status: 'idle', submission })
}

if (!submission.value?.deployment) {
return json({ status: 'error', submission }, { status: 400, statusText: 'Bad Request' })
}

return redirect(`/stacks/${submission.value.deployment.name}`)
}

export default function DeployRoute() {
const actionData = useActionData<typeof action>()

const [form, fields] = useForm({
id: 'deployment-form',
constraint: getFieldsetConstraint(DeploymentSchema),
lastSubmission: actionData?.submission,
defaultValue: {
services: [
{
name: '',
image: '',
},
],
},
onValidate: ({ formData }) => {
return parse(formData, {
schema: DeploymentSchema,
})
},
shouldRevalidate: 'onBlur',
})

const services = useFieldList(form.ref, fields.services)

return (
<div className="h-full min-w-0 overflow-y-auto">
<div className="container p-6">
<h2 className="text-2xl font-medium">Create new deployment</h2>

<Form className="mt-6" method="POST" {...form.props}>
{/**
* Make sure we perform default form submission on enter key press
* Browser selects the first submit button in the form by default
*/}
<input type="submit" hidden />

<section>
<label htmlFor={fields.name.id}>
<h3 className="text-lg font-medium">Name</h3>
<p className="text-muted-foreground">Give your deployment a name</p>
</label>
<Input className="mt-2" autoComplete="off" {...conform.input(fields.name)} />
{fields.name.error ? (
<p className="mt-0.5 text-sm text-destructive" id={fields.name.errorId}>
{fields.name.error}
</p>
) : null}
</section>

<section className="mt-6">
<h3 className="text-lg font-medium">Services</h3>
<p className="text-muted-foreground">Add services to your deployment</p>
{fields.services.error ? (
<p className="mt-0.5 text-sm text-destructive" id={fields.services.errorId}>
{fields.services.error}
</p>
) : null}
<ul className="mt-4 space-y-2">
{services.map((service, index) => {
return (
<li key={service.key} className="flex gap-x-4">
<ServiceFieldset config={service} className="grow" />
<Button
className="mt-7 shrink-0"
size="icon"
variant="destructive"
{...list.remove(fields.services.name, { index })}
>
<XIcon />
</Button>
</li>
)
})}
</ul>
<div className="mt-4 flex justify-end">
<Button variant="outline" {...list.insert(fields.services.name)}>
Add Service
</Button>
</div>
</section>

<div className="mt-6 flex flex-col items-end gap-y-2">
{form.error ? (
<p className="text-sm text-destructive" id={form.errorId}>
{form.error}
</p>
) : null}
<Button>Save & Deploy</Button>
</div>
</Form>
</div>
</div>
)
}

function ServiceFieldset({
config,
className,
}: {
config: FieldConfig<z.infer<typeof DeploymentServiceSchema>>
className?: string
}) {
const ref = useRef<HTMLFieldSetElement>(null)
const fields = useFieldset(ref, config)

return (
<fieldset
ref={ref}
className={cn('grid gap-4 sm:grid-cols-2', className)}
{...conform.fieldset(config)}
>
<div>
<label htmlFor={fields.name.id}>Name</label>
<Input className="mt-1" autoComplete="off" {...conform.input(fields.name)} />
{fields.name.error ? (
<p className="mt-0.5 text-sm text-destructive" id={fields.name.errorId}>
{fields.name.error}
</p>
) : null}
</div>
<div>
<label htmlFor={fields.image.id}>Image</label>
<Input className="mt-1" autoComplete="off" {...conform.input(fields.image)} />
{fields.image.error ? (
<p className="mt-0.5 text-sm text-destructive" id={fields.image.errorId}>
{fields.image.error}
</p>
) : null}
</div>
</fieldset>
)
}
6 changes: 6 additions & 0 deletions app/routes/deploy/schema.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { isStackNameUnique } from '~/lib/stack.server'
import { createDeploymentSchema } from './schema'

export const DeploymentSchemaServer = createDeploymentSchema({
isDeployentNameUnique: isStackNameUnique,
})
53 changes: 53 additions & 0 deletions app/routes/deploy/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { refine } from '@conform-to/zod'
import { z } from 'zod'

export const DeploymentServiceSchema = z.object({
name: z
.string({ required_error: 'Name is required' })
.min(1, { message: 'Name cannot be empty' }),
image: z
.string({ required_error: 'Image is required' })
.min(1, { message: 'Image cannot be empty' }),
})

export const DeploymentSchema = createDeploymentSchema()
export type Deployment = z.infer<typeof DeploymentSchema>

export function createDeploymentSchema(constraint?: {
isDeployentNameUnique: (name: string) => Promise<boolean>
}) {
return z.object({
name: z
.string({ required_error: 'Name is required' })
.min(3, {
message: 'Name must be at least 3 characters long',
})
.pipe(
z.string().superRefine((name, ctx) => {
return refine(ctx, {
validate: () => constraint?.isDeployentNameUnique(name),
message: `Deployment with name "${name}" already exists`,
})
}),
),
services: z
.array(DeploymentServiceSchema)
.min(1, { message: 'At least one service is required' }),
})
}

export const ComposeConfigSchema = z.object({
version: z.literal('3.8'),
services: z
.record(
z.string(),
z.object({
image: z.string(),
}),
)
.refine(value => Object.keys(value).length > 0, {
message: 'At least one service is required',
}),
})

export type ComposeConfig = z.infer<typeof ComposeConfigSchema>
29 changes: 29 additions & 0 deletions app/routes/deploy/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { ComposeConfigSchema, type Deployment, type ComposeConfig } from './schema'
import type { PartialDeep } from 'type-fest'
import YAML from 'yaml'

/**
* Takes a deployment and converts it to a valid docker-comopose config
*/
export function convertDeploymentToComposeConfig(deployment: Deployment): ComposeConfig {
const config = {
version: '3.8',
services: {},
} satisfies PartialDeep<ComposeConfig>

for (const service of deployment.services) {
config.services = {
...config.services,
[service.name]: {
image: service.image,
},
}
}

return ComposeConfigSchema.parse(config)
}

export function convertComposeConfigToYaml(config: ComposeConfig): string {
// TODO: Add spaces between sections
return YAML.stringify(config)
}
Loading

0 comments on commit fb07670

Please sign in to comment.