From 28b16aee7a05b700dc5d2ac091972eb1fe9de5bd Mon Sep 17 00:00:00 2001 From: Antonio De Lucreziis Date: Tue, 5 Mar 2024 18:55:38 +0100 Subject: [PATCH] feat: deploy form --- .gitignore | 4 +- config.yaml | 36 ------------ package.json | 3 +- pnpm-lock.yaml | 3 + src/client/NewDeployForm.jsx | 71 ++++++++++++++-------- src/config.ts | 10 ++-- src/forms.ts | 107 ++++++++++++++++++++++++++++++++++ src/jobs.ts | 28 +++++++++ src/pages/api/webhook.ts | 22 +++++++ src/pages/deploys/index.astro | 4 +- src/pages/deploys/new.astro | 8 ++- src/runners.ts | 46 +++++++++++++++ src/styles/main.scss | 62 ++++++++++---------- tsconfig.json | 1 + 14 files changed, 304 insertions(+), 101 deletions(-) delete mode 100644 config.yaml create mode 100644 src/forms.ts create mode 100644 src/jobs.ts create mode 100644 src/pages/api/webhook.ts create mode 100644 src/runners.ts diff --git a/.gitignore b/.gitignore index f081d0c..4c74179 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,6 @@ pnpm-debug.log* .DS_Store # editors -.vscode \ No newline at end of file +.vscode + +config.yaml \ No newline at end of file diff --git a/config.yaml b/config.yaml deleted file mode 100644 index af19a4d..0000000 --- a/config.yaml +++ /dev/null @@ -1,36 +0,0 @@ -deploys: - - name: project1 - url: https://github.com/username/project1 - branch: main - type: docker - options: - ports: - - 80:8080 - volumes: - - /var/www/html:/var/www/html - - name: project2 - url: https://github.com/username/project2 - type: dockerfile - options: - ports: - - 9000:8080 - volumes: - - /var/www/html:/var/www/html - - name: project2 - url: ssh://example.org/username/project2.git - commit: 04c540647a - type: docker-compose - options: - path: ./docker-compose.yml - - name: project3 - url: https://github.com/username/project3 - type: shell - options: - path: ./deploy.sh - - - - - - - diff --git a/package.json b/package.json index 46823b6..d46d8d8 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "lodash": "^4.17.21", "nodegit": "^0.27.0", "preact": "^10.19.4", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "zod": "^3.22.4" }, "devDependencies": { "@types/dockerode": "^3.3.23", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a1d24b..ee5a47a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ dependencies: typescript: specifier: ^5.3.3 version: 5.3.3 + zod: + specifier: ^3.22.4 + version: 3.22.4 devDependencies: '@types/dockerode': diff --git a/src/client/NewDeployForm.jsx b/src/client/NewDeployForm.jsx index ca6a44a..56bd7dc 100644 --- a/src/client/NewDeployForm.jsx +++ b/src/client/NewDeployForm.jsx @@ -1,7 +1,6 @@ import { useState } from 'preact/hooks' export const NewDeployForm = () => { - const [refType, setRefType] = useState('default') const [deployType, setDeployType] = useState('initial') return ( @@ -9,6 +8,36 @@ export const NewDeployForm = () => { + + + + + +
+ +
+ + ) +} + +const DeployRefOptions = ({}) => { + const [refType, setRefType] = useState('default') + + return ( + <> { placeholder="Ref value..." /> - - - - - - -
- -
- + ) } -export const DeployOptions = ({ type }) => { +const DeployOptions = ({ type }) => { switch (type) { case 'docker': return @@ -83,6 +90,14 @@ export const DeployOptions = ({ type }) => { const DockerDeploy = () => { return ( <> + + + { const DockerfileDeploy = () => { return ( <> + + { const ShellDeploy = () => { return ( <> + + { const DockerComposeDeploy = () => { return ( <> + + { - return ref.type === 'default' ? '' : ref.value +export const refToString = (ref: GitRef, defaultStr: string = '') => { + return ref.type === 'default' ? defaultStr : ref.value } const mutex = new Mutex() @@ -69,13 +69,13 @@ export function loadConfig(): Promise { }) } -export function updateConfig(fn: (config: Config) => Promise) { +export function updateConfig(fn: (config: Config) => Promise) { return mutex.runExclusive(async () => { const data = await readFile('./config.yaml', 'utf8') const config = yaml.load(data) as Config - const newConfig = await fn(config) + await fn(config) - await writeFile('./config.yaml', yaml.dump(newConfig)) + await writeFile('./config.yaml', yaml.dump(config)) }) } diff --git a/src/forms.ts b/src/forms.ts new file mode 100644 index 0000000..1df9e57 --- /dev/null +++ b/src/forms.ts @@ -0,0 +1,107 @@ +import type { Deploy, GitRef } from './config' + +function cutString(s: string, sep: string): [string, string] { + return [s.slice(0, s.indexOf(sep)), s.slice(s.indexOf(sep) + sep.length)] +} + +function notEmpty(s: string, message: string) { + if (s.trim().length === 0) { + throw new Error(message) + } + + return s +} + +export function parseRef(form: Record): GitRef { + const type = form['deploy-ref-type'] + const value = form['deploy-ref-value'] + + return type === 'default' ? { type } : { type, value } +} + +export function parseDeploy(form: Record): Deploy { + const name = form['deploy-name'] + const type = form['deploy-type'] + + if (type === 'docker') { + // const url = notEmpty(form['deploy-url'], 'must provide an url') + const containerName = form['deploy-options-name'] as string + const image = notEmpty(form['deploy-options-image'], 'must provide an image name') + const ports = form['deploy-options-ports'] as string + const env = form['deploy-options-env'] as string + const volumes = form['deploy-options-volumes'] as string + + return { + name, + type: 'docker', + options: { + image, + name: containerName, + env: Object.fromEntries(env.split(/\n/g).map(line => cutString(line.trim(), '='))), + ports: ports.split(/\n/g).map(line => line.trim()), + volumes: volumes.split(/\n/g).map(line => line.trim()), + }, + } + } + + if (type === 'shell') { + const path = form['deploy-options-path'] as string + const env = form['deploy-options-env'] as string + + const url = form['deploy-url'] as string + const ref = parseRef(form) + + return { + name, + url, + ref, + type: 'shell', + options: { + path, + env: Object.fromEntries(env.split(/\n/g).map(line => cutString(line.trim(), '='))), + }, + } + } + + if (type === 'dockerfile') { + const url = form['deploy-url'] as string + const ref = parseRef(form) + + const path = form['deploy-options-path'] as string + const ports = form['deploy-options-ports'] as string + const env = form['deploy-options-env'] as string + const volumes = form['deploy-options-volumes'] as string + + return { + name, + url, + ref, + type: 'dockerfile', + options: { + path, + env: Object.fromEntries(env.split(/\n/g).map(line => cutString(line.trim(), '='))), + ports: ports.split(/\n/g).map(line => line.trim()), + volumes: volumes.split(/\n/g).map(line => line.trim()), + }, + } + } + + if (type === 'docker-compose') { + const url = form['deploy-url'] as string + const ref = parseRef(form) + + const path = form['deploy-options-path'] as string + + return { + name, + url, + ref, + type: 'docker-compose', + options: { + path, + }, + } + } + + throw new Error('invalid deploy type') +} diff --git a/src/jobs.ts b/src/jobs.ts new file mode 100644 index 0000000..70691d5 --- /dev/null +++ b/src/jobs.ts @@ -0,0 +1,28 @@ +const queue: (() => Promise)[] = [] + +// to ensure that the while loop inside triggerProcessQueue is getting executed from only one call at a time +let working = false + +export function runPendingJobs() { + triggerProcessQueue() +} + +async function triggerProcessQueue() { + if (working) return + + working = true + { + while (queue.length > 0) { + const job = queue.shift()! + await job() + } + } + working = false +} + +export function addJob(job: () => Promise) { + queue.push(job) + + // starts concurrently a function to process jobs + triggerProcessQueue() +} diff --git a/src/pages/api/webhook.ts b/src/pages/api/webhook.ts new file mode 100644 index 0000000..0f816f8 --- /dev/null +++ b/src/pages/api/webhook.ts @@ -0,0 +1,22 @@ +import type { APIRoute } from 'astro' +import { loadConfig } from '../../config' + +export const POST: APIRoute = async ({ request }) => { + const body = await request.json() + const { html_url: htmlUrl, clone_url: cloneUrl, ssh_url: sshUrl } = body.repository + + const URLS = [htmlUrl, cloneUrl, sshUrl] + + const { deploys } = await loadConfig() + + for (const deploy of deploys) { + if (deploy.type !== 'docker') { + if (URLS.includes(deploy.url)) { + // TODO: trigger deploy + break + } + } + } + + return new Response('ok') +} diff --git a/src/pages/deploys/index.astro b/src/pages/deploys/index.astro index 1f6c1b3..854ea91 100644 --- a/src/pages/deploys/index.astro +++ b/src/pages/deploys/index.astro @@ -1,6 +1,6 @@ --- import { Deploy } from '../../client/Deploy' -import { loadConfig, refToString } from '../../config' +import { loadConfig } from '../../config' import Layout from '../../layouts/Layout.astro' const { deploys } = await loadConfig() @@ -10,6 +10,6 @@ const { deploys } = await loadConfig()

Deploys

New Deploy
- {deploys.map(deploy => )} + {deploys.toReversed().map(deploy => )}
diff --git a/src/pages/deploys/new.astro b/src/pages/deploys/new.astro index f503f07..ffce4dc 100644 --- a/src/pages/deploys/new.astro +++ b/src/pages/deploys/new.astro @@ -2,12 +2,18 @@ import Layout from '../../layouts/Layout.astro' import { NewDeployForm } from '../../client/NewDeployForm.jsx' +import { parseDeploy } from '../../forms.ts' +import { updateConfig } from '../../config' if (Astro.request.method === 'POST') { const body = await Astro.request.formData() const formData = Object.fromEntries(body.entries()) - console.log(formData) + const newDeploy = parseDeploy(formData) + + updateConfig(async config => { + config.deploys.push(newDeploy) + }) return Astro.redirect('/deploys') } diff --git a/src/runners.ts b/src/runners.ts new file mode 100644 index 0000000..f946a0b --- /dev/null +++ b/src/runners.ts @@ -0,0 +1,46 @@ +import { refToString, type GitRef, type ShellDeploy, type Deploy } from './config' + +import { existsSync } from 'fs' +import { promisify } from 'util' +import child_process from 'child_process' + +const exec = promisify(child_process.exec) + +function getDeployDirectory(deploy: Deploy & { url: string; ref: GitRef }): string { + const { url, ref } = deploy + + const repoSlug = url.replace(/(^\w+:|^)\/\//, '').replace(/[^a-zA-Z]+/g, '-') + const slug = + ref.type === 'default' + ? `${deploy.name}_${repoSlug}` + : `${deploy.name}_${repoSlug}@${ref.value}` + + return `${process.env.CLONE_PATH ?? './data.local/repos'}/${slug}` +} + +async function cloneOrUpdateRepo(deploy: Deploy & { url: string; ref: GitRef }) { + const repoDir = getDeployDirectory(deploy) + + if (existsSync(repoDir)) { + await exec(`git -C ${repoDir} pull`) + } else { + await exec(`git clone ${deploy.url} ${repoDir}`) + } +} + +export async function shellRunner(deploy: ShellDeploy) { + const { url, ref, options } = deploy + const { path, env } = options + + const projectName = url.split('/').slice(-1)[0].replace('.git', '') + + await cloneOrUpdateRepo(deploy) + + const envStr = Object.entries(env as any) + .map(([key, value]) => `${key}=${value}`) + .join(' ') + + return exec(`cd ${projectName} && ${envStr} ${path}`) +} + + diff --git a/src/styles/main.scss b/src/styles/main.scss index 8391350..ca049fc 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -82,19 +82,19 @@ button, display: inline-block; color: #333; - background-color: #fff; + background-color: #00000018; font-size: 18px; font-weight: 500; - padding: 0.25rem 0.75rem; - border-radius: 4px; + padding: 0.5rem 0.75rem; + border-radius: 2rem; cursor: pointer; transition: background-color 150ms ease-in-out; - border: 1px solid #e0e0e0; + // border: 1px solid #e0e0e0; &:hover { - background-color: #e0e0e0; + background-color: #00000030; } } @@ -114,9 +114,9 @@ textarea { border: none; border: 1px solid #ddd; - border-radius: 6px; + border-radius: 1rem; - padding: 0 0.25rem; + padding: 0.25rem 0.75rem; display: flex; align-items: center; @@ -134,7 +134,9 @@ textarea { textarea { min-height: 2rem; - padding: 7px 0.25rem; + // padding: 7px 0.25rem; + + padding: 0.5rem 0.75rem; } form { @@ -275,7 +277,7 @@ form { .deploy { width: 100%; - background: #fff; + border: 1px solid #ddd; padding: 1rem; border-radius: 1rem; } @@ -290,7 +292,7 @@ body { line-height: 1.25; color: #333; - background: #f4f4f4; + background: #fff; display: grid; grid-template-columns: auto 1fr; @@ -301,7 +303,7 @@ body { min-width: 20vw; - background-color: #fff; + background-color: #ededf0; color: #333; display: flex; @@ -309,12 +311,12 @@ body { z-index: 1; - box-shadow: 0 0 1rem 0 #0002, 0 0 0.25rem 0 #0002; + // box-shadow: 0 0 1rem 0 #0002, 0 0 0.25rem 0 #0002; .header { padding: 1rem; - font-weight: 700; - font-size: 42px; + font-weight: 300; + font-size: 48px; } nav { @@ -339,18 +341,18 @@ body { flex-direction: row; align-items: center; - gap: 0.25rem; + gap: 0.5rem; cursor: pointer; transition: background 150ms ease-in-out; font-size: 16px; - padding: 0.125rem 0.25rem; - border-radius: 6px; + padding: 0.5rem 0.75rem; + border-radius: 100px; &:hover { - background-color: #f0f0f0; + background-color: #00000018; } .material-symbols-outlined { @@ -360,7 +362,7 @@ body { .children { flex-direction: column; - padding-left: 2rem; + padding-left: 1.5rem; display: flex; flex-direction: column; @@ -371,21 +373,21 @@ body { position: relative; - &::before { - content: ''; - background: #666; + // &::before { + // content: ''; + // background: #666; - position: absolute; - left: -0.25rem; + // position: absolute; + // left: -0.5rem; - width: 5px; - height: 5px; + // width: 5px; + // height: 5px; - border-radius: 100%; + // border-radius: 100%; - top: 50%; - transform: translate(0, calc(-50% + 1px)); - } + // top: 50%; + // transform: translate(0, calc(-50% + 1px)); + // } } } diff --git a/tsconfig.json b/tsconfig.json index 11f6093..3641000 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "astro/tsconfigs/strict", "compilerOptions": { + "strict": true, "jsx": "react-jsx", "jsxImportSource": "preact", "exactOptionalPropertyTypes": true