Skip to content

Commit 97798e2

Browse files
authored
Feat: Create pg:upgrade:prepare command (#3265)
2 parents ea51346 + 36ee49b commit 97798e2

File tree

6 files changed

+287
-10
lines changed

6 files changed

+287
-10
lines changed

packages/cli/src/commands/pg/upgrade.ts renamed to packages/cli/src/commands/pg/upgrade/index.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ import color from '@heroku-cli/color'
22
import {Command, flags} from '@heroku-cli/command'
33
import {Args, ux} from '@oclif/core'
44
import heredoc from 'tsheredoc'
5-
import {getAddon} from '../../lib/pg/fetcher'
6-
import pgHost from '../../lib/pg/host'
7-
import {legacyEssentialPlan, databaseNameFromUrl} from '../../lib/pg/util'
8-
import {PgDatabase} from '../../lib/pg/types'
5+
import {getAddon} from '../../../lib/pg/fetcher'
6+
import pgHost from '../../../lib/pg/host'
7+
import {legacyEssentialPlan, databaseNameFromUrl} from '../../../lib/pg/util'
8+
import {PgDatabase} from '../../../lib/pg/types'
99
import * as Heroku from '@heroku-cli/schema'
10-
import confirmCommand from '../../lib/confirmCommand'
11-
import {nls} from '../../nls'
10+
import confirmCommand from '../../../lib/confirmCommand'
11+
import {nls} from '../../../nls'
1212

1313
export default class Upgrade extends Command {
1414
static topic = 'pg';
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import color from '@heroku-cli/color'
2+
import {Command, flags} from '@heroku-cli/command'
3+
import {Args, ux} from '@oclif/core'
4+
import heredoc from 'tsheredoc'
5+
import {getAddon} from '../../../lib/pg/fetcher'
6+
import pgHost from '../../../lib/pg/host'
7+
import {legacyEssentialPlan, essentialNumPlan, formatResponseWithCommands} from '../../../lib/pg/util'
8+
import {PgDatabase, PgUpgradeError, PgUpgradeResponse} from '../../../lib/pg/types'
9+
import confirmCommand from '../../../lib/confirmCommand'
10+
import {nls} from '../../../nls'
11+
12+
export default class Upgrade extends Command {
13+
static topic = 'pg';
14+
static description = heredoc(`
15+
Prepares the upgrade for Standard-tier and higher leader databases and schedules it for the next available maintenance window. To start a version upgrade on Essential-tier and follower databases, use ${color.cmd('heroku pg:upgrade:run')} instead.
16+
`)
17+
18+
static flags = {
19+
confirm: flags.string({char: 'c'}),
20+
version: flags.string({char: 'v', description: 'Postgres version to upgrade to'}),
21+
app: flags.app({required: true}),
22+
}
23+
24+
static args = {
25+
database: Args.string({description: `${nls('pg:database:arg:description')} ${nls('pg:database:arg:description:default:suffix')}`}),
26+
}
27+
28+
public async run(): Promise<void> {
29+
const {flags, args} = await this.parse(Upgrade)
30+
const {app, version, confirm} = flags
31+
const {database} = args
32+
33+
const db = await getAddon(this.heroku, app, database)
34+
if (legacyEssentialPlan(db))
35+
ux.error(`You can only use ${color.cmd('pg:upgrade:*')} commands on Essential-* and higher plans.`)
36+
37+
if (essentialNumPlan(db))
38+
ux.error(`You can only use ${color.cmd('heroku pg:upgrade:prepare')} on Standard-tier and higher leader databases. For Essential-tier databases, use ${color.cmd('heroku pg:upgrade:run')} instead.`)
39+
40+
const versionPhrase = version ? heredoc(`Postgres version ${version}`) : heredoc('the latest supported Postgres version')
41+
const {body: replica} = await this.heroku.get<PgDatabase>(`/client/v11/databases/${db.id}`, {hostname: pgHost()})
42+
43+
if (replica.following)
44+
ux.error(`You can only use ${color.cmd('heroku pg:upgrade:prepare')} on Standard-tier and higher leader databases. For follower databases, use ${color.cmd('heroku pg:upgrade:run')} instead.`)
45+
46+
await confirmCommand(app, confirm, heredoc(`
47+
Destructive action
48+
This command prepares the upgrade for ${color.addon(db.name)} to ${versionPhrase} and schedules to upgrade it during the next available maintenance window.
49+
`))
50+
51+
try {
52+
const data = {version}
53+
ux.action.start(`Preparing upgrade on ${color.addon(db.name)}`)
54+
const response = await this.heroku.post<PgUpgradeResponse>(`/client/v11/databases/${db.id}/upgrade/prepare`, {hostname: pgHost(), body: data})
55+
ux.action.stop(heredoc(`done\n${formatResponseWithCommands(response.body.message)}`))
56+
} catch (error) {
57+
const response = error as PgUpgradeError
58+
ux.error(heredoc(`${formatResponseWithCommands(response.body.message)}\n\nError ID: ${response.body.id}`))
59+
}
60+
}
61+
}

packages/cli/src/lib/pg/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,18 @@ export type PgDatabaseTenant = {
167167

168168
export type PgDatabase = PgDatabaseService & PgDatabaseTenant
169169

170+
export type PgUpgradeResponse = {
171+
message: string
172+
}
173+
174+
export type PgUpgradeError = {
175+
status: number,
176+
body: {
177+
id: string,
178+
message: string,
179+
}
180+
}
181+
170182
export type AddOnWithPlan = Required<Heroku.AddOnAttachment['addon']> & {plan: Required<Heroku.AddOn['plan']>}
171183
export type AddOnAttachmentWithConfigVarsAndPlan = Required<Heroku.AddOnAttachment> & {
172184
config_vars: Heroku.AddOn['config_vars']

packages/cli/src/lib/pg/util.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ export function getConfigVarNameFromAttachment(attachment: Required<AddOnAttachm
4040
return getConfigVarName(configVars)
4141
}
4242

43+
export function formatResponseWithCommands(response: string): string {
44+
return response.replace(/`(.*?)`/g, (_, word) => color.cmd(word))
45+
}
46+
4347
export function presentCredentialAttachments(app: string, credAttachments: Required<AddOnAttachment>[], credentials: CredentialsInfo, cred: string) {
4448
const isForeignApp = (attOrAddon: Required<AddOnAttachment>) => attOrAddon.app.name === app ? 0 : 1
4549
const comparators = [

packages/cli/test/unit/commands/pg/upgrade.unit.test.ts renamed to packages/cli/test/unit/commands/pg/upgrade/index.unit.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import {stderr} from 'stdout-stderr'
2-
import Cmd from '../../../../src/commands/pg/upgrade'
3-
import runCommand from '../../../helpers/runCommand'
4-
import expectOutput from '../../../helpers/utils/expectOutput'
2+
import Cmd from '../../../../../src/commands/pg/upgrade'
3+
import runCommand from '../../../../helpers/runCommand'
4+
import expectOutput from '../../../../helpers/utils/expectOutput'
55
import {expect} from 'chai'
66
import * as nock from 'nock'
77
import heredoc from 'tsheredoc'
8-
import * as fixtures from '../../../fixtures/addons/fixtures'
8+
import * as fixtures from '../../../../fixtures/addons/fixtures'
99

1010
describe('pg:upgrade', function () {
1111
const hobbyAddon = fixtures.addons['www-db']
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import {stderr} from 'stdout-stderr'
2+
import Cmd from '../../../../../src/commands/pg/upgrade/prepare'
3+
import runCommand from '../../../../helpers/runCommand'
4+
import expectOutput from '../../../../helpers/utils/expectOutput'
5+
import {expect} from 'chai'
6+
import * as nock from 'nock'
7+
import heredoc from 'tsheredoc'
8+
import * as fixtures from '../../../../fixtures/addons/fixtures'
9+
import color from '@heroku-cli/color'
10+
import {ux} from '@oclif/core'
11+
import * as sinon from 'sinon'
12+
const stripAnsi = require('strip-ansi')
13+
14+
describe('pg:upgrade:prepare', function () {
15+
const addon = fixtures.addons['dwh-db']
16+
let uxWarnStub: sinon.SinonStub
17+
let uxPromptStub: sinon.SinonStub
18+
19+
before(function () {
20+
uxWarnStub = sinon.stub(ux, 'warn')
21+
uxPromptStub = sinon.stub(ux, 'prompt').resolves('myapp')
22+
})
23+
24+
beforeEach(async function () {
25+
uxWarnStub.resetHistory()
26+
uxPromptStub.resetHistory()
27+
})
28+
29+
afterEach(async function () {
30+
nock.cleanAll()
31+
})
32+
33+
after(function () {
34+
uxWarnStub.restore()
35+
uxPromptStub.restore()
36+
})
37+
38+
it('refuses to prepare upgrade on legacy essential dbs', async function () {
39+
const hobbyAddon = fixtures.addons['www-db']
40+
41+
nock('https://api.heroku.com')
42+
.post('/actions/addon-attachments/resolve')
43+
.reply(200, [{addon: hobbyAddon}])
44+
await runCommand(Cmd, [
45+
'--app',
46+
'myapp',
47+
'--confirm',
48+
'myapp',
49+
]).catch(error => {
50+
expectOutput(error.message, heredoc(`
51+
You can only use ${color.cmd('pg:upgrade:*')} commands on Essential-* and higher plans.
52+
`))
53+
})
54+
})
55+
56+
it('refuses to prepare upgrade on essential tier dbs', async function () {
57+
const essentialAddon = {
58+
name: 'postgres-1', plan: {name: 'heroku-postgresql:essential-0'},
59+
}
60+
61+
nock('https://api.heroku.com')
62+
.post('/actions/addon-attachments/resolve')
63+
.reply(200, [{addon: essentialAddon}])
64+
65+
await runCommand(Cmd, [
66+
'--app',
67+
'myapp',
68+
'--confirm',
69+
'myapp',
70+
]).catch(error => {
71+
expect(error.message).to.equal(`You can only use ${color.cmd('heroku pg:upgrade:prepare')} on Standard-tier and higher leader databases. For Essential-tier databases, use ${color.cmd('heroku pg:upgrade:run')} instead.`)
72+
})
73+
})
74+
75+
it('refuses to upgrade follower dbs', async function () {
76+
nock('https://api.heroku.com')
77+
.post('/actions/addon-attachments/resolve')
78+
.reply(200, [{addon: addon}])
79+
nock('https://api.data.heroku.com')
80+
.get(`/client/v11/databases/${addon.id}`)
81+
.reply(200, {
82+
following: 'postgres://xxx.com:5432/abcdefghijklmn',
83+
leader: {
84+
addon_id: '5ba2ba8b-07a9-4a65-a808-585a50e37f98',
85+
name: 'postgresql-leader',
86+
},
87+
})
88+
await runCommand(Cmd, [
89+
'--app',
90+
'myapp',
91+
'--confirm',
92+
'myapp',
93+
]).catch(error => {
94+
expectOutput(error.message, heredoc(`
95+
You can only use ${color.cmd('heroku pg:upgrade:prepare')} on Standard-tier and higher leader databases. For follower databases, use ${color.cmd('heroku pg:upgrade:run')} instead.
96+
`))
97+
})
98+
})
99+
100+
it('upgrades db with version flag', async function () {
101+
nock('https://api.heroku.com')
102+
.post('/actions/addon-attachments/resolve')
103+
.reply(200, [{addon}])
104+
nock('https://api.heroku.com')
105+
.get('/apps/myapp/config-vars')
106+
.reply(200, {DATABASE_URL: 'postgres://db1'})
107+
nock('https://api.data.heroku.com')
108+
.get(`/client/v11/databases/${addon.id}`)
109+
.reply(200)
110+
nock('https://api.data.heroku.com')
111+
.post(`/client/v11/databases/${addon.id}/upgrade/prepare`)
112+
.reply(200, {message: 'Your database is scheduled for upgrade during your next available maintenance window.\nRun heroku pg:upgrade:wait to track its status.\nYou can also run this upgrade manually before the maintenance window with heroku pg:upgrade:run. You can only run the upgrade after it\'s fully prepared, which can take up to a day.'})
113+
114+
const message = heredoc(`
115+
Destructive action
116+
This command prepares the upgrade for ${addon.name} to Postgres version 15 and schedules to upgrade it during the next available maintenance window.
117+
`)
118+
119+
await runCommand(Cmd, [
120+
'--app',
121+
'myapp',
122+
'--version',
123+
'15',
124+
])
125+
126+
expect(stripAnsi(uxPromptStub.args[0].toString())).contains('To proceed, type myapp')
127+
expect(stripAnsi(uxWarnStub.args[0].toString())).to.eq(message)
128+
129+
expectOutput(stderr.output, heredoc(`
130+
Preparing upgrade on ${addon.name}...
131+
Preparing upgrade on ${addon.name}... done
132+
Your database is scheduled for upgrade during your next available maintenance window.
133+
Run heroku pg:upgrade:wait to track its status.
134+
You can also run this upgrade manually before the maintenance window with heroku pg:upgrade:run. You can only run the upgrade after it's fully prepared, which can take up to a day.
135+
`))
136+
})
137+
138+
it('upgrades db without a version flag', async function () {
139+
nock('https://api.heroku.com')
140+
.post('/actions/addon-attachments/resolve')
141+
.reply(200, [{addon}])
142+
nock('https://api.heroku.com')
143+
.get('/apps/myapp/config-vars')
144+
.reply(200, {DATABASE_URL: 'postgres://db1'})
145+
nock('https://api.data.heroku.com')
146+
.get(`/client/v11/databases/${addon.id}`)
147+
.reply(200)
148+
nock('https://api.data.heroku.com')
149+
.post(`/client/v11/databases/${addon.id}/upgrade/prepare`)
150+
.reply(200, {message: 'Your database is scheduled for upgrade during your next available maintenance window.\nRun heroku pg:upgrade:wait to track its status.\nYou can also run this upgrade manually before the maintenance window with heroku pg:upgrade:run. You can only run the upgrade after it\'s fully prepared, which can take up to a day.'})
151+
152+
const message = heredoc(`
153+
Destructive action
154+
This command prepares the upgrade for ${addon.name} to the latest supported Postgres version and schedules to upgrade it during the next available maintenance window.
155+
`)
156+
157+
await runCommand(Cmd, [
158+
'--app',
159+
'myapp',
160+
])
161+
162+
expect(stripAnsi(uxPromptStub.args[0].toString())).contains('To proceed, type myapp')
163+
expect(stripAnsi(uxWarnStub.args[0].toString())).to.eq(message)
164+
165+
expectOutput(stderr.output, heredoc(`
166+
Preparing upgrade on ${addon.name}...
167+
Preparing upgrade on ${addon.name}... done
168+
Your database is scheduled for upgrade during your next available maintenance window.
169+
Run heroku pg:upgrade:wait to track its status.
170+
You can also run this upgrade manually before the maintenance window with heroku pg:upgrade:run. You can only run the upgrade after it's fully prepared, which can take up to a day.
171+
`))
172+
})
173+
174+
it('catches the error', async function () {
175+
nock('https://api.heroku.com')
176+
.post('/actions/addon-attachments/resolve')
177+
.reply(200, [{addon}])
178+
nock('https://api.heroku.com')
179+
.get('/apps/myapp/config-vars')
180+
.reply(200, {DATABASE_URL: 'postgres://db1'})
181+
nock('https://api.data.heroku.com')
182+
.get(`/client/v11/databases/${addon.id}`)
183+
.reply(200)
184+
nock('https://api.data.heroku.com')
185+
.post(`/client/v11/databases/${addon.id}/upgrade/prepare`)
186+
.reply(422, {id: 'unprocessable_entity', message: 'database has an upgrade already scheduled, please check `pg:upgrade:wait` for more information on the status of your upgrade.'})
187+
188+
await runCommand(Cmd, [
189+
'--app',
190+
'myapp',
191+
'--confirm',
192+
'myapp',
193+
]).catch(error => {
194+
expect(error.message).to.equal(heredoc(`
195+
database has an upgrade already scheduled, please check ${color.cmd('pg:upgrade:wait')} for more information on the status of your upgrade.
196+
197+
Error ID: unprocessable_entity`))
198+
})
199+
})
200+
})

0 commit comments

Comments
 (0)