Create BuildController

This commit is contained in:
Cory Sanin 2025-01-12 23:11:58 -05:00
parent 79bbee93d3
commit da8d0fcc7e
6 changed files with 254 additions and 15 deletions

156
src/BuildController.ts Normal file
View File

@ -0,0 +1,156 @@
import spawn from 'child_process';
import EventEmitter from 'events';
import type { Build, DB } from "./DB.ts";
const docker_images = {
arch: 'corysanin/archy-build-thing:arch',
artix: 'corysanin/archy-build-thing:artix',
}
type LogType = 'std' | 'err' | 'finish';
interface BuildEvent {
id: number;
type: LogType;
message: any;
}
class BuildController extends EventEmitter {
private db: DB;
private process: spawn.ChildProcess | null = null;
private running: boolean = false;
constructor(config = {}) {
super();
setInterval(this.triggerBuild, 60000);
}
triggerBuild = () => {
if (!this.running) {
this.running = true;
this.kickOffBuild();
return true;
}
return false;
}
private kickOffBuild = async () => {
this.running = true;
const build = await this.db.dequeue();
if (build === null) {
this.running = false;
return;
}
try {
await this.pullImage(build.distro);
await this.build(build);
}
catch (e) {
console.error(e);
this.db.finishBuild(build.id, 'error');
}
this.kickOffBuild();
}
private pullImage = (distro: string) => {
return new Promise<void>((resolve, reject) => {
if (!(distro in docker_images)) {
return reject();
}
const docker = spawn.spawn('docker', ['pull', docker_images[distro]]);
docker.stdout.on('data', (data) => {
console.log(`${data}`);
});
docker.stderr.on('data', (data) => {
console.error(`${data}`);
});
docker.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(code);
}
});
docker.on('error', (err) => {
reject(err);
});
});
}
private build = async (build: Build) => {
return new Promise<void>((resolve, reject) => {
const docker = this.process = spawn.spawn('docker', this.createBuildParams(build));
docker.on('spawn', () => {
const remainder = {
std: '',
err: ''
}
this.db.startBuild(build.id, docker.pid);
let createLogFunction = (type: LogType) => {
return (data: Buffer | string) => {
const str = data.toString();
const readyToLog = remainder[type] + str.substring(0, str.lastIndexOf('\n'));
remainder[type] = str.substring(str.lastIndexOf('\n'));
this.db.appendLog(build.id, readyToLog);
this.emitLog({
id: build.id,
type: type,
message: readyToLog
});
};
};
docker.stdout.on('data', createLogFunction('std'));
docker.stderr.on('data', createLogFunction('err'));
});
docker.on('close', (code) => {
this.process = null;
this.emitLog({
id: build.id,
type: 'finish',
message: code
});
if (code === 0) {
this.db.finishBuild(build.id, 'success');
resolve();
}
else {
this.db.finishBuild(build.id, 'error');
reject(code);
}
});
});
}
private emitLog = (msg: BuildEvent) => {
this.emit('log', msg);
}
private createBuildParams = (build: Build) => {
// TODO: implement patch
const params = ['run', '--rm', '-e', `REPO=${build.repo}`];
if (build.commit) {
// TODO: implement COMMIT
params.push('-e', `COMMIT=${build.commit}`);
}
params.push(docker_images[build.distro]);
return params;
}
cancelBuild = async (pid: number) => {
const p = this.process
if (p && p.pid === pid) {
return p.kill();
}
}
setDB = (db: DB) => {
this.db = db;
}
}
export default BuildController;
export { BuildController };
export type { };

View File

@ -1,6 +1,9 @@
import { Sequelize, DataTypes, Op, } from 'sequelize'; import { Sequelize, DataTypes, Op, } from 'sequelize';
import type { ModelStatic, Filterable } from 'sequelize'; import type { ModelStatic, Filterable } from 'sequelize';
type Status = 'queued' | 'running' | 'cancelled' | 'success' | 'error';
type Dependencies = 'stable' | 'testing' | 'staging';
interface DBConfig { interface DBConfig {
db?: string; db?: string;
user?: string; user?: string;
@ -9,6 +12,20 @@ interface DBConfig {
port?: number; port?: number;
} }
interface Build {
id: number;
repo: string;
commit?: string;
patch?: string;
distro: string;
dependencies: Dependencies;
startTime?: Date;
endTime?: Date;
status: Status;
pid?: number;
log?: string;
}
const MONTH = 1000 * 60 * 60 * 24 * 24; const MONTH = 1000 * 60 * 60 * 24 * 24;
const FRESH = { const FRESH = {
[Op.or]: [ [Op.or]: [
@ -16,7 +33,7 @@ const FRESH = {
{ startTime: { [Op.is]: null } } { startTime: { [Op.is]: null } }
] ]
} }
const SELECT = ['id', 'repo', 'commit', 'distro', 'startTime', 'endTime', 'status']; const SELECT = ['id', 'repo', 'commit', 'distro', 'dependencies', 'startTime', 'endTime', 'status'];
class DB { class DB {
private build: ModelStatic<any>; private build: ModelStatic<any>;
@ -50,6 +67,11 @@ class DB {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false allowNull: false
}, },
dependencies: {
type: DataTypes.ENUM('stable', 'testing', 'staging'),
allowNull: false,
defaultValue: 'stable'
},
startTime: { startTime: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: true allowNull: true
@ -99,7 +121,7 @@ class DB {
}); });
} }
public async finishBuild(id: number, status: string): Promise<void> { public async finishBuild(id: number, status: Status): Promise<void> {
await this.build.update({ await this.build.update({
endTime: new Date(), endTime: new Date(),
status status
@ -111,8 +133,9 @@ class DB {
} }
public async appendLog(id: number, log: string): Promise<void> { public async appendLog(id: number, log: string): Promise<void> {
const sanitizedLog = log.replace(/'/g, "''");
await this.build.update({ await this.build.update({
log: Sequelize.literal(`log || '${log}'`) log: Sequelize.literal(`log || '${sanitizedLog}'`)
}, { }, {
where: { where: {
id id
@ -120,11 +143,11 @@ class DB {
}); });
} }
public async getBuild(id: number): Promise<any> { public async getBuild(id: number): Promise<Build> {
return await this.build.findByPk(id); return await this.build.findByPk(id);
} }
public async getBuilds(): Promise<any> { public async getBuilds(): Promise<Build[]> {
return await this.build.findAll({ return await this.build.findAll({
attributes: SELECT, attributes: SELECT,
order: [['id', 'DESC']], order: [['id', 'DESC']],
@ -132,7 +155,7 @@ class DB {
}); });
} }
public async getBuildsByStatus(status: string): Promise<any> { public async getBuildsByStatus(status: Status): Promise<Build[]> {
return await this.build.findAll({ return await this.build.findAll({
attributes: SELECT, attributes: SELECT,
order: [['id', 'DESC']], order: [['id', 'DESC']],
@ -143,7 +166,7 @@ class DB {
}); });
} }
public async getBuildsByDistro(distro: string): Promise<any> { public async getBuildsByDistro(distro: string): Promise<Build[]> {
return await this.build.findAll({ return await this.build.findAll({
attributes: SELECT, attributes: SELECT,
order: [['id', 'DESC']], order: [['id', 'DESC']],
@ -154,7 +177,7 @@ class DB {
}); });
} }
public async getBuildsBy(filterable: Filterable): Promise<any> { public async getBuildsBy(filterable: Filterable): Promise<Build[]> {
return await this.build.findAll({ return await this.build.findAll({
attributes: SELECT, attributes: SELECT,
order: [['id', 'DESC']], order: [['id', 'DESC']],
@ -165,7 +188,18 @@ class DB {
}); });
} }
public async searchBuilds(query: string): Promise<any> { public async dequeue(): Promise<Build> {
return await this.build.findOne({
attributes: SELECT,
order: [['id', 'ASC']],
where: {
status: 'queued'
},
limit: 1
});
}
public async searchBuilds(query: string): Promise<Build[]> {
return await this.build.findAll({ return await this.build.findAll({
attributes: SELECT, attributes: SELECT,
order: [['id', 'DESC']], order: [['id', 'DESC']],
@ -193,4 +227,4 @@ class DB {
export default DB; export default DB;
export { DB }; export { DB };
export type { DBConfig }; export type { DBConfig, Status, Build };

View File

@ -4,6 +4,7 @@ import type { Express } from "express";
import express, { application } from 'express'; import express, { application } from 'express';
import bodyParser from "body-parser"; import bodyParser from "body-parser";
import type { DB } from "./DB.ts"; import type { DB } from "./DB.ts";
import type { BuildController } from "./BuildController.ts";
interface WebConfig { interface WebConfig {
port?: number; port?: number;
@ -19,6 +20,7 @@ function notStupidParseInt(v: string | undefined): number {
class Web { class Web {
private _webserver: http.Server | null = null; private _webserver: http.Server | null = null;
private db: DB; private db: DB;
private buildController: BuildController;
constructor(options: WebConfig = {}) { constructor(options: WebConfig = {}) {
const app: Express = express(); const app: Express = express();
@ -71,8 +73,9 @@ class Web {
}); });
app.post('/build/?', async (req, res) => { app.post('/build/?', async (req, res) => {
const build = await this.db.createBuild(req.body.repo, req.body.commit || null, req.body.patch || null, req.body.distro); const buildId = await this.db.createBuild(req.body.repo, req.body.commit || null, req.body.patch || null, req.body.distro);
res.redirect(`/build/${build}`); res.redirect(`/build/${buildId}`);
this.buildController.triggerBuild();
}); });
app.get('/build/:num/?', async (req, res) => { app.get('/build/:num/?', async (req, res) => {
@ -87,7 +90,8 @@ class Web {
titlesuffix: `Build #${req.params.num}`, titlesuffix: `Build #${req.params.num}`,
description: `Building ${build.repo} on ${build.distro}` description: `Building ${build.repo} on ${build.distro}`
}, },
build build,
log: build.log?.split('\n')
}); });
}); });
@ -116,6 +120,10 @@ class Web {
setDB = (db: DB) => { setDB = (db: DB) => {
this.db = db; this.db = db;
} }
setBuildController = (buildController: BuildController) => {
this.buildController = buildController;
}
} }
export default Web; export default Web;

View File

@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { Web } from './Web.ts'; import { Web } from './Web.ts';
import { DB } from './DB.ts'; import { DB } from './DB.ts';
import { BuildController } from './BuildController.ts';
import type { WebConfig } from './Web.ts'; import type { WebConfig } from './Web.ts';
import type { DBConfig } from './DB.ts'; import type { DBConfig } from './DB.ts';
@ -13,9 +14,12 @@ interface compositeConfig {
const config: compositeConfig = JSON.parse(await fs.promises.readFile(process.env.config || path.join('config', 'config.json'), 'utf-8')); const config: compositeConfig = JSON.parse(await fs.promises.readFile(process.env.config || path.join('config', 'config.json'), 'utf-8'));
const web = new Web(config.web); const web = new Web(config.web);
const buildController = new BuildController();
await new Promise((resolve) => setTimeout(resolve, 1500)); await new Promise((resolve) => setTimeout(resolve, 1500));
const db = new DB(config.db); const db = new DB(config.db);
web.setDB(db); web.setDB(db);
web.setBuildController(buildController);
buildController.setDB(db);
process.on('SIGTERM', () => { process.on('SIGTERM', () => {
web.close(); web.close();

View File

@ -176,4 +176,38 @@ input[type="submit"] {
.span-2 { .span-2 {
grid-column: span 2; grid-column: span 2;
} }
} }
.logs {
font-family: 'Courier New', monospace;
border-left: #d96a14 .2em solid;
background: #2a2a2a;
margin-top: 1em;
overflow-x: auto;
overflow-wrap: anywhere;
p {
padding: .1em .5em;
min-height: 1.5em;
&:nth-child(even) {
background-color: #222;
}
}
}
.sidebar_search {
padding: .3em 1em;
form input {
font-size: 1.1em;
width: 100%;
background: #222;
color: var(--color-text);
border: var(--primary-light) solid .12em;
&:hover {
background: #2a2a2a;
}
}
}

View File

@ -18,7 +18,10 @@
<label>Start time</label> <span><%= build.startTime %></span> <label>Start time</label> <span><%= build.startTime %></span>
</div> </div>
<p><a href="/build/<%= build.id %>/logs">Full logs</a></p> <p><a href="/build/<%= build.id %>/logs">Full logs</a></p>
<div class="logs"> <div class="logs" id="logs">
<% (log || []).forEach(line => { %>
<p><%= line %></p>
<% }) %>
</div> </div>
</div> </div>
</body> </body>