Skip to content

Commit c49bf0d

Browse files
committed
Updates to 'data:pg:attachments:create'
1 parent 0df7705 commit c49bf0d

3 files changed

Lines changed: 345 additions & 224 deletions

File tree

Lines changed: 153 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
1-
import {color, pg, utils} from '@heroku/heroku-cli-util'
1+
import type {pg} from '@heroku/heroku-cli-util'
2+
23
import {flags as Flags, HerokuAPIError} from '@heroku-cli/command'
34
import * as Heroku from '@heroku-cli/schema'
5+
import * as color from '@heroku/heroku-cli-util/color'
6+
import {AddonResolver, getAddonService, isAdvancedDatabase} from '@heroku/heroku-cli-util/utils'
47
import {Args, ux} from '@oclif/core'
8+
import inquirer from 'inquirer'
59
import tsheredoc from 'tsheredoc'
610

711
import {trapConfirmationRequired} from '../../../../lib/addons/util.js'
812
import BaseCommand from '../../../../lib/data/baseCommand.js'
13+
import {sortByLeaderAndName, sortByOwnerAndName} from '../../../../lib/data/credentialUtils.js'
14+
import {
15+
AdvancedCredentialState, type CredentialsInfo, type InfoResponse, PoolStatus,
16+
} from '../../../../lib/data/types.js'
917

18+
// eslint-disable-next-line import/no-named-as-default-member
19+
const {prompt} = inquirer
1020
const heredoc = tsheredoc.default
1121

1222
export default class DataPgAttachmentsCreate extends BaseCommand {
@@ -17,25 +27,32 @@ export default class DataPgAttachmentsCreate extends BaseCommand {
1727
}),
1828
}
1929

30+
static baseFlags = BaseCommand.baseFlagsWithoutPrompt()
31+
2032
static description = 'attach an existing Postgres Advanced database to an app'
2133

34+
static examples = [
35+
'<%= config.bin %> <%= command.id %> database_name --app example-app',
36+
]
37+
2238
static flags = {
2339
app: Flags.app({required: true}),
2440
as: Flags.string({description: 'name for Postgres database attachment'}),
2541
confirm: Flags.string({char: 'c', description: 'pass in the app name to skip confirmation prompts'}),
2642
credential: Flags.string({
2743
description: 'credential to use for database',
28-
exclusive: ['pool'],
2944
}),
3045
pool: Flags.string({description: 'instance pool to attach'}),
3146
remote: Flags.remote(),
3247
}
3348

49+
static promptFlagActive = false
50+
3451
public async run(): Promise<void> {
3552
const {args, flags} = await this.parse(DataPgAttachmentsCreate)
3653
const {database: databaseArg} = args
37-
const {app, as, confirm, credential, pool} = flags
38-
const addonResolver = new utils.AddonResolver(this.heroku)
54+
let {app, as, confirm, credential, pool} = flags
55+
const addonResolver = new AddonResolver(this.heroku)
3956

4057
// For attachment creation, app is always the target app where the attachment will be created.
4158
// When attaching to the same app, both add-on name and attachment name will resolve without issues
@@ -44,46 +61,62 @@ export default class DataPgAttachmentsCreate extends BaseCommand {
4461
// find the correct add-on.
4562
let addon: pg.ExtendedAddon
4663
try {
47-
addon = await addonResolver.resolve(databaseArg, app, utils.pg.addonService())
64+
addon = await addonResolver.resolve(databaseArg, app, getAddonService())
4865
} catch (error: unknown) {
4966
if (error instanceof HerokuAPIError && error.http.statusCode === 404) {
50-
addon = await addonResolver.resolve(databaseArg, undefined, utils.pg.addonService())
67+
addon = await addonResolver.resolve(databaseArg, undefined, getAddonService())
5168
} else {
5269
throw error
5370
}
5471
}
5572

56-
if (!utils.pg.isAdvancedDatabase(addon)) {
73+
if (!isAdvancedDatabase(addon)) {
5774
const cmd = `heroku addons:attach ${addon.name} -a ${app}${as ? ` --as ${as}` : ''}`
5875
+ `${credential ? ` --credential ${credential}` : ''}`
5976
ux.error(
6077
'You can only use this command on Advanced-tier databases.\n'
61-
+ `Use ${color.code(cmd)} instead.`,
78+
+ `Run ${color.code(cmd)} instead.`,
6279
)
6380
}
6481

82+
process.stderr.write(heredoc`
83+
84+
Attach Postgres Advanced database to app
85+
${color.disabled('Press Ctrl+C to cancel')}
86+
87+
`)
88+
89+
if (credential === undefined) {
90+
credential = await this.promptCredential(addon.id)
91+
}
92+
93+
if (pool === undefined) {
94+
pool = await this.promptPool(addon.id)
95+
}
96+
97+
if (as === undefined) {
98+
as = await this.promptAttachmentName()
99+
}
100+
65101
const createAttachment = async (confirmed?: string): Promise<Required<Heroku.AddOnAttachment>> => {
66-
let namespace: string | undefined
67-
let attachMessage: string | undefined
68-
if (credential) {
69-
namespace = 'role:' + credential
70-
attachMessage = `Attaching ${color.yellow(credential) + ' on '}${color.addon(addon.name)}`
71-
+ `${as ? ' as ' + color.attachment(as) : ''} to ${color.app(app)}`
72-
} else if (pool) {
73-
namespace = 'pool:' + pool
74-
attachMessage = `Attaching ${color.yellow(pool) + ' on '}${color.addon(addon.name)}`
75-
+ `${as ? ' as ' + color.attachment(as) : ''} to ${color.app(app)}`
76-
} else {
77-
attachMessage = `Attaching ${color.addon(addon.name)}`
78-
+ `${as ? ' as ' + color.attachment(as) : ''} to ${color.app(app)}`
102+
const namespaceConfig = {
103+
pool,
104+
proxy: 'false',
105+
role: credential,
79106
}
80107

108+
const parts: string[] = []
109+
if (credential) parts.push(`credential ${color.yellow(credential)}`)
110+
if (pool) parts.push(`pool ${color.yellow(pool)}`)
111+
const partsStr = parts.length > 0 ? ` with ${parts.join(' and ')} ` : ''
112+
const attachMessage = `Attaching ${color.addon(addon.name)}${partsStr}${as ? ' as ' + color.attachment(as) : ''} to ${color.app(app)}`
113+
81114
const body = {
82115
addon: {name: addon.name},
83116
app: {name: app},
84117
confirm: confirmed,
85118
name: as,
86-
namespace,
119+
namespace_config: namespaceConfig,
87120
}
88121

89122
try {
@@ -94,45 +127,116 @@ export default class DataPgAttachmentsCreate extends BaseCommand {
94127
return attachment
95128
} catch (error) {
96129
ux.action.stop(color.red('!'))
97-
throw error
98-
}
99-
}
100130

101-
if (credential) {
102-
const {body: credentialConfig} = await this.heroku.get<Required<Heroku.AddOnConfig>[]>(
103-
`/addons/${addon.name}/config/role:${encodeURIComponent(credential)}`,
104-
)
105-
if (credentialConfig.length === 0) {
106-
ux.error(heredoc`
107-
The credential ${color.name(credential)} doesn't exist on the database ${color.datastore(addon.name)}.
108-
Use ${color.code(`heroku data:pg:credentials ${addon.name} -a ${app}`)} to list the credentials on the database.`,
109-
{exit: 1},
110-
)
111-
}
112-
} else if (pool) {
113-
const {body: poolConfig} = await this.heroku.get<Required<Heroku.AddOnConfig>[]>(
114-
`/addons/${addon.name}/config/pool:${encodeURIComponent(pool)}`,
115-
)
116-
if (poolConfig.length === 0) {
117-
ux.error(heredoc`
118-
The pool ${color.name(pool)} doesn't exist on the database ${color.datastore(addon.name)}.
119-
Use ${color.code(`heroku data:pg:info ${addon.name} -a ${app}`)} to list the pools on the database.`,
120-
{exit: 1},
121-
)
131+
if (error instanceof Error && error.message.includes('invalid credential provided')) {
132+
ux.error(
133+
heredoc(`
134+
The credential ${color.name(credential)} doesn't exist on the database ${color.datastore(addon.name)}.
135+
Run ${color.command(`heroku data:pg:credentials ${addon.name} -a ${app}`)} to list the credentials on the database.
136+
`).trimEnd(),
137+
{exit: 1},
138+
)
139+
}
140+
141+
if (error instanceof Error && error.message.includes('invalid pool provided')) {
142+
ux.error(
143+
heredoc(`
144+
The pool ${color.name(pool)} doesn't exist on the database ${color.datastore(addon.name)}.
145+
Run ${color.command(`heroku data:pg:info ${addon.name} -a ${app}`)} to list the pools on the database.
146+
`).trimEnd(),
147+
{exit: 1},
148+
)
149+
}
150+
151+
throw error
122152
}
123153
}
124154

125155
const attachment = await trapConfirmationRequired<Required<Heroku.AddOnAttachment>>(app, confirm, (confirmed?: string) => createAttachment(confirmed))
126156

127157
try {
128158
ux.action.start(`Setting ${color.attachment(attachment.name)} config vars and restarting ${color.app(app)}`)
129-
const {body: releases} = await this.heroku.get<Required<Heroku.Release>[]>(`/apps/${app}/releases`, {
130-
headers: {Range: 'version ..; max=1, order=desc'}, partial: true,
131-
})
159+
const {body: releases} = await this.heroku.get<Required<Heroku.Release>[]>(
160+
`/apps/${app}/releases`, {
161+
headers: {Range: 'version ..; max=1, order=desc'}, partial: true,
162+
},
163+
)
132164
ux.action.stop(`done, v${releases[0].version}`)
133165
} catch (error) {
134166
ux.action.stop(color.red('!'))
135167
throw error
136168
}
137169
}
170+
171+
private async promptAttachmentName(): Promise<string | undefined> {
172+
const {attachmentName} = await prompt<{attachmentName: string}>({
173+
message: 'Name for Postgres database attachment, leave blank to randomly generate):',
174+
name: 'attachmentName',
175+
type: 'input',
176+
})
177+
process.stderr.write('\n')
178+
179+
return attachmentName.trim() || undefined
180+
}
181+
182+
private async promptCredential(addonId: string): Promise<string | undefined> {
183+
const {body: {items: credentials}} = await this.dataApi.get<CredentialsInfo>(
184+
`/data/postgres/v1/${addonId}/credentials`,
185+
)
186+
const sortedCredentials = sortByOwnerAndName(credentials)
187+
188+
if (sortedCredentials.length === 0) {
189+
return undefined
190+
}
191+
192+
if (sortedCredentials.length === 1) {
193+
return sortedCredentials[0].name
194+
}
195+
196+
const choices = sortedCredentials.map(cred => ({
197+
disabled: cred.state === AdvancedCredentialState.ACTIVE ? false : 'isn\'t active',
198+
name: cred.type === 'owner' ? `${cred.name} (owner)` : cred.name,
199+
value: cred.name,
200+
}))
201+
202+
const {credential} = await prompt<{credential: string}>({
203+
choices,
204+
message: 'Which credential do you want to use?',
205+
name: 'credential',
206+
type: 'list',
207+
})
208+
process.stderr.write('\n')
209+
210+
return credential || undefined
211+
}
212+
213+
private async promptPool(addonId: string): Promise<string | undefined> {
214+
const {body: {pools}} = await this.dataApi.get<InfoResponse>(`/data/postgres/v1/${addonId}/info`)
215+
216+
const sortedPools = sortByLeaderAndName(pools)
217+
218+
if (sortedPools.length === 0) {
219+
return undefined
220+
}
221+
222+
if (sortedPools.length === 1) {
223+
return sortedPools[0].name
224+
}
225+
226+
const choices = sortedPools.map(p => ({
227+
disabled: p.status === PoolStatus.AVAILABLE ? false : 'isn\'t available',
228+
name: `${p.name} (${p.expected_count} @ ${p.expected_level})`,
229+
value: p.name,
230+
}))
231+
232+
const {pool} = await prompt<{pool: string}>({
233+
choices,
234+
message: 'Which instance pool would you like to attach?',
235+
name: 'pool',
236+
type: 'list',
237+
})
238+
process.stderr.write('\n')
239+
240+
return pool || undefined
241+
}
138242
}

src/lib/data/credentialUtils.ts

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
1-
import type {CredentialInfo} from './types.js'
1+
import type {CredentialInfo, PoolInfoResponse} from './types.js'
2+
3+
export function isLeaderPool(pool: PoolInfoResponse): boolean {
4+
return pool.name === 'leader'
5+
}
6+
7+
export function isOwnerCredential(cred: CredentialInfo): boolean {
8+
return cred.type === 'owner'
9+
}
10+
11+
export function sortByLeaderAndName(pools: PoolInfoResponse[]) {
12+
return pools.sort((a, b) => {
13+
const isLeaderA = isLeaderPool(a)
14+
const isLeaderB = isLeaderPool(b)
15+
16+
return isLeaderB < isLeaderA ? -1 : (isLeaderA < isLeaderB ? 1 : a.name.localeCompare(b.name))
17+
})
18+
}
219

3-
// NEW This is new. something similar exists in core: packages/cli/src/commands/pg/credentials.ts:61
4-
// protected sortByDefaultAndName(credentials: CredentialInfo[]) {
5-
// return credentials.sort((a, b) => {
6-
// const isDefaultA = this.isDefaultCredential(a)
7-
// const isDefaultB = this.isDefaultCredential(b)
8-
//
9-
// return isDefaultB < isDefaultA ? -1 : (isDefaultA < isDefaultB ? 1 : a.name.localeCompare(b.name))
10-
// })
11-
// }
1220
export function sortByOwnerAndName(credentials: CredentialInfo[]) {
1321
return credentials.sort((a, b) => {
1422
const isOwnerA = isOwnerCredential(a)
@@ -17,11 +25,3 @@ export function sortByOwnerAndName(credentials: CredentialInfo[]) {
1725
return isOwnerB < isOwnerA ? -1 : (isOwnerA < isOwnerB ? 1 : a.name.localeCompare(b.name))
1826
})
1927
}
20-
21-
// NEW This is new because we are now sorting on type instead of the name: packages/cli/src/commands/pg/credentials.ts:70
22-
// protected isDefaultCredential(cred: CredentialInfo): boolean {
23-
// return cred.name === 'default'
24-
// }
25-
export function isOwnerCredential(cred: CredentialInfo): boolean {
26-
return cred.type === 'owner'
27-
}

0 commit comments

Comments
 (0)