@starship-ci/client
Version:
Starship CI Client
738 lines (737 loc) • 27.9 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.StarshipClient = exports.formatChainID = exports.defaultStarshipContext = void 0;
const chalk_1 = __importDefault(require("chalk"));
const deepmerge_1 = __importDefault(require("deepmerge"));
const fs_1 = require("fs");
const yaml = __importStar(require("js-yaml"));
const mkdirp_1 = require("mkdirp");
const os = __importStar(require("os"));
const path_1 = require("path");
const shell = __importStar(require("shelljs"));
const deps_1 = require("./deps");
const package_1 = require("./package");
const verifiers_1 = require("./verifiers");
exports.defaultStarshipContext = {
name: '',
repo: 'starship',
repoUrl: 'https://hyperweb-io.github.io/starship/',
chart: 'starship/devnet',
namespace: '',
version: '',
timeout: '10m',
restartThreshold: 3
};
const defaultName = 'starship';
const defaultVersion = 'v1.8.0';
// TODO talk to Anmol about moving these into yaml, if not already possible?
const defaultPorts = {
explorer: {
rest: 8080
},
registry: {
grpc: 9090,
rest: 8080
},
chains: {
ethereum: {
rpc: 8551,
rest: 8545,
ws: 8546
},
solana: {
rpc: 8899,
ws: 8900,
exposer: 8081,
faucet: 9900
},
defaultPorts: {
rpc: 26657,
grpc: 9090,
'grpc-web': 9091,
rest: 1317,
exposer: 8081,
faucet: 8000,
cometmock: 22331
}
},
relayers: {
defaultPorts: {
rest: 3000,
exposer: 8081
}
}
};
const defaultExecOptions = {
log: true,
silent: false,
ignoreError: true
};
const formatChainID = (input) => {
// Replace underscores with hyphens
let formattedName = input.replace(/_/g, '-');
// Truncate the string to a maximum length of 63 characters
if (formattedName.length > 63) {
formattedName = formattedName.substring(0, 63);
}
return formattedName;
};
exports.formatChainID = formatChainID;
class StarshipClient {
ctx;
version;
dependencies = deps_1.dependencies;
depsChecked = false;
config;
podPorts = defaultPorts;
podStatuses = new Map(); // To keep track of pod statuses
constructor(ctx) {
this.ctx = (0, deepmerge_1.default)(exports.defaultStarshipContext, ctx);
// TODO add semver check against net
this.version = (0, package_1.readAndParsePackageJson)().version;
}
exec(cmd, options = {}) {
const opts = { ...defaultExecOptions, ...options };
this.checkDependencies();
const str = cmd.join(' ');
if (opts.log)
this.log(str);
const result = shell.exec(str, { silent: opts.silent });
if (result.code !== 0 && !opts.ignoreError) {
this.log(chalk_1.default.red('Error: ') + result.stderr);
this.exit(result.code);
}
return result;
}
log(str) {
// add log level
console.log(str);
}
exit(code) {
shell.exit(code);
}
checkDependencies() {
if (this.depsChecked)
return;
// so CI/CD and local dev work nicely
const platform = process.env.NODE_ENV === 'test' ? 'linux' : os.platform();
const messages = [];
const depMessages = [];
const missingDependencies = this.dependencies.filter((dep) => !dep.installed);
if (!missingDependencies.length) {
this.depsChecked = true;
return;
}
this.dependencies.forEach((dep) => {
if (missingDependencies.find((d) => d.name === dep.name)) {
depMessages.push(`${chalk_1.default.red('x')}${dep.name}`);
}
else {
depMessages.push(`${chalk_1.default.green('✓')}${dep.name}`);
}
});
messages.push('\n'); // Adding a newline for better readability
missingDependencies.forEach((dep) => {
messages.push(chalk_1.default.bold.white(dep.name + ': ') + chalk_1.default.cyan(dep.url));
if (dep.name === 'helm' && platform === 'darwin') {
messages.push(chalk_1.default.gray('Alternatively, you can install using brew: ') +
chalk_1.default.white.bold('`brew install helm`'));
}
if (dep.name === 'kubectl' && platform === 'darwin') {
messages.push(chalk_1.default.gray('Alternatively, you can install Docker for Mac which includes Kubernetes: ') + chalk_1.default.white.bold(dep.macUrl));
}
if (dep.name === 'docker' && platform === 'darwin') {
messages.push(chalk_1.default.gray('For macOS, you may also consider Docker for Mac: ') +
chalk_1.default.white.bold(dep.macUrl));
}
else if (dep.name === 'docker') {
messages.push(chalk_1.default.gray('For advanced Docker usage and installation on other platforms, see: ') + chalk_1.default.white.bold(dep.url));
}
messages.push('\n'); // Adding a newline for separation between dependencies
});
this.log(depMessages.join('\n'));
this.log('\nPlease install the missing dependencies:');
this.log(messages.join('\n'));
this.exit(1);
}
setup() {
this.setupHelm();
}
loadYaml(filename) {
const path = filename.startsWith('/')
? filename
: (0, path_1.resolve)((process.cwd(), filename));
const fileContents = (0, fs_1.readFileSync)(path, 'utf8');
return yaml.load(fileContents);
}
saveYaml(filename, obj) {
const path = filename.startsWith('/')
? filename
: (0, path_1.resolve)((process.cwd(), filename));
const yamlContent = yaml.dump(obj);
mkdirp_1.mkdirp.sync((0, path_1.dirname)(path));
(0, fs_1.writeFileSync)(path, yamlContent, 'utf8');
}
loadConfig() {
this.ensureFileExists(this.ctx.config);
this.config = this.loadYaml(this.ctx.config);
this.overrideNameAndVersion();
}
saveConfig() {
this.saveYaml(this.ctx.config, this.config);
}
savePodPorts(filename) {
this.saveYaml(filename, this.podPorts);
}
loadPodPorts(filename) {
this.ensureFileExists(filename);
this.podPorts = this.loadYaml(filename);
}
setConfig(config) {
this.config = config;
this.overrideNameAndVersion();
}
setContext(ctx) {
this.ctx = ctx;
}
setPodPorts(ports) {
this.podPorts = (0, deepmerge_1.default)(defaultPorts, ports);
}
overrideNameAndVersion() {
if (!this.config) {
throw new Error('no config!');
}
// Override config name and version if provided in context
if (this.ctx.name) {
this.config.name = this.ctx.name;
}
// Use default name and version if not provided
if (!this.config.name) {
this.log(chalk_1.default.yellow('No name specified, using default name: ' + defaultName));
this.config.name = defaultName;
}
if (!this.ctx.version) {
this.log(chalk_1.default.yellow('No version specified, using default version: ' + defaultVersion));
this.ctx.version = defaultVersion;
}
this.log('config again: ' + this.config);
}
getArgs() {
const args = [];
if (this.ctx.namespace) {
args.push('--namespace', this.ctx.namespace);
}
return args;
}
getDeployArgs() {
const args = this.getArgs();
if (this.ctx.namespace) {
args.push('--create-namespace');
}
return args;
}
// TODO do we need this here?
test() {
this.exec([
'yarn',
'run',
'jest',
`--testPathPattern=../${this.ctx.repo}`,
'--verbose',
'--bail'
]);
}
async stop() {
this.stopPortForward();
this.setPodStatues(); // set pod statues before deleting the helm
this.deleteHelm();
await this.waitForPodsTermination();
}
async start() {
this.checkConnection();
this.setup();
this.deploy();
await this.waitForPods(); // Ensure waitForPods completes before starting port forwarding
this.startPortForward();
}
setupHelm() {
this.exec(['helm', 'repo', 'add', this.ctx.repo, this.ctx.repoUrl], {
ignoreError: false
});
this.exec(['helm', 'repo', 'update'], { ignoreError: false });
this.exec(['helm', 'search', 'repo', this.ctx.chart, '--version', this.ctx.version], { ignoreError: false });
}
ensureFileExists(filename) {
const path = filename.startsWith('/')
? filename
: (0, path_1.resolve)((process.cwd(), filename));
if (!(0, fs_1.existsSync)(path)) {
throw new Error(`Configuration file not found: ${filename}`);
}
}
deploy(options = []) {
this.ensureFileExists(this.ctx.config);
this.log('Installing the helm chart. This is going to take a while.....');
const cmd = [
'helm',
'install',
'-f',
this.ctx.config,
this.config.name,
this.ctx.chart,
'--version',
this.ctx.version,
'--timeout',
this.ctx.timeout,
...this.getDeployArgs(),
...options
];
// Determine the data directory of the config file
const datadir = (0, path_1.resolve)((0, path_1.dirname)(this.ctx.config));
// Iterate through each chain to add script arguments
this.config.chains.forEach((chain, chainIndex) => {
if (chain.scripts) {
Object.keys(chain.scripts).forEach((scriptKey) => {
const script = chain.scripts?.[scriptKey];
if (script && script.file) {
const scriptPath = (0, path_1.resolve)(datadir, script.file);
cmd.push(`--set-file chains[${chainIndex}].scripts.${scriptKey}.data=${scriptPath}`);
}
});
}
});
this.exec(cmd, { ignoreError: false });
this.log('Run "starship get-pods" to check the status of the cluster');
}
debug() {
this.ensureFileExists(this.ctx.config);
this.deploy(['--dry-run', '--debug']);
}
deleteHelm() {
this.exec(['helm', 'delete', this.config.name, ...this.getArgs()]);
}
getPods() {
this.exec([
'kubectl',
'get pods',
...this.getArgs()
// "--all-namespaces"
]);
}
checkConnection() {
const result = this.exec(['kubectl', 'get', 'nodes'], {
log: false,
silent: true
});
if (result.code !== 0) {
this.log(chalk_1.default.red('Error: Unable to connect to the Kubernetes cluster.'));
this.log(chalk_1.default.red('Please ensure that the Kubernetes cluster is configured correctly.'));
this.exit(1);
}
else {
this.log(chalk_1.default.green('Kubernetes cluster connection is working correctly.'));
}
}
getPodNames() {
const result = this.exec([
'kubectl',
'get',
'pods',
'--no-headers',
'-o',
'custom-columns=:metadata.name',
...this.getArgs()
], { log: false, silent: true });
// Split the output by new lines and filter out any empty lines
const podNames = result.split('\n').filter((name) => name.trim() !== '');
return podNames;
}
areAllPodsRunning() {
let allRunning = true;
this.podStatuses.forEach((status) => {
if (status.phase !== 'Running' || !status.ready) {
allRunning = false;
}
});
return allRunning;
}
checkPodStatus(podName, exitEarly = true) {
const result = this.exec([
'kubectl',
'get',
'pods',
podName,
'--no-headers',
'-o',
'custom-columns=:status.phase,:status.containerStatuses[*].ready,:status.containerStatuses[*].restartCount,:status.containerStatuses[*].state.waiting.reason',
...this.getArgs()
], { log: false, silent: true }).trim();
// Ensure the output contains valid fields to split
const parts = result.split(/\s+/);
if (parts.length < 3) {
this.log(chalk_1.default.red(`Unexpected pod status output for ${podName}: ${result}`));
return;
}
const [phase, readyList, restartCountList, reason] = parts;
// Validate readyList and restartCountList before applying split
if (!readyList || !restartCountList) {
this.log(chalk_1.default.red(`Invalid ready or restart count for pod ${podName}: ${result}`));
return;
}
const ready = readyList.split(',').every((state) => state === 'true');
const restarts = restartCountList
.split(',')
.reduce((acc, count) => acc + parseInt(count, 10), 0);
// check for repeated image pull errors
this.checkImagePullFailures(podName, exitEarly);
this.podStatuses.set(podName, {
phase,
ready,
restartCount: restarts,
reason
});
if (restarts > this.ctx.restartThreshold) {
this.log(`${chalk_1.default.red('Error:')} Pod ${podName} has restarted more than ${this.ctx.restartThreshold} times.`);
if (exitEarly)
this.exit(1);
}
}
setPodStatues() {
const podNames = this.getPodNames();
podNames.forEach((podName) => {
this.checkPodStatus(podName, false); // set exitEarly to false, only set the this.PodStatuses
});
}
async waitForPods() {
const podNames = this.getPodNames();
// Remove pods that are no longer active from the podStatuses map
this.podStatuses.forEach((_value, podName) => {
if (!podNames.includes(podName)) {
this.podStatuses.delete(podName);
}
});
// Check the status of each pod retrieved
podNames.forEach((podName) => {
this.checkPodStatus(podName);
});
this.displayPodStatuses();
if (!this.areAllPodsRunning()) {
await new Promise((resolve) => setTimeout(resolve, 2500));
await this.waitForPods(); // Recursive call
}
else {
this.log(chalk_1.default.green('All pods are running!'));
// once the pods are in running state, wait for 10 more seconds
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
async waitForPodsTermination() {
const podNames = this.getPodNames();
// Remove pods that are no longer active from the podStatuses map
this.podStatuses.forEach((_value, podName) => {
if (!podNames.includes(podName)) {
this.podStatuses.delete(podName);
}
});
if (this.podStatuses.size === 0) {
this.log(chalk_1.default.green('All pods have been successfully terminated!'));
// once the pods are in done state, wait for 1 more seconds
await new Promise((resolve) => setTimeout(resolve, 1000));
return;
}
// Check the status of each pod to terminating
podNames.forEach((podName) => {
const podStatus = this.podStatuses.get(podName);
podStatus.phase = 'Terminating';
});
this.displayPodStatuses();
await new Promise((resolve) => setTimeout(resolve, 2500));
await this.waitForPodsTermination(); // Recursive call
}
displayPodStatuses() {
console.clear();
this.podStatuses.forEach((status, podName) => {
let statusColor;
if (status.phase === 'Running' && status.ready) {
statusColor = chalk_1.default.green(status.phase);
}
else if (status.phase === 'Running' && !status.ready) {
statusColor = chalk_1.default.yellow('RunningButNotReady');
}
else if (status.phase === 'Terminating') {
statusColor = chalk_1.default.red(status.phase);
}
else {
statusColor = chalk_1.default.red(status.phase);
}
console.log(`[${chalk_1.default.blue(podName)}]: ${statusColor}, ${chalk_1.default.gray(`Ready: ${status.ready}, Restarts: ${status.restartCount}`)}`);
});
}
checkImagePullFailures(podName, exitEarly = true) {
// Fetch events from kubectl describe for the given pod
const eventLines = this.getPodEventsFromDescribe(podName);
const errorPattern = /Failed to pull image/;
const imageErrors = {};
// Parse through event lines to identify image pull failures
eventLines.forEach((line) => {
const message = line || '';
if (errorPattern.test(message)) {
const imageMatch = message.match(/image "(.*?)"/);
if (imageMatch && imageMatch[1]) {
const imageName = imageMatch[1];
imageErrors[imageName] = (imageErrors[imageName] || 0) + 1;
}
}
});
// Log errors for images that have failed more than twice
Object.entries(imageErrors).forEach(([imageName, errorCount]) => {
if (errorCount >= 3) {
this.log(`${chalk_1.default.red(`
Error: Image '${imageName}' failed to pull ${errorCount} times for pod ${podName}.
Please check the image name and ensure it is correct.
Run "starship stop" to stop the deployment which would be in stuck state.
`)}`);
if (exitEarly)
this.exit(1);
}
});
}
getPodEventsFromDescribe(podName) {
// Execute the 'kubectl describe pod' command
const result = this.exec(['kubectl', 'describe', 'pod', podName, ...this.getArgs()], { log: false, silent: true });
// Check if the command was successful
if (result.code !== 0) {
this.log(chalk_1.default.red(`Failed to describe pod ${podName}: ${result.stderr}`));
return [];
}
const describeOutput = result.stdout;
// Extract the 'Events' section from the describe output
const eventsSection = describeOutput.split('Events:')[1];
if (!eventsSection) {
this.log(chalk_1.default.yellow(`No events found for pod ${podName}`));
return [];
}
// Split the events section into individual lines
const eventLines = eventsSection
.split('\n')
.filter((line) => line.trim() !== '');
return eventLines;
}
forwardPort(chain, localPort, externalPort) {
let podName;
if (typeof chain.id === 'string') {
podName = `${(0, exports.formatChainID)(chain.id)}-genesis-0`;
}
else {
podName = `${chain.name}-${chain.id}-0`;
}
if (localPort !== undefined && externalPort !== undefined) {
this.exec([
'kubectl',
'port-forward',
`pods/${podName}`,
`${localPort}:${externalPort}`,
...this.getArgs(),
'>',
'/dev/null',
'2>&1',
'&'
]);
this.log(chalk_1.default.yellow(`Forwarded ${podName}: local ${localPort} -> target (host) ${externalPort}`));
}
}
forwardPortCometmock(chain, localPort, externalPort) {
let podName;
if (typeof chain.id === 'string') {
podName = `${(0, exports.formatChainID)(chain.id)}-cometmock-0`;
}
else {
podName = `${chain.name}-${chain.id}-0`;
}
if (localPort !== undefined && externalPort !== undefined) {
this.exec([
'kubectl',
'port-forward',
`pods/${podName}`,
`${localPort}:${externalPort}`,
...this.getArgs(),
'>',
'/dev/null',
'2>&1',
'&'
]);
this.log(chalk_1.default.yellow(`Forwarded ${podName}: local ${localPort} -> target (host) ${externalPort}`));
}
}
forwardPortRelayer(relayer, localPort, externalPort) {
if (localPort !== undefined && externalPort !== undefined) {
this.exec([
'kubectl',
'port-forward',
`pods/${relayer.type}-${relayer.name}-0`,
`${localPort}:${externalPort}`,
...this.getArgs(),
'>',
'/dev/null',
'2>&1',
'&'
]);
this.log(chalk_1.default.yellow(`Forwarded ${relayer.name}: local ${localPort} -> target (host) ${externalPort}`));
}
}
forwardPortService(serviceName, localPort, externalPort) {
if (localPort !== undefined && externalPort !== undefined) {
this.exec([
'kubectl',
'port-forward',
`service/${serviceName}`,
`${localPort}:${externalPort}`,
...this.getArgs(),
'>',
'/dev/null',
'2>&1',
'&'
]);
this.log(chalk_1.default.yellow(`Forwarded ${serviceName}: local ${localPort} -> target (host) ${externalPort}`));
}
}
startPortForward() {
if (!this.config) {
throw new Error('no config!');
}
this.log('Attempting to stop any existing port-forwards...');
this.stopPortForward();
this.log('Starting new port forwarding...');
this.config.chains?.forEach((chain) => {
const chainPodPorts = this.podPorts.chains[chain.name] || this.podPorts.chains.defaultPorts;
Object.entries(chain.ports || {}).forEach(([portName, portValue]) => {
if (chainPodPorts[portName]) {
if (chain.cometmock?.enabled && portName === 'rpc') {
this.forwardPortCometmock(chain, portValue, chainPodPorts.cometmock);
}
else {
this.forwardPort(chain, portValue, chainPodPorts[portName]);
}
}
});
});
this.config.relayers?.forEach((relayer) => {
const relayerPodPorts = this.podPorts.relayers[relayer.name] ||
this.podPorts.relayers.defaultPorts;
if (relayer.ports?.rest)
this.forwardPortRelayer(relayer, relayer.ports.rest, relayerPodPorts.rest);
if (relayer.ports?.exposer)
this.forwardPortRelayer(relayer, relayer.ports.exposer, relayerPodPorts.exposer);
});
if (this.config.registry?.enabled) {
this.forwardPortService('registry', this.config.registry.ports.rest, this.podPorts.registry.rest);
this.forwardPortService('registry', this.config.registry.ports.grpc, this.podPorts.registry.grpc);
}
if (this.config.explorer?.enabled) {
this.forwardPortService('explorer', this.config.explorer.ports.rest, this.podPorts.explorer.rest);
}
// Forward ports for frontend services
this.config.frontends?.forEach((frontend) => {
if (frontend.ports) {
if (frontend.ports.rest) {
this.forwardPortService(frontend.name, frontend.ports.rest, frontend.ports.rest);
}
}
});
}
getForwardPids() {
const result = this.exec([
'ps',
'-ef',
'|',
'grep',
'-i',
"'kubectl port-forward'",
'|',
'grep',
'-v',
"'grep'",
'|',
'awk',
"'{print $2}'"
], { log: false, silent: true });
const pids = (result || '')
.split('\n')
.map((pid) => pid.trim())
.filter((a) => a !== '');
return pids;
}
stopPortForward() {
this.log(chalk_1.default.green('Trying to stop all port-forward, if any....'));
const pids = this.getForwardPids();
pids.forEach((pid) => {
this.exec(['kill', '-15', pid], { log: false, silent: true });
});
this.exec(['sleep', '2'], { log: false, silent: true });
}
printForwardPids() {
const pids = this.getForwardPids();
pids.forEach((pid) => {
console.log(pid);
});
}
async verify() {
this.log(chalk_1.default.blue('Verifying services...'));
const results = await (0, verifiers_1.verify)(this.config);
let allSuccess = true;
for (const result of results) {
const statusColor = result.status === 'success'
? 'green'
: result.status === 'skipped'
? 'yellow'
: 'red';
const status = chalk_1.default[statusColor](result.status.toUpperCase());
const message = result.message || result.error || '';
const details = result.details
? JSON.stringify(result.details, null, 2)
: '';
this.log(`${status} ${result.service} (${result.endpoint}): ${message}`);
if (details && result.status === 'failure') {
this.log(chalk_1.default.gray(`Details: ${details}`));
}
if (result.status === 'failure') {
allSuccess = false;
}
}
if (!allSuccess) {
this.log(chalk_1.default.red('\nSome services failed verification. Please check the logs above.'));
this.exit(1);
}
else {
this.log(chalk_1.default.green('\nAll services verified successfully!'));
this.exit(0);
}
}
}
exports.StarshipClient = StarshipClient;