feat: deploy form

main
Antonio De Lucreziis 11 months ago
parent 6ffa9ca777
commit 28b16aee7a

2
.gitignore vendored

@ -22,3 +22,5 @@ pnpm-debug.log*
# editors # editors
.vscode .vscode
config.yaml

@ -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

@ -23,7 +23,8 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"nodegit": "^0.27.0", "nodegit": "^0.27.0",
"preact": "^10.19.4", "preact": "^10.19.4",
"typescript": "^5.3.3" "typescript": "^5.3.3",
"zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@types/dockerode": "^3.3.23", "@types/dockerode": "^3.3.23",

@ -47,6 +47,9 @@ dependencies:
typescript: typescript:
specifier: ^5.3.3 specifier: ^5.3.3
version: 5.3.3 version: 5.3.3
zod:
specifier: ^3.22.4
version: 3.22.4
devDependencies: devDependencies:
'@types/dockerode': '@types/dockerode':

@ -1,7 +1,6 @@
import { useState } from 'preact/hooks' import { useState } from 'preact/hooks'
export const NewDeployForm = () => { export const NewDeployForm = () => {
const [refType, setRefType] = useState('default')
const [deployType, setDeployType] = useState('initial') const [deployType, setDeployType] = useState('initial')
return ( return (
@ -9,6 +8,36 @@ export const NewDeployForm = () => {
<label for="deploy-name">Name</label> <label for="deploy-name">Name</label>
<input id="deploy-name" name="deploy-name" type="text" placeholder="Project name..." /> <input id="deploy-name" name="deploy-name" type="text" placeholder="Project name..." />
<label for="deploy-type">Type</label>
<select
id="deploy-type"
name="deploy-type"
value={deployType}
onChange={e => setDeployType(e.target.value)}
>
<option value="initial" disabled>
Select a deploy type...
</option>
<option value="docker">Docker</option>
<option value="dockerfile">Dockerfile</option>
<option value="docker-compose">Docker Compose</option>
<option value="shell">Shell</option>
</select>
<DeployOptions type={deployType} />
<div class="row right">
<button type="submit">Create</button>
</div>
</form>
)
}
const DeployRefOptions = ({}) => {
const [refType, setRefType] = useState('default')
return (
<>
<label for="deploy-url">Url</label> <label for="deploy-url">Url</label>
<input <input
id="deploy-url" id="deploy-url"
@ -39,33 +68,11 @@ export const NewDeployForm = () => {
placeholder="Ref value..." placeholder="Ref value..."
/> />
</div> </div>
</>
<label for="deploy-type">Type</label>
<select
id="deploy-type"
name="deploy-type"
value={deployType}
onChange={e => setDeployType(e.target.value)}
>
<option value="initial" disabled>
Select a deploy type...
</option>
<option value="docker">Docker</option>
<option value="dockerfile">Dockerfile</option>
<option value="docker-compose">Docker Compose</option>
<option value="shell">Shell</option>
</select>
<DeployOptions type={deployType} />
<div class="row right">
<button type="submit">Create</button>
</div>
</form>
) )
} }
export const DeployOptions = ({ type }) => { const DeployOptions = ({ type }) => {
switch (type) { switch (type) {
case 'docker': case 'docker':
return <DockerDeploy /> return <DockerDeploy />
@ -83,6 +90,14 @@ export const DeployOptions = ({ type }) => {
const DockerDeploy = () => { const DockerDeploy = () => {
return ( return (
<> <>
<label for="deploy-options-image">Container Name</label>
<input
id="deploy-options-name"
name="deploy-options-name"
type="text"
placeholder="Custom container name..."
/>
<label for="deploy-options-image">Image</label> <label for="deploy-options-image">Image</label>
<input <input
id="deploy-options-image" id="deploy-options-image"
@ -121,6 +136,8 @@ const DockerDeploy = () => {
const DockerfileDeploy = () => { const DockerfileDeploy = () => {
return ( return (
<> <>
<DeployRefOptions />
<label for="deploy-options-path">Path</label> <label for="deploy-options-path">Path</label>
<input <input
id="deploy-options-path" id="deploy-options-path"
@ -159,6 +176,8 @@ const DockerfileDeploy = () => {
const ShellDeploy = () => { const ShellDeploy = () => {
return ( return (
<> <>
<DeployRefOptions />
<label for="deploy-options-path">Path</label> <label for="deploy-options-path">Path</label>
<input <input
id="deploy-options-path" id="deploy-options-path"
@ -181,6 +200,8 @@ const ShellDeploy = () => {
const DockerComposeDeploy = () => { const DockerComposeDeploy = () => {
return ( return (
<> <>
<DeployRefOptions />
<label for="deploy-options-path">Path</label> <label for="deploy-options-path">Path</label>
<input <input
id="deploy-options-path" id="deploy-options-path"

@ -56,8 +56,8 @@ export type Config = {
deploys: Deploy[] deploys: Deploy[]
} }
export const refToString = (ref: GitRef) => { export const refToString = (ref: GitRef, defaultStr: string = '<default>') => {
return ref.type === 'default' ? '<default>' : ref.value return ref.type === 'default' ? defaultStr : ref.value
} }
const mutex = new Mutex() const mutex = new Mutex()
@ -69,13 +69,13 @@ export function loadConfig(): Promise<Config> {
}) })
} }
export function updateConfig(fn: (config: Config) => Promise<Config>) { export function updateConfig(fn: (config: Config) => Promise<void>) {
return mutex.runExclusive(async () => { return mutex.runExclusive(async () => {
const data = await readFile('./config.yaml', 'utf8') const data = await readFile('./config.yaml', 'utf8')
const config = yaml.load(data) as Config 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))
}) })
} }

@ -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<string, any>): GitRef {
const type = form['deploy-ref-type']
const value = form['deploy-ref-value']
return type === 'default' ? { type } : { type, value }
}
export function parseDeploy(form: Record<string, any>): 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')
}

@ -0,0 +1,28 @@
const queue: (() => Promise<void>)[] = []
// 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<void>) {
queue.push(job)
// starts concurrently a function to process jobs
triggerProcessQueue()
}

@ -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')
}

@ -1,6 +1,6 @@
--- ---
import { Deploy } from '../../client/Deploy' import { Deploy } from '../../client/Deploy'
import { loadConfig, refToString } from '../../config' import { loadConfig } from '../../config'
import Layout from '../../layouts/Layout.astro' import Layout from '../../layouts/Layout.astro'
const { deploys } = await loadConfig() const { deploys } = await loadConfig()
@ -10,6 +10,6 @@ const { deploys } = await loadConfig()
<h1>Deploys</h1> <h1>Deploys</h1>
<a class="button" href="/deploys/new">New Deploy</a> <a class="button" href="/deploys/new">New Deploy</a>
<div class="deploys"> <div class="deploys">
{deploys.map(deploy => <Deploy deploy={deploy} />)} {deploys.toReversed().map(deploy => <Deploy deploy={deploy} />)}
</div> </div>
</Layout> </Layout>

@ -2,12 +2,18 @@
import Layout from '../../layouts/Layout.astro' import Layout from '../../layouts/Layout.astro'
import { NewDeployForm } from '../../client/NewDeployForm.jsx' import { NewDeployForm } from '../../client/NewDeployForm.jsx'
import { parseDeploy } from '../../forms.ts'
import { updateConfig } from '../../config'
if (Astro.request.method === 'POST') { if (Astro.request.method === 'POST') {
const body = await Astro.request.formData() const body = await Astro.request.formData()
const formData = Object.fromEntries(body.entries()) const formData = Object.fromEntries(body.entries())
console.log(formData) const newDeploy = parseDeploy(formData)
updateConfig(async config => {
config.deploys.push(newDeploy)
})
return Astro.redirect('/deploys') return Astro.redirect('/deploys')
} }

@ -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}`)
}

@ -82,19 +82,19 @@ button,
display: inline-block; display: inline-block;
color: #333; color: #333;
background-color: #fff; background-color: #00000018;
font-size: 18px; font-size: 18px;
font-weight: 500; font-weight: 500;
padding: 0.25rem 0.75rem; padding: 0.5rem 0.75rem;
border-radius: 4px; border-radius: 2rem;
cursor: pointer; cursor: pointer;
transition: background-color 150ms ease-in-out; transition: background-color 150ms ease-in-out;
border: 1px solid #e0e0e0; // border: 1px solid #e0e0e0;
&:hover { &:hover {
background-color: #e0e0e0; background-color: #00000030;
} }
} }
@ -114,9 +114,9 @@ textarea {
border: none; border: none;
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 6px; border-radius: 1rem;
padding: 0 0.25rem; padding: 0.25rem 0.75rem;
display: flex; display: flex;
align-items: center; align-items: center;
@ -134,7 +134,9 @@ textarea {
textarea { textarea {
min-height: 2rem; min-height: 2rem;
padding: 7px 0.25rem; // padding: 7px 0.25rem;
padding: 0.5rem 0.75rem;
} }
form { form {
@ -275,7 +277,7 @@ form {
.deploy { .deploy {
width: 100%; width: 100%;
background: #fff; border: 1px solid #ddd;
padding: 1rem; padding: 1rem;
border-radius: 1rem; border-radius: 1rem;
} }
@ -290,7 +292,7 @@ body {
line-height: 1.25; line-height: 1.25;
color: #333; color: #333;
background: #f4f4f4; background: #fff;
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
@ -301,7 +303,7 @@ body {
min-width: 20vw; min-width: 20vw;
background-color: #fff; background-color: #ededf0;
color: #333; color: #333;
display: flex; display: flex;
@ -309,12 +311,12 @@ body {
z-index: 1; 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 { .header {
padding: 1rem; padding: 1rem;
font-weight: 700; font-weight: 300;
font-size: 42px; font-size: 48px;
} }
nav { nav {
@ -339,18 +341,18 @@ body {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.5rem;
cursor: pointer; cursor: pointer;
transition: background 150ms ease-in-out; transition: background 150ms ease-in-out;
font-size: 16px; font-size: 16px;
padding: 0.125rem 0.25rem; padding: 0.5rem 0.75rem;
border-radius: 6px; border-radius: 100px;
&:hover { &:hover {
background-color: #f0f0f0; background-color: #00000018;
} }
.material-symbols-outlined { .material-symbols-outlined {
@ -360,7 +362,7 @@ body {
.children { .children {
flex-direction: column; flex-direction: column;
padding-left: 2rem; padding-left: 1.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -371,21 +373,21 @@ body {
position: relative; position: relative;
&::before { // &::before {
content: ''; // content: '';
background: #666; // background: #666;
position: absolute; // position: absolute;
left: -0.25rem; // left: -0.5rem;
width: 5px; // width: 5px;
height: 5px; // height: 5px;
border-radius: 100%; // border-radius: 100%;
top: 50%; // top: 50%;
transform: translate(0, calc(-50% + 1px)); // transform: translate(0, calc(-50% + 1px));
} // }
} }
} }

@ -1,6 +1,7 @@
{ {
"extends": "astro/tsconfigs/strict", "extends": "astro/tsconfigs/strict",
"compilerOptions": { "compilerOptions": {
"strict": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"jsxImportSource": "preact", "jsxImportSource": "preact",
"exactOptionalPropertyTypes": true "exactOptionalPropertyTypes": true

Loading…
Cancel
Save