From 2a78011ca511750115c6ee742a327f328e6c75a1 Mon Sep 17 00:00:00 2001 From: Michal Kimle Date: Tue, 27 Sep 2022 09:42:44 +0200 Subject: [PATCH] Add `info` airnode-deployer command --- .changeset/tender-beers-cry.md | 5 + packages/airnode-deployer/src/cli/index.ts | 25 ++- .../src/infrastructure/index.test.ts | 169 +++++++++++++++++- .../src/infrastructure/index.ts | 150 +++++++++++++++- .../airnode-deployer/src/utils/cli.test.ts | 24 ++- packages/airnode-deployer/src/utils/cli.ts | 19 +- .../airnode-deployer/test/snapshots/index.ts | 11 ++ 7 files changed, 386 insertions(+), 17 deletions(-) create mode 100644 .changeset/tender-beers-cry.md diff --git a/.changeset/tender-beers-cry.md b/.changeset/tender-beers-cry.md new file mode 100644 index 0000000000..1620eb7047 --- /dev/null +++ b/.changeset/tender-beers-cry.md @@ -0,0 +1,5 @@ +--- +'@api3/airnode-deployer': minor +--- + +Add `info` airnode-deployer command for retrieving info about the deployment diff --git a/packages/airnode-deployer/src/cli/index.ts b/packages/airnode-deployer/src/cli/index.ts index 851f1a7a9a..f2ffde1d56 100644 --- a/packages/airnode-deployer/src/cli/index.ts +++ b/packages/airnode-deployer/src/cli/index.ts @@ -9,7 +9,7 @@ import { deploy, removeWithReceipt } from './commands'; import * as logger from '../utils/logger'; import { longArguments } from '../utils/cli'; import { MultiMessageError } from '../utils/infrastructure'; -import { listAirnodes, removeAirnode } from '../infrastructure'; +import { deploymentInfo, listAirnodes, removeAirnode } from '../infrastructure'; function drawHeader() { loggerUtils.log( @@ -48,6 +48,7 @@ async function runCommand(command: () => Promise) { const cliExamples = [ 'deploy -c config/config.json -s config/secrets.env -r config/receipt.json', 'list --cloud-providers gcp', + 'info 5bbcd317', 'remove-with-receipt -r config/receipt.json', 'remove-with-deployment-details --airnode-address 0x6abEdc0A4d1A79eD62160396456c95C5607369D3 --stage dev --cloud-provider aws --region us-east-1', ]; @@ -193,6 +194,28 @@ yargs(hideBin(process.argv)) await listAirnodes(args.cloudProviders); } ) + .command( + 'info ', + 'Displays info about deployed Airnode', + (yargs) => { + yargs.positional('deployment-id', { + description: `ID of the deployment (from 'list' command)`, + type: 'string', + demandOption: true, + }); + }, + 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 type explicitely + const goDeploymentInfo = await go(() => deploymentInfo(args.deploymentId as string)); + if (!goDeploymentInfo.success) { + // 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 eb847d0a69..161c67c49e 100644 --- a/packages/airnode-deployer/src/infrastructure/index.test.ts +++ b/packages/airnode-deployer/src/infrastructure/index.test.ts @@ -11,7 +11,7 @@ import { getSpinner } from '../utils/logger'; import { parseSecretsFile } from '../utils'; import { Directory, DirectoryStructure } from '../utils/infrastructure'; import { mockBucketDirectoryStructure } from '../../test/fixtures'; -import { listAirnodes01, listAirnodes02, listAirnodes03 } from '../../test/snapshots'; +import { deploymentInfo01, listAirnodes01, listAirnodes02, listAirnodes03 } from '../../test/snapshots'; const exec = jest.fn(); jest.spyOn(util, 'promisify').mockImplementation(() => exec); @@ -992,3 +992,170 @@ describe('listAirnodes', () => { expect(gcpGetFileFromBucketSpy).not.toHaveBeenCalled(); }); }); + +describe('deploymentInfo', () => { + const bucket = 'airnode-123456789'; + const configPath = path.join(__dirname, '..', '..', 'test', 'fixtures', 'config.valid.json'); + const directoryStructure = pick(mockBucketDirectoryStructure, [ + '0xd0624E6C2C8A1DaEdE9Fa7E9C409167ed5F256c6', + '0xA30CA71Ba54E83127214D3271aEA8F5D6bD4Dace', + ]); + + let awsGetAirnodeBucketSpy: jest.SpyInstance; + let awsGetBucketDirectoryStructureSpy: jest.SpyInstance; + let awsGetFileFromBucketSpy: jest.SpyInstance; + let gcpGetAirnodeBucketSpy: jest.SpyInstance; + let gcpGetBucketDirectoryStructureSpy: jest.SpyInstance; + let gcpGetFileFromBucketSpy: jest.SpyInstance; + let consoleSpy: jest.SpyInstance; + + beforeEach(() => { + awsGetAirnodeBucketSpy = jest.spyOn(aws, 'getAirnodeBucket').mockResolvedValue(bucket); + awsGetBucketDirectoryStructureSpy = jest + .spyOn(aws, 'getBucketDirectoryStructure') + .mockResolvedValue(directoryStructure); + awsGetFileFromBucketSpy = jest + .spyOn(aws, 'getFileFromBucket') + .mockResolvedValue(fs.readFileSync(configPath).toString()); + gcpGetAirnodeBucketSpy = jest.spyOn(gcp, 'getAirnodeBucket').mockResolvedValue(bucket); + gcpGetBucketDirectoryStructureSpy = jest + .spyOn(gcp, 'getBucketDirectoryStructure') + .mockResolvedValue(directoryStructure); + gcpGetFileFromBucketSpy = jest + .spyOn(gcp, 'getFileFromBucket') + .mockResolvedValue(fs.readFileSync(configPath).toString()); + consoleSpy = jest.spyOn(console, 'log'); + }); + + it('shows info about the deployment', async () => { + consoleSpy.mockImplementationOnce(() => {}); + consoleSpy.mockImplementationOnce(() => {}); + consoleSpy.mockImplementationOnce(() => {}); + consoleSpy.mockImplementationOnce(() => {}); + consoleSpy.mockImplementationOnce(() => {}); + consoleSpy.mockImplementationOnce((output: string) => { + for (const line of deploymentInfo01.split('\n')) { + expect(output).toContain(line); + if (line.includes('3580a278')) { + // eslint-disable-next-line jest/no-conditional-expect + expect(output).toContain('(current)'); + } + } + }); + + const deploymentId = '7195b548'; + + 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.deploymentInfo(deploymentId); + process.env.FORCE_COLOR = originalColorVariable; + + expect(awsGetAirnodeBucketSpy).toHaveBeenCalledTimes(1); + expect(gcpGetAirnodeBucketSpy).not.toHaveBeenCalled(); + expect(awsGetBucketDirectoryStructureSpy).toHaveBeenCalledTimes(1); + expect(awsGetBucketDirectoryStructureSpy).toHaveBeenCalledWith(bucket); + expect(gcpGetBucketDirectoryStructureSpy).not.toHaveBeenCalled(); + expect(awsGetFileFromBucketSpy).toHaveBeenCalledTimes(1); + expect(awsGetFileFromBucketSpy).toHaveBeenNthCalledWith( + 1, + bucket, + '0xd0624E6C2C8A1DaEdE9Fa7E9C409167ed5F256c6/dev/1662558010204/config.json' + ); + expect(gcpGetFileFromBucketSpy).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenNthCalledWith(1, 'Cloud provider: AWS (us-east-1)'); + expect(consoleSpy).toHaveBeenNthCalledWith(2, 'Airnode address: 0xd0624E6C2C8A1DaEdE9Fa7E9C409167ed5F256c6'); + expect(consoleSpy).toHaveBeenNthCalledWith(3, 'Stage: dev'); + expect(consoleSpy).toHaveBeenNthCalledWith(4, 'Airnode version: 0.8.0'); + expect(consoleSpy).toHaveBeenNthCalledWith(5, 'Deployment ID: 7195b548'); + }); + + 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 = '7195b548'; + + 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.deploymentInfo(deploymentId)).rejects.toThrow( + new Error(`No deployment with id '${deploymentId}' found`) + ); + process.env.FORCE_COLOR = originalColorVariable; + + expect(awsGetAirnodeBucketSpy).toHaveBeenCalledTimes(1); + expect(gcpGetAirnodeBucketSpy).toHaveBeenCalledTimes(1); + expect(awsGetBucketDirectoryStructureSpy).not.toHaveBeenCalled(); + expect(gcpGetBucketDirectoryStructureSpy).toHaveBeenCalledTimes(1); + expect(gcpGetBucketDirectoryStructureSpy).toHaveBeenCalledWith(bucket); + expect(awsGetFileFromBucketSpy).not.toHaveBeenCalled(); + expect(gcpGetFileFromBucketSpy).toHaveBeenCalledTimes(3); + expect(gcpGetFileFromBucketSpy).toHaveBeenNthCalledWith( + 1, + bucket, + '0xd0624E6C2C8A1DaEdE9Fa7E9C409167ed5F256c6/dev/1662558010204/config.json' + ); + expect(gcpGetFileFromBucketSpy).toHaveBeenNthCalledWith( + 2, + bucket, + '0xd0624E6C2C8A1DaEdE9Fa7E9C409167ed5F256c6/prod/1662558071950/config.json' + ); + expect(gcpGetFileFromBucketSpy).toHaveBeenNthCalledWith( + 3, + bucket, + '0xA30CA71Ba54E83127214D3271aEA8F5D6bD4Dace/dev/1662559204554/config.json' + ); + }); + + it(`fails if the deployment can't be found`, async () => { + const nonexistingDeploymentId = '2c6ef2b3'; + + 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.deploymentInfo(nonexistingDeploymentId)).rejects.toThrow( + new Error(`No deployment with id '${nonexistingDeploymentId}' found`) + ); + process.env.FORCE_COLOR = originalColorVariable; + + expect(awsGetAirnodeBucketSpy).toHaveBeenCalledTimes(1); + expect(gcpGetAirnodeBucketSpy).toHaveBeenCalledTimes(1); + expect(awsGetBucketDirectoryStructureSpy).toHaveBeenCalledTimes(1); + expect(awsGetBucketDirectoryStructureSpy).toHaveBeenCalledWith(bucket); + expect(gcpGetBucketDirectoryStructureSpy).toHaveBeenCalledTimes(1); + expect(gcpGetBucketDirectoryStructureSpy).toHaveBeenCalledWith(bucket); + expect(awsGetFileFromBucketSpy).toHaveBeenCalledTimes(3); + expect(awsGetFileFromBucketSpy).toHaveBeenNthCalledWith( + 1, + bucket, + '0xd0624E6C2C8A1DaEdE9Fa7E9C409167ed5F256c6/dev/1662558010204/config.json' + ); + expect(awsGetFileFromBucketSpy).toHaveBeenNthCalledWith( + 2, + bucket, + '0xd0624E6C2C8A1DaEdE9Fa7E9C409167ed5F256c6/prod/1662558071950/config.json' + ); + expect(awsGetFileFromBucketSpy).toHaveBeenNthCalledWith( + 3, + bucket, + '0xA30CA71Ba54E83127214D3271aEA8F5D6bD4Dace/dev/1662559204554/config.json' + ); + expect(gcpGetFileFromBucketSpy).toHaveBeenCalledTimes(3); + expect(gcpGetFileFromBucketSpy).toHaveBeenNthCalledWith( + 1, + bucket, + '0xd0624E6C2C8A1DaEdE9Fa7E9C409167ed5F256c6/dev/1662558010204/config.json' + ); + expect(gcpGetFileFromBucketSpy).toHaveBeenNthCalledWith( + 2, + bucket, + '0xd0624E6C2C8A1DaEdE9Fa7E9C409167ed5F256c6/prod/1662558071950/config.json' + ); + expect(gcpGetFileFromBucketSpy).toHaveBeenNthCalledWith( + 3, + bucket, + '0xA30CA71Ba54E83127214D3271aEA8F5D6bD4Dace/dev/1662559204554/config.json' + ); + }); +}); diff --git a/packages/airnode-deployer/src/infrastructure/index.ts b/packages/airnode-deployer/src/infrastructure/index.ts index 7c9fd8cc39..280920fc40 100644 --- a/packages/airnode-deployer/src/infrastructure/index.ts +++ b/packages/airnode-deployer/src/infrastructure/index.ts @@ -25,7 +25,13 @@ import { } from '../utils/infrastructure'; import { version as nodeVersion } from '../../package.json'; import { deriveAirnodeAddress, shortenAirnodeAddress } from '../utils'; -import { airnodeAddressReadable, cloudProviderReadable, hashDeployment, lastUpdateReadable } from '../utils/cli'; +import { + airnodeAddressReadable, + cloudProviderReadable, + hashDeployment, + hashDeploymentVersion, + timestampReadable, +} from '../utils/cli'; export const TF_STATE_FILENAME = 'default.tfstate'; @@ -489,13 +495,20 @@ export async function removeAirnode(airnodeAddress: string, stage: string, cloud spinner.succeed(`Removed Airnode ${airnodeAddress} ${stage} from ${type} ${region}`); } +export type DeploymentVersion = { + id: string; + timestamp: string; +}; + export type Deployment = { id: string; - cloudProvider: string; + cloudProvider: CloudProvider['type']; + region: string; airnodeAddress: string; stage: string; airnodeVersion: string; lastUpdate: string; + versions?: DeploymentVersion[]; }; export async function listAirnodes(cloudProviders: readonly CloudProvider['type'][]) { @@ -550,11 +563,12 @@ export async function listAirnodes(cloudProviders: readonly CloudProvider['type' deployments.push({ id, - cloudProvider: cloudProviderReadable(cloudProvider, region), - airnodeAddress: airnodeAddressReadable(airnodeAddress), + cloudProvider, + region, + airnodeAddress, stage, airnodeVersion, - lastUpdate: lastUpdateReadable(latestDeployment), + lastUpdate: latestDeployment, }); } } @@ -575,15 +589,133 @@ export async function listAirnodes(cloudProviders: readonly CloudProvider['type' }); table.push( - ...sortedDeployments.map(({ id, cloudProvider, airnodeAddress, stage, airnodeVersion, lastUpdate }) => [ + ...sortedDeployments.map(({ id, cloudProvider, region, airnodeAddress, stage, airnodeVersion, lastUpdate }) => [ id, - cloudProvider, - airnodeAddress, + cloudProviderReadable(cloudProvider, region), + airnodeAddressReadable(airnodeAddress), stage, airnodeVersion, - lastUpdate, + timestampReadable(lastUpdate), ]) ); consoleLog(table.toString()); } + +// TODO: +// I know that a big chunk of the functionis very similar to the `listAirnodes` function above. +// Refactor to unite the functionality would be complicated at the moment but I plan to do that +// once https://github.com/api3dao/airnode/issues/1473 is implemented +export async function deploymentInfo(deploymentId: string) { + const spinner = logger.getSpinner().start(`Fetching info about deployment '${deploymentId}'`); + if (logger.inDebugMode()) { + spinner.info(); + } + + // TODO: Same comment as above, will be removed + const cloudProviders = ['aws', 'gcp'] as const; + let deployment: Deployment | null = null; + + for (const cloudProvider of cloudProviders) { + if (deployment) break; + + const goCloudDeploymentInfo = await go(async () => { + const bucketName = await cloudProviderLib[cloudProvider].getAirnodeBucket(); + if (!bucketName) { + logger.debug(`No deployment available on ${cloudProvider.toUpperCase()}. Skipping.`); + return; + } + + const directoryStructure = await cloudProviderLib[cloudProvider].getBucketDirectoryStructure(bucketName); + for (const [airnodeAddress, addressDirectory] of Object.entries(directoryStructure)) { + if (deployment) break; + + if (addressDirectory.type !== FileSystemType.Directory) { + logger.warn( + `Invalid item in bucket '${bucketName}' (${cloudProvider.toUpperCase()}) with key '${ + addressDirectory.bucketKey + }'. Skipping.` + ); + continue; + } + + for (const [stage, stageDirectory] of Object.entries(addressDirectory.children)) { + if (deployment) break; + + if (stageDirectory.type !== FileSystemType.Directory) { + logger.warn( + `Invalid item in bucket '${bucketName}' (${cloudProvider.toUpperCase()}) with key '${ + stageDirectory.bucketKey + }'. Skipping.` + ); + continue; + } + + const latestDeployment = Object.keys(stageDirectory.children).sort().reverse()[0]; + const bucketConfigPath = `${airnodeAddress}/${stage}/${latestDeployment}/config.json`; + const config = JSON.parse( + await cloudProviderLib[cloudProvider].getFileFromBucket(bucketName, bucketConfigPath) + ) as Config; + const region = (config.nodeSettings.cloudProvider as CloudProvider).region; + const airnodeVersion = config.nodeSettings.nodeVersion; + const id = hashDeployment(cloudProvider, region, airnodeAddress, stage, airnodeVersion); + + if (id !== deploymentId) continue; + + const deploymentVersions = Object.keys(stageDirectory.children).map((versionTimestamp) => ({ + id: hashDeploymentVersion(cloudProvider, region, airnodeAddress, stage, airnodeVersion, versionTimestamp), + timestamp: versionTimestamp, + })); + deployment = { + id, + cloudProvider, + region, + airnodeAddress, + stage, + airnodeVersion, + lastUpdate: latestDeployment, + versions: deploymentVersions, + }; + } + } + }); + + if (!goCloudDeploymentInfo.success) { + // TODO: Again, this spinner mess will be fixed once https://github.com/api3dao/airnode/issues/1473 is done + spinner.stop(); + logger.fail(`Failed to fetch deployment info from ${cloudProvider.toUpperCase()}`); + spinner.start(`Fetching info about deployment '${deploymentId}'`); + if (logger.inDebugMode()) { + spinner.info(); + } + } + } + + if (!deployment) { + const message = `No deployment with id '${deploymentId}' found`; + spinner.fail(message); + throw new Error(message); + } + + const { id, cloudProvider, region, airnodeAddress, stage, airnodeVersion, lastUpdate, versions } = + deployment as Deployment; + const sortedVersions = sortBy(versions, 'timestamp').reverse(); + const currentVersionId = sortedVersions.find((version) => version.timestamp === lastUpdate)!.id; + const table = new Table({ + head: ['Version ID', 'Deployment time'], + style: { + head: ['bold'], + }, + }); + table.push(...sortedVersions.map(({ id, timestamp }) => [id, timestampReadable(timestamp)])); + + spinner.succeed(); + consoleLog(`Cloud provider: ${cloudProviderReadable(cloudProvider, region)}`); + consoleLog(`Airnode address: ${airnodeAddress}`); + consoleLog(`Stage: ${stage}`); + consoleLog(`Airnode version: ${airnodeVersion}`); + consoleLog(`Deployment ID: ${id}`); + const tableString = table.toString(); + const tableStringWithCurrent = tableString.replace(new RegExp(`(?<=${currentVersionId}.*?)\n`), ' (current)\n'); + consoleLog(tableStringWithCurrent); +} diff --git a/packages/airnode-deployer/src/utils/cli.test.ts b/packages/airnode-deployer/src/utils/cli.test.ts index 0fef6db5b1..954c0ee19f 100644 --- a/packages/airnode-deployer/src/utils/cli.test.ts +++ b/packages/airnode-deployer/src/utils/cli.test.ts @@ -2,9 +2,10 @@ import { airnodeAddressReadable, cloudProviderReadable, hashDeployment, - lastUpdateReadable, + timestampReadable, longArguments, printableArguments, + hashDeploymentVersion, } from './cli'; describe('longArguments', () => { @@ -30,7 +31,7 @@ describe('printableArguments', () => { }); describe('hashDeployment', () => { - it('creates a unique hash from depkloyment details', () => { + it('creates a unique hash from deployment details', () => { const cloudProvider = 'aws'; const region = 'us-east-1'; const airnodeAddress = '0xA30CA71Ba54E83127214D3271aEA8F5D6bD4Dace'; @@ -41,6 +42,21 @@ describe('hashDeployment', () => { }); }); +describe('hashDeploymentVersion', () => { + it('creates a unique hash from deployment version details', () => { + const cloudProvider = 'aws'; + const region = 'us-east-1'; + const airnodeAddress = '0xA30CA71Ba54E83127214D3271aEA8F5D6bD4Dace'; + const stage = 'dev'; + const airnodeVersion = '0.9.5'; + const timestamp = '1664256335137'; + + expect(hashDeploymentVersion(cloudProvider, region, airnodeAddress, stage, airnodeVersion, timestamp)).toEqual( + 'e2d3286d' + ); + }); +}); + describe('cloudProviderReadable', () => { it('returns a human-readble cloud provider identification', () => { expect(cloudProviderReadable('aws', 'us-east-1')).toEqual('AWS (us-east-1)'); @@ -53,10 +69,10 @@ describe('airnodeAddressReadable', () => { }); }); -describe('lastUpdateReadable', () => { +describe('timestampReadable', () => { it('returns a human-readable time of deployment', () => { // Can't really check for a specific string as the timezone might be different on CI and I don't think // it makes much sense mocking it - expect(lastUpdateReadable('1663745263102')).toMatch(/2022-09-\d{2} \d{2}:\d{2}:\d{2} .*/); + expect(timestampReadable('1663745263102')).toMatch(/2022-09-\d{2} \d{2}:\d{2}:\d{2} .*/); }); }); diff --git a/packages/airnode-deployer/src/utils/cli.ts b/packages/airnode-deployer/src/utils/cli.ts index 9f8589643f..b0ef000bd2 100644 --- a/packages/airnode-deployer/src/utils/cli.ts +++ b/packages/airnode-deployer/src/utils/cli.ts @@ -29,6 +29,21 @@ export function hashDeployment( .substring(0, 8); } +export function hashDeploymentVersion( + cloudProvider: CloudProvider['type'], + region: string, + airnodeAddress: string, + stage: string, + airnodeVersion: string, + timestamp: string +) { + return crypto + .createHash('sha256') + .update([cloudProvider, region, airnodeAddress, stage, airnodeVersion, timestamp].join('')) + .digest('hex') + .substring(0, 8); +} + export function cloudProviderReadable(cloudProvider: CloudProvider['type'], region: string) { return `${cloudProvider.toUpperCase()} (${region})`; } @@ -37,6 +52,6 @@ export function airnodeAddressReadable(airnodeAddress: string) { return `${airnodeAddress.slice(0, 8)}...${airnodeAddress.slice(-6)}`; } -export function lastUpdateReadable(deploymentTimestamp: string) { - return format(parseInt(deploymentTimestamp), 'yyyy-MM-dd HH:mm:ss zzz'); +export function timestampReadable(timestamp: string) { + return format(parseInt(timestamp), 'yyyy-MM-dd HH:mm:ss zzz'); } diff --git a/packages/airnode-deployer/test/snapshots/index.ts b/packages/airnode-deployer/test/snapshots/index.ts index ec7f2a92f8..f0561f0f51 100644 --- a/packages/airnode-deployer/test/snapshots/index.ts +++ b/packages/airnode-deployer/test/snapshots/index.ts @@ -31,3 +31,14 @@ export const listAirnodes02 = `┌─────────────── export const listAirnodes03 = `┌───────────────┬────────────────┬─────────────────┬───────┬─────────────────┬─────────────┐ │ Deployment ID │ Cloud provider │ Airnode address │ Stage │ Airnode version │ Last update │ └───────────────┴────────────────┴─────────────────┴───────┴─────────────────┴─────────────┘`; + +// Ignoring the last column due to the length and content variability +export const deploymentInfo01 = `┌────────────┬─ +│ Version ID │ Deployment time +├────────────┼ +│ 3580a278 │ +├────────────┼ +│ 5ef508c5 │ +├────────────┼ +│ 1f8210a2 │ +└────────────┴`;