diff --git a/packages/cli/src/commands/pg/promote.ts b/packages/cli/src/commands/pg/promote.ts new file mode 100644 index 0000000000..c0854a6f75 --- /dev/null +++ b/packages/cli/src/commands/pg/promote.ts @@ -0,0 +1,179 @@ +/* eslint-disable complexity */ +import color from '@heroku-cli/color' +import {Command, flags} from '@heroku-cli/command' +import {Args, ux} from '@oclif/core' +import * as Heroku from '@heroku-cli/schema' +import heredoc from 'tsheredoc' +import {getAttachment, getRelease} from '../../lib/pg/fetcher' +import pgHost from '../../lib/pg/host' +import {PgStatus, PgDatabase} from '../../lib/pg/types' + +export default class Promote extends Command { + static topic = 'pg'; + static description = 'sets DATABASE as your DATABASE_URL'; + static flags = { + force: flags.boolean({char: 'f'}), + app: flags.app({required: true}), + remote: flags.remote(), + } + + static args = { + database: Args.string({required: true}), + } + + public async run(): Promise { + const {flags, args} = await this.parse(Promote) + const {force, app} = flags + const {database} = args + const attachment = await getAttachment(this.heroku, app, database) + ux.action.start(`Ensuring an alternate alias for existing ${color.green('DATABASE_URL')}`) + const {body: attachments} = await this.heroku.get(`/apps/${app}/addon-attachments`) + const current = attachments.find(a => a.name === 'DATABASE') + if (!current) + return + // eslint-disable-next-line eqeqeq + if (current.addon?.name === attachment.addon.name && current.namespace == attachment.namespace) { + if (attachment.namespace) { + ux.error(`${color.cyan(attachment.name)} is already promoted on ${color.app(app)}`) + } else { + ux.error(`${color.addon(attachment.addon.name)} is already promoted on ${color.app(app)}`) + } + } + + const existing = attachments.filter(a => a.addon?.id === current.addon?.id && a.namespace === current.namespace) + .find(a => a.name !== 'DATABASE') + if (existing) { + ux.action.stop(color.green(existing.name + '_URL')) + } else { + // The current add-on occupying the DATABASE attachment has no + // other attachments. In order to promote this database without + // error, we can create a secondary attachment, just-in-time. + const {body: backup} = await this.heroku.post('/addon-attachments', { + body: { + app: {name: app}, + addon: {name: current.addon?.name}, + namespace: current.namespace, + confirm: app, + }, + }) + ux.action.stop(color.green(backup.name + '_URL')) + } + + if (!force) { + const {body: status} = await this.heroku.get(`/client/v11/databases/${attachment.addon.id}/wait_status`, { + hostname: pgHost(), + }) + if (status['waiting?']) { + ux.error(heredoc(` + Database cannot be promoted while in state: ${status.message} + + Promoting this database can lead to application errors and outage. Please run ${color.cmd('heroku pg:wait')} to wait for database to become available. + + To ignore this error, you can pass the --force flag to promote the database and risk application issues. + `)) + } + } + + let promotionMessage + if (attachment.namespace) { + promotionMessage = `Promoting ${color.cyan(attachment.name)} to ${color.green('DATABASE_URL')} on ${color.app(app)}` + } else { + promotionMessage = `Promoting ${color.addon(attachment.addon.name)} to ${color.green('DATABASE_URL')} on ${color.app(app)}` + } + + ux.action.start(promotionMessage) + await this.heroku.post('/addon-attachments', { + body: { + name: 'DATABASE', + app: {name: app}, + addon: {name: attachment.addon.name}, + namespace: attachment.namespace || null, + confirm: app, + }, + }) + ux.action.stop() + const currentPooler = attachments.find(a => a.namespace === 'connection-pooling:default' && a.addon?.id === current.addon?.id && a.name === 'DATABASE_CONNECTION_POOL') + if (currentPooler) { + ux.action.start('Reattaching pooler to new leader') + await this.heroku.post('/addon-attachments', { + body: { + name: currentPooler.name, + app: {name: app}, + addon: {name: attachment.addon.name}, + namespace: 'connection-pooling:default', + confirm: app, + }, + }) + ux.action.stop() + } + + const {body: promotedDatabaseDetails} = await this.heroku.get(`/client/v11/databases/${attachment.addon.id}`, { + hostname: pgHost(), + }) + if (promotedDatabaseDetails.following) { + const unfollowLeaderCmd = `heroku pg:unfollow ${attachment.addon.name}` + ux.warn(heredoc(` + Your database has been promoted but it is currently a follower database in read-only mode. + + Promoting a database with ${color.cmd('heroku pg:promote')} doesn't automatically unfollow its leader. + + Use ${color.cmd(unfollowLeaderCmd)} to stop this follower from replicating from its leader (${color.yellow(promotedDatabaseDetails.leader as string)}) and convert it into a writable database. + `)) + } + + const {body: formation} = await this.heroku.get(`/apps/${app}/formation`) + const releasePhase = formation.find(process => process.type === 'release') + if (releasePhase) { + ux.action.start('Checking release phase') + const {body: releases} = await this.heroku.get(`/apps/${app}/releases`, { + partial: true, + headers: { + Range: 'version ..; max=5, order=desc', + }, + }) + + const attach = releases.find(release => release.description?.includes('Attach DATABASE')) + const detach = releases.find(release => release.description?.includes('Detach DATABASE')) + if (!attach || !detach) { + ux.error('Unable to check release phase. Check your Attach DATABASE release for failures.') + } + + const endTime = Date.now() + 900000 // 15 minutes from now + const [attachId, detachId] = [attach?.id as string, detach?.id as string] + while (true) { + const attach = await getRelease(this.heroku, app, attachId) + if (attach && attach.status === 'succeeded') { + let msg = 'pg:promote succeeded.' + const detach = await getRelease(this.heroku, app, detachId) + if (detach && detach.status === 'failed') { + msg += ` It is safe to ignore the failed ${detach.description} release.` + } + + ux.action.stop(msg) + return + } + + if (attach && attach.status === 'failed') { + let msg = `pg:promote failed because ${attach.description} release was unsuccessful. Your application is currently running ` + const detach = await getRelease(this.heroku, app, detachId) + if (detach && detach.status === 'succeeded') { + msg += 'without an attached DATABASE_URL.' + } else { + msg += `with ${current.addon?.name} attached as DATABASE_URL.` + } + + msg += ' Check your release phase logs for failure causes.' + ux.action.stop(msg) + return + } + + if (Date.now() > endTime) { + ux.action.stop('timeout. Check your Attach DATABASE release for failures.') + return + } + + await new Promise(resolve => setTimeout(resolve, 5000)) + } + } + } +} diff --git a/packages/cli/src/lib/pg/fetcher.ts b/packages/cli/src/lib/pg/fetcher.ts index 4085aae609..a165cc7e82 100644 --- a/packages/cli/src/lib/pg/fetcher.ts +++ b/packages/cli/src/lib/pg/fetcher.ts @@ -153,3 +153,8 @@ export async function database(heroku: APIClient, app: string, db?: string, name return database } + +export async function getRelease(heroku: APIClient, appName: string, id: string) { + const {body: release} = await heroku.get(`/apps/${appName}/releases/${id}`) + return release +} diff --git a/packages/cli/src/lib/pg/types.ts b/packages/cli/src/lib/pg/types.ts index 447e69c958..fbc1f3ca68 100644 --- a/packages/cli/src/lib/pg/types.ts +++ b/packages/cli/src/lib/pg/types.ts @@ -99,6 +99,11 @@ export type PgDatabaseService = { }> } +export type PgStatus = { + 'waiting?': boolean + message: string +} + type TenantInfo = 'Plan' | 'Status' | 'Connections' | 'PG Version' | 'Created' | 'Data Size' | 'Tables' | 'Fork/Follow' | 'Rollback' | 'Continuous Protection' @@ -119,7 +124,7 @@ export type PgDatabaseTenant = { }> } -export type PgDatabase = PgDatabaseService | PgDatabaseTenant +export type PgDatabase = PgDatabaseService & PgDatabaseTenant export type AddOnWithPlan = Required & { plan: Required } export type AddOnAttachmentWithConfigVarsAndPlan = Required & { diff --git a/packages/cli/test/unit/commands/pg/promote.unit.test.ts b/packages/cli/test/unit/commands/pg/promote.unit.test.ts new file mode 100644 index 0000000000..0030572f11 --- /dev/null +++ b/packages/cli/test/unit/commands/pg/promote.unit.test.ts @@ -0,0 +1,848 @@ +import {stderr} from 'stdout-stderr' +import Cmd from '../../../../src/commands/pg/promote' +import runCommand from '../../../helpers/runCommand' +import expectOutput from '../../../helpers/utils/expectOutput' +import {expect} from 'chai' +import * as nock from 'nock' +import heredoc from 'tsheredoc' +import * as fixtures from '../../../fixtures/addons/fixtures' +import stripAnsi = require('strip-ansi') + +describe('pg:promote when argument is database', () => { + const addon = fixtures.addons['dwh-db'] + const pgbouncerAddonID = 'c667bce0-3238-4202-8550-e1dc323a02a2' + + beforeEach(() => { + nock('https://api.heroku.com') + .post('/actions/addon-attachments/resolve') + .reply(200, [{addon}]) + .get('/apps/myapp/formation') + .reply(200, []) + nock('https://api.data.heroku.com') + .get(`/client/v11/databases/${addon.id}/wait_status`) + .reply(200, {message: 'available', 'waiting?': false}) + .get(`/client/v11/databases/${addon.id}`) + .reply(200, {following: null}) + }) + + afterEach(() => { + nock.cleanAll() + }) + + it('promotes db and attaches pgbouncer if DATABASE_CONNECTION_POOL is an attachment', async () => { + nock('https://api.heroku.com') + .get('/apps/myapp/addon-attachments').reply(200, [ + { + name: 'DATABASE', + addon: {name: 'postgres-2'}, + namespace: null, + }, + { + name: 'DATABASE_CONNECTION_POOL', + id: pgbouncerAddonID, + addon: {name: 'postgres-2'}, + namespace: 'connection-pooling:default', + }, + ]) + .post('/addon-attachments', { + app: {name: 'myapp'}, + addon: {name: 'postgres-2'}, + namespace: null, + confirm: 'myapp', + }).reply(201, {name: 'RED'}) + .post('/addon-attachments', { + name: 'DATABASE', + app: {name: 'myapp'}, + addon: {name: addon.name}, + namespace: null, + confirm: 'myapp', + }).reply(201) + .delete(`/addon-attachments/${pgbouncerAddonID}`).reply(200) + .post('/addon-attachments', { + name: 'DATABASE_CONNECTION_POOL', + app: {name: 'myapp'}, + addon: {name: addon.name}, + namespace: 'connection-pooling:default', + confirm: 'myapp', + }).reply(201) + + await runCommand(Cmd, [ + '--app', + 'myapp', + 'DATABASE', + ]) + expectOutput(stderr.output, heredoc(` + Ensuring an alternate alias for existing DATABASE_URL... + Ensuring an alternate alias for existing DATABASE_URL... RED_URL + Promoting ${addon.name} to DATABASE_URL on ⬢ myapp... + Promoting ${addon.name} to DATABASE_URL on ⬢ myapp... done + Reattaching pooler to new leader... + Reattaching pooler to new leader... done + `)) + }) + + it('promotes db and does not detach pgbouncers attached to new leader under other name than DATABASE_CONNECTION_POOL', async () => { + nock('https://api.heroku.com') + .get('/apps/myapp/addon-attachments') + .reply(200, [ + { + name: 'DATABASE', + addon: {name: 'postgres-2'}, + namespace: null, + }, + { + name: 'DATABASE_CONNECTION_POOL2', + id: '12345', + addon: {name: addon.name, id: '1'}, + namespace: 'connection-pooling:default', + }, + ]) + .post('/addon-attachments', { + app: {name: 'myapp'}, addon: {name: 'postgres-2'}, namespace: null, confirm: 'myapp', + }) + .reply(201, {name: 'RED'}) + .post('/addon-attachments', { + name: 'DATABASE', app: {name: 'myapp'}, addon: {name: addon.name}, namespace: null, confirm: 'myapp', + }) + .reply(201) + + await runCommand(Cmd, [ + '--app', + 'myapp', + 'DATABASE', + ]) + expectOutput(stderr.output, heredoc(` + Ensuring an alternate alias for existing DATABASE_URL... + Ensuring an alternate alias for existing DATABASE_URL... RED_URL + Promoting ${addon.name} to DATABASE_URL on ⬢ myapp... + Promoting ${addon.name} to DATABASE_URL on ⬢ myapp... done + `)) + }) + + it('promotes db and does not reattach pgbouncer if DATABASE_CONNECTION_POOL attached to database being promoted, but not old leader', async () => { + nock('https://api.heroku.com') + .get('/apps/myapp/addon-attachments') + .reply(200, [ + { + name: 'DATABASE', + addon: {name: 'postgres-2'}, + namespace: null, + }, + { + name: 'DATABASE_CONNECTION_POOL', + id: '12345', + addon: {name: addon.name, id: addon.id}, + namespace: 'connection-pooling:default', + }, + ]) + .post('/addon-attachments', { + app: {name: 'myapp'}, addon: {name: 'postgres-2'}, namespace: null, confirm: 'myapp', + }) + .reply(201, {name: 'RED'}) + .post('/addon-attachments', { + name: 'DATABASE', app: {name: 'myapp'}, addon: {name: addon.name}, namespace: null, confirm: 'myapp', + }) + .reply(201) + + await runCommand(Cmd, [ + '--app', + 'myapp', + 'DATABASE', + ]) + expectOutput(stderr.output, heredoc(` + Ensuring an alternate alias for existing DATABASE_URL... + Ensuring an alternate alias for existing DATABASE_URL... RED_URL + Promoting ${addon.name} to DATABASE_URL on ⬢ myapp... + Promoting ${addon.name} to DATABASE_URL on ⬢ myapp... done + `)) + }) + + it('promotes the db and creates another attachment if current DATABASE does not have another', async () => { + nock('https://api.heroku.com') + .get('/apps/myapp/addon-attachments') + .reply(200, [ + {name: 'DATABASE', addon: {name: 'postgres-2'}, namespace: null}, + ]) + .post('/addon-attachments', { + app: {name: 'myapp'}, + addon: {name: 'postgres-2'}, + namespace: null, + confirm: 'myapp', + }) + .reply(201, {name: 'RED'}) + .post('/addon-attachments', { + name: 'DATABASE', + app: {name: 'myapp'}, + addon: {name: addon.name}, + namespace: null, + confirm: 'myapp', + }) + .reply(201) + + await runCommand(Cmd, [ + '--app', + 'myapp', + 'DATABASE', + ]) + expectOutput(stderr.output, heredoc(` + Ensuring an alternate alias for existing DATABASE_URL... + Ensuring an alternate alias for existing DATABASE_URL... RED_URL + Promoting ${addon.name} to DATABASE_URL on ⬢ myapp... + Promoting ${addon.name} to DATABASE_URL on ⬢ myapp... done + `)) + }) + + it('promotes the db and does not create another attachment if current DATABASE has another', async () => { + nock('https://api.heroku.com') + .get('/apps/myapp/addon-attachments') + .reply(200, [ + { + name: 'DATABASE', + addon: {name: 'postgres-2'}, + namespace: null, + }, + { + name: 'RED', + addon: {name: 'postgres-2'}, + namespace: null, + }, + ]) + .post('/addon-attachments', { + name: 'DATABASE', + app: {name: 'myapp'}, + addon: {name: addon.name}, + namespace: null, + confirm: 'myapp', + }) + .reply(201) + + await runCommand(Cmd, [ + '--app', + 'myapp', + 'DATABASE', + ]) + expectOutput(stderr.output, heredoc(` + Ensuring an alternate alias for existing DATABASE_URL... + Ensuring an alternate alias for existing DATABASE_URL... RED_URL + Promoting ${addon.name} to DATABASE_URL on ⬢ myapp... + Promoting ${addon.name} to DATABASE_URL on ⬢ myapp... done + `)) + }) + + it('does not promote the db if is already is DATABASE', async () => { + nock('https://api.heroku.com') + .get('/apps/myapp/addon-attachments') + .reply(200, [ + {name: 'DATABASE', addon: {name: addon.name}, namespace: null}, + {name: 'PURPLE', addon: {name: addon.name}, namespace: null}, + ]) + const err = `${addon.name} is already promoted on ⬢ myapp` + await runCommand(Cmd, [ + '--app', + 'myapp', + 'DATABASE', + ]).catch((error: Error) => { + expect(stripAnsi(error.message)).to.equal(err) + }) + }) +}) + +describe('pg:promote when argument is a credential attachment', () => { + const addon = fixtures.addons['dwh-db'] + + beforeEach(() => { + nock('https://api.heroku.com') + .post('/actions/addon-attachments/resolve', { + app: 'myapp', + addon_attachment: 'DATABASE', + addon_service: 'heroku-postgresql', + }) + .reply(200, [{addon, name: 'PURPLE', namespace: 'credential:hello'}]) + .get('/apps/myapp/formation') + .reply(200, []) + nock('https://api.data.heroku.com') + .get(`/client/v11/databases/${addon.id}/wait_status`) + .reply(200, {message: 'available', 'waiting?': false}) + .get(`/client/v11/databases/${addon.id}`) + .reply(200, {following: null}) + }) + + afterEach(() => { + nock.cleanAll() + }) + + it('promotes the credential and creates another attachment if current DATABASE does not have another', async () => { + nock('https://api.heroku.com') + .get('/apps/myapp/addon-attachments') + .reply(200, [ + { + name: 'DATABASE', + addon: {name: 'postgres-2'}, + }, + { + name: 'RED', + addon: {name: addon.name}, + namespace: 'credential:hello', + }, + ]) + .post('/addon-attachments', { + app: {name: 'myapp'}, addon: {name: 'postgres-2'}, confirm: 'myapp', + }) + .reply(201, {name: 'RED'}) + .post('/addon-attachments', { + name: 'DATABASE', + app: {name: 'myapp'}, + addon: {name: addon.name}, + namespace: 'credential:hello', + confirm: 'myapp', + }) + .reply(201) + + await runCommand(Cmd, [ + '--app', + 'myapp', + 'DATABASE', + ]) + expectOutput(stderr.output, heredoc(` + Ensuring an alternate alias for existing DATABASE_URL... + Ensuring an alternate alias for existing DATABASE_URL... RED_URL + Promoting PURPLE to DATABASE_URL on ⬢ myapp... + Promoting PURPLE to DATABASE_URL on ⬢ myapp... done + `)) + }) + + it('promotes the credential and creates another attachment if current DATABASE does not have another and current DATABASE is a credential', async () => { + nock('https://api.heroku.com') + .get('/apps/myapp/addon-attachments') + .reply(200, [ + { + name: 'PURPLE', + addon: {name: addon.name}, + namespace: 'credential:hello', + }, + { + name: 'DATABASE', + addon: {name: addon.name}, + namespace: 'credential:goodbye', + }, + ]) + .post('/addon-attachments', { + app: {name: 'myapp'}, + addon: {name: addon.name}, + namespace: 'credential:goodbye', + confirm: 'myapp', + }) + .reply(201, {name: 'RED'}) + .post('/addon-attachments', { + name: 'DATABASE', + app: {name: 'myapp'}, + addon: {name: addon.name}, + namespace: 'credential:hello', + confirm: 'myapp', + }) + .reply(201) + + await runCommand(Cmd, [ + '--app', + 'myapp', + 'DATABASE', + ]) + expectOutput(stderr.output, heredoc(` + Ensuring an alternate alias for existing DATABASE_URL... + Ensuring an alternate alias for existing DATABASE_URL... RED_URL + Promoting PURPLE to DATABASE_URL on ⬢ myapp... + Promoting PURPLE to DATABASE_URL on ⬢ myapp... done + `)) + }) + + it('promotes the credential and does not create another attachment if current DATABASE has another', async () => { + nock('https://api.heroku.com') + .get('/apps/myapp/addon-attachments') + .reply(200, [ + { + name: 'DATABASE', + addon: {name: 'postgres-2'}, + }, + { + name: 'RED', + addon: {name: 'postgres-2'}, + }, + { + name: 'PURPLE', + addon: {name: addon.name}, + namespace: 'credential:hello', + }, + ]) + .post('/addon-attachments', { + name: 'DATABASE', app: {name: 'myapp'}, addon: {name: addon.name}, namespace: 'credential:hello', confirm: 'myapp', + }) + .reply(201) + + await runCommand(Cmd, [ + '--app', + 'myapp', + 'DATABASE', + ]) + expectOutput(stderr.output, heredoc(` + Ensuring an alternate alias for existing DATABASE_URL... + Ensuring an alternate alias for existing DATABASE_URL... RED_URL + Promoting PURPLE to DATABASE_URL on ⬢ myapp... + Promoting PURPLE to DATABASE_URL on ⬢ myapp... done + `)) + }) + + it('promotes the credential if the current promoted database is for the same addon, but the default credential', async () => { + nock('https://api.heroku.com') + .get('/apps/myapp/addon-attachments') + .reply(200, [ + { + name: 'DATABASE', + addon: {name: addon.name}, + namespace: null, + }, { + name: 'RED', addon: {name: addon.name}, + namespace: null, + }, { + name: 'PURPLE', + addon: {name: addon.name}, + namespace: 'credential:hello', + }, + ]) + .post('/addon-attachments', { + name: 'DATABASE', + app: {name: 'myapp'}, + addon: {name: addon.name}, + namespace: 'credential:hello', + confirm: 'myapp', + }) + .reply(201) + + await runCommand(Cmd, [ + '--app', + 'myapp', + 'DATABASE', + ]) + expectOutput(stderr.output, heredoc(` + Ensuring an alternate alias for existing DATABASE_URL... + Ensuring an alternate alias for existing DATABASE_URL... RED_URL + Promoting PURPLE to DATABASE_URL on ⬢ myapp... + Promoting PURPLE to DATABASE_URL on ⬢ myapp... done + `)) + }) + + it('promotes the credential if the current promoted database is for the same addon, but another credential', async () => { + nock('https://api.heroku.com') + .get('/apps/myapp/addon-attachments') + .reply(200, [ + { + name: 'DATABASE', + addon: {name: addon.name}, + namespace: 'credential:goodbye', + }, { + name: 'RED', + addon: {name: addon.name}, + namespace: 'credential:goodbye', + }, { + name: 'PURPLE', + addon: {name: addon.name}, + namespace: 'credential:hello', + }, + ]) + .post('/addon-attachments', { + name: 'DATABASE', + app: {name: 'myapp'}, + addon: {name: addon.name}, + namespace: 'credential:hello', + confirm: 'myapp', + }) + .reply(201) + + await runCommand(Cmd, [ + '--app', + 'myapp', + 'DATABASE', + ]) + expectOutput(stderr.output, heredoc(` + Ensuring an alternate alias for existing DATABASE_URL... + Ensuring an alternate alias for existing DATABASE_URL... RED_URL + Promoting PURPLE to DATABASE_URL on ⬢ myapp... + Promoting PURPLE to DATABASE_URL on ⬢ myapp... done + `)) + }) + + it('does not promote the credential if it already is DATABASE', async () => { + nock('https://api.heroku.com') + .get('/apps/myapp/addon-attachments') + .reply(200, [ + { + name: 'RED', + addon: {name: addon.name}, namespace: null, + }, { + name: 'DATABASE', + addon: {name: addon.name}, + namespace: 'credential:hello', + }, { + name: 'PURPLE', + addon: {name: addon.name}, + namespace: 'credential:hello', + }, + ]) + const err = 'PURPLE is already promoted on ⬢ myapp' + await runCommand(Cmd, [ + '--app', + 'myapp', + 'DATABASE', + ]) + .catch((error: Error) => { + expect(stripAnsi(error.message)).to.equal(err) + }) + }) +}) + +describe('pg:promote when release phase is present', () => { + const addon = fixtures.addons['dwh-db'] + + beforeEach(() => { + nock('https://api.heroku.com:') + .get('/apps/myapp/formation') + .reply(200, [{type: 'release'}]) + .get('/apps/myapp/addon-attachments') + .reply(200, [ + { + name: 'DATABASE', + addon: {name: addon.name}, + namespace: 'credential:goodbye', + }, { + name: 'RED', + addon: {name: addon.name}, + namespace: 'credential:goodbye', + }, { + name: 'PURPLE', + addon: {name: addon.name}, + namespace: 'credential:hello', + }, + ]) + .post('/addon-attachments', { + name: 'DATABASE', + app: {name: 'myapp'}, + addon: {name: addon.name}, + namespace: 'credential:hello', + confirm: 'myapp', + }) + .reply(201) + .post('/addon-attachments', { + name: 'DATABASE', app: {name: 'myapp'}, addon: {name: addon.name}, namespace: null, confirm: 'myapp', + }) + .reply(201) + .post('/actions/addon-attachments/resolve', { + app: 'myapp', addon_attachment: 'DATABASE', addon_service: 'heroku-postgresql', + }) + .reply(201, [{ + name: 'PURPLE', addon: {name: addon.name, id: addon.id}, namespace: 'credential:hello', + }]) + nock('https://api.data.heroku.com') + .get(`/client/v11/databases/${addon.id}/wait_status`) + .reply(200, {message: 'available', 'waiting?': false}) + .get(`/client/v11/databases/${addon.id}`) + .reply(200, {following: null}) + }) + afterEach(() => { + nock.cleanAll() + }) + + it('checks release phase', async () => { + nock('https://api.heroku.com:') + .get('/apps/myapp/releases') + .reply(200, [{id: 1, description: 'Attach DATABASE'}, {id: 2, description: 'Detach DATABASE'}]) + .get('/apps/myapp/releases/1') + .reply(200, {status: 'succeeded'}) + .get('/apps/myapp/releases/2') + .reply(200, {status: 'succeeded'}) + + await runCommand(Cmd, [ + '--app', + 'myapp', + 'DATABASE', + ]) + expectOutput(stderr.output, heredoc(` + Ensuring an alternate alias for existing DATABASE_URL... + Ensuring an alternate alias for existing DATABASE_URL... RED_URL + Promoting PURPLE to DATABASE_URL on ⬢ myapp... + Promoting PURPLE to DATABASE_URL on ⬢ myapp... done + Checking release phase... + Checking release phase... pg:promote succeeded. + `)) + }) + + it('checks release phase for detach failure', async () => { + nock('https://api.heroku.com:') + .get('/apps/myapp/releases') + .reply(200, [{id: 1, description: 'Attach DATABASE'}, {id: 2, description: 'Detach DATABASE'}]) + .get('/apps/myapp/releases/1') + .reply(200, {status: 'succeeded'}) + .get('/apps/myapp/releases/2') + .reply(200, {status: 'failed', description: 'Detach DATABASE'}) + + await runCommand(Cmd, [ + '--app', + 'myapp', + 'DATABASE', + ]) + expectOutput(stderr.output, heredoc(` + Ensuring an alternate alias for existing DATABASE_URL... + Ensuring an alternate alias for existing DATABASE_URL... RED_URL + Promoting PURPLE to DATABASE_URL on ⬢ myapp... + Promoting PURPLE to DATABASE_URL on ⬢ myapp... done + Checking release phase... + Checking release phase... pg:promote succeeded. It is safe to ignore the failed Detach DATABASE release. + `)) + }) + + it('checks release phase for attach failure', async () => { + nock('https://api.heroku.com:') + .get('/apps/myapp/releases') + .reply(200, [{id: 1, description: 'Attach DATABASE'}, {id: 2, description: 'Detach DATABASE'}]) + .get('/apps/myapp/releases/1') + .reply(200, {status: 'failed', description: 'Attach DATABASE'}) + .get('/apps/myapp/releases/2') + .reply(200, {status: 'failed', description: 'Attach DATABASE'}) + + await runCommand(Cmd, [ + '--app', + 'myapp', + 'DATABASE', + ]) + expectOutput(stderr.output, heredoc(` + Ensuring an alternate alias for existing DATABASE_URL... + Ensuring an alternate alias for existing DATABASE_URL... RED_URL + Promoting PURPLE to DATABASE_URL on ⬢ myapp... + Promoting PURPLE to DATABASE_URL on ⬢ myapp... done + Checking release phase... + Checking release phase... pg:promote failed because Attach DATABASE release was unsuccessful. Your application is currently running with ${addon.name} attached as DATABASE_URL. Check your release phase logs for failure causes. + `)) + }) + + it('checks release phase for attach failure and detach success', async () => { + nock('https://api.heroku.com:') + .get('/apps/myapp/releases') + .reply(200, [{id: 1, description: 'Attach DATABASE'}, {id: 2, description: 'Detach DATABASE'}]) + .get('/apps/myapp/releases/1') + .reply(200, {status: 'failed', description: 'Attach DATABASE'}) + .get('/apps/myapp/releases/2') + .reply(200, {status: 'succeeded', description: 'Attach DATABASE'}) + + await runCommand(Cmd, [ + '--app', + 'myapp', + 'DATABASE', + ]) + expectOutput(stderr.output, heredoc(` + Ensuring an alternate alias for existing DATABASE_URL... + Ensuring an alternate alias for existing DATABASE_URL... RED_URL + Promoting PURPLE to DATABASE_URL on ⬢ myapp... + Promoting PURPLE to DATABASE_URL on ⬢ myapp... done + Checking release phase... + Checking release phase... pg:promote failed because Attach DATABASE release was unsuccessful. Your application is currently running without an attached DATABASE_URL. Check your release phase logs for failure causes. + `)) + }) + + it('checks release phase for attach failure and detach success', () => { + nock('https://api.heroku.com:') + .get('/apps/myapp/releases') + .reply(200, []) + return expect(runCommand(Cmd, [ + '--app', + 'myapp', + 'DATABASE', + ])).to.be.rejected + }) +}) + +describe('pg:promote when database is not available or force flag is present', () => { + const addon = fixtures.addons['dwh-db'] + + beforeEach(() => { + nock('https://api.heroku.com') + .post('/actions/addon-attachments/resolve') + .reply(200, [{addon}]) + .get('/apps/myapp/formation') + .reply(200, []) + nock('https://api.data.heroku.com') + .get(`/client/v11/databases/${addon.id}`) + .reply(200, {following: null}) + }) + afterEach(() => { + nock.cleanAll() + }) + + it('warns user if database is unavailable', async () => { + nock('https://api.heroku.com') + .get('/apps/myapp/addon-attachments') + .reply(200, [ + { + name: 'DATABASE', + addon: {name: 'postgres-2'}, + namespace: null, + }, { + name: 'RED', + addon: {name: 'postgres-2'}, + namespace: null, + }, + ]) + nock('https://api.data.heroku.com') + .get(`/client/v11/databases/${addon.id}/wait_status`) + .reply(200, {'waiting?': true, message: 'pending'}) + + const err = heredoc(` + Database cannot be promoted while in state: pending + + Promoting this database can lead to application errors and outage. Please run heroku pg:wait to wait for database to become available. + + To ignore this error, you can pass the --force flag to promote the database and risk application issues. + `) + await runCommand(Cmd, [ + '--app', + 'myapp', + 'DATABASE', + ]).catch((error: Error) => { + expect(stripAnsi(error.message)).to.equal(err) + }) + }) + + it('promotes database in unavailable state if --force flag is present', async () => { + nock('https://api.heroku.com') + .get('/apps/myapp/addon-attachments') + .reply(200, [ + { + name: 'DATABASE', + addon: {name: 'postgres-2'}, + namespace: null, + }, { + name: 'RED', + addon: {name: 'postgres-2'}, + namespace: null, + }, + ]) + .post('/addon-attachments', { + name: 'DATABASE', + app: {name: 'myapp'}, + addon: {name: addon.name}, + namespace: null, + confirm: 'myapp', + }) + .reply(201) + nock('https://api.data.heroku.com') + .get(`/client/v11/databases/${addon.id}/wait_status`) + .reply(200, {'waiting?': true, message: 'pending'}) + + await runCommand(Cmd, [ + '--app', + 'myapp', + '--force', + 'DATABASE', + ]) + expectOutput(stderr.output, heredoc(` + Ensuring an alternate alias for existing DATABASE_URL... + Ensuring an alternate alias for existing DATABASE_URL... RED_URL + Promoting ${addon.name} to DATABASE_URL on ⬢ myapp... + Promoting ${addon.name} to DATABASE_URL on ⬢ myapp... done + `)) + }) + + it('promotes database in available state if --force flag is present', async () => { + nock('https://api.heroku.com') + .get('/apps/myapp/addon-attachments') + .reply(200, [ + { + name: 'DATABASE', + addon: {name: 'postgres-2'}, + namespace: null, + }, { + name: 'RED', + addon: {name: 'postgres-2'}, + namespace: null, + }, + ]) + .post('/addon-attachments', { + name: 'DATABASE', + app: {name: 'myapp'}, + addon: {name: addon.name}, + namespace: null, + confirm: 'myapp', + }) + .reply(201) + nock('https://api.data.heroku.com') + .get(`/client/v11/databases/${addon.id}/wait_status`) + .reply(200, {'waiting?': false, message: 'available'}) + + await runCommand(Cmd, [ + '--app', + 'myapp', + '--force', + 'DATABASE', + ]) + expectOutput(stderr.output, heredoc(` + Ensuring an alternate alias for existing DATABASE_URL... + Ensuring an alternate alias for existing DATABASE_URL... RED_URL + Promoting ${addon.name} to DATABASE_URL on ⬢ myapp... + Promoting ${addon.name} to DATABASE_URL on ⬢ myapp... done + `)) + }) +}) + +describe('pg:promote when promoted database is a follower', () => { + const addon = fixtures.addons['dwh-db'] + + beforeEach(() => { + nock('https://api.heroku.com') + .post('/actions/addon-attachments/resolve') + .reply(200, [{addon}]) + .get('/apps/myapp/formation') + .reply(200, []) + nock('https://api.data.heroku.com') + .get(`/client/v11/databases/${addon.id}/wait_status`) + .reply(200, {'waiting?': false, message: 'available'}) + }) + afterEach(() => { + nock.cleanAll() + }) + + it('warns user if database is a follower', async () => { + nock('https://api.heroku.com') + .get('/apps/myapp/addon-attachments') + .reply(200, [ + { + name: 'DATABASE', + addon: {name: 'postgres-2'}, + namespace: null, + }, { + name: 'RED', + addon: {name: 'postgres-2'}, + namespace: null, + }, + ]) + .post('/addon-attachments', { + name: 'DATABASE', + app: {name: 'myapp'}, + addon: {name: addon.name}, + namespace: null, confirm: 'myapp', + }) + .reply(201) + nock('https://api.data.heroku.com') + .get(`/client/v11/databases/${addon.id}`) + .reply(200, { + following: 'postgres://xxx.com:5432/abcdefghijklmn', + leader: { + addon_id: '5ba2ba8b-07a9-4a65-a808-585a50e37f98', + name: 'postgresql-leader', + }, + }) + + await runCommand(Cmd, [ + '--app', + 'myapp', + 'DATABASE', + ]) + expect(stderr.output).to.include('Your database has been promoted but it is currently a follower') + }) +}) diff --git a/packages/cli/test/unit/commands/upgrade.unit.test.ts b/packages/cli/test/unit/commands/pg/upgrade.unit.test.ts similarity index 91% rename from packages/cli/test/unit/commands/upgrade.unit.test.ts rename to packages/cli/test/unit/commands/pg/upgrade.unit.test.ts index 1419cc248d..9196c8b2e4 100644 --- a/packages/cli/test/unit/commands/upgrade.unit.test.ts +++ b/packages/cli/test/unit/commands/pg/upgrade.unit.test.ts @@ -1,11 +1,11 @@ import {stderr} from 'stdout-stderr' -import Cmd from '../../../src/commands/pg/upgrade' -import runCommand from '../../helpers/runCommand' -import expectOutput from '../../helpers/utils/expectOutput' +import Cmd from '../../../../src/commands/pg/upgrade' +import runCommand from '../../../helpers/runCommand' +import expectOutput from '../../../helpers/utils/expectOutput' import {expect} from 'chai' import * as nock from 'nock' import heredoc from 'tsheredoc' -import * as fixtures from '../../fixtures/addons/fixtures' +import * as fixtures from '../../../fixtures/addons/fixtures' describe('pg:upgrade', () => { const hobbyAddon = fixtures.addons['www-db'] diff --git a/packages/pg-v5/commands/promote.js b/packages/pg-v5/commands/promote.js deleted file mode 100644 index 257462fc18..0000000000 --- a/packages/pg-v5/commands/promote.js +++ /dev/null @@ -1,179 +0,0 @@ -'use strict' - -const cli = require('heroku-cli-util') -const host = require('../lib/host') - -async function run(context, heroku) { - const fetcher = require('../lib/fetcher')(heroku) - const {app, args, flags} = context - const {force} = flags - const attachment = await fetcher.attachment(app, args.database) - - let current - let attachments - - await cli.action(`Ensuring an alternate alias for existing ${cli.color.configVar('DATABASE_URL')}`, (async function () { - // Finds or creates a non-DATABASE attachment for the DB currently - // attached as DATABASE. - // - // If current DATABASE is attached by other names, return one of them. - // If current DATABASE is only attachment, create a new one and return it. - // If no current DATABASE, return nil. - attachments = await heroku.get(`/apps/${app}/addon-attachments`) - current = attachments.find(a => a.name === 'DATABASE') - if (!current) return - - if (current.addon.name === attachment.addon.name && current.namespace === attachment.namespace) { - if (attachment.namespace) { - throw new Error(`${cli.color.attachment(attachment.name)} is already promoted on ${cli.color.app(app)}`) - } else { - throw new Error(`${cli.color.addon(attachment.addon.name)} is already promoted on ${cli.color.app(app)}`) - } - } - - let existing = attachments.filter(a => a.addon.id === current.addon.id && a.namespace === current.namespace).find(a => a.name !== 'DATABASE') - if (existing) return cli.action.done(cli.color.configVar(existing.name + '_URL')) - - // The current add-on occupying the DATABASE attachment has no - // other attachments. In order to promote this database without - // error, we can create a secondary attachment, just-in-time. - - let backup = await heroku.post('/addon-attachments', { - body: { - app: {name: app}, - addon: {name: current.addon.name}, - namespace: current.namespace, - confirm: app, - }, - }) - cli.action.done(cli.color.configVar(backup.name + '_URL')) - })()) - - if (!force) { - let status = await heroku.request({ - host: host(attachment.addon), - path: `/client/v11/databases/${attachment.addon.id}/wait_status`, - }) - - if (status['waiting?']) { - throw new Error(`Database cannot be promoted while in state: ${status.message} -\nPromoting this database can lead to application errors and outage. Please run pg:wait to wait for database to become available. -\nTo ignore this error, you can pass the --force flag to promote the database and risk application issues.`) - } - } - - let promotionMessage - if (attachment.namespace) { - promotionMessage = `Promoting ${cli.color.attachment(attachment.name)} to ${cli.color.configVar('DATABASE_URL')} on ${cli.color.app(app)}` - } else { - promotionMessage = `Promoting ${cli.color.addon(attachment.addon.name)} to ${cli.color.configVar('DATABASE_URL')} on ${cli.color.app(app)}` - } - - await cli.action(promotionMessage, (async function () { - await heroku.post('/addon-attachments', { - body: { - name: 'DATABASE', - app: {name: app}, - addon: {name: attachment.addon.name}, - namespace: attachment.namespace, - confirm: app, - }, - }) - })()) - - // eslint-disable-next-line eqeqeq - let currentPooler = attachments.find(a => a.namespace === 'connection-pooling:default' && a.addon.id == current.addon.id && a.name == 'DATABASE_CONNECTION_POOL') - if (currentPooler) { - await cli.action('Reattaching pooler to new leader', (async function () { - await heroku.post('/addon-attachments', { - body: { - name: currentPooler.name, - app: {name: app}, - addon: {name: attachment.addon.name}, - namespace: 'connection-pooling:default', - confirm: app, - }, - }) - })()) - return cli.action.done() - } - - let promotedDatabaseDetails = await heroku.request({ - host: host(attachment.addon), - path: `/client/v11/databases/${attachment.addon.id}`, - }) - - // eslint-disable-next-line no-implicit-coercion, no-extra-boolean-cast - if (!!promotedDatabaseDetails.following) { - let unfollowLeaderCmd = `heroku pg:unfollow ${attachment.addon.name}` - cli.warn(`WARNING: Your database has been promoted but it is currently a follower database in read-only mode. - \n Promoting a database with ${cli.color.cmd('heroku pg:promote')} doesn't automatically unfollow its leader. - \n Use ${cli.color.cmd(unfollowLeaderCmd)} to stop this follower from replicating from its leader (${cli.color.addon(promotedDatabaseDetails.leader.name)}) and convert it into a writable database.`) - } - - let releasePhase = ((await heroku.get(`/apps/${app}/formation`))) - .find(formation => formation.type === 'release') - - if (releasePhase) { - await cli.action('Checking release phase', (async function () { - let releases = await heroku.request({ - path: `/apps/${app}/releases`, - partial: true, - headers: { - Range: 'version ..; max=5, order=desc', - }, - }) - let attach = releases.find(release => release.description.includes('Attach DATABASE')) - let detach = releases.find(release => release.description.includes('Detach DATABASE')) - - if (!attach || !detach) { - throw new Error('Unable to check release phase. Check your Attach DATABASE release for failures.') - } - - let endTime = Date.now() + 900000 // 15 minutes from now - let [attachId, detachId] = [attach.id, detach.id] - while (true) { - let attach = await fetcher.release(app, attachId) - if (attach && attach.status === 'succeeded') { - let msg = 'pg:promote succeeded.' - let detach = await fetcher.release(app, detachId) - if (detach && detach.status === 'failed') { - msg += ` It is safe to ignore the failed ${detach.description} release.` - } - - return cli.action.done(msg) - } - - if (attach && attach.status === 'failed') { - let msg = `pg:promote failed because ${attach.description} release was unsuccessful. Your application is currently running ` - let detach = await fetcher.release(app, detachId) - if (detach && detach.status === 'succeeded') { - msg += 'without an attached DATABASE_URL.' - } else { - msg += `with ${current.addon.name} attached as DATABASE_URL.` - } - - msg += ' Check your release phase logs for failure causes.' - return cli.action.done(msg) - } - - if (Date.now() > endTime) { - return cli.action.done('timeout. Check your Attach DATABASE release for failures.') - } - - await new Promise(resolve => setTimeout(resolve, 5000)) - } - })()) - } -} - -module.exports = { - topic: 'pg', - command: 'promote', - description: 'sets DATABASE as your DATABASE_URL', - needsApp: true, - needsAuth: true, - flags: [{name: 'force', char: 'f'}], - args: [{name: 'database'}], - run: cli.command({preauth: true}, run), -} diff --git a/packages/pg-v5/index.js b/packages/pg-v5/index.js index c5208d0b01..f1f7aee2f0 100644 --- a/packages/pg-v5/index.js +++ b/packages/pg-v5/index.js @@ -13,7 +13,6 @@ exports.commands = flatten([ require('./commands/killall'), require('./commands/locks'), require('./commands/outliers'), - require('./commands/promote'), require('./commands/ps'), require('./commands/psql'), require('./commands/pull'), diff --git a/packages/pg-v5/test/unit/commands/promote.unit.test.js b/packages/pg-v5/test/unit/commands/promote.unit.test.js deleted file mode 100644 index 29b169197d..0000000000 --- a/packages/pg-v5/test/unit/commands/promote.unit.test.js +++ /dev/null @@ -1,615 +0,0 @@ -'use strict' -/* global beforeEach afterEach */ - -const cli = require('heroku-cli-util') -const {expect} = require('chai') -const nock = require('nock') -const proxyquire = require('proxyquire') - -describe('pg:promote when argument is database', () => { - let api - let pg - - const pgbouncerAddonID = 'c667bce0-3238-4202-8550-e1dc323a02a2' - - const attachment = { - addon: { - name: 'postgres-1', - id: 'c667bce0-3238-4202-8550-e1dc323a02a2', - }, - namespace: null, - } - - const fetcher = () => { - return { - attachment: () => attachment, - } - } - - const host = () => { - return 'https://api.data.heroku.com' - } - - const cmd = proxyquire('../../../commands/promote', { - '../lib/fetcher': fetcher, - '../lib/host': host, - }) - - beforeEach(() => { - api = nock('https://api.heroku.com:443') - api.get('/apps/myapp/formation').reply(200, []) - pg = nock('https://api.data.heroku.com') - pg.get(`/client/v11/databases/${attachment.addon.id}/wait_status`).reply(200, {message: 'available', 'waiting?': false}) - pg.get(`/client/v11/databases/${attachment.addon.id}`).reply(200, {following: null}) - api.delete(`/addon-attachments/${pgbouncerAddonID}`).reply(200) - cli.mockConsole() - }) - - afterEach(() => { - nock.cleanAll() - api.done() - pg.done() - }) - - it('promotes db and attaches pgbouncer if DATABASE_CONNECTION_POOL is an attachment', () => { - api.get('/apps/myapp/addon-attachments').reply(200, [ - {name: 'DATABASE', addon: {name: 'postgres-2'}, namespace: null}, - {name: 'DATABASE_CONNECTION_POOL', id: pgbouncerAddonID, addon: {name: 'postgres-2'}, namespace: 'connection-pooling:default'}, - ]) - api.post('/addon-attachments', { - app: {name: 'myapp'}, - addon: {name: 'postgres-2'}, - namespace: null, - confirm: 'myapp', - }).reply(201, {name: 'RED'}) - api.post('/addon-attachments', { - name: 'DATABASE', - app: {name: 'myapp'}, - addon: {name: 'postgres-1'}, - namespace: null, - confirm: 'myapp', - }).reply(201) - api.delete(`/addon-attachments/${pgbouncerAddonID}`).reply(200) - api.post('/addon-attachments', { - name: 'DATABASE_CONNECTION_POOL', - app: {name: 'myapp'}, - addon: {name: 'postgres-1'}, - namespace: 'connection-pooling:default', - confirm: 'myapp', - }).reply(201) - return cmd.run({app: 'myapp', args: {}, flags: {}}) - .then(() => expect(cli.stderr, 'to equal', `Ensuring an alternate alias for existing DATABASE_URL... RED_URL -Promoting postgres-1 to DATABASE_URL on myapp... done -Reattaching pooler to new leader... done -`)) - }) - - it('promotes db and does not detach pgbouncers attached to new leader under other name than DATABASE_CONNECTION_POOL', () => { - api.get('/apps/myapp/addon-attachments').reply(200, [ - {name: 'DATABASE', addon: {name: 'postgres-2'}, namespace: null}, - // { name: 'DATABASE_CONNECTION_POOL', id: pgbouncerAddonID, addon: { name: 'postgres-2', id: '2' }, namespace: "connection-pooling:default" }, - {name: 'DATABASE_CONNECTION_POOL2', id: '12345', addon: {name: 'postgres-1', id: '1'}, namespace: 'connection-pooling:default'}, - ]) - api.post('/addon-attachments', { - app: {name: 'myapp'}, - addon: {name: 'postgres-2'}, - namespace: null, - confirm: 'myapp', - }).reply(201, {name: 'RED'}) - api.post('/addon-attachments', { - name: 'DATABASE', - app: {name: 'myapp'}, - addon: {name: 'postgres-1'}, - namespace: null, - confirm: 'myapp', - }).reply(201) - return cmd.run({app: 'myapp', args: {}, flags: {}}) - .then(() => expect(cli.stderr, 'to equal', `Ensuring an alternate alias for existing DATABASE_URL... RED_URL -Promoting postgres-1 to DATABASE_URL on myapp... done -`)) - }) - - it('promotes db and does not reattach pgbouncer if DATABASE_CONNECTION_POOL attached to database being promoted, but not old leader', () => { - api.get('/apps/myapp/addon-attachments').reply(200, [ - {name: 'DATABASE', addon: {name: 'postgres-2'}, namespace: null}, - {name: 'DATABASE_CONNECTION_POOL', id: '12345', addon: {name: 'postgres-1', id: '1'}, namespace: 'connection-pooling:default'}, - ]) - api.post('/addon-attachments', { - app: {name: 'myapp'}, - addon: {name: 'postgres-2'}, - namespace: null, - confirm: 'myapp', - }).reply(201, {name: 'RED'}) - api.post('/addon-attachments', { - name: 'DATABASE', - app: {name: 'myapp'}, - addon: {name: 'postgres-1'}, - namespace: null, - confirm: 'myapp', - }).reply(201) - return cmd.run({app: 'myapp', args: {}, flags: {}}) - .then(() => expect(cli.stderr, 'to equal', `Ensuring an alternate alias for existing DATABASE_URL... RED_URL -Promoting postgres-1 to DATABASE_URL on myapp... done -`)) - }) - - it('promotes the db and creates another attachment if current DATABASE does not have another', () => { - api.get('/apps/myapp/addon-attachments').reply(200, [ - {name: 'DATABASE', addon: {name: 'postgres-2'}, namespace: null}, - ]) - api.post('/addon-attachments', { - app: {name: 'myapp'}, - addon: {name: 'postgres-2'}, - namespace: null, - confirm: 'myapp', - }).reply(201, {name: 'RED'}) - api.post('/addon-attachments', { - name: 'DATABASE', - app: {name: 'myapp'}, - addon: {name: 'postgres-1'}, - namespace: null, - confirm: 'myapp', - }).reply(201) - return cmd.run({app: 'myapp', args: {}, flags: {}}) - .then(() => expect(cli.stderr).to.equal(`Ensuring an alternate alias for existing DATABASE_URL... RED_URL -Promoting postgres-1 to DATABASE_URL on myapp... done -`)) - }) - - it('promotes the db and does not create another attachment if current DATABASE has another', () => { - api.get('/apps/myapp/addon-attachments').reply(200, [ - {name: 'DATABASE', addon: {name: 'postgres-2'}, namespace: null}, - {name: 'RED', addon: {name: 'postgres-2'}, namespace: null}, - ]) - api.post('/addon-attachments', { - name: 'DATABASE', - app: {name: 'myapp'}, - addon: {name: 'postgres-1'}, - namespace: null, - confirm: 'myapp', - }).reply(201) - return cmd.run({app: 'myapp', args: {}, flags: {}}) - .then(() => expect(cli.stderr).to.equal(`Ensuring an alternate alias for existing DATABASE_URL... RED_URL -Promoting postgres-1 to DATABASE_URL on myapp... done -`)) - }) - - it('does not promote the db if is already is DATABASE', () => { - api.get('/apps/myapp/addon-attachments').reply(200, [ - {name: 'DATABASE', addon: {name: 'postgres-1'}, namespace: null}, - {name: 'PURPLE', addon: {name: 'postgres-1'}, namespace: null}, - ]) - const err = 'postgres-1 is already promoted on myapp' - return expect(cmd.run({app: 'myapp', args: {}, flags: {}})).to.be.rejectedWith(Error, err) - }) -}) - -describe('pg:promote when argument is a credential attachment', () => { - const credentialAttachment = { - name: 'PURPLE', - addon: { - name: 'postgres-1', - id: 'c667bce0-3238-4202-8550-e1dc323a02a2', - }, - namespace: 'credential:hello', - } - - const fetcher = () => { - return { - attachment: () => credentialAttachment, - } - } - - const host = () => { - return 'https://api.data.heroku.com' - } - - const cmd = proxyquire('../../../commands/promote', { - '../lib/fetcher': fetcher, - '../lib/host': host, - }) - - let api - let pg - - beforeEach(() => { - api = nock('https://api.heroku.com:443') - api.get('/apps/myapp/formation').reply(200, []) - pg = nock('https://api.data.heroku.com') - pg.get(`/client/v11/databases/${credentialAttachment.addon.id}/wait_status`).reply(200, {message: 'available', 'waiting?': false}) - pg.get(`/client/v11/databases/${credentialAttachment.addon.id}`).reply(200, {following: null}) - cli.mockConsole() - }) - - afterEach(() => { - nock.cleanAll() - api.done() - pg.done() - }) - - it('promotes the credential and creates another attachment if current DATABASE does not have another', () => { - api.get('/apps/myapp/addon-attachments').reply(200, [ - {name: 'DATABASE', addon: {name: 'postgres-2'}}, - {name: 'RED', addon: {name: 'postgres-1'}, namespace: 'credential:hello'}, - ]) - api.post('/addon-attachments', { - app: {name: 'myapp'}, - addon: {name: 'postgres-2'}, - confirm: 'myapp', - }).reply(201, {name: 'RED'}) - api.post('/addon-attachments', { - name: 'DATABASE', - app: {name: 'myapp'}, - addon: {name: 'postgres-1'}, - namespace: 'credential:hello', - confirm: 'myapp', - }).reply(201) - return cmd.run({app: 'myapp', args: {}, flags: {}}) - .then(() => expect(cli.stderr).to.equal(`Ensuring an alternate alias for existing DATABASE_URL... RED_URL -Promoting PURPLE to DATABASE_URL on myapp... done -`)) - }) - - it('promotes the credential and creates another attachment if current DATABASE does not have another and current DATABASE is a credential', () => { - api.get('/apps/myapp/addon-attachments').reply(200, [ - {name: 'PURPLE', addon: {name: 'postgres-1'}, namespace: 'credential:hello'}, - {name: 'DATABASE', addon: {name: 'postgres-1'}, namespace: 'credential:goodbye'}, - ]) - api.post('/addon-attachments', { - app: {name: 'myapp'}, - addon: {name: 'postgres-1'}, - namespace: 'credential:goodbye', - confirm: 'myapp', - }).reply(201, {name: 'RED'}) - api.post('/addon-attachments', { - name: 'DATABASE', - app: {name: 'myapp'}, - addon: {name: 'postgres-1'}, - namespace: 'credential:hello', - confirm: 'myapp', - }).reply(201) - return cmd.run({app: 'myapp', args: {}, flags: {}}) - .then(() => expect(cli.stderr).to.equal(`Ensuring an alternate alias for existing DATABASE_URL... RED_URL -Promoting PURPLE to DATABASE_URL on myapp... done -`)) - }) - - it('promotes the credential and does not create another attachment if current DATABASE has another', () => { - api.get('/apps/myapp/addon-attachments').reply(200, [ - {name: 'DATABASE', addon: {name: 'postgres-2'}}, - {name: 'RED', addon: {name: 'postgres-2'}}, - {name: 'PURPLE', addon: {name: 'postgres-1'}, namespace: 'credential:hello'}, - ]) - api.post('/addon-attachments', { - name: 'DATABASE', - app: {name: 'myapp'}, - addon: {name: 'postgres-1'}, - namespace: 'credential:hello', - confirm: 'myapp', - }).reply(201) - return cmd.run({app: 'myapp', args: {}, flags: {}}) - .then(() => expect(cli.stderr).to.equal(`Ensuring an alternate alias for existing DATABASE_URL... RED_URL -Promoting PURPLE to DATABASE_URL on myapp... done -`)) - }) - - it('promotes the credential if the current promoted database is for the same addon, but the default credential', () => { - api.get('/apps/myapp/addon-attachments').reply(200, [ - {name: 'DATABASE', addon: {name: 'postgres-1'}, namespace: null}, - {name: 'RED', addon: {name: 'postgres-1'}, namespace: null}, - {name: 'PURPLE', addon: {name: 'postgres-1'}, namespace: 'credential:hello'}, - ]) - api.post('/addon-attachments', { - name: 'DATABASE', - app: {name: 'myapp'}, - addon: {name: 'postgres-1'}, - namespace: 'credential:hello', - confirm: 'myapp', - }).reply(201) - return cmd.run({app: 'myapp', args: {}, flags: {}}) - .then(() => expect(cli.stderr).to.equal(`Ensuring an alternate alias for existing DATABASE_URL... RED_URL -Promoting PURPLE to DATABASE_URL on myapp... done -`)) - }) - - it('promotes the credential if the current promoted database is for the same addon, but another credential', () => { - api.get('/apps/myapp/addon-attachments').reply(200, [ - {name: 'DATABASE', addon: {name: 'postgres-1'}, namespace: 'credential:goodbye'}, - {name: 'RED', addon: {name: 'postgres-1'}, namespace: 'credential:goodbye'}, - {name: 'PURPLE', addon: {name: 'postgres-1'}, namespace: 'credential:hello'}, - ]) - api.post('/addon-attachments', { - name: 'DATABASE', - app: {name: 'myapp'}, - addon: {name: 'postgres-1'}, - namespace: 'credential:hello', - confirm: 'myapp', - }).reply(201) - return cmd.run({app: 'myapp', args: {}, flags: {}}) - .then(() => expect(cli.stderr).to.equal(`Ensuring an alternate alias for existing DATABASE_URL... RED_URL -Promoting PURPLE to DATABASE_URL on myapp... done -`)) - }) - - it('does not promote the credential if it already is DATABASE', () => { - api.get('/apps/myapp/addon-attachments').reply(200, [ - {name: 'RED', addon: {name: 'postgres-1'}, namespace: null}, - {name: 'DATABASE', addon: {name: 'postgres-1'}, namespace: 'credential:hello'}, - {name: 'PURPLE', addon: {name: 'postgres-1'}, namespace: 'credential:hello'}, - ]) - const err = 'PURPLE is already promoted on myapp' - return expect(cmd.run({app: 'myapp', args: {}, flags: {}})).to.be.rejectedWith(Error, err) - }) -}) - -describe('pg:promote when release phase is present', () => { - let api - let pg - - const addonID = 'c667bce0-3238-4202-8550-e1dc323a02a2' - const host = () => { - return 'https://api.data.heroku.com' - } - - const cmd = proxyquire('../../../commands/promote', { - '../lib/host': host, - }) - - beforeEach(() => { - api = nock('https://api.heroku.com:443') - api.get('/apps/myapp/formation').reply(200, [{type: 'release'}]) - api.get('/apps/myapp/addon-attachments').reply(200, [ - {name: 'DATABASE', addon: {name: 'postgres-1'}, namespace: 'credential:goodbye'}, - {name: 'RED', addon: {name: 'postgres-1'}, namespace: 'credential:goodbye'}, - {name: 'PURPLE', addon: {name: 'postgres-1'}, namespace: 'credential:hello'}, - ]) - api.post('/addon-attachments', { - name: 'DATABASE', - app: {name: 'myapp'}, - addon: {name: 'postgres-1'}, - namespace: 'credential:hello', - confirm: 'myapp', - }).reply(201) - api.post('/addon-attachments', { - name: 'DATABASE', - app: {name: 'myapp'}, - addon: {name: 'postgres-1'}, - namespace: null, - confirm: 'myapp', - }).reply(201) - api.post('/actions/addon-attachments/resolve', { - app: 'myapp', - addon_attachment: 'DATABASE_URL', - addon_service: 'heroku-postgresql', - }).reply(201, [{ - name: 'PURPLE', - addon: {name: 'postgres-1', id: addonID}, - namespace: 'credential:hello', - }]) - - pg = nock('https://api.data.heroku.com') - pg.get(`/client/v11/databases/${addonID}/wait_status`).reply(200, {message: 'available', 'waiting?': false}) - pg.get(`/client/v11/databases/${addonID}`).reply(200, {following: null}) - - cli.mockConsole() - }) - - afterEach(() => { - nock.cleanAll() - pg.done() - api.done() - }) - - it('checks release phase', () => { - api.get('/apps/myapp/releases').reply(200, [{id: 1, description: 'Attach DATABASE'}, {id: 2, description: 'Detach DATABASE'}]) - api.get('/apps/myapp/releases/1').reply(200, {status: 'succeeded'}) - api.get('/apps/myapp/releases/2').reply(200, {status: 'succeeded'}) - return cmd.run({app: 'myapp', args: {}, flags: {}}) - .then(() => expect(cli.stderr).to.equal(`Ensuring an alternate alias for existing DATABASE_URL... RED_URL -Promoting PURPLE to DATABASE_URL on myapp... done -Checking release phase... pg:promote succeeded. -`)) - }) - - it('checks release phase for detach failure', () => { - api.get('/apps/myapp/releases').reply(200, [{id: 1, description: 'Attach DATABASE'}, {id: 2, description: 'Detach DATABASE'}]) - api.get('/apps/myapp/releases/1').reply(200, {status: 'succeeded'}) - api.get('/apps/myapp/releases/2').reply(200, {status: 'failed', description: 'Detach DATABASE'}) - return cmd.run({app: 'myapp', args: {}, flags: {}}) - .then(() => expect(cli.stderr).to.equal(`Ensuring an alternate alias for existing DATABASE_URL... RED_URL -Promoting PURPLE to DATABASE_URL on myapp... done -Checking release phase... pg:promote succeeded. It is safe to ignore the failed Detach DATABASE release. -`)) - }) - - it('checks release phase for attach failure', () => { - api.get('/apps/myapp/releases').reply(200, [{id: 1, description: 'Attach DATABASE'}, {id: 2, description: 'Detach DATABASE'}]) - api.get('/apps/myapp/releases/1').reply(200, {status: 'failed', description: 'Attach DATABASE'}) - api.get('/apps/myapp/releases/2').reply(200, {status: 'failed', description: 'Attach DATABASE'}) - return cmd.run({app: 'myapp', args: {}, flags: {}}) - .then(() => expect(cli.stderr).to.equal(`Ensuring an alternate alias for existing DATABASE_URL... RED_URL -Promoting PURPLE to DATABASE_URL on myapp... done -Checking release phase... pg:promote failed because Attach DATABASE release was unsuccessful. Your application is currently running with postgres-1 attached as DATABASE_URL. Check your release phase logs for failure causes. -`)) - }) - - it('checks release phase for attach failure and detach success', () => { - api.get('/apps/myapp/releases').reply(200, [{id: 1, description: 'Attach DATABASE'}, {id: 2, description: 'Detach DATABASE'}]) - api.get('/apps/myapp/releases/1').reply(200, {status: 'failed', description: 'Attach DATABASE'}) - api.get('/apps/myapp/releases/2').reply(200, {status: 'succeeded', description: 'Attach DATABASE'}) - return cmd.run({app: 'myapp', args: {}, flags: {}}) - .then(() => expect(cli.stderr).to.equal(`Ensuring an alternate alias for existing DATABASE_URL... RED_URL -Promoting PURPLE to DATABASE_URL on myapp... done -Checking release phase... pg:promote failed because Attach DATABASE release was unsuccessful. Your application is currently running without an attached DATABASE_URL. Check your release phase logs for failure causes. -`)) - }) - - it('checks release phase for attach failure and detach success', () => { - api.get('/apps/myapp/releases').reply(200, []) - return expect(cmd.run({app: 'myapp', args: {}, flags: {}})).to.be.rejected - }) -}) - -describe('pg:promote when database is not available or force flag is present', () => { - let api - let pg - - const attachment = { - addon: { - name: 'postgres-1', - id: 'c667bce0-3238-4202-8550-e1dc323a02a2', - }, - namespace: null, - } - - const fetcher = () => { - return { - attachment: () => attachment, - } - } - - const host = () => { - return 'https://api.data.heroku.com' - } - - const cmd = proxyquire('../../../commands/promote', { - '../lib/fetcher': fetcher, - '../lib/host': host, - }) - - beforeEach(() => { - api = nock('https://api.heroku.com:443') - api.get('/apps/myapp/formation').reply(200, []) - pg = nock('https://api.data.heroku.com') - pg.get(`/client/v11/databases/${attachment.addon.id}`).reply(200, {following: null}) - cli.mockConsole() - }) - - afterEach(() => { - nock.cleanAll() - api.done() - pg.done() - }) - - it('warns user if database is unavailable', () => { - api.get('/apps/myapp/addon-attachments').reply(200, [ - {name: 'DATABASE', addon: {name: 'postgres-2'}, namespace: null}, - {name: 'RED', addon: {name: 'postgres-2'}, namespace: null}, - ]) - - pg.get(`/client/v11/databases/${attachment.addon.id}/wait_status`).reply(200, {'waiting?': true, message: 'pending'}) - - const err = `Database cannot be promoted while in state: pending -\nPromoting this database can lead to application errors and outage. Please run pg:wait to wait for database to become available. -\nTo ignore this error, you can pass the --force flag to promote the database and risk application issues.` - return expect(cmd.run({app: 'myapp', args: {}, flags: {}})).to.be.rejectedWith(Error, err) - }) - - it('promotes database in unavailable state if --force flag is present', () => { - api.get('/apps/myapp/addon-attachments').reply(200, [ - {name: 'DATABASE', addon: {name: 'postgres-2'}, namespace: null}, - {name: 'RED', addon: {name: 'postgres-2'}, namespace: null}, - ]) - - api.post('/addon-attachments', { - name: 'DATABASE', - app: {name: 'myapp'}, - addon: {name: 'postgres-1'}, - namespace: null, - confirm: 'myapp', - }).reply(201) - - pg.get(`/client/v11/databases/${attachment.addon.id}/wait_status`).reply(200, {'waiting?': true, message: 'pending'}) - - return cmd.run({app: 'myapp', args: {}, flags: {force: true}}) - .then(() => expect(cli.stderr).to.equal(`Ensuring an alternate alias for existing DATABASE_URL... RED_URL -Promoting postgres-1 to DATABASE_URL on myapp... done\n`)) - }) - - it('promotes database in available state if --force flag is present', () => { - api.get('/apps/myapp/addon-attachments').reply(200, [ - {name: 'DATABASE', addon: {name: 'postgres-2'}, namespace: null}, - {name: 'RED', addon: {name: 'postgres-2'}, namespace: null}, - ]) - - pg.get(`/client/v11/databases/${attachment.addon.id}/wait_status`).reply(200, {'waiting?': false, message: 'available'}) - - api.post('/addon-attachments', { - name: 'DATABASE', - app: {name: 'myapp'}, - addon: {name: 'postgres-1'}, - namespace: null, - confirm: 'myapp', - }).reply(201) - - return cmd.run({app: 'myapp', args: {}, flags: {force: true}}) - .then(() => expect(cli.stderr).to.equal(`Ensuring an alternate alias for existing DATABASE_URL... RED_URL -Promoting postgres-1 to DATABASE_URL on myapp... done\n`)) - }) -}) - -describe('pg:promote when promoted database is a follower', () => { - let api - let pg - - const attachment = { - addon: { - name: 'postgres-1', - id: 'c667bce0-3238-4202-8550-e1dc323a02a2', - }, - namespace: null, - } - - const fetcher = () => { - return { - attachment: () => attachment, - } - } - - const host = () => { - return 'https://api.data.heroku.com' - } - - const cmd = proxyquire('../../../commands/promote', { - '../lib/fetcher': fetcher, - '../lib/host': host, - }) - - beforeEach(() => { - api = nock('https://api.heroku.com:443') - api.get('/apps/myapp/formation').reply(200, []) - pg = nock('https://api.data.heroku.com') - pg.get(`/client/v11/databases/${attachment.addon.id}/wait_status`).reply(200, {'waiting?': false, message: 'available'}) - - cli.mockConsole() - }) - - afterEach(() => { - nock.cleanAll() - api.done() - pg.done() - }) - - it('warns user if database is a follower', () => { - api.get('/apps/myapp/addon-attachments').reply(200, [ - {name: 'DATABASE', addon: {name: 'postgres-2'}, namespace: null}, - {name: 'RED', addon: {name: 'postgres-2'}, namespace: null}, - ]) - - api.post('/addon-attachments', { - name: 'DATABASE', - app: {name: 'myapp'}, - addon: {name: 'postgres-1'}, - namespace: null, - confirm: 'myapp', - }).reply(201) - - pg.get(`/client/v11/databases/${attachment.addon.id}`).reply(200, { - following: 'postgres://xxx.com:5432/abcdefghijklmn', - leader: {addon_id: '5ba2ba8b-07a9-4a65-a808-585a50e37f98', name: 'postgresql-leader'}, - }) - - return cmd.run({app: 'myapp', args: {}, flags: {}}) - .then(() => expect(cli.stderr).to.include('Your database has been promoted but it is currently a follower')) - }) -})