Create BuildController
This commit is contained in:
parent
79bbee93d3
commit
da8d0fcc7e
156
src/BuildController.ts
Normal file
156
src/BuildController.ts
Normal 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 { };
|
54
src/DB.ts
54
src/DB.ts
@ -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 };
|
||||||
|
14
src/Web.ts
14
src/Web.ts
@ -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;
|
||||||
|
@ -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();
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user