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