feat: docker-image deploy and force-deploy button and api endpoint
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -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()),
|
||||
|
||||
+47
-1
@@ -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`)
|
||||
}
|
||||
|
||||
+15
-3
@@ -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>
|
||||
<a class="button" href="/deploys/new">
|
||||
<span class="material-symbols-outlined">add</span> New Deploy
|
||||
</a>
|
||||
<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)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+55
-14
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user