From ad079bd10859a1a2cabefde88a8dab8129f5870f Mon Sep 17 00:00:00 2001 From: Michal Kimle Date: Wed, 18 Jan 2023 12:49:55 +0100 Subject: [PATCH] Add rollback command to deployer --- .changeset/sweet-papayas-bake.md | 5 + .../airnode-deployer/src/cli/commands.test.ts | 32 ++- packages/airnode-deployer/src/cli/commands.ts | 25 ++- packages/airnode-deployer/src/cli/index.ts | 48 ++++- .../src/infrastructure/index.test.ts | 204 ++++++++++++++++++ .../src/infrastructure/index.ts | 66 ++++-- 6 files changed, 359 insertions(+), 21 deletions(-) create mode 100644 .changeset/sweet-papayas-bake.md diff --git a/.changeset/sweet-papayas-bake.md b/.changeset/sweet-papayas-bake.md new file mode 100644 index 0000000000..3425a99bc2 --- /dev/null +++ b/.changeset/sweet-papayas-bake.md @@ -0,0 +1,5 @@ +--- +'@api3/airnode-deployer': minor +--- + +Add rollback command to deployer diff --git a/packages/airnode-deployer/src/cli/commands.test.ts b/packages/airnode-deployer/src/cli/commands.test.ts index f90427d961..b75e77a2dd 100644 --- a/packages/airnode-deployer/src/cli/commands.test.ts +++ b/packages/airnode-deployer/src/cli/commands.test.ts @@ -1,18 +1,21 @@ +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', () => ({ @@ -20,6 +23,11 @@ jest.mock('../utils', () => ({ writeReceiptFile: jest.fn(), })); +const mockSpinner = { + stop: jest.fn(), + succeed: jest.fn(), +}; + const gcpReceipt: receipt.Receipt = { airnodeWallet: { airnodeAddress: '0xF347ADEd76F7AC2013e379078738aBfF75780C2e', @@ -44,6 +52,7 @@ 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; @@ -51,9 +60,14 @@ describe('deployer commands', () => { 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 () => { @@ -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(); diff --git a/packages/airnode-deployer/src/cli/commands.ts b/packages/airnode-deployer/src/cli/commands.ts index 7442af68a9..f78f3704aa 100644 --- a/packages/airnode-deployer/src/cli/commands.ts +++ b/packages/airnode-deployer/src/cli/commands.ts @@ -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'; @@ -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`); +} diff --git a/packages/airnode-deployer/src/cli/index.ts b/packages/airnode-deployer/src/cli/index.ts index ec86efd46d..1e21949f90 100644 --- a/packages/airnode-deployer/src/cli/index.ts +++ b/packages/airnode-deployer/src/cli/index.ts @@ -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'; @@ -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', ]; @@ -216,6 +217,51 @@ yargs(hideBin(process.argv)) } } ) + .command( + 'rollback ', + '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) diff --git a/packages/airnode-deployer/src/infrastructure/index.test.ts b/packages/airnode-deployer/src/infrastructure/index.test.ts index d3279a1638..99844b06d0 100644 --- a/packages/airnode-deployer/src/infrastructure/index.test.ts +++ b/packages/airnode-deployer/src/infrastructure/index.test.ts @@ -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(); + }); +}); diff --git a/packages/airnode-deployer/src/infrastructure/index.ts b/packages/airnode-deployer/src/infrastructure/index.ts index 9c599c91ee..1faa171246 100644 --- a/packages/airnode-deployer/src/infrastructure/index.ts +++ b/packages/airnode-deployer/src/infrastructure/index.ts @@ -738,23 +738,14 @@ export async function deploymentInfo(deploymentId: string) { consoleLog(tableStringWithCurrent); } -export async function fetchFiles(deploymentId: string, outputDir: string, versionId?: string) { - const cloudProviderType = deploymentId.slice(0, 3) as CloudProvider['type']; - if (!availableCloudProviders.includes(cloudProviderType)) { - throw new Error(`Invalid deployment ID '${deploymentId}'`); - } - - const spinner = logger - .getSpinner() - .start(`Fetching files for deployment '${deploymentId}'${versionId ? ` and version '${versionId}'` : ''}'`); - if (logger.inDebugMode()) { - spinner.info(); - } - +async function downloadDeploymentFiles( + cloudProviderType: CloudProvider['type'], + deploymentId: string, + versionId?: string +) { const goCloudDeploymentInfo = await go(() => fetchDeployments(cloudProviderType, deploymentId)); if (!goCloudDeploymentInfo.success) { - spinner.stop(); throw new Error( `Failed to fetch info about '${deploymentId}' from ${cloudProviderType.toUpperCase()}: ${ goCloudDeploymentInfo.error @@ -763,7 +754,6 @@ export async function fetchFiles(deploymentId: string, outputDir: string, versio } if (goCloudDeploymentInfo.data.length === 0) { - spinner.stop(); throw new Error(`No deployment with ID '${deploymentId}' found`); } @@ -773,7 +763,6 @@ export async function fetchFiles(deploymentId: string, outputDir: string, versio if (versionId) { requestedVersion = cloudDeploymentInfo.versions.find((version) => version.id === versionId); if (!requestedVersion) { - spinner.stop(); throw new Error(`No deployment with ID '${deploymentId}' and version '${versionId}' found`); } } @@ -791,6 +780,29 @@ export async function fetchFiles(deploymentId: string, outputDir: string, versio secretsFileBucketPath ); + return { configContent, secretsContent, version }; +} + +export async function fetchFiles(deploymentId: string, outputDir: string, versionId?: string) { + const cloudProviderType = deploymentId.slice(0, 3) as CloudProvider['type']; + if (!availableCloudProviders.includes(cloudProviderType)) { + throw new Error(`Invalid deployment ID '${deploymentId}'`); + } + + const spinner = logger + .getSpinner() + .start(`Fetching files for deployment '${deploymentId}'${versionId ? ` and version '${versionId}'` : ''}'`); + if (logger.inDebugMode()) { + spinner.info(); + } + + const goDownloadDeploymentFiles = await go(() => downloadDeploymentFiles(cloudProviderType, deploymentId, versionId)); + if (!goDownloadDeploymentFiles.success) { + spinner.stop(); + throw goDownloadDeploymentFiles.error; + } + const { configContent, secretsContent, version } = goDownloadDeploymentFiles.data; + const goOutputWritable = goSync(() => fs.accessSync(outputDir, fs.constants.W_OK)); if (!goOutputWritable.success) { spinner.stop(); @@ -809,3 +821,25 @@ export async function fetchFiles(deploymentId: string, outputDir: string, versio spinner.succeed(`Files successfully downloaded as '${outputFile}'`); } + +export async function saveDeploymentFiles( + deploymentId: string, + versionId: string, + configPath: string, + secretsPath: string +) { + const cloudProviderType = deploymentId.slice(0, 3) as CloudProvider['type']; + if (!availableCloudProviders.includes(cloudProviderType)) { + throw new Error(`Invalid deployment ID '${deploymentId}'`); + } + + const goDownloadDeploymentFiles = await go(() => downloadDeploymentFiles(cloudProviderType, deploymentId, versionId)); + if (!goDownloadDeploymentFiles.success) { + logger.getSpinner().stop(); + throw goDownloadDeploymentFiles.error; + } + const { configContent, secretsContent } = goDownloadDeploymentFiles.data; + + fs.writeFileSync(configPath, configContent); + fs.writeFileSync(secretsPath, secretsContent); +}