af-consul
Version:
A highly specialized function library
405 lines (365 loc) • 14.7 kB
text/typescript
/* eslint-disable no-console,prefer-spread */
// noinspection UnnecessaryLocalVariableJS,JSUnusedGlobalSymbols
import Consul from 'consul';
import { Mutex } from 'async-mutex';
import { blue, cyan, magenta, reset, yellow } from './lib/color';
import { getCurlText } from './lib/curl-text';
import getHttpRequestText from './lib/http-request-text';
import {
IAPIArgs,
ICache,
ICLOptions,
IConsul, IConsulAgentConfig,
IConsulAgentOptions,
IConsulAPI,
IConsulHealthServiceInfo,
IConsulServiceInfo, IFullConsulAgentConfig,
IFullConsulAgentOptions,
ILogger,
IRegisterConfig,
IRegisterOptions,
ISocketInfo,
TRegisterResult,
} from './interfaces';
import loggerStub from './lib/logger-stub';
import { getFQDNCached } from './lib/fqdn';
import { CONSUL_DEBUG_ON, DEBUG, MAX_API_CACHED, PREFIX } from './constants';
import { minimizeCache, parseBoolean, serviceConfigDiff } from './lib/utils';
import { getConfigHash } from './lib/hash';
const mutex = new Mutex();
const dbg = {
on: CONSUL_DEBUG_ON,
curl: /af-consul:curl/i.test(DEBUG),
};
const debug = (msg: string) => {
if (dbg.on) {
console.log(`${magenta}${PREFIX}${reset}: ${msg}`);
}
};
const agentTypeS = Symbol.for('agentType');
const consulConfigTypes = ['reg', 'dev', 'prd'] as (keyof IFullConsulAgentConfig)[];
const getConsulAgentOptions = async (clOptions: ICLOptions): Promise<IFullConsulAgentOptions> => {
const {
agent,
service,
} = clOptions.config.consul;
// const regAgent = { ..._.pick(reg, ['host', 'port', 'secure', 'token']) };
const secure_ = parseBoolean(agent.reg.secure);
const result: IFullConsulAgentOptions = {} as IFullConsulAgentOptions;
const reg = {
host: agent.reg.host || (await getFQDNCached()) || process.env.HOST_HOSTNAME || service?.host || '127.0.0.1',
port: String(agent.reg.port || (secure_ ? 433 : 8500)),
secure: secure_,
defaults: agent.reg.token ? { token: agent.reg.token } : undefined,
};
result.reg = reg;
(['dev', 'prd'] as (keyof IFullConsulAgentConfig)[]).forEach((id) => {
if (agent[id]) {
const {
host,
port,
secure,
token,
dc,
} = agent[id] as IConsulAgentConfig;
result[id] = {
host: String(host || reg.host),
port: String(port || reg.port),
secure: parseBoolean(secure == null ? reg.secure : secure),
defaults: token ? { token } : reg.defaults,
dc,
};
} else {
result[id] = { ...reg };
}
});
consulConfigTypes.forEach((id) => {
if (!Number(result[id].port)) {
throw new Error(`The port for consul agent[${id}] is invalid: [${result[id].port}]`);
}
// @ts-ignore
result[id][agentTypeS] = id;
});
return result;
};
let requestCounter = 0;
export const prepareConsulAPI = async (clOptions: ICLOptions): Promise<IConsulAPI> => {
let logger = (clOptions.logger || loggerStub) as ILogger;
if (!logger?.info) {
logger = loggerStub;
}
const fullConsulAgentOptions: IFullConsulAgentOptions = await getConsulAgentOptions(clOptions);
if (dbg.on) {
debug(`CONSUL AGENT OPTIONS:\n${JSON.stringify(fullConsulAgentOptions, undefined, 2)}`);
}
const consulInstances = {} as { reg: IConsul, dev: IConsul, prd: IConsul };
consulConfigTypes.forEach((id) => {
const consulAgentOptions = fullConsulAgentOptions[id];
const consulInstance: IConsul = new Consul(consulAgentOptions) as IConsul; // { host, port, secure, defaults: { token } }
// @ts-ignore
consulInstance[agentTypeS] = id;
consulInstances[id] = consulInstance;
consulInstance._ext('onRequest', (request, next) => {
request._id_ = ++requestCounter;
if (dbg.on) {
const msg = dbg.curl ? getCurlText(request) : getHttpRequestText(request);
debug(`[${request._id_}] ${yellow}${msg}${reset}`);
}
next();
});
consulInstance._ext('onResponse', (request, next) => {
const rqId = `[${request._id_}] `;
try {
const { res } = request || {};
const {
statusCode = 0,
body = null,
} = res || {};
debug(`${rqId}HTTP Status: ${statusCode}`);
if (statusCode > 299 && !request.opts?.skipCodes?.includes?.(statusCode)) {
const serviceName = request._args?.[0]?.name ?? '';
if (body) {
logger.error(`${rqId}[${serviceName ? `consul.${serviceName}` : 'CONSUL'}] ERROR: ${JSON.stringify(body)}`);
} else {
debug(`${rqId}res.body not found! res: ${res}`);
}
}
} catch (err: Error | any) {
logger.error(`ERROR (onResponse ${rqId}): \n err.message: ${err.message}\n err.stack:\n${err.stack}\n`);
}
next();
});
});
const getAgentTypeByServiceID = (serviceId: string): keyof IFullConsulAgentConfig => {
const agentType = serviceId.substring(0, 3);
return (/(dev|prd)/.test(agentType) ? agentType : 'reg') as keyof IFullConsulAgentConfig;
};
const getAgentOptionsByServiceID = (serviceId: string): IConsulAgentOptions => fullConsulAgentOptions[getAgentTypeByServiceID(serviceId)];
const getConsulInstanceByServiceID = (serviceId: string): IConsul => consulInstances[getAgentTypeByServiceID(serviceId)];
// @ts-ignore request.res.body request.res.statusCode
const common = async (fnName: string, {
consulInstance,
agentOptions,
options,
withError,
result,
}: IAPIArgs): Promise<any> => {
let fn: IConsul;
if (consulInstance) {
fn = consulInstance;
} else if (agentOptions) {
fn = Consul(agentOptions) as IConsul;
} else {
fn = consulInstances.reg;
}
const namesArr = fnName.split('.');
const method: string = namesArr.pop() as string;
namesArr.forEach((v) => {
// @ts-ignore
fn = fn[v];
});
try {
// @ts-ignore
const res = await fn[method].call(fn, options);
return result || res;
} catch (err: Error | any) {
logger.error(`[consul.${fnName}] ERROR:\n err.message: ${err.message}\n err.stack:\n${err.stack}\n`);
return withError ? err : false;
}
};
const api = {
// Returns the services the agent is managing. - список сервисов на этом агенте
async agentServiceList (apiArgs: IAPIArgs = {}) {
// ### GET http://<*.host>:<*.port>/v1/agent/services
if (!apiArgs.consulInstance && !apiArgs.agentOptions) {
apiArgs.consulInstance = consulInstances.reg;
}
const result = await common('agent.service.list', apiArgs);
return result;
},
// Lists services in a given datacenter
async catalogServiceList (dc: string, apiArgs: IAPIArgs = {}): Promise<{ [serviceId: string]: string[] }> {
// ### GET https://<context.host>:<context.port>/v1/catalog/services?dc=<dc>
if (!apiArgs.consulInstance && !apiArgs.agentOptions) {
const agentType = (Object.entries(fullConsulAgentOptions)
.find(([, v]) => v.dc === dc) || ['dev'])[0];
// @ts-ignore
apiArgs.consulInstance = consulInstances[agentType];
}
apiArgs.options = dc;
const result = await common('catalog.service.list', apiArgs);
return result;
},
// Returns the nodes and health info of a service
async consulHealthService (apiArgs: IAPIArgs): Promise<IConsulHealthServiceInfo[]> {
// ### GET https://<context.host>:<context.port>/v1/health/service/<apiArgs.options.serviceId>?passing=true&dc=<apiArgs.options.dc || context.dc>
const {
service: serviceId,
dc,
} = apiArgs.options;
if (!dc) {
const agentOptions = getAgentOptionsByServiceID(serviceId);
apiArgs.options.dc = agentOptions.dc || undefined;
}
if (!apiArgs.consulInstance && !apiArgs.agentOptions) {
apiArgs.consulInstance = getConsulInstanceByServiceID(serviceId);
}
const result = await common('health.service', apiArgs);
return result;
},
async getServiceInfo (serviceName: string): Promise<IConsulServiceInfo | undefined> {
// ### GET https://<context.host>:<context.port>/v1/health/service/<apiArgs.options.serviceId>?passing=true&dc=<apiArgs.options.dc || context.dc>
const result = await this.consulHealthService({
options: {
service: serviceName,
passing: true,
},
});
const res = result?.[0]?.Service;
if (!res) {
logger.debug(`No info about service ID ${cyan}${serviceName}`); // VVR
}
return res;
},
async getServiceSocket (serviceName: string, defaults: ISocketInfo): Promise<ISocketInfo> {
if (process.env.USE_DEFAULT_SERVICE_SOCKET) {
return defaults;
}
// В функции consulHealthService используется агент dev/prd в зависимости от префикаса
const result: IConsulHealthServiceInfo[] = await this.consulHealthService({
options: {
service: serviceName,
passing: true,
},
});
if (!result || !result.length) {
logger.warn(`CONSUL: No working service found: ${cyan}${serviceName}${reset}. Return defaults ${defaults.host}:${defaults.port}`);
return defaults;
}
const {
Address = result[0].Node?.Node,
Port,
} = (result[0].Service || {}) as IConsulServiceInfo;
const host = await getFQDNCached(Address);
return {
host: host || Address || '',
port: Port,
};
},
// Registers a new service.
async agentServiceRegister (options: IRegisterConfig, withError: boolean = false): Promise<boolean> {
// ### PUT http://<reg.host>:<reg.port>/v1/agent/service/register
const result = await common('agent.service.register', { options, withError, result: true });
return result;
},
// Deregister a service.
async agentServiceDeregister (serviceId: string, apiArgs: IAPIArgs = {}): Promise<boolean> {
// ### PUT http://<reg.host>:<reg.port>/v1/agent/service/deregister/<serviceId>
apiArgs.options = serviceId;
apiArgs.result = true;
if (!apiArgs.agentOptions && !apiArgs.consulInstance) {
apiArgs.consulInstance = consulInstances.reg;
}
const result = await common('agent.service.deregister', apiArgs);
return result;
},
async deregisterIfNeed (serviceId: string, agentOptions?: IConsulAgentOptions): Promise<boolean> {
const apiArgs: IAPIArgs = { agentOptions };
const healthServiceInfo = await this.checkIfServiceRegistered(serviceId, apiArgs);
if (healthServiceInfo) {
const nodeHost = (healthServiceInfo.Node?.Node || '').toLowerCase()
.split('.')[0] || '';
const [agentType = 'reg'] = Object.entries(fullConsulAgentOptions)
.find(([, aOpt]) => aOpt.host.toLowerCase()
.startsWith(nodeHost)) || [];
// @ts-ignore
apiArgs.consulInstance = consulInstances[agentType];
const isDeregister = await this.agentServiceDeregister(serviceId, apiArgs);
// @ts-ignore
const m = (wasnt: string = '') => `Previous registration of service '${cyan}${serviceId}${reset}'${wasnt} removed from consul agent ${blue}${fullConsulAgentOptions[agentType].host}${reset}`;
if (isDeregister) {
logger.info(m());
} else {
logger.error(m(' was NOT'));
return false;
}
} else {
logger.info(`Service '${cyan}${serviceId}${reset}' is not registered in Consul`);
}
return true;
},
// Returns the members as seen by the consul agent. - список агентов (нод)
agentMembers: async (apiArgs: IAPIArgs = {}) => {
// ### GET http://<reg.host>:<reg.port>/v1/agent/members
if (!apiArgs.consulInstance && !apiArgs.agentOptions) {
apiArgs.consulInstance = consulInstances.reg;
}
const result = await common('agent.members', apiArgs);
return result;
},
async checkIfServiceRegistered (serviceIdOrName: string, apiArgs: IAPIArgs = {}): Promise<IConsulHealthServiceInfo | undefined> {
if (!apiArgs.consulInstance && !apiArgs.agentOptions) {
apiArgs.consulInstance = getConsulInstanceByServiceID(serviceIdOrName);
}
const result = await this.consulHealthService({ options: { service: serviceIdOrName } });
return result?.[0];
},
async registerService (registerConfig: IRegisterConfig, registerOptions: IRegisterOptions): Promise<TRegisterResult> {
const serviceId = registerConfig.id || registerConfig.name;
const srv = `Service '${cyan}${serviceId}${reset}'`;
const serviceInfo = await this.getServiceInfo(serviceId);
const diff = serviceConfigDiff(registerConfig, serviceInfo);
const isAlreadyRegistered = !!serviceInfo;
const already = (): TRegisterResult => {
if (!registerOptions.noAlreadyRegisteredMessage) {
logger.info(`${srv} already registered in Consul`);
}
return 'already';
};
switch (registerOptions.registerType) {
case 'if-config-differ': {
if (!diff.length) {
return already();
}
logger.info(`${srv}. Configuration difference detected. New: config.${diff[0]}=${diff[1]} / Current: config.${diff[2]}=${diff[3]}`);
break;
}
case 'if-not-registered': {
if (isAlreadyRegistered) {
return already();
}
break;
}
}
if (isAlreadyRegistered && registerOptions.deleteOtherInstance) {
if (await this.agentServiceDeregister(serviceId)) {
logger.info(`Previous registration of ${srv} removed from Consul`);
}
}
const isJustRegistered = await this.agentServiceRegister(registerConfig);
if (isJustRegistered) {
logger.info(`${srv} is registered in Consul`);
} else {
logger.error(`${srv} is NOT registered in Consul`);
}
return isJustRegistered ? 'just' : false;
},
agentOptions: fullConsulAgentOptions,
getConsulAgentOptions,
};
return api;
};
const consulApiCache: ICache<IConsulAPI> = {};
export const getConsulApiCached = async (clOptions: ICLOptions): Promise<IConsulAPI> => mutex
.runExclusive<IConsulAPI>(async () => {
const hash = getConfigHash(clOptions);
if (!consulApiCache[hash]) {
minimizeCache(consulApiCache, MAX_API_CACHED);
const value = await prepareConsulAPI(clOptions);
consulApiCache[hash] = {
created: Date.now(),
value,
};
}
return consulApiCache[hash].value;
});