1- import { color , pg , utils } from '@heroku/heroku-cli-util'
1+ import type { pg } from '@heroku/heroku-cli-util'
2+
23import { flags as Flags , HerokuAPIError } from '@heroku-cli/command'
34import * 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'
47import { Args , ux } from '@oclif/core'
8+ import inquirer from 'inquirer'
59import tsheredoc from 'tsheredoc'
610
711import { trapConfirmationRequired } from '../../../../lib/addons/util.js'
812import 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
1020const heredoc = tsheredoc . default
1121
1222export 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}
0 commit comments