first pass

This commit is contained in:
2025-11-29 14:50:33 -05:00
parent cf3015638a
commit 0bfab0bdc8
10 changed files with 631 additions and 0 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
/config.ts
# ---> Node # ---> Node
# Logs # Logs
logs logs

6
Makefile Normal file
View File

@@ -0,0 +1,6 @@
build:
echo nothing to build!
start:
pnpm start

19
Makefile.template Normal file
View File

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

15
package.json Normal file
View File

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

218
pnpm-lock.yaml generated Normal file
View File

@@ -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: {}

16
service.template Normal file
View File

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

29
src/config.ts Normal file
View File

@@ -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,
},
])
);

68
src/deploy.ts Normal file
View File

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

216
src/giteaTypes.ts Normal file
View File

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

42
src/index.ts Normal file
View File

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