Skip to content

Commit

Permalink
Add rollback command to deployer
Browse files Browse the repository at this point in the history
  • Loading branch information
amarthadan committed Jan 18, 2023
1 parent 620aa0e commit ad079bd
Show file tree
Hide file tree
Showing 6 changed files with 359 additions and 21 deletions.
5 changes: 5 additions & 0 deletions .changeset/sweet-papayas-bake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@api3/airnode-deployer': minor
---

Add rollback command to deployer
32 changes: 29 additions & 3 deletions packages/airnode-deployer/src/cli/commands.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,33 @@
import fs from 'fs';
import { join } from 'path';
import { mockReadFileSync } from '../../test/mock-utils';
import { readFileSync } from 'fs';
import { receipt } from '@api3/airnode-validator';
import { deploy, removeWithReceipt } from './commands';
import { Ora } from 'ora';
import { deploy, removeWithReceipt, rollback } from './commands';
import { version as packageVersion } from '../../package.json';
import * as logger from '../utils/logger';
import { removeAirnode } from '../infrastructure';

const readExampleConfig = () => JSON.parse(readFileSync(join(__dirname, '../../config/config.example.json'), 'utf-8'));
const readExampleConfig = () =>
JSON.parse(fs.readFileSync(join(__dirname, '../../config/config.example.json'), 'utf-8'));

jest.mock('../infrastructure', () => ({
...jest.requireActual('../infrastructure'),
deployAirnode: jest.fn(),
removeAirnode: jest.fn(),
saveDeploymentFiles: jest.fn(),
}));

jest.mock('../utils', () => ({
...jest.requireActual('../utils'),
writeReceiptFile: jest.fn(),
}));

const mockSpinner = {
stop: jest.fn(),
succeed: jest.fn(),
};

const gcpReceipt: receipt.Receipt = {
airnodeWallet: {
airnodeAddress: '0xF347ADEd76F7AC2013e379078738aBfF75780C2e',
Expand All @@ -44,16 +52,22 @@ const gcpReceipt: receipt.Receipt = {
describe('deployer commands', () => {
let mockDeployAirnode: jest.SpyInstance;
let mockRemoveAirnode: jest.SpyInstance;
let mockSaveDeploymentFiles: jest.SpyInstance;
let mockWriteReceiptFile: jest.SpyInstance;
let loggerFailSpy: jest.SpyInstance;
let loggerSucceedSpy: jest.SpyInstance;

beforeEach(() => {
mockDeployAirnode = jest.requireMock('../infrastructure').deployAirnode;
mockRemoveAirnode = jest.requireMock('../infrastructure').removeAirnode;
mockSaveDeploymentFiles = jest.requireMock('../infrastructure').saveDeploymentFiles;
mockWriteReceiptFile = jest.requireMock('../utils').writeReceiptFile;
loggerFailSpy = jest.spyOn(logger, 'fail').mockImplementation(() => {});
loggerSucceedSpy = jest.spyOn(logger, 'succeed').mockImplementation(() => {});
jest.spyOn(logger, 'getSpinner').mockImplementation(() => ({ start: () => mockSpinner } as unknown as Ora));
jest.spyOn(logger, 'inDebugMode').mockImplementation(() => false);
jest.spyOn(fs, 'mkdtempSync').mockImplementation(() => join(__dirname, '../../config/'));
jest.spyOn(fs, 'rmSync').mockImplementation(() => {});
});

it('can deploy Airnode', async () => {
Expand Down Expand Up @@ -148,6 +162,18 @@ describe('deployer commands', () => {
expect(loggerFailSpy).toHaveBeenCalledTimes(2);
});

it('rollbacks the Airnode deployment', async () => {
const deploymentId = 'aws7195b548';
const deploymentVersion = '1f8210a2';
mockDeployAirnode.mockReturnValueOnce({});

await rollback(deploymentId, deploymentVersion, 'mocked receipt filename', true);

expect(mockSaveDeploymentFiles).toHaveBeenCalledTimes(1);
expect(mockDeployAirnode).toHaveBeenCalledTimes(1);
expect(mockWriteReceiptFile).toHaveBeenCalledTimes(1);
});

describe('fail with invalid node version', () => {
it('when using deploy', async () => {
const config = readExampleConfig();
Expand Down
25 changes: 24 additions & 1 deletion packages/airnode-deployer/src/cli/commands.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { loadConfig } from '@api3/airnode-node';
import { go } from '@api3/promise-utils';
import { bold } from 'chalk';
import { deployAirnode, removeAirnode } from '../infrastructure';
import { deployAirnode, removeAirnode, saveDeploymentFiles } from '../infrastructure';
import { writeReceiptFile, parseReceiptFile, parseSecretsFile } from '../utils';
import * as logger from '../utils/logger';
import { logAndReturnError, MultiMessageError } from '../utils/infrastructure';
Expand Down Expand Up @@ -79,3 +82,23 @@ export async function removeWithReceipt(receiptFilename: string) {
// If the function throws, the CLI will fail with a non zero status code
await removeAirnode(deploymentId);
}

export async function rollback(deploymentId: string, versionId: string, receiptFile: string, autoRemove: boolean) {
const spinner = logger.getSpinner().start(`Rollback of deployment '${deploymentId}' to version '${versionId}'`);
if (logger.inDebugMode()) {
spinner.info();
}

const configDirTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'airnode'));
const configPathTmp = path.join(configDirTmp, 'config.json');
const secretsPathTmp = path.join(configDirTmp, 'secrets.env');

try {
await saveDeploymentFiles(deploymentId, versionId, configPathTmp, secretsPathTmp);
await deploy(configPathTmp, secretsPathTmp, receiptFile, autoRemove);
} finally {
fs.rmSync(configDirTmp, { recursive: true });
}

spinner.succeed(`Rollback of deployment '${deploymentId}' to version '${versionId}' successful`);
}
48 changes: 47 additions & 1 deletion packages/airnode-deployer/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import sortBy from 'lodash/sortBy';
import { availableCloudProviders, CloudProvider, version as getNodeVersion } from '@api3/airnode-node';
import { logger as loggerUtils } from '@api3/airnode-utilities';
import { go } from '@api3/promise-utils';
import { deploy, removeWithReceipt } from './commands';
import { deploy, removeWithReceipt, rollback } from './commands';
import * as logger from '../utils/logger';
import { longArguments } from '../utils/cli';
import { MultiMessageError } from '../utils/infrastructure';
Expand Down Expand Up @@ -50,6 +50,7 @@ const cliExamples = [
'list --cloud-providers gcp',
'info aws808e2a22',
'fetch-files aws808e2a22',
'rollback aws808e2a22 5bbcd317',
'remove-with-receipt -r config/receipt.json',
'remove aws808e2a22',
];
Expand Down Expand Up @@ -216,6 +217,51 @@ yargs(hideBin(process.argv))
}
}
)
.command(
'rollback <deployment-id> <version-id>',
'Deploy one of the previous Airnode deployment versions',
(yargs) => {
yargs.positional('deployment-id', {
description: `ID of the deployment (from 'list' command)`,
type: 'string',
});
yargs.positional('version-id', {
description: `ID of the deployment version (from 'info' command)`,
type: 'string',
});
yargs.option('receipt', {
alias: 'r',
description: 'Output path for receipt file',
default: 'config/receipt.json',
type: 'string',
});
// Flag arguments without value are not supported. See: https://github.com/yargs/yargs/issues/1532
yargs.option('auto-remove', {
description: 'Enable automatic removal of deployed resources for failed deployments',
default: true,
type: 'boolean',
});
},
async (args) => {
logger.debugMode(args.debug as boolean);
logger.debug(`Running command ${args._[0]} with arguments ${longArguments(args)}`);

// Looks like due to the bug in yargs (https://github.com/yargs/yargs/issues/1649) we need to specify the types explicitely
const goRollback = await go(() =>
rollback(
args.deploymentId as string,
args.versionId as string,
args.receipt as string,
args.autoRemove as boolean
)
);
if (!goRollback.success) {
logger.fail(goRollback.error.message);
// eslint-disable-next-line functional/immutable-data
process.exitCode = 1;
}
}
)
.example(cliExamples.map((line) => [`$0 ${line}\n`]))
.help()
.demandCommand(1)
Expand Down
204 changes: 204 additions & 0 deletions packages/airnode-deployer/src/infrastructure/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1819,3 +1819,207 @@ describe('fetchFiles', () => {
);
});
});

describe('saveDeploymentFiles', () => {
const bucket = {
name: 'airnode-123456789',
region: 'us-east-1',
};
const configPath = path.join(__dirname, '..', '..', 'test', 'fixtures', 'config.aws.valid.json');
const secretsPath = path.join(__dirname, '..', '..', 'test', 'fixtures', 'secrets.valid.env');
const directoryStructure = pick(mockBucketDirectoryStructure, [
'0xd0624E6C2C8A1DaEdE9Fa7E9C409167ed5F256c6',
'0xA30CA71Ba54E83127214D3271aEA8F5D6bD4Dace',
]);

let tempOutputDirectory: string;
let tempConfigPath: string;
let tempSecretsPath: string;
let awsGetAirnodeBucketSpy: jest.SpyInstance;
let awsGetBucketDirectoryStructureSpy: jest.SpyInstance;
let awsGetFileFromBucketSpy: jest.SpyInstance;

beforeEach(() => {
tempOutputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'airnode-deployer-saveDeploymentFiles-test'));
tempConfigPath = path.join(tempOutputDirectory, 'config.json');
tempSecretsPath = path.join(tempOutputDirectory, 'secrets.env');
awsGetAirnodeBucketSpy = jest.spyOn(aws, 'getAirnodeBucket').mockResolvedValue(bucket);
awsGetBucketDirectoryStructureSpy = jest
.spyOn(aws, 'getBucketDirectoryStructure')
.mockResolvedValue(directoryStructure);
awsGetFileFromBucketSpy = jest.spyOn(aws, 'getFileFromBucket').mockImplementation((_bucket, path) => {
if (path.includes('config.json')) {
return Promise.resolve(fs.readFileSync(configPath).toString());
}
if (path.includes('secrets.env')) {
return Promise.resolve(fs.readFileSync(secretsPath).toString());
}

throw new Error(`Mocking fetching of unsupported file '${path}'`);
});
});

afterEach(() => {
fs.rmSync(tempOutputDirectory, { recursive: true });
});

it('saves deployment files', async () => {
const deploymentId = 'aws7195b548';
const deploymentVersion = '1f8210a2';

const originalColorVariable = process.env.FORCE_COLOR;
// I have to disable table coloring so I can compare the output
process.env.FORCE_COLOR = '0';
await infrastructure.saveDeploymentFiles(deploymentId, deploymentVersion, tempConfigPath, tempSecretsPath);
process.env.FORCE_COLOR = originalColorVariable;

expect(awsGetAirnodeBucketSpy).toHaveBeenCalledTimes(1);
expect(awsGetBucketDirectoryStructureSpy).toHaveBeenCalledTimes(1);
expect(awsGetBucketDirectoryStructureSpy).toHaveBeenCalledWith(bucket.name);
expect(awsGetFileFromBucketSpy).toHaveBeenCalledTimes(4);
expect(awsGetFileFromBucketSpy).toHaveBeenNthCalledWith(
1,
bucket.name,
'0xd0624E6C2C8A1DaEdE9Fa7E9C409167ed5F256c6/dev/1662558010204/config.json'
);
expect(awsGetFileFromBucketSpy).toHaveBeenNthCalledWith(
2,
bucket.name,
'0xd0624E6C2C8A1DaEdE9Fa7E9C409167ed5F256c6/dev/1662558010204/secrets.env'
);
expect(awsGetFileFromBucketSpy).toHaveBeenNthCalledWith(
3,
bucket.name,
'0xd0624E6C2C8A1DaEdE9Fa7E9C409167ed5F256c6/dev/1662557983568/config.json'
);
expect(awsGetFileFromBucketSpy).toHaveBeenNthCalledWith(
4,
bucket.name,
'0xd0624E6C2C8A1DaEdE9Fa7E9C409167ed5F256c6/dev/1662557983568/secrets.env'
);

const configContent = fs.readFileSync(configPath);
const savedConfigContent = fs.readFileSync(tempConfigPath);
expect(savedConfigContent).toEqual(configContent);
const secretsContent = fs.readFileSync(secretsPath);
const savedSecretsContent = fs.readFileSync(tempSecretsPath);
expect(savedSecretsContent).toEqual(secretsContent);
});

it('fails if called with an invalid deployment ID', async () => {
const invalidDeploymentId = 'xxx2c6ef2b3';
const deploymentVersion = '1f8210a2';

const originalColorVariable = process.env.FORCE_COLOR;
// I have to disable table coloring so I can compare the output
process.env.FORCE_COLOR = '0';
await expect(
infrastructure.saveDeploymentFiles(invalidDeploymentId, deploymentVersion, tempConfigPath, tempSecretsPath)
).rejects.toThrow(new Error(`Invalid deployment ID '${invalidDeploymentId}'`));
process.env.FORCE_COLOR = originalColorVariable;

expect(awsGetAirnodeBucketSpy).not.toHaveBeenCalled();
expect(awsGetBucketDirectoryStructureSpy).not.toHaveBeenCalled();
expect(awsGetFileFromBucketSpy).not.toHaveBeenCalled();
});

it(`fails if the deployment can't be found`, async () => {
const nonExistingDeploymentId = 'aws2c6ef2b3';
const deploymentVersion = '1f8210a2';

const originalColorVariable = process.env.FORCE_COLOR;
// I have to disable table coloring so I can compare the output
process.env.FORCE_COLOR = '0';
await expect(
infrastructure.saveDeploymentFiles(nonExistingDeploymentId, deploymentVersion, tempConfigPath, tempSecretsPath)
).rejects.toThrow(new Error(`No deployment with ID '${nonExistingDeploymentId}' found`));
process.env.FORCE_COLOR = originalColorVariable;

expect(awsGetAirnodeBucketSpy).toHaveBeenCalledTimes(1);
expect(awsGetBucketDirectoryStructureSpy).toHaveBeenCalledTimes(1);
expect(awsGetBucketDirectoryStructureSpy).toHaveBeenCalledWith(bucket.name);
expect(awsGetFileFromBucketSpy).toHaveBeenCalledTimes(6);
expect(awsGetFileFromBucketSpy).toHaveBeenNthCalledWith(
1,
bucket.name,
'0xd0624E6C2C8A1DaEdE9Fa7E9C409167ed5F256c6/dev/1662558010204/config.json'
);
expect(awsGetFileFromBucketSpy).toHaveBeenNthCalledWith(
2,
bucket.name,
'0xd0624E6C2C8A1DaEdE9Fa7E9C409167ed5F256c6/dev/1662558010204/secrets.env'
);
expect(awsGetFileFromBucketSpy).toHaveBeenNthCalledWith(
3,
bucket.name,
'0xd0624E6C2C8A1DaEdE9Fa7E9C409167ed5F256c6/prod/1662558071950/config.json'
);
expect(awsGetFileFromBucketSpy).toHaveBeenNthCalledWith(
4,
bucket.name,
'0xd0624E6C2C8A1DaEdE9Fa7E9C409167ed5F256c6/prod/1662558071950/secrets.env'
);
expect(awsGetFileFromBucketSpy).toHaveBeenNthCalledWith(
5,
bucket.name,
'0xA30CA71Ba54E83127214D3271aEA8F5D6bD4Dace/dev/1662559204554/config.json'
);
expect(awsGetFileFromBucketSpy).toHaveBeenNthCalledWith(
6,
bucket.name,
'0xA30CA71Ba54E83127214D3271aEA8F5D6bD4Dace/dev/1662559204554/secrets.env'
);
});

it(`fails if deployment version can't be found`, async () => {
const deploymentId = 'aws7195b548';
const nonExistingDeploymentVersion = '11223344';

const originalColorVariable = process.env.FORCE_COLOR;
// I have to disable table coloring so I can compare the output
process.env.FORCE_COLOR = '0';
await expect(
infrastructure.saveDeploymentFiles(deploymentId, nonExistingDeploymentVersion, tempConfigPath, tempSecretsPath)
).rejects.toThrow(
new Error(`No deployment with ID '${deploymentId}' and version '${nonExistingDeploymentVersion}' found`)
);
process.env.FORCE_COLOR = originalColorVariable;

expect(awsGetAirnodeBucketSpy).toHaveBeenCalledTimes(1);
expect(awsGetBucketDirectoryStructureSpy).toHaveBeenCalledTimes(1);
expect(awsGetBucketDirectoryStructureSpy).toHaveBeenCalledWith(bucket.name);
expect(awsGetFileFromBucketSpy).toHaveBeenCalledTimes(2);
expect(awsGetFileFromBucketSpy).toHaveBeenNthCalledWith(
1,
bucket.name,
'0xd0624E6C2C8A1DaEdE9Fa7E9C409167ed5F256c6/dev/1662558010204/config.json'
);
expect(awsGetFileFromBucketSpy).toHaveBeenNthCalledWith(
2,
bucket.name,
'0xd0624E6C2C8A1DaEdE9Fa7E9C409167ed5F256c6/dev/1662558010204/secrets.env'
);
});

it(`fails if there's a problem with the cloud provider`, async () => {
const expectedError = new Error('example error');
awsGetAirnodeBucketSpy = jest.spyOn(aws, 'getAirnodeBucket').mockRejectedValue(expectedError);

const deploymentId = 'aws7195b548';
const deploymentVersion = '1f8210a2';

const originalColorVariable = process.env.FORCE_COLOR;
// I have to disable table coloring so I can compare the output
process.env.FORCE_COLOR = '0';
await expect(
infrastructure.saveDeploymentFiles(deploymentId, deploymentVersion, tempConfigPath, tempSecretsPath)
).rejects.toThrow(
new Error(`Failed to fetch info about '${deploymentId}' from AWS: Error: ${expectedError.message}`)
);
process.env.FORCE_COLOR = originalColorVariable;

expect(awsGetAirnodeBucketSpy).toHaveBeenCalledTimes(1);
expect(awsGetBucketDirectoryStructureSpy).not.toHaveBeenCalled();
expect(awsGetFileFromBucketSpy).not.toHaveBeenCalled();
});
});
Loading

0 comments on commit ad079bd

Please sign in to comment.