diff --git a/CHANGELOG.md b/CHANGELOG.md index 271dd96..6ca270a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Change Log This project adheres to [Semantic Versioning](http://semver.org/). All notable changes will be documented in this file. +## [Unreleased](https://github.com/OldSneerJaw/borealis-pg-cli/compare/v1.2.0...HEAD) +- Adds the `borealis-pg:integrations` command to retrieve a list of data integrations for an add-on +- Adds the `borealis-pg:integrations:register` command to register a new data integration with an add-on +- Adds the `borealis-pg:integrations:remove` command to remove/deregister a data integration + ## [1.2.0](https://github.com/OldSneerJaw/borealis-pg-cli/compare/v1.1.0...v1.2.0) - Support the new secure tunnel connection info config var (`DATABASE_TUNNEL_BPG_CONN_INFO`) - Require SSL/TLS for DB connections when using the `borealis-pg:run` command diff --git a/src/commands/borealis-pg/integrations/index.test.ts b/src/commands/borealis-pg/integrations/index.test.ts new file mode 100644 index 0000000..afd11f0 --- /dev/null +++ b/src/commands/borealis-pg/integrations/index.test.ts @@ -0,0 +1,138 @@ +import {borealisPgApiBaseUrl, expect, herokuApiBaseUrl, test} from '../../../test-utils' + +const fakeAddonId = '0818035e-0103-4f85-880d-c3b4a712cf8d' +const fakeAddonName = 'borealis-pg-my-fake-addon' + +const fakeAttachmentId = 'eaa7f0f9-9562-4ba3-b8dc-3c488ad73666' +const fakeAttachmentName = 'MY_COOL_DB' + +const fakeHerokuAppId = '2ee2aea8-9a2f-48b2-8f86-b4aa504b35f7' +const fakeHerokuAppName = 'my-fake-heroku-app' + +const fakeHerokuAuthToken = 'my-fake-heroku-auth-token' +const fakeHerokuAuthId = 'my-fake-heroku-auth' + +const fakeIntegration1Name = 'my-first-fake-integration' +const fakeIntegration1DbUsername = 'my-first-fake-integration-db-user' +const fakeIntegration1SshUsername = 'my-first-fake-integration-ssh-user' +const fakeIntegration1WriteAccess = true +const fakeIntegration1CreatedAt = '2023-01-23T09:44:27.023Z' + +const fakeIntegration2Name = 'my-second-fake-integration' +const fakeIntegration2DbUsername = 'my-second-fake-integration-db-user' +const fakeIntegration2SshUsername = 'my-second-fake-integration-ssh-user' +const fakeIntegration2WriteAccess = false +const fakeIntegration2CreatedAt = '2023-02-15T13:05:59.817Z' + +const defaultTestContext = test.stdout() + .stderr() + .nock(herokuApiBaseUrl, api => api + .post('/oauth/authorizations', { + description: 'Borealis PG CLI plugin temporary auth token', + expires_in: 180, + scope: ['read', 'identity'], + }) + .reply(201, {id: fakeHerokuAuthId, access_token: {token: fakeHerokuAuthToken}}) + .delete(`/oauth/authorizations/${fakeHerokuAuthId}`) + .reply(200) + .get(`/apps/${fakeHerokuAppName}/addons`) + .reply(200, [ + { + addon_service: {name: 'other-addon-service'}, + id: '362885fa-b06b-434d-b3eb-a0ac53e3f840', + name: 'other-addon', + }, + {addon_service: {name: 'borealis-pg'}, id: fakeAddonId, name: fakeAddonName}, + ]) + .get(`/addons/${fakeAddonId}/addon-attachments`) + .reply(200, [ + { + addon: {id: fakeAddonId, name: fakeAddonName}, + app: {id: fakeHerokuAppId, name: fakeHerokuAppName}, + id: fakeAttachmentId, + name: fakeAttachmentName, + }, + ])) + +describe('data integration list command', () => { + defaultTestContext + .nock( + borealisPgApiBaseUrl, + {reqheaders: {authorization: `Bearer ${fakeHerokuAuthToken}`}}, + api => api.get(`/heroku/resources/${fakeAddonName}/data-integrations`) + .reply(200, { + integrations: [ + { + name: fakeIntegration1Name, + dbUsername: fakeIntegration1DbUsername, + sshUsername: fakeIntegration1SshUsername, + writeAccess: fakeIntegration1WriteAccess, + createdAt: fakeIntegration1CreatedAt, + }, + { + name: fakeIntegration2Name, + dbUsername: fakeIntegration2DbUsername, + sshUsername: fakeIntegration2SshUsername, + writeAccess: fakeIntegration2WriteAccess, + createdAt: fakeIntegration2CreatedAt, + }, + ], + })) + .command(['borealis-pg:integrations', '--app', fakeHerokuAppName]) + .it('outputs the list of registered data integrations', ctx => { + expect(ctx.stderr).to.endWith( + `Fetching data integration list for add-on ${fakeAddonName}... done\n`) + + expect(ctx.stdout).to.containIgnoreSpaces( + ' Data Integration DB Username SSH Username Write Access Created At') + expect(ctx.stdout).to.containIgnoreSpaces( + ` ${fakeIntegration1Name} ${fakeIntegration1DbUsername} ${fakeIntegration1SshUsername} ${fakeIntegration1WriteAccess} ${fakeIntegration1CreatedAt} \n` + + ` ${fakeIntegration2Name} ${fakeIntegration2DbUsername} ${fakeIntegration2SshUsername} ${fakeIntegration2WriteAccess} ${fakeIntegration2CreatedAt}`) + }) + + defaultTestContext + .nock( + borealisPgApiBaseUrl, + api => api.get(`/heroku/resources/${fakeAddonName}/data-integrations`) + .reply(200, {integrations: []})) + .command(['borealis-pg:integrations', '-a', fakeHerokuAppName]) + .it('outputs a warning if there are no data integrations', ctx => { + expect(ctx.stderr).to.endWith( + `Fetching data integration list for add-on ${fakeAddonName}... done\n` + + ' › Warning: No data integrations found\n') + expect(ctx.stdout).to.equal('') + }) + + defaultTestContext + .nock( + borealisPgApiBaseUrl, + api => api.get(`/heroku/resources/${fakeAddonName}/data-integrations`) + .reply(404, {reason: 'Does not exist'})) + .command(['borealis-pg:integrations', '-a', fakeHerokuAppName]) + .catch('Add-on is not a Borealis Isolated Postgres add-on') + .it('exits with an error if the add-on was not found', ctx => { + expect(ctx.stdout).to.equal('') + }) + + defaultTestContext + .nock( + borealisPgApiBaseUrl, + api => api.get(`/heroku/resources/${fakeAddonName}/data-integrations`) + .reply(422, {reason: 'Not ready yet'})) + .command(['borealis-pg:integrations', '-a', fakeHerokuAppName]) + .catch('Add-on is not finished provisioning') + .it('exits with an error if the add-on is not done provisioning', ctx => { + expect(ctx.stdout).to.equal('') + }) + + defaultTestContext + .nock( + borealisPgApiBaseUrl, + api => api.get(`/heroku/resources/${fakeAddonName}/data-integrations`) + .reply(500, {reason: 'Something went wrong'})) + .command(['borealis-pg:integrations', '-a', fakeHerokuAppName]) + .catch('Add-on service is temporarily unavailable. Try again later.') + .it('exits with an error if the Borealis PG API indicates a server error', ctx => { + expect(ctx.stdout).to.equal('') + }) +}) diff --git a/src/commands/borealis-pg/integrations/index.ts b/src/commands/borealis-pg/integrations/index.ts new file mode 100644 index 0000000..63caa1d --- /dev/null +++ b/src/commands/borealis-pg/integrations/index.ts @@ -0,0 +1,91 @@ +import color from '@heroku-cli/color' +import {Command} from '@heroku-cli/command' +import cli from 'cli-ux' +import {HTTP, HTTPError} from 'http-call' +import {applyActionSpinner} from '../../../async-actions' +import {getBorealisPgApiUrl, getBorealisPgAuthHeader} from '../../../borealis-api' +import { + addonOptionName, + appOptionName, + cliOptions, + processAddonAttachmentInfo, +} from '../../../command-components' +import {createHerokuAuth, fetchAddonAttachmentInfo, removeHerokuAuth} from '../../../heroku-api' + +export default class ListDataIntegrationsCommand extends Command { + static description = `lists registered data integrations for a Borealis Isolated Postgres add-on + +A data integration allows a third party service access to an add-on database +via a secure tunnel using semi-permanent SSH server and database credentials.` + + static flags = { + [addonOptionName]: cliOptions.addon, + [appOptionName]: cliOptions.app, + } + + async run() { + const {flags} = this.parse(ListDataIntegrationsCommand) + const authorization = await createHerokuAuth(this.heroku) + const attachmentInfo = + await fetchAddonAttachmentInfo(this.heroku, flags.addon, flags.app, this.error) + const {addonName} = processAddonAttachmentInfo(attachmentInfo, this.error) + try { + const response = await applyActionSpinner( + `Fetching data integration list for add-on ${color.addon(addonName)}`, + HTTP.get( + getBorealisPgApiUrl(`/heroku/resources/${addonName}/data-integrations`), + {headers: {Authorization: getBorealisPgAuthHeader(authorization)}}), + ) + + const responseBody = response.body as {integrations: Array} + if (responseBody.integrations.length > 0) { + const columns: {[name: string]: any} = { + name: {header: 'Data Integration'}, + dbUsername: {header: 'DB Username'}, + sshUsername: {header: 'SSH Username'}, + writeAccess: {header: 'Write Access'}, + createdAt: {header: 'Created At'}, + } + const normalizedIntegrations = responseBody.integrations.map(value => { + return { + name: value.name, + dbUsername: value.dbUsername, + sshUsername: value.sshUsername, + writeAccess: value.writeAccess, + createdAt: new Date(value.createdAt), + } + }) + + this.log() + cli.table(normalizedIntegrations, columns, {'no-truncate': true}) + } else { + this.warn('No data integrations found') + } + } finally { + await removeHerokuAuth(this.heroku, authorization.id as string) + } + } + + async catch(err: any) { + /* istanbul ignore else */ + if (err instanceof HTTPError) { + if (err.statusCode === 404) { + this.error('Add-on is not a Borealis Isolated Postgres add-on') + } else if (err.statusCode === 422) { + this.error('Add-on is not finished provisioning') + } else { + this.error('Add-on service is temporarily unavailable. Try again later.') + } + } else { + throw err + } + } +} + +interface DataIntegrationInfo { + name: string; + dbUsername: string; + sshUsername: string; + writeAccess: boolean; + createdAt: string; +} diff --git a/src/commands/borealis-pg/integrations/register.test.ts b/src/commands/borealis-pg/integrations/register.test.ts new file mode 100644 index 0000000..0a75176 --- /dev/null +++ b/src/commands/borealis-pg/integrations/register.test.ts @@ -0,0 +1,278 @@ +import {borealisPgApiBaseUrl, expect, herokuApiBaseUrl, test} from '../../../test-utils' + +const fakeAddonId = 'bde71749-e560-42d7-b9ab-ccb6d91b17b5' +const fakeAddonName = 'borealis-pg-my-fake-addon' + +const fakeAttachmentId = '8c76b180-afb4-41fe-8f8d-79bfc8d0e3fa' +const fakeAttachmentName = 'MY_COOL_DB' + +const fakeHerokuAppId = 'a9faf548-3d67-4507-8f3a-8384af204ef0' +const fakeHerokuAppName = 'my-fake-heroku-app' + +const fakeHerokuAuthToken = 'my-fake-heroku-auth-token' +const fakeHerokuAuthId = 'my-fake-heroku-auth' + +const fakeIntegrationName = 'integration1' +const fakeSshPublicKey = + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK5PkBlx+xU/skHZwhR/PPMCKAbQYhgiHlntFkhhC9Q0' + +const fakeDbHost = 'my-fake-db-host' +const fakeDbPort = 33_333 +const fakeDbName = 'my_cool_db' +const fakeDbUsername = 'my_fake_db_user' +const fakeDbPassword = 'my-fake-db-password' +const fakeSshHost = '1.2.4.8' +const fakeSshPort = 22_222 +const fakeSshUsername = 'my-imaginary-ssh-user' +const fakePublicSshHostKey = + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINiVZAXPnABknX23CvXDyuWN6a7u6OGyWEnU4u/PWVup' + +const expectedResponseContent = { + dbHost: fakeDbHost, + dbPort: fakeDbPort, + dbName: fakeDbName, + dbUsername: fakeDbUsername, + dbPassword: fakeDbPassword, + sshHost: fakeSshHost, + sshPort: fakeSshPort, + sshUsername: fakeSshUsername, + publicSshHostKey: fakePublicSshHostKey, +} + +const defaultTestContext = test.stdout() + .stderr() + .nock( + herokuApiBaseUrl, + api => api + .post('/oauth/authorizations', { + description: 'Borealis PG CLI plugin temporary auth token', + expires_in: 180, + scope: ['read', 'identity'], + }) + .reply(201, {id: fakeHerokuAuthId, access_token: {token: fakeHerokuAuthToken}}) + .delete(`/oauth/authorizations/${fakeHerokuAuthId}`) + .reply(200) + .get(`/apps/${fakeHerokuAppName}/addons`) + .reply(200, [ + { + addon_service: {name: 'other-addon-service'}, + id: '8555365d-0164-4796-ba5a-a1517baee077', + name: 'other-addon', + }, + {addon_service: {name: 'borealis-pg'}, id: fakeAddonId, name: fakeAddonName}, + ]) + .get(`/addons/${fakeAddonId}/addon-attachments`) + .reply(200, [ + { + addon: {id: fakeAddonId, name: fakeAddonName}, + app: {id: fakeHerokuAppId, name: fakeHerokuAppName}, + id: fakeAttachmentId, + name: fakeAttachmentName, + }, + ])) + +describe('data integration registration command', () => { + defaultTestContext + .nock( + borealisPgApiBaseUrl, + {reqheaders: {authorization: `Bearer ${fakeHerokuAuthToken}`}}, + api => api + .post( + `/heroku/resources/${fakeAddonName}/data-integrations`, + { + integrationName: fakeIntegrationName, + sshPublicKey: fakeSshPublicKey, + enableWriteAccess: false, + }) + .reply(201, expectedResponseContent)) + .command([ + 'borealis-pg:integrations:register', + '--app', + fakeHerokuAppName, + '--name', + fakeIntegrationName, + fakeSshPublicKey, + ]) + .it('registers a data integration without write access', ctx => { + expect(ctx.stderr).to.endWith( + `Registering data integration with add-on ${fakeAddonName}... done\n`) + expect(ctx.stdout).to.containIgnoreSpaces(`Database Host: ${fakeDbHost}`) + expect(ctx.stdout).to.containIgnoreSpaces(`Database Port: ${fakeDbPort}`) + expect(ctx.stdout).to.containIgnoreSpaces(`Database Name: ${fakeDbName}`) + expect(ctx.stdout).to.containIgnoreSpaces(`Database Username: ${fakeDbUsername}`) + expect(ctx.stdout).to.containIgnoreSpaces(`Database Password: ${fakeDbPassword}`) + expect(ctx.stdout).to.containIgnoreSpaces(`SSH Host: ${fakeSshHost}`) + expect(ctx.stdout).to.containIgnoreSpaces(`SSH Port: ${fakeSshPort}`) + expect(ctx.stdout).to.containIgnoreSpaces(`SSH Username: ${fakeSshUsername}`) + expect(ctx.stdout).to.containIgnoreSpaces( + `SSH Server Public Host Key: ${fakePublicSshHostKey}`) + }) + + defaultTestContext + .nock( + borealisPgApiBaseUrl, + {reqheaders: {authorization: `Bearer ${fakeHerokuAuthToken}`}}, + api => api + .post( + `/heroku/resources/${fakeAddonName}/data-integrations`, + { + integrationName: fakeIntegrationName, + sshPublicKey: fakeSshPublicKey, + enableWriteAccess: true, + }) + .reply(201, expectedResponseContent)) + .command([ + 'borealis-pg:integrations:register', + '-a', + fakeHerokuAppName, + '-n', + fakeIntegrationName, + '-w', + fakeSshPublicKey, + ]) + .it('registers a data integration with write access', ctx => { + expect(ctx.stderr).to.endWith( + `Registering data integration with add-on ${fakeAddonName}... done\n`) + expect(ctx.stdout).to.containIgnoreSpaces(`Database Host: ${fakeDbHost}`) + expect(ctx.stdout).to.containIgnoreSpaces(`Database Port: ${fakeDbPort}`) + expect(ctx.stdout).to.containIgnoreSpaces(`Database Name: ${fakeDbName}`) + expect(ctx.stdout).to.containIgnoreSpaces(`Database Username: ${fakeDbUsername}`) + expect(ctx.stdout).to.containIgnoreSpaces(`Database Password: ${fakeDbPassword}`) + expect(ctx.stdout).to.containIgnoreSpaces(`SSH Host: ${fakeSshHost}`) + expect(ctx.stdout).to.containIgnoreSpaces(`SSH Port: ${fakeSshPort}`) + expect(ctx.stdout).to.containIgnoreSpaces(`SSH Username: ${fakeSshUsername}`) + expect(ctx.stdout).to.containIgnoreSpaces( + `SSH Server Public Host Key: ${fakePublicSshHostKey}`) + }) + + defaultTestContext + .nock( + borealisPgApiBaseUrl, + api => api.post(`/heroku/resources/${fakeAddonName}/data-integrations`) + .reply(400, {reason: 'Bad integration name'})) + .command([ + 'borealis-pg:integrations:register', + '--write-access', + '--app', + fakeHerokuAppName, + '--name', + 'invalid-integration-name!', + fakeSshPublicKey, + ]) + .catch('Bad integration name') + .it('exits with an error if the request was invalid', ctx => { + expect(ctx.stdout).to.equal('') + }) + + defaultTestContext + .nock( + borealisPgApiBaseUrl, + api => api.post(`/heroku/resources/${fakeAddonName}/data-integrations`) + .reply(403, {reason: 'DB write access disabled'})) + .command([ + 'borealis-pg:integrations:register', + '-a', + fakeHerokuAppName, + '-n', + fakeIntegrationName, + fakeSshPublicKey, + ]) + .catch('Add-on database write access has been revoked') + .it('exits with an error if DB write access was revoked', ctx => { + expect(ctx.stdout).to.equal('') + }) + + defaultTestContext + .nock( + borealisPgApiBaseUrl, + api => api.post(`/heroku/resources/${fakeAddonName}/data-integrations`) + .reply(404, {reason: 'Add-on does not exist'})) + .command([ + 'borealis-pg:integrations:register', + '-a', + fakeHerokuAppName, + '-n', + fakeIntegrationName, + fakeSshPublicKey, + ]) + .catch('Add-on is not a Borealis Isolated Postgres add-on') + .it('exits with an error if the add-on was not found', ctx => { + expect(ctx.stdout).to.equal('') + }) + + defaultTestContext + .nock( + borealisPgApiBaseUrl, + api => api.post(`/heroku/resources/${fakeAddonName}/data-integrations`) + .reply(409, {reason: 'Already registered'})) + .command([ + 'borealis-pg:integrations:register', + '--app', + fakeHerokuAppName, + '--name', + 'invalid-integration-name!', + fakeSshPublicKey, + ]) + .catch('A data integration with that name is already registered') + .it('exits with an error if the data integration is already registered', ctx => { + expect(ctx.stdout).to.equal('') + }) + + defaultTestContext + .nock( + borealisPgApiBaseUrl, + api => api.post(`/heroku/resources/${fakeAddonName}/data-integrations`) + .reply(422, {reason: 'Not ready yet'})) + .command([ + 'borealis-pg:integrations:register', + '-a', + fakeHerokuAppName, + '-n', + fakeIntegrationName, + fakeSshPublicKey, + ]) + .catch('Add-on is not finished provisioning') + .it('exits with an error if the add-on is not fully provisioned', ctx => { + expect(ctx.stdout).to.equal('') + }) + + defaultTestContext + .nock( + borealisPgApiBaseUrl, + api => api.post(`/heroku/resources/${fakeAddonName}/data-integrations`) + .reply(500, {reason: 'Something went wrong'})) + .command([ + 'borealis-pg:integrations:register', + '-a', + fakeHerokuAppName, + '-n', + fakeIntegrationName, + fakeSshPublicKey, + ]) + .catch('Add-on service is temporarily unavailable. Try again later.') + .it('exits with an error if the Borealis PG API indicates a server error', ctx => { + expect(ctx.stdout).to.equal('') + }) + + test.stdout() + .stderr() + .command([ + 'borealis-pg:integrations:register', + '-a', + fakeHerokuAppName, + '-n', + fakeIntegrationName, + ]) + .catch(/^Missing 1 required arg:/) + .it('exits with an error if there is no SSH public key argument', ctx => { + expect(ctx.stdout).to.equal('') + }) + + test.stdout() + .stderr() + .command(['borealis-pg:integrations:register', '-a', fakeHerokuAppName, fakeSshPublicKey]) + .catch(/^Missing required flag:/) + .it('exits with an error if there is no integration name option', ctx => { + expect(ctx.stdout).to.equal('') + }) +}) diff --git a/src/commands/borealis-pg/integrations/register.ts b/src/commands/borealis-pg/integrations/register.ts new file mode 100644 index 0000000..7e302bb --- /dev/null +++ b/src/commands/borealis-pg/integrations/register.ts @@ -0,0 +1,163 @@ +import color from '@heroku-cli/color' +import {Command, flags} from '@heroku-cli/command' +import {OAuthAuthorization} from '@heroku-cli/schema' +import {HTTP, HTTPError} from 'http-call' +import {applyActionSpinner} from '../../../async-actions' +import {getBorealisPgApiUrl, getBorealisPgAuthHeader} from '../../../borealis-api' +import { + addonOptionName, + appOptionName, + cliOptions, + consoleColours, + formatCliOptionName, + processAddonAttachmentInfo, + writeAccessOptionName, +} from '../../../command-components' +import {createHerokuAuth, fetchAddonAttachmentInfo, removeHerokuAuth} from '../../../heroku-api' + +const keyColour = consoleColours.dataFieldName +const valueColour = consoleColours.dataFieldValue + +const sshPublicKeyCliArgName = 'SSH_PUBLIC_KEY' +const dataIntegrationOptionName = 'name' + +export default class RegisterDataIntegrationsCommand extends Command { + static description = + `registers a data integration with a Borealis Isolated Postgres add-on + +A data integration allows a third party service access to an add-on database +via a secure tunnel using semi-permanent SSH server and database credentials. +Typical uses include extract, transform and load (ETL) services and data +warehouses. + +An SSH public key is required for SSH client authorization. It must be an RSA, +ECDSA or Ed25519 public key in OpenSSH format. It will typically be provided +to you by the third party service. + +The ${formatCliOptionName(dataIntegrationOptionName)} option is used internally to identify a data integration and to +generate a unique database username for it; it must must consist only of +lowercase letters, digits and underscores (_), and have between 1 and 25 +characters. + +Note that, in some cases, the service may require read and write access to an +add-on database, in which case you can supply the ${formatCliOptionName(writeAccessOptionName)} option. + +The output includes an SSH server public host key value. This can be used to +validate the identity of the SSH server if the data integration service +supports it.` + + static examples = [ + `$ heroku borealis-pg:integrations:register --${appOptionName} sushi --${dataIntegrationOptionName} my_integration1 'ssh-ed25519 SSHPUBLICKEY1==='`, + `$ heroku borealis-pg:integrations:register --${writeAccessOptionName} --${appOptionName} sushi --${dataIntegrationOptionName} my_integration2 'ssh-rsa SSHPUBLICKEY2==='`, + ] + + static args = [ + { + name: sshPublicKeyCliArgName, + description: 'an SSH public key to authorize for access', + required: true, + }, + ] + + static flags = { + [addonOptionName]: cliOptions.addon, + [appOptionName]: cliOptions.app, + [dataIntegrationOptionName]: flags.string({ + char: 'n', + description: 'name of the add-on data integration', + required: true, + }), + [writeAccessOptionName]: cliOptions.writeAccess, + } + + async run() { + const {args, flags} = this.parse(RegisterDataIntegrationsCommand) + const sshPublicKey = args[sshPublicKeyCliArgName] + const integrationName = flags[dataIntegrationOptionName] + const enableWriteAccess = flags[writeAccessOptionName] + const authorization = await createHerokuAuth(this.heroku) + const attachmentInfo = + await fetchAddonAttachmentInfo(this.heroku, flags.addon, flags.app, this.error) + const {addonName} = processAddonAttachmentInfo(attachmentInfo, this.error) + + try { + const dataIntegrationInfo = await applyActionSpinner( + `Registering data integration with add-on ${color.addon(addonName)}`, + this.registerIntegration( + addonName, + {integrationName, sshPublicKey, enableWriteAccess}, + authorization, + ), + ) + + this.printResult(dataIntegrationInfo) + } finally { + await removeHerokuAuth(this.heroku, authorization.id as string) + } + } + + private async registerIntegration( + addonName: string, + registrationInfo: RegistrationInfo, + authorization: OAuthAuthorization): Promise { + const response: HTTP = await HTTP.post( + getBorealisPgApiUrl(`/heroku/resources/${addonName}/data-integrations`), + {headers: {Authorization: getBorealisPgAuthHeader(authorization)}, body: registrationInfo}) + + return response.body + } + + private printResult(dataIntegrationInfo: DataIntegrationInfo) { + this.log() + this.log(` ${keyColour('Database Host')}: ${valueColour(dataIntegrationInfo.dbHost)}`) + this.log(` ${keyColour('Database Port')}: ${valueColour(dataIntegrationInfo.dbPort.toString())}`) + this.log(` ${keyColour('Database Name')}: ${valueColour(dataIntegrationInfo.dbName)}`) + this.log(` ${keyColour('Database Username')}: ${valueColour(dataIntegrationInfo.dbUsername)}`) + this.log(` ${keyColour('Database Password')}: ${valueColour(dataIntegrationInfo.dbPassword)}`) + this.log(` ${keyColour('SSH Host')}: ${valueColour(dataIntegrationInfo.sshHost)}`) + this.log(` ${keyColour('SSH Port')}: ${valueColour(dataIntegrationInfo.sshPort.toString())}`) + this.log(` ${keyColour('SSH Username')}: ${valueColour(dataIntegrationInfo.sshUsername)}`) + this.log(` ${keyColour('SSH Server Public Host Key')}: ${valueColour(dataIntegrationInfo.publicSshHostKey)}`) + } + + async catch(err: any) { + /* istanbul ignore else */ + if (err instanceof HTTPError) { + if (err.statusCode === 400) { + // Typically this happens because the maximum number of integrations was reached or the args + // or options are invalid + this.error(err.body.reason.toString()) + } else if (err.statusCode === 403) { + this.error('Add-on database write access has been revoked') + } else if (err.statusCode === 404) { + this.error('Add-on is not a Borealis Isolated Postgres add-on') + } else if (err.statusCode === 409) { + this.error('A data integration with that name is already registered') + } else if (err.statusCode === 422) { + this.error('Add-on is not finished provisioning') + } else { + this.error('Add-on service is temporarily unavailable. Try again later.') + } + } else { + throw err + } + } +} + +interface RegistrationInfo { + enableWriteAccess: boolean; + integrationName: string; + sshPublicKey: string; +} + +interface DataIntegrationInfo { + dbHost: string; + dbPort: number; + dbName: string; + dbUsername: string; + dbPassword: string; + sshHost: string; + sshPort: number; + sshUsername: string; + publicSshHostKey: string; +} diff --git a/src/commands/borealis-pg/integrations/remove.test.ts b/src/commands/borealis-pg/integrations/remove.test.ts new file mode 100644 index 0000000..6b35e8e --- /dev/null +++ b/src/commands/borealis-pg/integrations/remove.test.ts @@ -0,0 +1,215 @@ +import {borealisPgApiBaseUrl, expect, herokuApiBaseUrl, test} from '../../../test-utils' + +const fakeAddonId = 'd5e50676-9b3d-4e46-bf7f-653169a1154b' +const fakeAddonName = 'borealis-pg-my-fake-addon' + +const fakeAttachmentId = '449cd296-020c-4339-a63c-932407d3b9a7' +const fakeAttachmentName = 'MY_COOL_DB' + +const fakeHerokuAppId = 'e80bd645-c817-4a8f-889c-2040fc4c424f' +const fakeHerokuAppName = 'my-fake-heroku-app' + +const fakeHerokuAuthToken = 'my-fake-heroku-auth-token' +const fakeHerokuAuthId = 'my-fake-heroku-auth' + +const fakeIntegration1 = 'my-first-fake-data-integration' +const fakeIntegration2 = 'my-second-fake-data-integration' + +const defaultTestContext = test.stdout() + .stderr() + .nock( + herokuApiBaseUrl, + api => api + .post('/oauth/authorizations', { + description: 'Borealis PG CLI plugin temporary auth token', + expires_in: 180, + scope: ['read', 'identity'], + }) + .reply(201, {id: fakeHerokuAuthId, access_token: {token: fakeHerokuAuthToken}}) + .delete(`/oauth/authorizations/${fakeHerokuAuthId}`) + .reply(200) + .get(`/apps/${fakeHerokuAppName}/addons`) + .reply(200, [ + { + addon_service: {name: 'other-addon-service'}, + id: 'c9c5f62e-8849-4ac4-bda1-3a3f3f17c3ac', + name: 'other-addon', + }, + {addon_service: {name: 'borealis-pg'}, id: fakeAddonId, name: fakeAddonName}, + ]) + .get(`/addons/${fakeAddonId}/addon-attachments`) + .reply(200, [ + { + addon: {id: fakeAddonId, name: fakeAddonName}, + app: {id: fakeHerokuAppId, name: fakeHerokuAppName}, + id: fakeAttachmentId, + name: fakeAttachmentName, + }, + ])) + +describe('data integration removal command', () => { + defaultTestContext + .nock( + borealisPgApiBaseUrl, + {reqheaders: {authorization: `Bearer ${fakeHerokuAuthToken}`}}, + api => api.delete(`/heroku/resources/${fakeAddonName}/data-integrations/${fakeIntegration1}`) + .reply(200, {success: true})) + .command([ + 'borealis-pg:integrations:remove', + '--confirm', + fakeIntegration1, + '--app', + fakeHerokuAppName, + '--name', + fakeIntegration1, + ]) + .it('removes the requested data integration', ctx => { + expect(ctx.stderr).to.endWith( + `Removing data integration from add-on ${fakeAddonName}... done\n`) + expect(ctx.stdout).to.equal('') + }) + + defaultTestContext + .nock( + borealisPgApiBaseUrl, + {reqheaders: {authorization: `Bearer ${fakeHerokuAuthToken}`}}, + api => api.delete(`/heroku/resources/${fakeAddonName}/data-integrations/${fakeIntegration1}`) + .reply(200, {success: true})) + .command([ + 'borealis-pg:integrations:deregister', + '-c', + fakeIntegration1, + '-a', + fakeHerokuAppName, + '-n', + fakeIntegration1, + ]) + .it('removes the requested data integration via the command alias', ctx => { + expect(ctx.stderr).to.endWith( + `Removing data integration from add-on ${fakeAddonName}... done\n`) + expect(ctx.stdout).to.equal('') + }) + + defaultTestContext + .nock( + borealisPgApiBaseUrl, + {reqheaders: {authorization: `Bearer ${fakeHerokuAuthToken}`}}, + api => api.delete(`/heroku/resources/${fakeAddonName}/data-integrations/${fakeIntegration1}`) + .reply(200, {success: true})) + .stdin(` ${fakeIntegration1} `, 500) // Fakes keyboard input for the confirmation prompt + .command(['borealis-pg:integrations:remove', '-a', fakeHerokuAppName, '-n', fakeIntegration1]) + .it('removes the requested data integration after a successful confirmation prompt', ctx => { + expect(ctx.stderr).to.endWith( + `Removing data integration from add-on ${fakeAddonName}... done\n`) + expect(ctx.stdout).to.equal('') + }) + + test.stdout() + .stderr() + .stdin('WRONG!', 500) // Fakes keyboard input for the confirmation prompt + .command(['borealis-pg:integrations:remove', '-a', fakeHerokuAppName, '-n', fakeIntegration2]) + .catch(/^Invalid confirmation provided/) + .it('exits with an error if the confirmation prompt fails', ctx => { + expect(ctx.stdout).to.equal('') + }) + + test.stdout() + .stderr() + .command([ + 'borealis-pg:integrations:remove', + '-c', + 'WRONG!', + '-a', + fakeHerokuAppName, + '-n', + fakeIntegration2, + ]) + .catch(/^Invalid confirmation provided/) + .it('exits with an error if the --confirm option has the wrong value', ctx => { + expect(ctx.stdout).to.equal('') + }) + + defaultTestContext + .nock( + borealisPgApiBaseUrl, + api => api.delete(`/heroku/resources/${fakeAddonName}/data-integrations/${fakeIntegration2}`) + .reply(403, {reason: 'DB write access revoked!'})) + .command([ + 'borealis-pg:integrations:remove', + '-c', + fakeIntegration2, + '-a', + fakeHerokuAppName, + '-n', + fakeIntegration2, + ]) + .catch('Add-on database write access has been revoked') + .it('exits with an error if add-on DB write access has been revoked', ctx => { + expect(ctx.stdout).to.equal('') + }) + + defaultTestContext + .nock( + borealisPgApiBaseUrl, + api => api.delete(`/heroku/resources/${fakeAddonName}/data-integrations/${fakeIntegration2}`) + .reply(404, {reason: 'That data integration could not be found'})) + .command([ + 'borealis-pg:integrations:remove', + '-c', + fakeIntegration2, + '-a', + fakeHerokuAppName, + '-n', + fakeIntegration2, + ]) + .catch('Data integration does not exist') + .it('exits with an error if the data integration is not register', ctx => { + expect(ctx.stdout).to.equal('') + }) + + defaultTestContext + .nock( + borealisPgApiBaseUrl, + api => api.delete(`/heroku/resources/${fakeAddonName}/data-integrations/${fakeIntegration1}`) + .reply(422, {reason: 'Not ready yet'})) + .command([ + 'borealis-pg:integrations:remove', + '-c', + fakeIntegration1, + '-a', + fakeHerokuAppName, + '-n', + fakeIntegration1, + ]) + .catch('Add-on is not finished provisioning') + .it('exits with an error if the add-on is not fully provisioned', ctx => { + expect(ctx.stdout).to.equal('') + }) + + defaultTestContext + .nock( + borealisPgApiBaseUrl, + api => api.delete(`/heroku/resources/${fakeAddonName}/data-integrations/${fakeIntegration2}`) + .reply(503, {reason: 'Something went wrong'})) + .command([ + 'borealis-pg:integrations:remove', + '-c', + fakeIntegration2, + '-a', + fakeHerokuAppName, + '-n', + fakeIntegration2, + ]) + .catch('Add-on service is temporarily unavailable. Try again later.') + .it('exits with an error if the Borealis PG API indicates a server error', ctx => { + expect(ctx.stdout).to.equal('') + }) + + test.stdout() + .stderr() + .command(['borealis-pg:integrations:remove', '-a', fakeHerokuAppName]) + .catch(/^Missing required flag:/) + .it('exits with an error if the data integration option is missing', ctx => { + expect(ctx.stdout).to.equal('') + }) +}) diff --git a/src/commands/borealis-pg/integrations/remove.ts b/src/commands/borealis-pg/integrations/remove.ts new file mode 100644 index 0000000..18e1360 --- /dev/null +++ b/src/commands/borealis-pg/integrations/remove.ts @@ -0,0 +1,90 @@ +import color from '@heroku-cli/color' +import {Command, flags} from '@heroku-cli/command' +import cli from 'cli-ux' +import {HTTP, HTTPError} from 'http-call' +import {applyActionSpinner} from '../../../async-actions' +import {getBorealisPgApiUrl, getBorealisPgAuthHeader} from '../../../borealis-api' +import { + addonOptionName, + appOptionName, + cliOptions, + consoleColours, + processAddonAttachmentInfo, +} from '../../../command-components' +import {createHerokuAuth, fetchAddonAttachmentInfo, removeHerokuAuth} from '../../../heroku-api' + +const dataIntegrationNameColour = consoleColours.dataFieldValue + +const confirmOptionName = 'confirm' +const dataIntegrationOptionName = 'name' + +export default class RemoveDataIntegrationCommand extends Command { + static description = 'removes a data integration from a Borealis Isolated Postgres add-on' + + static examples = [ + `$ heroku borealis-pg:integrations:remove --${appOptionName} sushi --${dataIntegrationOptionName} my_integration1`, + `$ heroku borealis-pg:integrations:remove --${confirmOptionName} my_integration2 --${appOptionName} sushi --${dataIntegrationOptionName} my_integration2`, + ] + + static aliases = ['borealis-pg:integrations:deregister'] + + static flags = { + [addonOptionName]: cliOptions.addon, + [appOptionName]: cliOptions.app, + [confirmOptionName]: flags.string({ + char: 'c', + description: 'bypass the confirmation prompt by providing the name of the integration', + }), + [dataIntegrationOptionName]: flags.string({ + char: 'n', + description: 'name of the add-on data integration', + required: true, + }), + } + + async run() { + const {flags} = this.parse(RemoveDataIntegrationCommand) + const integrationName = flags[dataIntegrationOptionName] + const confirmation = flags.confirm ? + flags.confirm : + (await cli.prompt('Enter the name of the data integration to confirm its removal')) + + if (confirmation.trim() !== integrationName) { + this.error( + `Invalid confirmation provided. Expected ${dataIntegrationNameColour(integrationName)}.`) + } + + const authorization = await createHerokuAuth(this.heroku) + const attachmentInfo = + await fetchAddonAttachmentInfo(this.heroku, flags.addon, flags.app, this.error) + const {addonName} = processAddonAttachmentInfo(attachmentInfo, this.error) + + try { + await applyActionSpinner( + `Removing data integration from add-on ${color.addon(addonName)}`, + HTTP.delete( + getBorealisPgApiUrl(`/heroku/resources/${addonName}/data-integrations/${integrationName}`), + {headers: {Authorization: getBorealisPgAuthHeader(authorization)}}), + ) + } finally { + await removeHerokuAuth(this.heroku, authorization.id as string) + } + } + + async catch(err: any) { + /* istanbul ignore else */ + if (err instanceof HTTPError) { + if (err.statusCode === 403) { + this.error('Add-on database write access has been revoked') + } else if (err.statusCode === 404) { + this.error('Data integration does not exist') + } else if (err.statusCode === 422) { + this.error('Add-on is not finished provisioning') + } else { + this.error('Add-on service is temporarily unavailable. Try again later.') + } + } else { + throw err + } + } +}