-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathpsql.ts
176 lines (154 loc) · 6.66 KB
/
psql.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
import {HTTP, HTTPError} from '@heroku/http-call'
import color from '@heroku-cli/color'
import {Command, flags} from '@heroku-cli/command'
import fs from 'fs'
import {applyActionSpinner} from '../../async-actions'
import {getBorealisPgApiUrl, getBorealisPgAuthHeader} from '../../borealis-api'
import {
addonOptionName,
appOptionName,
cliOptions,
consoleColours,
formatCliOptionName,
localPgHostname,
portOptionName,
processAddonAttachmentInfo,
writeAccessOptionName,
} from '../../command-components'
import {createHerokuAuth, fetchAddonAttachmentInfo, removeHerokuAuth} from '../../heroku-api'
import {
DbConnectionInfo,
FullConnectionInfo,
openSshTunnel,
SshConnectionInfo,
tunnelServices,
} from '../../ssh-tunneling'
const binaryPathOptionName = 'binary-path'
export default class PsqlCommand extends Command {
static description = `runs psql with a secure tunnel to a Borealis Isolated Postgres add-on
This operation establishes a temporary secure tunnel to an add-on database to
provide an interactive psql session. It requires that the psql command is
installed on the local machine; generally, psql is installed along with
PostgreSQL (https://www.postgresql.org/download/).
The psql session will be initiated as a database user role that is
specifically tied to the current Heroku user account. By default the user role
allows read-only access to the add-on database; to enable read and write
access, supply the ${formatCliOptionName(writeAccessOptionName)} option.
Note that any tables, indexes, views or other objects that are created when
connected as a personal user role will be owned by that user role rather than
the application database user role unless ownership is explicitly reassigned
afterward (for example, by using the REASSIGN OWNED command).
To override the path to the psql binary, supply the ${formatCliOptionName(binaryPathOptionName)} option.
See also the ${consoleColours.cliCmdName('borealis-pg:run')} command to execute a noninteractive script or the
${consoleColours.cliCmdName('borealis-pg:tunnel')} command to start a secure tunnel session that can be used
in combination with any PostgreSQL client (e.g. a graphical user interface like
pgAdmin).`
static examples = [
`$ heroku borealis-pg:psql --${appOptionName} sushi --${binaryPathOptionName} /path/to/psql`,
`$ heroku borealis-pg:psql --${appOptionName} sushi --${addonOptionName} BOREALIS_PG_MAROON --${writeAccessOptionName}`,
`$ heroku borealis-pg:psql --${addonOptionName} borealis-pg-hex-12345`,
]
static flags = {
[addonOptionName]: cliOptions.addon,
[appOptionName]: cliOptions.app,
[binaryPathOptionName]: flags.string({
char: 'b',
description: 'custom path to a psql binary',
required: false,
}),
[portOptionName]: cliOptions.port,
[writeAccessOptionName]: cliOptions.writeAccess,
}
async run() {
const {flags} = await this.parse(PsqlCommand)
const customBinaryPath = flags[binaryPathOptionName]
if (customBinaryPath && !fs.existsSync(customBinaryPath)) {
this.error(`The file "${customBinaryPath}" does not exist`)
}
const attachmentInfo =
await fetchAddonAttachmentInfo(this.heroku, flags.addon, flags.app, this.error)
const addonInfo = processAddonAttachmentInfo(attachmentInfo, this.error)
const [sshConnInfo, dbConnInfo] = await this.prepareUsers(
addonInfo,
flags[writeAccessOptionName])
this.executePsql(
{ssh: sshConnInfo, db: dbConnInfo, localPgPort: flags.port},
customBinaryPath ?? 'psql')
// Prevent Ctrl+C from ending the process
tunnelServices.nodeProcess.on('SIGINT', _ => null)
}
private async prepareUsers(
addonInfo: {addonName: string; appName: string; attachmentName: string},
enableWriteAccess: boolean): Promise<[SshConnectionInfo, DbConnectionInfo]> {
const authorization = await createHerokuAuth(this.heroku)
try {
const sshConnInfoPromise = HTTP
.post<SshConnectionInfo>(
getBorealisPgApiUrl(`/heroku/resources/${addonInfo.addonName}/personal-ssh-users`),
{headers: {Authorization: getBorealisPgAuthHeader(authorization)}})
.then(value => value.body)
const dbConnInfoPromise = HTTP
.post<DbConnectionInfo>(
getBorealisPgApiUrl(`/heroku/resources/${addonInfo.addonName}/personal-db-users`),
{
headers: {Authorization: getBorealisPgAuthHeader(authorization)},
body: {enableWriteAccess},
})
.then(value => value.body)
const fullConnInfoPromise = Promise.allSettled([sshConnInfoPromise, dbConnInfoPromise])
const accessLevelName = enableWriteAccess ? 'read/write' : 'read-only'
const [sshConnInfoResult, dbConnInfoResult] = await applyActionSpinner(
`Configuring ${accessLevelName} user session for add-on ${color.addon(addonInfo.addonName)}`,
fullConnInfoPromise,
)
if (sshConnInfoResult.status === 'rejected') {
throw sshConnInfoResult.reason
} else if (dbConnInfoResult.status === 'rejected') {
throw dbConnInfoResult.reason
}
return [sshConnInfoResult.value, dbConnInfoResult.value]
} finally {
await removeHerokuAuth(this.heroku, authorization.id as string)
}
}
private executePsql(connInfo: FullConnectionInfo, psqlPath: string): void {
openSshTunnel(
connInfo,
{debug: this.debug, info: this.log, warn: this.warn, error: this.error},
sshClient => tunnelServices.childProcessFactory.spawn(psqlPath, {
env: {
...tunnelServices.nodeProcess.env,
PGHOST: localPgHostname,
PGPORT: connInfo.localPgPort.toString(),
PGDATABASE: connInfo.db.dbName,
PGUSER: connInfo.db.dbUsername,
PGPASSWORD: connInfo.db.dbPassword,
},
shell: true,
stdio: 'inherit',
}).on('exit', (code, _) => {
sshClient.end()
tunnelServices.nodeProcess.exit(code ?? undefined)
}),
)
}
async catch(err: any) {
/* istanbul ignore else */
if (err instanceof HTTPError) {
if (err.statusCode === 403) {
this.error(
'Access to the add-on database has been temporarily revoked for personal users. ' +
'Generally this indicates the database has persistently exceeded its storage limit. ' +
'Try upgrading to a new add-on plan to restore access.')
} else if (err.statusCode === 404) {
this.error('Add-on is not a Borealis Isolated Postgres add-on')
} else if (err.statusCode === 422) {
this.error('Add-on is not finished provisioning')
} else {
this.error('Add-on service is temporarily unavailable. Try again later.')
}
} else {
throw err
}
}
}