From a4c208fbf3c875a86722db117cf54454e6d90c1a Mon Sep 17 00:00:00 2001 From: Eric Black Date: Mon, 15 Apr 2024 15:27:35 -0700 Subject: [PATCH 1/9] Convert pg promote to ocif --- packages/cli/src/commands/pg/promote.ts | 195 ++++++++++++++++++++++++ packages/cli/src/lib/pg/fetcher.ts | 5 + packages/cli/src/lib/pg/types.ts | 7 +- packages/pg-v5/commands/promote.js | 179 ---------------------- packages/pg-v5/index.js | 1 - 5 files changed, 206 insertions(+), 181 deletions(-) create mode 100644 packages/cli/src/commands/pg/promote.ts delete mode 100644 packages/pg-v5/commands/promote.js diff --git a/packages/cli/src/commands/pg/promote.ts b/packages/cli/src/commands/pg/promote.ts new file mode 100644 index 0000000000..b7d9d51a45 --- /dev/null +++ b/packages/cli/src/commands/pg/promote.ts @@ -0,0 +1,195 @@ +/* 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 {attachment, 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'; + 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 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 + 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)}`) + } else { + throw new Error(`${color.yellow(dbAttachment.addon.name)} is already promoted on ${color.magenta(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/${dbAttachment.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('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. + `)) + // 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)}` + } else { + promotionMessage = `Promoting ${color.addon(dbAttachment.addon.name)} to ${color.green('DATABASE_URL')} on ${color.magenta(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, + 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}, + namespace: 'connection-pooling:default', + confirm: app, + }, + }) + // })()) + ux.action.stop() + } + + const {body: promotedDatabaseDetails} = await this.heroku.get(`/client/v11/databases/${dbAttachment.addon.id}`, { + hostname: pgHost(), + }) + if (promotedDatabaseDetails.following) { + const unfollowLeaderCmd = `heroku pg:unfollow ${dbAttachment.addon.name}` + ux.warn(heredoc(` + WARNING: 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 [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 ux.action.done(msg) + } + + 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 ux.action.done(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.') + } + + 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/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'), From af9603450437cbeb5443231901d004e40ba3c8a3 Mon Sep 17 00:00:00 2001 From: Eric Black Date: Thu, 18 Apr 2024 17:47:30 -0700 Subject: [PATCH 2/9] Convert pg promote test, clean up --- packages/cli/src/commands/pg/promote.ts | 73 +- .../unit/commands/pg/promote.unit.test.ts | 837 ++++++++++++++++++ .../commands/{ => pg}/upgrade.unit.test.ts | 8 +- .../test/unit/commands/promote.unit.test.js | 615 ------------- 4 files changed, 868 insertions(+), 665 deletions(-) create mode 100644 packages/cli/test/unit/commands/pg/promote.unit.test.ts rename packages/cli/test/unit/commands/{ => pg}/upgrade.unit.test.ts (91%) delete mode 100644 packages/pg-v5/test/unit/commands/promote.unit.test.js 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')) - }) -}) From 64bb90db4d0aaeaf457b28248ab0b4ecd9155295 Mon Sep 17 00:00:00 2001 From: Eric Black Date: Fri, 19 Apr 2024 10:03:40 -0700 Subject: [PATCH 3/9] Update to require database argument --- packages/cli/src/commands/pg/promote.ts | 2 +- .../unit/commands/pg/promote.unit.test.ts | 28 +++++++++++++++---- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/pg/promote.ts b/packages/cli/src/commands/pg/promote.ts index 0085ee5851..c672417868 100644 --- a/packages/cli/src/commands/pg/promote.ts +++ b/packages/cli/src/commands/pg/promote.ts @@ -18,7 +18,7 @@ export default class Promote extends Command { }; static args = { - database: Args.string(), + database: Args.string({required: true}), }; public async run(): Promise { diff --git a/packages/cli/test/unit/commands/pg/promote.unit.test.ts b/packages/cli/test/unit/commands/pg/promote.unit.test.ts index 179f653879..fb8416c220 100644 --- a/packages/cli/test/unit/commands/pg/promote.unit.test.ts +++ b/packages/cli/test/unit/commands/pg/promote.unit.test.ts @@ -73,6 +73,7 @@ describe('pg:promote when argument is database', () => { await runCommand(Cmd, [ '--app', 'myapp', + 'DATABASE', ]) expectOutput(stderr.output, heredoc(` Ensuring an alternate alias for existing DATABASE_URL... @@ -112,6 +113,7 @@ describe('pg:promote when argument is database', () => { await runCommand(Cmd, [ '--app', 'myapp', + 'DATABASE', ]) expectOutput(stderr.output, heredoc(` Ensuring an alternate alias for existing DATABASE_URL... @@ -149,6 +151,7 @@ describe('pg:promote when argument is database', () => { await runCommand(Cmd, [ '--app', 'myapp', + 'DATABASE', ]) expectOutput(stderr.output, heredoc(` Ensuring an alternate alias for existing DATABASE_URL... @@ -183,6 +186,7 @@ describe('pg:promote when argument is database', () => { await runCommand(Cmd, [ '--app', 'myapp', + 'DATABASE', ]) expectOutput(stderr.output, heredoc(` Ensuring an alternate alias for existing DATABASE_URL... @@ -219,6 +223,7 @@ describe('pg:promote when argument is database', () => { await runCommand(Cmd, [ '--app', 'myapp', + 'DATABASE', ]) expectOutput(stderr.output, heredoc(` Ensuring an alternate alias for existing DATABASE_URL... @@ -239,6 +244,7 @@ describe('pg:promote when argument is database', () => { await runCommand(Cmd, [ '--app', 'myapp', + 'DATABASE', ]).catch((error: Error) => { expect(stripAnsi(error.message)).to.equal(err) }) @@ -252,7 +258,7 @@ describe('pg:promote when argument is a credential attachment', () => { nock('https://api.heroku.com') .post('/actions/addon-attachments/resolve', { app: 'myapp', - addon_attachment: 'DATABASE_URL', + addon_attachment: 'DATABASE', addon_service: 'heroku-postgresql', }) .reply(200, [{addon, name: 'PURPLE', namespace: 'credential:hello'}]) @@ -300,6 +306,7 @@ describe('pg:promote when argument is a credential attachment', () => { await runCommand(Cmd, [ '--app', 'myapp', + 'DATABASE', ]) expectOutput(stderr.output, heredoc(` Ensuring an alternate alias for existing DATABASE_URL... @@ -343,6 +350,7 @@ describe('pg:promote when argument is a credential attachment', () => { await runCommand(Cmd, [ '--app', 'myapp', + 'DATABASE', ]) expectOutput(stderr.output, heredoc(` Ensuring an alternate alias for existing DATABASE_URL... @@ -378,6 +386,7 @@ describe('pg:promote when argument is a credential attachment', () => { await runCommand(Cmd, [ '--app', 'myapp', + 'DATABASE', ]) expectOutput(stderr.output, heredoc(` Ensuring an alternate alias for existing DATABASE_URL... @@ -388,9 +397,6 @@ describe('pg:promote when argument is a credential attachment', () => { }) 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, [ @@ -419,6 +425,7 @@ describe('pg:promote when argument is a credential attachment', () => { await runCommand(Cmd, [ '--app', 'myapp', + 'DATABASE', ]) expectOutput(stderr.output, heredoc(` Ensuring an alternate alias for existing DATABASE_URL... @@ -458,6 +465,7 @@ describe('pg:promote when argument is a credential attachment', () => { await runCommand(Cmd, [ '--app', 'myapp', + 'DATABASE', ]) expectOutput(stderr.output, heredoc(` Ensuring an alternate alias for existing DATABASE_URL... @@ -488,6 +496,7 @@ describe('pg:promote when argument is a credential attachment', () => { await runCommand(Cmd, [ '--app', 'myapp', + 'DATABASE', ]) .catch((error: Error) => { expect(stripAnsi(error.message)).to.equal(err) @@ -531,7 +540,7 @@ describe('pg:promote when release phase is present', () => { }) .reply(201) .post('/actions/addon-attachments/resolve', { - app: 'myapp', addon_attachment: 'DATABASE_URL', addon_service: 'heroku-postgresql', + app: 'myapp', addon_attachment: 'DATABASE', addon_service: 'heroku-postgresql', }) .reply(201, [{ name: 'PURPLE', addon: {name: addon.name, id: addon.id}, namespace: 'credential:hello', @@ -558,6 +567,7 @@ describe('pg:promote when release phase is present', () => { await runCommand(Cmd, [ '--app', 'myapp', + 'DATABASE', ]) expectOutput(stderr.output, heredoc(` Ensuring an alternate alias for existing DATABASE_URL... @@ -581,6 +591,7 @@ describe('pg:promote when release phase is present', () => { await runCommand(Cmd, [ '--app', 'myapp', + 'DATABASE', ]) expectOutput(stderr.output, heredoc(` Ensuring an alternate alias for existing DATABASE_URL... @@ -604,6 +615,7 @@ describe('pg:promote when release phase is present', () => { await runCommand(Cmd, [ '--app', 'myapp', + 'DATABASE', ]) expectOutput(stderr.output, heredoc(` Ensuring an alternate alias for existing DATABASE_URL... @@ -627,6 +639,7 @@ describe('pg:promote when release phase is present', () => { await runCommand(Cmd, [ '--app', 'myapp', + 'DATABASE', ]) expectOutput(stderr.output, heredoc(` Ensuring an alternate alias for existing DATABASE_URL... @@ -645,6 +658,7 @@ describe('pg:promote when release phase is present', () => { return expect(runCommand(Cmd, [ '--app', 'myapp', + 'DATABASE', ])).to.be.rejected }) }) @@ -695,6 +709,7 @@ describe('pg:promote when database is not available or force flag is present', ( await runCommand(Cmd, [ '--app', 'myapp', + 'DATABASE', ]).catch((error: Error) => { expect(stripAnsi(error.message)).to.equal(err) }) @@ -730,6 +745,7 @@ describe('pg:promote when database is not available or force flag is present', ( '--app', 'myapp', '--force', + 'DATABASE', ]) expectOutput(stderr.output, heredoc(` Ensuring an alternate alias for existing DATABASE_URL... @@ -769,6 +785,7 @@ describe('pg:promote when database is not available or force flag is present', ( '--app', 'myapp', '--force', + 'DATABASE', ]) expectOutput(stderr.output, heredoc(` Ensuring an alternate alias for existing DATABASE_URL... @@ -831,6 +848,7 @@ describe('pg:promote when promoted database is a follower', () => { await runCommand(Cmd, [ '--app', 'myapp', + 'DATABASE', ]) expect(stderr.output).to.include('Your database has been promoted but it is currently a follower') }) From 34544c2047c2e95c065a97a8f66fc23e9844f7bd Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 19 Apr 2024 13:36:19 -0700 Subject: [PATCH 4/9] Update packages/cli/src/commands/pg/promote.ts Co-authored-by: Santiago Bosio --- packages/cli/src/commands/pg/promote.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/pg/promote.ts b/packages/cli/src/commands/pg/promote.ts index c672417868..cc5159577a 100644 --- a/packages/cli/src/commands/pg/promote.ts +++ b/packages/cli/src/commands/pg/promote.ts @@ -117,7 +117,7 @@ export default class Promote extends Command { 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. + 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. `)) } From 943fe4ad162a2c52699709b7b21f037135c7d434 Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 19 Apr 2024 13:36:31 -0700 Subject: [PATCH 5/9] Update packages/cli/src/commands/pg/promote.ts Co-authored-by: Santiago Bosio --- packages/cli/src/commands/pg/promote.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/pg/promote.ts b/packages/cli/src/commands/pg/promote.ts index cc5159577a..abe0acefed 100644 --- a/packages/cli/src/commands/pg/promote.ts +++ b/packages/cli/src/commands/pg/promote.ts @@ -67,7 +67,7 @@ export default class Promote extends Command { 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('pg:wait')} to wait for database to become available. + 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. `)) From 5f07687b0e5691c446eac43aaba7117a9476b37e Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 19 Apr 2024 13:37:02 -0700 Subject: [PATCH 6/9] Update packages/cli/src/commands/pg/promote.ts Co-authored-by: Santiago Bosio --- packages/cli/src/commands/pg/promote.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/pg/promote.ts b/packages/cli/src/commands/pg/promote.ts index abe0acefed..b126630e60 100644 --- a/packages/cli/src/commands/pg/promote.ts +++ b/packages/cli/src/commands/pg/promote.ts @@ -149,7 +149,8 @@ export default class Promote extends Command { msg += ` It is safe to ignore the failed ${detach.description} release.` } - return ux.action.stop(msg) + ux.action.stop(msg) + return } if (attach && attach.status === 'failed') { From a194f97a291ac753da9ce40b131ff1eaa5cb6f87 Mon Sep 17 00:00:00 2001 From: Eric Black Date: Fri, 19 Apr 2024 13:43:21 -0700 Subject: [PATCH 7/9] Remove semicolons, clean up test --- packages/cli/src/commands/pg/promote.ts | 4 ++-- .../cli/test/unit/commands/pg/promote.unit.test.ts | 11 ++--------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/pg/promote.ts b/packages/cli/src/commands/pg/promote.ts index b126630e60..a27f227972 100644 --- a/packages/cli/src/commands/pg/promote.ts +++ b/packages/cli/src/commands/pg/promote.ts @@ -15,11 +15,11 @@ export default class Promote extends Command { 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) diff --git a/packages/cli/test/unit/commands/pg/promote.unit.test.ts b/packages/cli/test/unit/commands/pg/promote.unit.test.ts index fb8416c220..f759da17e8 100644 --- a/packages/cli/test/unit/commands/pg/promote.unit.test.ts +++ b/packages/cli/test/unit/commands/pg/promote.unit.test.ts @@ -6,7 +6,7 @@ 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') +import stripAnsi = require('strip-ansi') describe('pg:promote when argument is database', () => { const addon = fixtures.addons['dwh-db'] @@ -16,7 +16,6 @@ describe('pg:promote when argument is database', () => { 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') @@ -45,14 +44,12 @@ describe('pg:promote when argument is database', () => { 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'}, @@ -60,8 +57,7 @@ describe('pg:promote when argument is database', () => { namespace: null, confirm: 'myapp', }).reply(201) - nock('https://api.heroku.com').delete(`/addon-attachments/${pgbouncerAddonID}`).reply(200) - nock('https://api.heroku.com') + .delete(`/addon-attachments/${pgbouncerAddonID}`).reply(200) .post('/addon-attachments', { name: 'DATABASE_CONNECTION_POOL', app: {name: 'myapp'}, @@ -262,7 +258,6 @@ describe('pg:promote when argument is a credential attachment', () => { 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') @@ -670,7 +665,6 @@ describe('pg:promote when database is not available or force flag is present', ( 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') @@ -803,7 +797,6 @@ describe('pg:promote when promoted database is a follower', () => { 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') From 3996d89e730109541d338afe60548dcfd3067908 Mon Sep 17 00:00:00 2001 From: Eric Black Date: Fri, 19 Apr 2024 14:13:24 -0700 Subject: [PATCH 8/9] Fix test --- packages/cli/test/unit/commands/pg/promote.unit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/test/unit/commands/pg/promote.unit.test.ts b/packages/cli/test/unit/commands/pg/promote.unit.test.ts index f759da17e8..0030572f11 100644 --- a/packages/cli/test/unit/commands/pg/promote.unit.test.ts +++ b/packages/cli/test/unit/commands/pg/promote.unit.test.ts @@ -696,7 +696,7 @@ describe('pg:promote when database is not available or force flag is present', ( 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. + 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. `) From a994bd0b2d6f8973d29af9d5c44477fad3a5e1a8 Mon Sep 17 00:00:00 2001 From: Eric Black Date: Fri, 19 Apr 2024 14:55:20 -0700 Subject: [PATCH 9/9] Fix lint --- packages/cli/src/commands/pg/promote.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/pg/promote.ts b/packages/cli/src/commands/pg/promote.ts index a27f227972..c0854a6f75 100644 --- a/packages/cli/src/commands/pg/promote.ts +++ b/packages/cli/src/commands/pg/promote.ts @@ -149,8 +149,8 @@ export default class Promote extends Command { msg += ` It is safe to ignore the failed ${detach.description} release.` } - ux.action.stop(msg) - return + ux.action.stop(msg) + return } if (attach && attach.status === 'failed') { @@ -163,11 +163,13 @@ export default class Promote extends Command { } msg += ' Check your release phase logs for failure causes.' - return ux.action.stop(msg) + ux.action.stop(msg) + return } if (Date.now() > endTime) { - return ux.action.stop('timeout. Check your Attach DATABASE release for failures.') + ux.action.stop('timeout. Check your Attach DATABASE release for failures.') + return } await new Promise(resolve => setTimeout(resolve, 5000))