diff --git a/packages/cli/src/commands/pg/promote.ts b/packages/cli/src/commands/pg/promote.ts index b7d9d51a45..0085ee5851 100644 --- a/packages/cli/src/commands/pg/promote.ts +++ b/packages/cli/src/commands/pg/promote.ts @@ -4,12 +4,10 @@ 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 {attachment, getRelease} from '../../lib/pg/fetcher' +import {getAttachment, getRelease} from '../../lib/pg/fetcher' import pgHost from '../../lib/pg/host' import {PgStatus, PgDatabase} from '../../lib/pg/types' -// const cli = require('heroku-cli-util') -// const host = require('../lib/host') export default class Promote extends Command { static topic = 'pg'; static description = 'sets DATABASE as your DATABASE_URL'; @@ -20,30 +18,25 @@ export default class Promote extends Command { }; static args = { - database: Args.string({required: true}), + database: Args.string(), }; public async run(): Promise { const {flags, args} = await this.parse(Promote) - // const fetcher = require('../lib/fetcher')(heroku) - // const {app, args, flags} = context const {force, app} = flags const {database} = args - const dbAttachment = await attachment(this.heroku, app, database) - // let current - // let attachments + const attachment = await getAttachment(this.heroku, app, database) ux.action.start(`Ensuring an alternate alias for existing ${color.green('DATABASE_URL')}`) - // await ux.action(`Ensuring an alternate alias for existing ${color.green('DATABASE_URL')}`, (async () => { const {body: attachments} = await this.heroku.get(`/apps/${app}/addon-attachments`) - // attachments = addonAttachments const current = attachments.find(a => a.name === 'DATABASE') if (!current) return - if (current.addon?.name === dbAttachment.addon.name && current.namespace === dbAttachment.namespace) { - if (dbAttachment.namespace) { - throw new Error(`${color.cyan(dbAttachment.name)} is already promoted on ${color.magenta(app)}`) + // 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 { - throw new Error(`${color.yellow(dbAttachment.addon.name)} is already promoted on ${color.magenta(app)}`) + ux.error(`${color.addon(attachment.addon.name)} is already promoted on ${color.app(app)}`) } } @@ -57,14 +50,17 @@ export default class Promote extends Command { // 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, + 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/${dbAttachment.addon.id}/wait_status`, { + const {body: status} = await this.heroku.get(`/client/v11/databases/${attachment.addon.id}/wait_status`, { hostname: pgHost(), }) if (status['waiting?']) { @@ -75,85 +71,74 @@ export default class Promote extends Command { To ignore this error, you can pass the --force flag to promote the database and risk application issues. `)) - // ux.error(`Database cannot be promoted while in state: ${status.message}\n\nPromoting this database can lead to application errors and outage. Please run pg:wait to wait for database to become available.\n\nTo ignore this error, you can pass the --force flag to promote the database and risk application issues.`) } } let promotionMessage - if (dbAttachment.namespace) { - promotionMessage = `Promoting ${color.cyan(dbAttachment.name)} to ${color.green('DATABASE_URL')} on ${color.magenta(app)}` + if (attachment.namespace) { + promotionMessage = `Promoting ${color.cyan(attachment.name)} to ${color.green('DATABASE_URL')} on ${color.app(app)}` } else { - promotionMessage = `Promoting ${color.addon(dbAttachment.addon.name)} to ${color.green('DATABASE_URL')} on ${color.magenta(app)}` + promotionMessage = `Promoting ${color.addon(attachment.addon.name)} to ${color.green('DATABASE_URL')} on ${color.app(app)}` } ux.action.start(promotionMessage) - // await ux.action(promotionMessage, (async function () { await this.heroku.post('/addon-attachments', { body: { name: 'DATABASE', app: {name: app}, - addon: {name: dbAttachment.addon.name}, - namespace: dbAttachment.namespace, + 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 ux.action('Reattaching pooler to new leader', (async function () { await this.heroku.post('/addon-attachments', { body: { name: currentPooler.name, app: {name: app}, - addon: {name: dbAttachment.addon.name}, + addon: {name: attachment.addon.name}, namespace: 'connection-pooling:default', confirm: app, }, }) - // })()) ux.action.stop() } - const {body: promotedDatabaseDetails} = await this.heroku.get(`/client/v11/databases/${dbAttachment.addon.id}`, { + const {body: promotedDatabaseDetails} = await this.heroku.get(`/client/v11/databases/${attachment.addon.id}`, { hostname: pgHost(), }) if (promotedDatabaseDetails.following) { - const unfollowLeaderCmd = `heroku pg:unfollow ${dbAttachment.addon.name}` + const unfollowLeaderCmd = `heroku pg:unfollow ${attachment.addon.name}` ux.warn(heredoc(` - WARNING: Your database has been promoted but it is currently a follower database in read-only mode. + 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.cyan(unfollowLeaderCmd)} to stop this follower from replicating from its leader (${color.yellow(promotedDatabaseDetails.leader as string)}) and convert it into a writable database. `)) - // ux.warn(`WARNING: Your database has been promoted but it is currently a follower database in read-only mode.\n \n Promoting a database with ${color.cyan.bold('heroku pg:promote')} doesn't automatically unfollow its leader.\n \n Use ${color.cyan.bold(unfollowLeaderCmd)} to stop this follower from replicating from its leader (${color.yellow(promotedDatabaseDetails.leader.name)}) 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') - // await ux.action('Checking release phase', (async function () { const {body: releases} = await this.heroku.get(`/apps/${app}/releases`, { partial: true, headers: { Range: 'version ..; max=5, order=desc', }, }) - // let releases = await this.heroku.request({ - // path: `/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 + 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) @@ -164,8 +149,7 @@ export default class Promote extends Command { msg += ` It is safe to ignore the failed ${detach.description} release.` } - ux.action.stop(msg) - // return ux.action.done(msg) + return ux.action.stop(msg) } if (attach && attach.status === 'failed') { @@ -178,18 +162,15 @@ export default class Promote extends Command { } msg += ' Check your release phase logs for failure causes.' - ux.action.stop(msg) - // return ux.action.done(msg) + return ux.action.stop(msg) } if (Date.now() > endTime) { - ux.action.stop('timeout. Check your Attach DATABASE release for failures.') - // return ux.action.done('timeout. Check your Attach DATABASE release for failures.') + return ux.action.stop('timeout. Check your Attach DATABASE release for failures.') } await new Promise(resolve => setTimeout(resolve, 5000)) } - // })()) } } } 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..179f653879 --- /dev/null +++ b/packages/cli/test/unit/commands/pg/promote.unit.test.ts @@ -0,0 +1,837 @@ +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' +const 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}]) + nock('https://api.heroku.com') + .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', + }, + ]) + nock('https://api.heroku.com') + .post('/addon-attachments', { + app: {name: 'myapp'}, + addon: {name: 'postgres-2'}, + namespace: null, + confirm: 'myapp', + }).reply(201, {name: 'RED'}) + nock('https://api.heroku.com') + .post('/addon-attachments', { + name: 'DATABASE', + app: {name: 'myapp'}, + addon: {name: addon.name}, + namespace: null, + confirm: 'myapp', + }).reply(201) + nock('https://api.heroku.com').delete(`/addon-attachments/${pgbouncerAddonID}`).reply(200) + nock('https://api.heroku.com') + .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', + ]) + 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', + ]) + 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', + ]) + 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', + ]) + 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', + ]) + 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', + ]).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_URL', + addon_service: 'heroku-postgresql', + }) + .reply(200, [{addon, name: 'PURPLE', namespace: 'credential:hello'}]) + nock('https://api.heroku.com') + .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', + ]) + 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', + ]) + 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', + ]) + 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') + // .post('/actions/addon-attachments/resolve') + // .reply(200, [{addon}]) + 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', + ]) + 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', + ]) + 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', + ]) + .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_URL', 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', + ]) + 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', + ]) + 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', + ]) + 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', + ]) + 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', + ])).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}]) + nock('https://api.heroku.com') + .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 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', + ]).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', + ]) + 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', + ]) + 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}]) + nock('https://api.heroku.com') + .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', + ]) + 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/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')) - }) -})