@biorate/haproxy
Version:
Haproxy runner
215 lines (213 loc) • 6.54 kB
text/typescript
import * as HAProxy from 'haproxy';
import { timer } from '@biorate/tools';
import { injectable, kill } from '@biorate/inversion';
import { Connector } from '@biorate/connector';
import { path } from '@biorate/tools';
import { tmpdir, EOL } from 'os';
import { unlinkSync, writeFileSync } from 'fs';
import { promisify } from 'util';
import { HaproxyCantConnectError, HaproxyConnectionTimeoutError } from './errors';
import { IHaproxyConfig, IHaproxyConnection } from './interfaces';
export * from './errors';
export * from './interfaces';
/**
* @description Haproxy connector
*
* ### Features:
* - connector manager for haproxy
*
* @example
* ```
* import { inject, container, Types, Core } from '@biorate/inversion';
* import { IConfig, Config } from '@biorate/config';
* import { HaproxyConnector, HaproxyConfig } from '@biorate/haproxy';
*
* class Root extends Core() {
* @inject(HaproxyConnector) public connector: HaproxyConnector;
* }
*
* container.bind<IConfig>(Types.Config).to(Config).inSingletonScope();
* container.bind<HaproxyConnector>(HaproxyConnector).toSelf().inSingletonScope();
* container.bind<Root>(Root).toSelf().inSingletonScope();
*
* container.get<IConfig>(Types.Config).merge({
* Haproxy: [
* {
* name: 'connection',
* debug: false,
* readiness: {
* nodes: ['postgresql1', 'postgresql2', 'postgresql3'],
* retries: 10,
* delay: 1000,
* },
* config: {
* global: [
* 'maxconn 100',
* 'stats socket {{stat_socket_path}} mode 660 level admin expose-fd listeners',
* 'stats timeout 5s',
* ],
* defaults: [
* 'log global',
* 'retries 2',
* 'timeout client 30m',
* 'timeout connect 4s',
* 'timeout server 30m',
* 'timeout check 5s',
* ],
* 'listen stats': ['mode http', 'bind *:7001', 'stats enable', 'stats uri /'],
* 'listen postgres': [
* 'mode tcp',
* 'bind *:7000',
* 'option httpchk',
* 'http-check expect status 200',
* 'default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions',
* 'server postgresql1 127.0.0.1:5433 maxconn 100 check port 8008',
* 'server postgresql2 127.0.0.1:5434 maxconn 100 check port 8008',
* 'server postgresql3 127.0.0.1:5435 maxconn 100 check port 8008',
* ],
* },
* },
* ],
* });
*
* (async () => {
* const root = <Root>container.get<Root>(Root);
* await root.$run();
* })();
* ```
*/
()
export class HaproxyConnector extends Connector<IHaproxyConfig, IHaproxyConnection> {
/**
* @description config / connection mapping
*/
#configs = new WeakMap<IHaproxyConnection, IHaproxyConfig>();
/**
* @description Private connections storage
*/
private '#connections': Map<string, IHaproxyConnection>;
/**
* @description Private link to selected (used) connection
*/
private '#current': IHaproxyConnection | undefined;
/**
* @description Namespace path for fetching configuration
*/
protected readonly namespace = 'Haproxy';
/**
* @description Create connection
*/
protected async connect(config: IHaproxyConfig) {
let connection: IHaproxyConnection;
try {
this.cleanup(config);
const cfgFile = this.createConfig(config);
connection = new HAProxy(this.path(config, 'sock'), {
pidFile: this.path(config, 'pid'),
config: cfgFile,
});
for (const method of [
'start',
'stop',
'softstop',
'reload',
'verify',
'running',
'clear',
'disable',
'enable',
'pause',
'resume',
'errors',
'weight',
'maxconn',
'ratelimit',
'compression',
'info',
'session',
'stat',
])
connection[method] = promisify(connection[method].bind(connection));
await connection.start();
await this.readiness(connection, config);
this.#configs.set(connection, config);
} catch (e: unknown) {
throw new HaproxyCantConnectError(<Error>e);
}
return connection;
}
/**
* @description readiness check
*/
protected async readiness(connection: IHaproxyConnection, config: IHaproxyConfig) {
if (config?.readiness?.nodes?.length) {
let i = 0;
w: while (true) {
const stats = await connection.stat();
for (const stat of stats) {
if (!config.readiness.nodes.includes(stat.svname)) continue;
if (stat.status === 'UP') break w;
}
console.debug(`Attempt to connect to Haproxy: [%s]`, config.name);
await timer.wait(config?.readiness?.delay ?? 1000);
++i;
if (i > config?.readiness?.retries ?? 1)
throw new HaproxyConnectionTimeoutError(config.name);
}
}
}
/**
* @description Make path
*/
protected path(config: IHaproxyConfig, ext: string) {
return path.create(tmpdir(), `${config.name}.haproxy.${ext}`);
}
/**
* @description Cleanup files
*/
protected cleanup(config: IHaproxyConfig) {
try {
unlinkSync(this.path(config, 'sock'));
} catch {}
try {
unlinkSync(this.path(config, 'pid'));
} catch {}
try {
unlinkSync(this.path(config, 'config'));
} catch {}
}
/**
* @description Create config file
*/
protected createConfig(config: IHaproxyConfig) {
let data = '';
const file = this.path(config, 'config');
for (const header in config.config) {
data += header + EOL;
if (Array.isArray(config.config[header]))
for (const field of config.config[header] as string[]) data += ' ' + field + EOL;
else
for (const field in config.config[header] as {
[key: string]: string | number;
})
data += ' ' + field + ' ' + config.config[header][field] + EOL;
}
data = data.replace('{{stat_socket_path}}', this.path(config, 'sock'));
writeFileSync(file, data, 'utf-8');
if (config.debug) console.debug(`Haproxy [${config.name}] config:${EOL}`, data);
return file;
}
/**
* @description Destructor
*/
() protected async destructor() {
for (const [, connection] of this.connections) {
try {
await connection.stop();
this.cleanup(this.#configs.get(connection));
} catch (e) {
console.error(e);
}
}
}
}