feat: docker-image deploy and force-deploy button and api endpoint

main
Antonio De Lucreziis 7 months ago
parent d4b49d9955
commit 71f71811eb

@ -0,0 +1,33 @@
import { useState } from 'preact/hooks'
// target is a url to send a POST request to
export const TriggerButton = ({ target, children }) => {
const [loading, setLoading] = useState(false)
const handleClick = async () => {
setLoading(true)
const response = await fetch(target, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
if (response.ok) {
console.log('Success')
} else {
console.log('Failure')
}
setLoading(false)
}
return loading ? (
<button disabled class="loading">
<span class="material-symbols-outlined">refresh</span> Loading...
</button>
) : (
<button onClick={handleClick}>{children}</button>
)
}

@ -23,7 +23,7 @@ export const GitDeploy = z.object({
}) })
const DockerRunOptions = z.object({ const DockerRunOptions = z.object({
name: z.string(), name: z.string().optional(),
volumes: z.tuple([z.string(), z.string()]).array(), volumes: z.tuple([z.string(), z.string()]).array(),
ports: z.tuple([z.string(), z.string()]).array(), ports: z.tuple([z.string(), z.string()]).array(),
env: z.record(z.string()), env: z.record(z.string()),

@ -1,4 +1,4 @@
import type { Deploy, GitDeploy, GitRef, ShellDeploy } from '@/config' import type { Deploy, DockerDeploy, GitDeploy, GitRef, ShellDeploy } from '@/config'
import type { DeployFunction, Job, JobBase } from '@/jobs' import type { DeployFunction, Job, JobBase } from '@/jobs'
import path from 'path' import path from 'path'
@ -9,6 +9,11 @@ import { type Runner } from '@/runners'
import { debug } from '@/logger' import { debug } from '@/logger'
import Docker from 'dockerode'
import _ from 'lodash'
const docker = new Docker({ socketPath: '/var/run/docker.sock' })
const toSafePath = (target: string) => { const toSafePath = (target: string) => {
return '.' + path.posix.normalize('/' + target) return '.' + path.posix.normalize('/' + target)
} }
@ -72,6 +77,43 @@ export async function shellRunner(runner: Runner, deploy: ShellDeploy) {
) )
} }
export async function dockerImageRunner(runner: Runner, deploy: DockerDeploy) {
const containerName = deploy.options.name ?? deploy.name
const containers = await docker.listContainers({ all: true })
const prevContainerId = containers.find(c => c.Names.includes(`/${containerName}`))?.Id
if (prevContainerId) {
debug('[Deploy] [DockerImage] Removing previous container')
const oldContainer = docker.getContainer(prevContainerId)
await oldContainer.remove({ force: true })
}
debug('[Deploy] [DockerImage] Creating new container')
// create container and start
const container = await docker.createContainer({
Image: deploy.options.image,
name: containerName,
ExposedPorts: _.fromPairs(deploy.options.ports.map(([_, container]) => [container, {}])),
HostConfig: {
Binds: deploy.options.volumes.map(([host, container]) => `${host}:${container}`),
PortBindings: _.fromPairs(
deploy.options.ports.map(([host, container]) => [container, [{ HostPort: host }]])
),
},
Env: _.map(deploy.options.env, (value, key) => `${key}=${value}`),
})
const stream = await container.attach({ stream: true, stdout: true, stderr: true })
runner.dumpStream(stream)
debug('[Deploy] [DockerImage] Starting the new container')
await container.start()
}
export function createDeployJob(deploy: Deploy, submitter: any): [JobBase, DeployFunction] { export function createDeployJob(deploy: Deploy, submitter: any): [JobBase, DeployFunction] {
return [ return [
{ {
@ -80,11 +122,15 @@ export function createDeployJob(deploy: Deploy, submitter: any): [JobBase, Deplo
}, },
async runner => { async runner => {
debug('[Runner]', `Deploying "${deploy.name}"`) debug('[Runner]', `Deploying "${deploy.name}"`)
// TODO: Remove this sleep, it's just for testing
await sleep(1000) await sleep(1000)
// TODO: Add other deploy types // TODO: Add other deploy types
if (deploy.type === 'shell') { if (deploy.type === 'shell') {
await shellRunner(runner, deploy) await shellRunner(runner, deploy)
} else if (deploy.type === 'docker-image') {
await dockerImageRunner(runner, deploy)
} else { } else {
throw new Error(`deploy type "${deploy.type}" not yet implemented`) throw new Error(`deploy type "${deploy.type}" not yet implemented`)
} }

@ -35,6 +35,7 @@ export type DeployFunction = (runner: Runner) => Promise<void>
type QueuedJob = { type QueuedJob = {
uuid: string uuid: string
deployFn: DeployFunction deployFn: DeployFunction
completed?: () => void
} }
const emitter = new EventEmitter() const emitter = new EventEmitter()
@ -59,7 +60,7 @@ async function processQueue() {
working = true working = true
{ {
while (queue.length > 0) { while (queue.length > 0) {
const { uuid, deployFn } = queue.shift()! const { uuid, deployFn, completed } = queue.shift()!
const job = await jobsDB.update(async jobs => { const job = await jobsDB.update(async jobs => {
const job = jobs.find(job => job.uuid === uuid)! const job = jobs.find(job => job.uuid === uuid)!
@ -91,6 +92,12 @@ async function processQueue() {
} catch (e) { } catch (e) {
error = e!.toString() error = e!.toString()
} }
if (error) {
debug(`[Jobs] Error in job "${job.name}"`)
debug(`[Jobs] ${error}`)
}
debug(`[Jobs] Finished job`) debug(`[Jobs] Finished job`)
const completedAt = new Date().toISOString() const completedAt = new Date().toISOString()
@ -109,6 +116,7 @@ async function processQueue() {
return job return job
}) })
completed?.()
emitter.emit('job:completed', completedJob) emitter.emit('job:completed', completedJob)
} }
} }
@ -121,7 +129,11 @@ export type JobBase = {
} }
// Use this function to add new jobs to the work queue // Use this function to add new jobs to the work queue
export async function enqueueJob(jobBase: JobBase, deployFn: DeployFunction) { export async function enqueueJob(
jobBase: JobBase,
deployFn: DeployFunction,
completed?: () => void
) {
const uuid = randomUUID() const uuid = randomUUID()
const job: Job = { const job: Job = {
...jobBase, ...jobBase,
@ -134,7 +146,7 @@ export async function enqueueJob(jobBase: JobBase, deployFn: DeployFunction) {
jobs.push(job) jobs.push(job)
}) })
queue.push({ uuid, deployFn }) queue.push({ uuid, deployFn, completed })
emitter.emit('job:add', job) emitter.emit('job:add', job)
// starts concurrently a function to process jobs // starts concurrently a function to process jobs

@ -23,8 +23,8 @@ const { title } = Astro.props
<body> <body>
<Sidebar /> <Sidebar />
<div class="scroll-container"> <div class="scroll-container">
<div class="logo">phCD</div>
<main> <main>
<div class="phc-logo">phCD</div>
<slot /> <slot />
</main> </main>
</div> </div>

@ -0,0 +1,27 @@
import { loadConfig } from '@/config'
import { createDeployJob } from '@/deploys'
import { enqueueJob } from '@/jobs'
import { sleep } from '@/lib/utils'
import type { APIRoute } from 'astro'
export const POST: APIRoute = async ({ params: { name } }) => {
const { deploys } = await loadConfig()
const deploy = deploys.find(d => d.name === name)
if (!deploy) {
return new Response('Deploy not found', { status: 404 })
}
const [jobBase, deployFn] = createDeployJob(deploy, { event: 'manual' })
let onComplete
const completed = new Promise<void>(resolve => {
onComplete = resolve
})
await enqueueJob(jobBase, deployFn, onComplete)
await completed
return new Response('ok')
}

@ -1,11 +1,14 @@
--- ---
import { Deploy } from '@client/Deploy' import { Deploy } from '@client/Deploy'
import { Value } from '@client/Inspect' import { Value } from '@client/Inspect'
import { TriggerButton } from '@client/TriggerButton'
import { loadConfig } from '@/config' import { loadConfig } from '@/config'
import Layout from '@layouts/Layout.astro' import Layout from '@layouts/Layout.astro'
import { createUrlQuery } from '@/lib/utils' import { createUrlQuery } from '@/lib/utils'
import Docker from 'dockerode'
const { name } = Astro.params const { name } = Astro.params
const { deploys } = await loadConfig() const { deploys } = await loadConfig()
@ -19,6 +22,10 @@ if (!deploy) {
}) })
) )
} }
const docker = new Docker({ socketPath: '/var/run/docker.sock' })
const containers = await docker.listContainers()
const container = containers.find(c => c.Names.includes(`/${name}`))
--- ---
<Layout title={`${name} | Deploy | phCD`}> <Layout title={`${name} | Deploy | phCD`}>
@ -26,6 +33,34 @@ if (!deploy) {
<div class="kind">Deploy</div> <div class="kind">Deploy</div>
<div class="title">{name}</div> <div class="title">{name}</div>
</h1> </h1>
<div class="buttons">
<TriggerButton client:load target={Astro.url.href + '/force-deploy'}>
<span class="material-symbols-outlined">replay</span> Force re-deploy
</TriggerButton>
<TriggerButton client:load target={Astro.url.href + '/stop'}>
<span class="material-symbols-outlined">stop</span> Stop
</TriggerButton>
</div>
<h2>Settings</h2>
<Value value={deploy} borderless /> <Value value={deploy} borderless />
<h2>Containers</h2>
{
container ? (
<div class="card">
<div class="container">
<Value value={container} borderless />
</div>
</div>
) : (
<p>No container found</p>
)
}
<h2>Jobs</h2>
...
</Layout> </Layout>

@ -8,9 +8,11 @@ const { deploys } = await loadConfig()
<Layout title="Deploys | phCD"> <Layout title="Deploys | phCD">
<h1>Deploys</h1> <h1>Deploys</h1>
<a class="button" href="/deploys/new"> <div class="buttons">
<span class="material-symbols-outlined">add</span> New Deploy <a class="button" href="/deploys/new">
</a> <span class="material-symbols-outlined">add</span> New Deploy
</a>
</div>
<div class="deploys"> <div class="deploys">
{deploys.toReversed().map(deploy => <Deploy deploy={deploy} />)} {deploys.toReversed().map(deploy => <Deploy deploy={deploy} />)}
</div> </div>

@ -9,6 +9,7 @@ type RunnerLogOptions = {
export type Runner = { export type Runner = {
script: (source: string, options?: RunnerLogOptions) => Promise<void> script: (source: string, options?: RunnerLogOptions) => Promise<void>
command: (source: string, options?: RunnerLogOptions) => Promise<void> command: (source: string, options?: RunnerLogOptions) => Promise<void>
dumpStream: (stream: any) => void
} }
function onLine(stream: any, cb: (line: string) => void) { function onLine(stream: any, cb: (line: string) => void) {
@ -69,5 +70,10 @@ export function createSimpleRunner(logLn: (s: string) => void): Runner {
await runShell(source, silent ? undefined : logLn) await runShell(source, silent ? undefined : logLn)
}, },
dumpStream(stream: any) {
onLine(stream, (line: string) => {
debug('[Runner]', ' >', line)
})
},
} }
} }

@ -23,6 +23,19 @@ img {
display: block; display: block;
} }
//
// Animations
//
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
// //
// Typography // Typography
// //
@ -98,6 +111,8 @@ code {
button, button,
.button { .button {
border: none;
text-decoration: none; text-decoration: none;
display: inline-block; display: inline-block;
@ -106,7 +121,7 @@ button,
font-size: 18px; font-size: 18px;
font-weight: 500; font-weight: 500;
padding: 0.5rem 0.75rem; padding: 0.5rem 1rem 0.5rem 0.65rem;
border-radius: 2rem; border-radius: 2rem;
cursor: pointer; cursor: pointer;
transition: background-color 150ms ease-in-out; transition: background-color 150ms ease-in-out;
@ -116,10 +131,16 @@ button,
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
grid-auto-flow: column; grid-auto-flow: column;
gap: 0.25rem; gap: 0.35rem;
.material-symbols-outlined {
font-size: 22px;
}
& > .material-symbols-outlined { &.loading {
font-size: 18px; .material-symbols-outlined {
animation: spin 1s linear infinite;
}
} }
&:hover { &:hover {
@ -258,6 +279,8 @@ form {
align-items: center; align-items: center;
border-left: 1px solid #ddd; border-left: 1px solid #ddd;
word-break: break-word;
} }
& > .key, & > .key,
@ -386,6 +409,17 @@ form {
// border-radius: 1rem; // border-radius: 1rem;
} }
.card {
width: 100%;
border: 1px solid #ddd;
padding: 1rem;
border-radius: 1rem;
display: grid;
}
// //
// Structure // Structure
// //
@ -506,12 +540,9 @@ body {
& > .scroll-container { & > .scroll-container {
grid-area: main; grid-area: main;
display: grid; display: grid;
justify-items: center;
overflow-y: auto; overflow-y: auto;
position: relative; .phc-logo {
& > .logo {
position: absolute; position: absolute;
top: 1rem; top: 1rem;
left: 1rem; left: 1rem;
@ -520,23 +551,29 @@ body {
font-weight: 500; font-weight: 500;
line-height: 1; line-height: 1;
color: #666; color: #666;
-webkit-text-stroke: 4px #fff;
paint-order: stroke fill;
} }
main { main {
position: relative;
overflow-x: auto; overflow-x: auto;
padding: 5rem 1rem 2rem; padding: 4rem 1rem 2rem;
display: flex; display: grid;
flex-direction: column; grid-template-columns: minmax(auto, 80ch);
align-items: flex-start; justify-content: center;
align-content: start;
gap: 2rem; gap: 2rem;
justify-self: center; justify-self: center;
width: 100%; width: 100%;
max-width: 80ch; // max-width: 80ch;
// //
// Components // Components
@ -551,8 +588,12 @@ body {
font-weight: 400; font-weight: 400;
color: #666; color: #666;
} }
}
padding-bottom: 1rem; .buttons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
} }
.list { .list {

Loading…
Cancel
Save