Skip to content

Commit 343aafd

Browse files
AryanBansal-launchvenkatesh-cstkanujachordiya-contentstack
committed
CL-1753 | + Anuja | + venky | feat: add rollback command for previous deployments with GraphQL integration
Co-authored-by: Venkatesh <venkatesh.gopinath@contentstack.com> Co-authored-by: anujachordiya-contentstack <anuja.chordiya@gmail.com>
1 parent 3c2acb3 commit 343aafd

3 files changed

Lines changed: 355 additions & 0 deletions

File tree

src/commands/launch/rollback.ts

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
import chalk from 'chalk';
2+
import map from 'lodash/map';
3+
import find from 'lodash/find';
4+
import filter from 'lodash/filter';
5+
import isEmpty from 'lodash/isEmpty';
6+
import { FlagInput, Flags, cliux as ux } from '@contentstack/cli-utilities';
7+
8+
import { BaseCommand } from '../../base-command';
9+
import {
10+
environmentsQuery,
11+
latestLiveDeploymentQuery,
12+
rollbackDeploymentMutation,
13+
} from '../../graphql';
14+
import { Logger, selectOrg, selectProject } from '../../util';
15+
16+
export default class Rollback extends BaseCommand<typeof Rollback> {
17+
static description = 'Roll back to previous deployment';
18+
19+
static examples = [
20+
'$ <%= config.bin %> <%= command.id %>',
21+
'$ <%= config.bin %> <%= command.id %> -d "current working directory"',
22+
'$ <%= config.bin %> <%= command.id %> -c "path to the local config file"',
23+
// eslint-disable-next-line max-len
24+
'$ <%= config.bin %> <%= command.id %> -e "environment number or uid" --deployment=<deployment UID> --org=<org UID> --project=<Project UID> --reason="restoring previous build"',
25+
];
26+
27+
static flags: FlagInput = {
28+
org: Flags.string({
29+
description: '[Optional] Provide the organization UID',
30+
}),
31+
project: Flags.string({
32+
description: '[Optional] Provide the project UID',
33+
}),
34+
environment: Flags.string({
35+
char: 'e',
36+
description: 'Environment name or UID',
37+
}),
38+
deployment: Flags.string({
39+
description: '[Optional] Deployment UID to roll back to',
40+
}),
41+
reason: Flags.string({
42+
description: '[Optional] Reason for the rollback (saved to audit log)',
43+
}),
44+
};
45+
46+
async run(): Promise<void> {
47+
this.logger = new Logger(this.sharedConfig);
48+
this.log = this.logger.log.bind(this.logger);
49+
50+
if (!this.flags.environment) {
51+
await this.getConfig();
52+
}
53+
54+
await this.prepareApiClients();
55+
56+
if (!this.sharedConfig.currentConfig?.uid) {
57+
await selectOrg({
58+
log: this.log,
59+
flags: this.flags,
60+
config: this.sharedConfig,
61+
managementSdk: this.managementSdk,
62+
});
63+
await this.prepareApiClients(); // NOTE update org-id in header
64+
await selectProject({
65+
log: this.log,
66+
flags: this.flags,
67+
config: this.sharedConfig,
68+
apolloClient: this.apolloClient,
69+
});
70+
await this.prepareApiClients(); // NOTE update project-id in header
71+
}
72+
73+
await this.rollbackDeployment();
74+
}
75+
76+
/**
77+
* @method rollbackDeployment - resolve env, run select + review steps, fire mutation
78+
*
79+
* @memberof Rollback
80+
*/
81+
async rollbackDeployment(): Promise<void> {
82+
const environment = await this.resolveEnvironment();
83+
const currentLive = await this.fetchCurrentLiveDeployment(environment.uid);
84+
const eligibleSorted = this.getEligibleSorted(environment, currentLive?.uid);
85+
86+
if (isEmpty(eligibleSorted)) {
87+
this.log('No rollback-eligible deployments are available for this environment.', 'error');
88+
process.exit(1);
89+
}
90+
91+
this.printSelectStep(environment, currentLive, eligibleSorted);
92+
const target = await this.selectDeployment(eligibleSorted);
93+
94+
this.printReviewStep(currentLive, target, eligibleSorted);
95+
const reason = await this.promptReason();
96+
const confirmed = await ux.inquire<boolean>({
97+
type: 'confirm',
98+
name: 'confirm',
99+
message: 'Confirm & Rollback?',
100+
});
101+
102+
if (!confirmed) {
103+
ux.print(chalk.yellow('Rollback aborted.'));
104+
return;
105+
}
106+
107+
await this.apolloClient
108+
.mutate({
109+
mutation: rollbackDeploymentMutation,
110+
variables: {
111+
input: {
112+
deployment: target.uid,
113+
environment: environment.uid,
114+
...(reason ? { reason } : {}),
115+
},
116+
},
117+
})
118+
.then(({ data: { deployment: rolledBack } }) => {
119+
ux.print('');
120+
ux.print(chalk.green('✔ Instant rollback to a previous deployment is successful.'));
121+
ux.print(` New deployment: ${chalk.cyan(rolledBack.uid)} status: ${chalk.cyan(rolledBack.status)}`);
122+
ux.print('');
123+
})
124+
.catch((error) => {
125+
const code = error?.graphQLErrors?.[0]?.extensions?.exception?.name || error?.message;
126+
this.log(`Rollback failed. Please try again. (${code})`, 'error');
127+
process.exit(1);
128+
});
129+
}
130+
131+
/**
132+
* @method resolveEnvironment - resolve environment via flag, config, or prompt
133+
*
134+
* @memberof Rollback
135+
*/
136+
async resolveEnvironment(): Promise<any> {
137+
const environments = await this.apolloClient
138+
.query({ query: environmentsQuery })
139+
.then(({ data: { Environments } }) => map(Environments.edges, 'node'))
140+
.catch((error) => {
141+
this.log(error?.message, 'error');
142+
process.exit(1);
143+
});
144+
145+
let environment = find(
146+
environments,
147+
({ uid, name }) =>
148+
uid === this.flags.environment ||
149+
name === this.flags.environment ||
150+
uid === this.sharedConfig.currentConfig?.environments?.[0]?.uid,
151+
);
152+
153+
if (isEmpty(environment) && (this.flags.environment || this.sharedConfig.currentConfig?.environments?.[0]?.uid)) {
154+
this.log('Environment(s) not found!', 'error');
155+
process.exit(1);
156+
} else if (isEmpty(environment)) {
157+
environment = await ux
158+
.inquire({
159+
type: 'search-list',
160+
name: 'Environment',
161+
choices: map(environments, (row) => ({ ...row, value: row.name })),
162+
message: 'Choose an environment',
163+
})
164+
.then((name: any) => find(environments, { name }) as Record<string, any>);
165+
}
166+
167+
this.sharedConfig.environment = environment;
168+
return environment;
169+
}
170+
171+
/**
172+
* @method fetchCurrentLiveDeployment - fetch the currently live deployment for the environment
173+
*
174+
* @memberof Rollback
175+
*/
176+
async fetchCurrentLiveDeployment(environmentUid: string): Promise<any> {
177+
return this.apolloClient
178+
.query({
179+
query: latestLiveDeploymentQuery,
180+
variables: { query: { environment: environmentUid } },
181+
})
182+
.then(({ data }) => data?.latestLiveDeployment)
183+
.catch(() => undefined);
184+
}
185+
186+
/**
187+
* @method getEligibleSorted - eligible deployments excluding current live, sorted by number desc
188+
*
189+
* @memberof Rollback
190+
*/
191+
getEligibleSorted(environment: any, currentLiveUid?: string): any[] {
192+
const deployments = map(environment?.deployments?.edges, 'node');
193+
const eligible = filter(
194+
deployments,
195+
(d) => d.isRollbackEligible && d.uid !== currentLiveUid,
196+
);
197+
return [...eligible].sort((a, b) => (b.deploymentNumber || 0) - (a.deploymentNumber || 0));
198+
}
199+
200+
/**
201+
* @method selectDeployment - resolve target via --deployment flag or interactive picker
202+
*
203+
* @memberof Rollback
204+
*/
205+
async selectDeployment(eligibleSorted: any[]): Promise<any> {
206+
if (this.flags.deployment) {
207+
const match = find(eligibleSorted, ({ uid }) => uid === this.flags.deployment);
208+
if (isEmpty(match)) {
209+
this.log('Provided deployment UID is not rollback-eligible or does not exist.', 'error');
210+
process.exit(1);
211+
}
212+
return match;
213+
}
214+
215+
const choices = map(eligibleSorted, (d) => ({
216+
...d,
217+
name: `#${d.deploymentNumber} | ${sourceLabel(d) || '—'} | ${d.createdAt}`,
218+
value: d.uid,
219+
}));
220+
221+
const selectedUid = await ux.inquire<string>({
222+
type: 'search-list',
223+
name: 'Deployment',
224+
choices,
225+
message: 'Select a version to restore',
226+
});
227+
228+
return find(eligibleSorted, { uid: selectedUid }) as Record<string, any>;
229+
}
230+
231+
/**
232+
* @method promptReason - prompt for rollback reason unless provided via --reason flag
233+
*
234+
* @memberof Rollback
235+
*/
236+
async promptReason(): Promise<string | undefined> {
237+
if (this.flags.reason) {
238+
return this.flags.reason.trim() || undefined;
239+
}
240+
const input = await ux.inquire<string>({
241+
type: 'input',
242+
name: 'reason',
243+
message: 'Reason (saved to audit log) — press enter to skip:',
244+
});
245+
const trimmed = (input || '').trim();
246+
return trimmed ? trimmed : undefined;
247+
}
248+
249+
/**
250+
* @method printSelectStep - mirror the UI "select" step heading and table
251+
*
252+
* @memberof Rollback
253+
*/
254+
printSelectStep(environment: any, currentLive: any, eligibleSorted: any[]): void {
255+
ux.print('');
256+
ux.print(chalk.bold.underline('Roll back to previous deployment'));
257+
ux.print(`${chalk.dim('Environment:')} ${chalk.cyan(environment.name)}`);
258+
ux.print('');
259+
ux.print(chalk.bold('Currently live'));
260+
ux.print(` ${formatDeployment(currentLive)}`);
261+
ux.print('');
262+
ux.print(chalk.bold('Select a version to restore'));
263+
ux.print(chalk.dim('Choose a previously successful deployment to ensure stability.'));
264+
const count = eligibleSorted.length;
265+
ux.print(chalk.dim(`(${count} eligible deployment${count === 1 ? '' : 's'} available)`));
266+
ux.print('');
267+
}
268+
269+
/**
270+
* @method printReviewStep - mirror the UI "review" step warnings, skips info, and summary
271+
*
272+
* @memberof Rollback
273+
*/
274+
printReviewStep(currentLive: any, target: any, eligibleSorted: any[]): void {
275+
ux.print('');
276+
ux.print(chalk.bold.underline('Review rollback'));
277+
ux.print('');
278+
ux.print('You are about to replace your live site with the version below.');
279+
ux.print('This build will be pushed to the edge immediately.');
280+
ux.print('');
281+
ux.print(
282+
`${chalk.yellow.bold('Note:')} The rolled back instance will use the environment variables`,
283+
);
284+
ux.print(' associated with the selected deployment.');
285+
286+
const targetIndex = eligibleSorted.findIndex((d) => d.uid === target.uid);
287+
const skipped = targetIndex > 0 ? eligibleSorted.slice(0, targetIndex) : [];
288+
if (skipped.length > 0) {
289+
const list = skipped.map((d) => `#${d.deploymentNumber}`).join(', ');
290+
const noun = skipped.length === 1 ? 'good deployment' : 'good deployments';
291+
const verb = skipped.length === 1 ? 'stays' : 'stay';
292+
ux.print('');
293+
ux.print(
294+
`${chalk.blue('ⓘ')} Selecting #${target.deploymentNumber} skips ${skipped.length} ${noun}${list}`,
295+
);
296+
ux.print(` ${verb} in history and can be restored later.`);
297+
}
298+
299+
ux.print('');
300+
ux.print(` ${chalk.bold('Current Live')} ${formatDeployment(currentLive)}`);
301+
ux.print(` ${chalk.bold('Roll back to')} ${formatDeployment(target)}`);
302+
ux.print('');
303+
ux.print(
304+
chalk.dim('A new deployment may be initiated if any automations/commits/webhooks are triggered.'),
305+
);
306+
ux.print('');
307+
}
308+
}
309+
310+
function shortHash(hash?: string): string {
311+
return hash ? hash.substring(0, 7) : '';
312+
}
313+
314+
function sourceLabel(deployment?: any): string {
315+
if (!deployment) {
316+
return '';
317+
}
318+
const hash = shortHash(deployment.commitHash);
319+
if (deployment.gitBranch && hash) {
320+
return `${deployment.gitBranch} - ${hash}`;
321+
}
322+
return deployment.gitBranch || hash || '';
323+
}
324+
325+
function formatDeployment(deployment?: any): string {
326+
if (!deployment) {
327+
return chalk.dim('(none)');
328+
}
329+
const number = deployment.deploymentNumber ? `#${deployment.deploymentNumber}` : deployment.uid;
330+
const source = sourceLabel(deployment);
331+
const createdAt = deployment.createdAt || '';
332+
const numberCol = chalk.green(number.padEnd(6));
333+
const sourceCol = source ? chalk.cyan(source.padEnd(28)) : ''.padEnd(28);
334+
return `${numberCol} ${sourceCol} ${chalk.dim(createdAt)}`;
335+
}

src/graphql/mutation.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,24 @@ const importProjectMutation: DocumentNode = gql`
7676
}
7777
`;
7878

79+
const rollbackDeploymentMutation: DocumentNode = gql`
80+
mutation RollbackDeployment($input: RollbackDeploymentInput!) {
81+
deployment: rollbackDeployment(input: $input) {
82+
uid
83+
status
84+
createdAt
85+
updatedAt
86+
commitHash
87+
commitMessage
88+
deploymentUrl
89+
deploymentNumber
90+
}
91+
}
92+
`;
93+
7994
export {
8095
importProjectMutation,
8196
createDeploymentMutation,
97+
rollbackDeploymentMutation,
8298
createSignedUploadUrlMutation,
8399
};

src/graphql/queries.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,14 @@ const environmentsQuery: DocumentNode = gql`
161161
edges {
162162
node {
163163
uid
164+
status
165+
gitBranch
166+
commitHash
164167
createdAt
165168
commitMessage
166169
deploymentUrl
167170
deploymentNumber
171+
isRollbackEligible
168172
}
169173
}
170174
}

0 commit comments

Comments
 (0)