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({
name: z.string(),
name: z.string().optional(),
volumes: z.tuple([z.string(), z.string()]).array(),
ports: z.tuple([z.string(), z.string()]).array(),
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 path from 'path'
@ -9,6 +9,11 @@ import { type Runner } from '@/runners'
import { debug } from '@/logger'
import Docker from 'dockerode'
import _ from 'lodash'
const docker = new Docker({ socketPath: '/var/run/docker.sock' })
const toSafePath = (target: string) => {
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] {
return [
{
@ -80,11 +122,15 @@ export function createDeployJob(deploy: Deploy, submitter: any): [JobBase, Deplo
},
async runner => {
debug('[Runner]', `Deploying "${deploy.name}"`)
// TODO: Remove this sleep, it's just for testing
await sleep(1000)
// TODO: Add other deploy types
if (deploy.type === 'shell') {
await shellRunner(runner, deploy)
} else if (deploy.type === 'docker-image') {
await dockerImageRunner(runner, deploy)
} else {
throw new Error(`deploy type "${deploy.type}" not yet implemented`)
}

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

@ -23,8 +23,8 @@ const { title } = Astro.props
<body>
<Sidebar />
<div class="scroll-container">
<div class="logo">phCD</div>
<main>
<div class="phc-logo">phCD</div>
<slot />
</main>
</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 { Value } from '@client/Inspect'
import { TriggerButton } from '@client/TriggerButton'
import { loadConfig } from '@/config'
import Layout from '@layouts/Layout.astro'
import { createUrlQuery } from '@/lib/utils'
import Docker from 'dockerode'
const { name } = Astro.params
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`}>
@ -26,6 +33,34 @@ if (!deploy) {
<div class="kind">Deploy</div>
<div class="title">{name}</div>
</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 />
<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>

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

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

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

Loading…
Cancel
Save