heroku
Version:
CLI to interact with Heroku
218 lines (217 loc) • 9.04 kB
JavaScript
Object.defineProperty(exports, "__esModule", { value: true });
exports.sleep = void 0;
const color_1 = require("@heroku-cli/color");
const command_1 = require("@heroku-cli/command");
const core_1 = require("@oclif/core");
const heroku_cli_util_1 = require("@heroku/heroku-cli-util");
const assert = require("assert");
const node_fetch_1 = require("node-fetch");
const Stream = require("stream");
const util = require("util");
const api_1 = require("../../lib/api");
const key_by_1 = require("../../lib/pipelines/key-by");
const sleep = (time) => {
return new Promise(resolve => setTimeout(resolve, time));
};
exports.sleep = sleep;
function assertNotPromotingToSelf(source, target) {
assert.notStrictEqual(source, target, `Cannot promote from an app to itself: ${target}. Specify a different target app.`);
}
function findAppInPipeline(apps, target) {
const found = apps.find(app => (app.name === target) || (app.id === target));
assert(found, `Cannot find app ${color_1.default.app(target)}`);
return found;
}
const PROMOTION_ORDER = ['development', 'staging', 'production'];
const wait = (ms = 100) => new Promise(resolve => setTimeout(resolve, ms));
function isComplete(promotionTarget) {
return promotionTarget.status !== 'pending';
}
function isSucceeded(promotionTarget) {
return promotionTarget.status === 'succeeded';
}
function isFailed(promotionTarget) {
return promotionTarget.status === 'failed';
}
function pollPromotionStatus(heroku, id, needsReleaseCommand) {
return heroku.get(`/pipeline-promotions/${id}/promotion-targets`).then(function ({ body: targets }) {
if (targets.every(isComplete)) {
return targets;
}
//
// With only one target, we can return as soon as the release is created.
// The command will then read the release phase output
//
// `needsReleaseCommand` allows us to keep polling, as it can take a few
// seconds to get the release to succeeded after the release command
// finished.
//
if (needsReleaseCommand && targets.length === 1 && targets[0].release !== null) {
return targets;
}
return wait(1000).then(pollPromotionStatus.bind(null, heroku, id, needsReleaseCommand));
});
}
async function getCoupling(heroku, app) {
core_1.ux.log('Fetching app info...');
const { body: coupling } = await heroku.get(`/apps/${app}/pipeline-couplings`);
return coupling;
}
async function promote(heroku, label, id, sourceAppId, targetApps, secondFactor) {
const options = {
headers: {},
body: {
pipeline: { id },
source: { app: { id: sourceAppId } },
targets: targetApps.map(app => ({ app: { id: app.id } })),
},
};
if (secondFactor) {
options.headers = { 'Heroku-Two-Factor-Code': secondFactor };
}
try {
core_1.ux.log(`${label}...`);
const { body: promotions } = await heroku.post('/pipeline-promotions', options);
return promotions;
}
catch (error) {
if (!error.body || error.body.id !== 'two_factor') {
throw error;
}
const secondFactor = await heroku.twoFactorPrompt();
return promote(heroku, label, id, sourceAppId, targetApps, secondFactor);
}
}
function assertValidPromotion(app, source, target) {
if (!target || PROMOTION_ORDER.indexOf(source) < 0) { // eslint-disable-line unicorn/prefer-includes
throw new Error(`Cannot promote ${app} from '${source}' stage`);
}
}
function assertApps(app, targetApps, targetStage) {
if (targetApps.length === 0) {
throw new Error(`Cannot promote from ${color_1.default.app(app)} as there are no downstream apps in ${targetStage} stage`);
}
}
async function getRelease(heroku, app, releaseId) {
core_1.ux.log('Fetching release info...');
const { body: release } = await heroku.get(`/apps/${app}/releases/${releaseId}`);
return release;
}
async function streamReleaseCommand(heroku, targets, promotion) {
if (targets.length !== 1 || targets.every(isComplete)) {
return pollPromotionStatus(heroku, promotion.id, false);
}
const target = targets[0];
const release = await getRelease(heroku, target.app.id, target.release.id);
if (!release.output_stream_url) {
return pollPromotionStatus(heroku, promotion.id, false);
}
core_1.ux.log('Running release command...');
async function streamReleaseOutput(releaseStreamUrl) {
const finished = util.promisify(Stream.finished);
const fetchResponse = await (0, node_fetch_1.default)(releaseStreamUrl);
if (fetchResponse.status >= 400) {
throw new Error('stream release output not available');
}
fetchResponse.body.pipe(process.stdout);
await finished(fetchResponse.body);
}
async function retry(maxAttempts, fn) {
let currentAttempt = 0;
while (true) {
try {
await fn();
return;
}
catch (error) {
if (++currentAttempt === maxAttempts) {
throw error;
}
await (0, exports.sleep)(1000);
}
}
}
await retry(100, () => {
return streamReleaseOutput(release.output_stream_url);
});
return pollPromotionStatus(heroku, promotion.id, false);
}
class Promote extends command_1.Command {
async run() {
const { flags } = await this.parse(Promote);
const appNameOrId = flags.app;
const coupling = await getCoupling(this.heroku, appNameOrId);
core_1.ux.log(`Fetching apps from ${color_1.default.pipeline(coupling.pipeline.name)}...`);
const allApps = await (0, api_1.listPipelineApps)(this.heroku, coupling.pipeline.id);
const sourceStage = coupling.stage;
let promotionActionName = '';
let targetApps = [];
if (flags.to) {
// The user specified a specific set of apps they want to target
// We don't have to infer the apps or the stage they want to promote to
// Strip out any empty app names due to something like a trailing comma
const targetAppNames = flags.to.split(',').filter(appName => appName.length > 0);
// Now let's make sure that we can find every target app they specified
// The only requirement is that the app be in this pipeline. They can be at any stage.
targetApps = targetAppNames.reduce((acc, targetAppNameOrId) => {
assertNotPromotingToSelf(appNameOrId, targetAppNameOrId);
const app = findAppInPipeline(allApps, targetAppNameOrId);
if (app) {
acc.push(app);
}
return acc;
}, []);
promotionActionName = `Starting promotion to apps: ${targetAppNames.toString()}`;
}
else {
const targetStage = PROMOTION_ORDER[PROMOTION_ORDER.indexOf(sourceStage) + 1];
assertValidPromotion(appNameOrId, sourceStage, targetStage);
targetApps = allApps.filter(app => app.pipelineCoupling.stage === targetStage);
assertApps(appNameOrId, targetApps, targetStage);
promotionActionName = `Starting promotion to ${targetStage}`;
}
const promotion = await promote(this.heroku, promotionActionName, coupling.pipeline.id, coupling.app.id, targetApps);
const pollLoop = pollPromotionStatus(this.heroku, promotion.id, true);
core_1.ux.log('Waiting for promotion to complete...');
let promotionTargets = await pollLoop;
try {
promotionTargets = await streamReleaseCommand(this.heroku, promotionTargets, promotion);
}
catch (error) {
core_1.ux.error(error);
}
const appsByID = (0, key_by_1.default)(allApps, 'id');
const styledTargets = promotionTargets.reduce(function (memo, target) {
const app = appsByID[target.app.id];
const details = [target.status];
if (isFailed(target)) {
details.push(target.error_message);
}
memo[app.name] = details;
return memo;
}, {});
if (promotionTargets.every(isSucceeded)) {
core_1.ux.log('\nPromotion successful');
}
else {
core_1.ux.warn('\nPromotion to some apps failed');
}
heroku_cli_util_1.hux.styledObject(styledTargets);
}
}
exports.default = Promote;
Promote.description = 'promote the latest release of this app to its downstream app(s)';
Promote.examples = [
'$ heroku pipelines:promote -a my-app-staging',
];
Promote.flags = {
app: command_1.flags.app({
required: true,
}),
remote: command_1.flags.remote(),
to: command_1.flags.string({
char: 't',
description: 'comma separated list of apps to promote to',
}),
};
;