@heroku-cli/plugin-pg-v5
Version:
Heroku CLI plugin to manage Postgres.
180 lines (154 loc) • 7.01 kB
JavaScript
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),
}