diff --git a/.gitignore b/.gitignore index 2309cc8..a249393 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +/config.ts + # ---> Node # Logs logs diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..79c5220 --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ + +build: + echo nothing to build! + +start: + pnpm start \ No newline at end of file diff --git a/Makefile.template b/Makefile.template new file mode 100644 index 0000000..8157830 --- /dev/null +++ b/Makefile.template @@ -0,0 +1,19 @@ +SHELL := /bin/bash + +id: + echo $deploymentId + +status: + systemctl status deployer-$host + +log: + journalctl -u deployer-$host + +cleanlogs: + rm -rf $serviceDir/logs/* + +buildlog: + less +F build.log + +stop: + systemctl stop deployer-$host \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..e05ecfc --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "deployer2", + "scripts": { + "dev": "NODE_ENV=development PORT=6001 bun run --hot src/index.ts", + "start": "NODE_ENV=production bun run src/index.ts" + }, + "dependencies": { + "@types/object-hash": "^3.0.6", + "elysia": "1.4.16", + "object-hash": "^3.0.0" + }, + "devDependencies": { + "@types/bun": "latest" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..8508c3a --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,218 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@types/object-hash': + specifier: ^3.0.6 + version: 3.0.6 + elysia: + specifier: 1.4.16 + version: 1.4.16(@sinclair/typebox@0.34.41)(@types/bun@1.3.3)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3) + object-hash: + specifier: ^3.0.0 + version: 3.0.0 + devDependencies: + '@octokit/webhooks-types': + specifier: ^7.6.1 + version: 7.6.1 + '@types/bun': + specifier: latest + version: 1.3.3 + +packages: + + '@borewit/text-codec@0.1.1': + resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==} + + '@octokit/webhooks-types@7.6.1': + resolution: {integrity: sha512-S8u2cJzklBC0FgTwWVLaM8tMrDuDMVE4xiTK4EYXM9GntyvrdbSoxqDQa+Fh57CCNApyIpyeqPhhFEmHPfrXgw==} + + '@sinclair/typebox@0.34.41': + resolution: {integrity: sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==} + + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + + '@types/bun@1.3.3': + resolution: {integrity: sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g==} + + '@types/node@24.10.1': + resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + + '@types/object-hash@3.0.6': + resolution: {integrity: sha512-fOBV8C1FIu2ELinoILQ+ApxcUKz4ngq+IWUYrxSGjXzzjUALijilampwkMgEtJ+h2njAW3pi853QpzNVCHB73w==} + + bun-types@1.3.3: + resolution: {integrity: sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + elysia@1.4.16: + resolution: {integrity: sha512-KZtKN160/bdWVKg2hEgyoNXY8jRRquc+m6PboyisaLZL891I+Ufb7Ja6lDAD7vMQur8sLEWIcidZOzj5lWw9UA==} + peerDependencies: + '@sinclair/typebox': '>= 0.34.0 < 1' + '@types/bun': '>= 1.2.0' + exact-mirror: '>= 0.0.9' + file-type: '>= 20.0.0' + openapi-types: '>= 12.0.0' + typescript: '>= 5.0.0' + peerDependenciesMeta: + '@types/bun': + optional: true + typescript: + optional: true + + exact-mirror@0.2.5: + resolution: {integrity: sha512-u8Wu2lO8nio5lKSJubOydsdNtQmH8ENba5m0nbQYmTvsjksXKYIS1nSShdDlO8Uem+kbo+N6eD5I03cpZ+QsRQ==} + peerDependencies: + '@sinclair/typebox': ^0.34.15 + peerDependenciesMeta: + '@sinclair/typebox': + optional: true + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + file-type@21.1.1: + resolution: {integrity: sha512-ifJXo8zUqbQ/bLbl9sFoqHNTNWbnPY1COImFfM6CCy7z+E+jC1eY9YfOKkx0fckIg+VljAy2/87T61fp0+eEkg==} + engines: {node: '>=20'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + memoirist@0.4.0: + resolution: {integrity: sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + + strtok3@10.3.4: + resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} + engines: {node: '>=18'} + + token-types@6.1.1: + resolution: {integrity: sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==} + engines: {node: '>=14.16'} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + +snapshots: + + '@borewit/text-codec@0.1.1': {} + + '@octokit/webhooks-types@7.6.1': {} + + '@sinclair/typebox@0.34.41': {} + + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.1 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + + '@types/bun@1.3.3': + dependencies: + bun-types: 1.3.3 + + '@types/node@24.10.1': + dependencies: + undici-types: 7.16.0 + + '@types/object-hash@3.0.6': {} + + bun-types@1.3.3: + dependencies: + '@types/node': 24.10.1 + + cookie@1.1.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + elysia@1.4.16(@sinclair/typebox@0.34.41)(@types/bun@1.3.3)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3): + dependencies: + '@sinclair/typebox': 0.34.41 + cookie: 1.1.1 + exact-mirror: 0.2.5(@sinclair/typebox@0.34.41) + fast-decode-uri-component: 1.0.1 + file-type: 21.1.1 + memoirist: 0.4.0 + openapi-types: 12.1.3 + optionalDependencies: + '@types/bun': 1.3.3 + + exact-mirror@0.2.5(@sinclair/typebox@0.34.41): + optionalDependencies: + '@sinclair/typebox': 0.34.41 + + fast-decode-uri-component@1.0.1: {} + + file-type@21.1.1: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.4 + token-types: 6.1.1 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + ieee754@1.2.1: {} + + memoirist@0.4.0: {} + + ms@2.1.3: {} + + object-hash@3.0.0: {} + + openapi-types@12.1.3: {} + + strtok3@10.3.4: + dependencies: + '@tokenizer/token': 0.3.0 + + token-types@6.1.1: + dependencies: + '@borewit/text-codec': 0.1.1 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + + uint8array-extras@1.5.0: {} + + undici-types@7.16.0: {} diff --git a/service.template b/service.template new file mode 100644 index 0000000..d01afe5 --- /dev/null +++ b/service.template @@ -0,0 +1,16 @@ +[Unit] +Description=Deployment of $host (on port $port, from $repo/$branch:$commitHash) [$deploymentId] +After=network.target + +[Service] +Type=simple +Environment="PORT=$port" +ExecStart=make start +User=drm +WorkingDirectory=$serviceDir/src +Restart=no +StandardOutput=file:$logsDir/start.log +StandardError=file:$logsDir/start.log + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..7dc3aa3 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,29 @@ +import userConfig from "../config"; +import hash from "object-hash"; + +export type DeployerConfig = { + services: { + [host: string]: { + user: string; + repo: string; + branch: string; + }; + }; + giteaUrl: string; + directory: string; + basePort: number; + token: string; + systemServicesDir: string; +}; + +export const config = userConfig as DeployerConfig; +export const indexedConfig = Object.fromEntries( + Object.entries(config.services).map(([host, service], i) => [ + hash(service), + { + ...service, + host, + port: config.basePort + i, + }, + ]) +); diff --git a/src/deploy.ts b/src/deploy.ts new file mode 100644 index 0000000..1626f81 --- /dev/null +++ b/src/deploy.ts @@ -0,0 +1,68 @@ +import { $ } from "bun"; +import path from "path"; +import { config } from "./config"; + +type DeployInstance = { + host: string; + user: string; + repo: string; + branch: string; + commitHash: string; + port: number; +}; + +export const deploy = async ({ + host, + user, + repo, + branch, + port, + commitHash, +}: DeployInstance) => { + const deploymentId = new Date().toISOString(); + + const serviceDir = path.join(config.directory, host); + + // Fetch + await $` + cd ${serviceDir} + mkdir -p src + mkdir -p logs + git clone \ + -b ${branch} \ + http://deployer:${config.token}@${config.giteaUrl}/${user}/${repo} \ + ${serviceDir}/src + cd src + git fetch origin ${branch} + git reset --hard origin/${branch} + git checkout ${commitHash} + `; + + // Build + await $` + cd ${serviceDir}/src + make build + `; + + // Register service + const systemdServiceName = `deployer-${host}.service`; + await $` + cat template.service | envsubst > ${serviceDir}/${systemdServiceName} + ln -sf ${serviceDir}/${systemdServiceName} ${config.systemServicesDir}/${systemdServiceName} + `.env({ + host, + port: port.toString(), + repo, + branch, + commitHash, + deploymentId, + logsDir: path.join(serviceDir, "logs"), + serviceDir, + }); + + // Start! + await $` + systemctl daemon-reload + systemctl restart ${systemdServiceName} + `; +}; diff --git a/src/giteaTypes.ts b/src/giteaTypes.ts new file mode 100644 index 0000000..83496d3 --- /dev/null +++ b/src/giteaTypes.ts @@ -0,0 +1,216 @@ +export interface PushEvent { + ref: string; + before: string; + after: string; + compare_url: string; + commits: Commit[]; + total_commits: number; + head_commit: HeadCommit; + repository: Repository; + pusher: Pusher; + sender: Sender; +} + +export interface Commit { + id: string; + message: string; + url: string; + author: Author; + committer: Committer; + verification: any; + timestamp: string; + added: any; + removed: any; + modified: any; +} + +export interface Author { + name: string; + email: string; + username: string; +} + +export interface Committer { + name: string; + email: string; + username: string; +} + +export interface HeadCommit { + id: string; + message: string; + url: string; + author: Author2; + committer: Committer2; + verification: any; + timestamp: string; + added: any; + removed: any; + modified: any; +} + +export interface Author2 { + name: string; + email: string; + username: string; +} + +export interface Committer2 { + name: string; + email: string; + username: string; +} + +export interface Repository { + id: number; + owner: Owner; + name: string; + full_name: string; + description: string; + empty: boolean; + private: boolean; + fork: boolean; + template: boolean; + mirror: boolean; + size: number; + language: string; + languages_url: string; + html_url: string; + url: string; + link: string; + ssh_url: string; + clone_url: string; + original_url: string; + website: string; + stars_count: number; + forks_count: number; + watchers_count: number; + open_issues_count: number; + open_pr_counter: number; + release_counter: number; + default_branch: string; + archived: boolean; + created_at: string; + updated_at: string; + archived_at: string; + permissions: Permissions; + has_code: boolean; + has_issues: boolean; + internal_tracker: InternalTracker; + has_wiki: boolean; + has_pull_requests: boolean; + has_projects: boolean; + projects_mode: string; + has_releases: boolean; + has_packages: boolean; + has_actions: boolean; + ignore_whitespace_conflicts: boolean; + allow_merge_commits: boolean; + allow_rebase: boolean; + allow_rebase_explicit: boolean; + allow_squash_merge: boolean; + allow_fast_forward_only_merge: boolean; + allow_rebase_update: boolean; + allow_manual_merge: boolean; + autodetect_manual_merge: boolean; + default_delete_branch_after_merge: boolean; + default_merge_style: string; + default_allow_maintainer_edit: boolean; + avatar_url: string; + internal: boolean; + mirror_interval: string; + object_format_name: string; + mirror_updated: string; + topics: any[]; + licenses: any[]; +} + +export interface Owner { + id: number; + login: string; + login_name: string; + source_id: number; + full_name: string; + email: string; + avatar_url: string; + html_url: string; + language: string; + is_admin: boolean; + last_login: string; + created: string; + restricted: boolean; + active: boolean; + prohibit_login: boolean; + location: string; + website: string; + description: string; + visibility: string; + followers_count: number; + following_count: number; + starred_repos_count: number; + username: string; +} + +export interface Permissions { + admin: boolean; + push: boolean; + pull: boolean; +} + +export interface InternalTracker { + enable_time_tracker: boolean; + allow_only_contributors_to_track_time: boolean; + enable_issue_dependencies: boolean; +} + +export interface Pusher { + id: number; + login: string; + login_name: string; + source_id: number; + full_name: string; + email: string; + avatar_url: string; + html_url: string; + language: string; + is_admin: boolean; + last_login: string; + created: string; + restricted: boolean; + active: boolean; + prohibit_login: boolean; + location: string; + website: string; + description: string; + visibility: string; + followers_count: number; + following_count: number; + starred_repos_count: number; + username: string; +} + +export interface Sender { + id: number; + login: string; + login_name: string; + source_id: number; + full_name: string; + email: string; + avatar_url: string; + html_url: string; + language: string; + is_admin: boolean; + last_login: string; + created: string; + restricted: boolean; + active: boolean; + prohibit_login: boolean; + location: string; + website: string; + description: string; + visibility: string; + followers_count: number; + following_count: number; + starred_repos_count: number; + username: string; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..06c5be6 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,42 @@ +import { Elysia } from "elysia"; +import hash from "object-hash"; +import { indexedConfig } from "./config"; +import { deploy } from "./deploy"; +import { PushEvent } from "./giteaTypes"; + +const port = 6001; + +new Elysia() + .onError(({ error }) => console.error(error)) + .onRequest(({ request }) => { + console.log(request.method, new URL(request.url).pathname); + }) + .get("/ping", () => "pong") + .post("/gitea", ({ body }) => { + const event = body as PushEvent; + + const user = event.repository.owner.username; + const repo = event.repository.name; + const branch = event.ref.replace(/^(refs\/heads\/)/, ""); + const commitHash = event.before; + + const service = + indexedConfig[ + hash({ + user, + repo, + branch, + }) + ]; + + if (service == null) { + console.error(`No service defined for ${user}/${repo}:${branch}`); + return; + } + + void deploy({ ...service, commitHash }); + return "deploying"; + }) + .listen(port); + +console.log(`started deployer2 on :${port}`);