UNPKG

8.29 kBJavaScriptView Raw
1'use strict'
2
3const cli = require('heroku-cli-util')
4const debug = require('debug')('push')
5const psql = require('../lib/psql')
6const bastion = require('../lib/bastion')
7const cp = require('child_process')
8const util = require('../lib/util')
9
10const env = process.env
11
12function parseExclusions (rawExcludeList) {
13 return (rawExcludeList || '').split(';').map(function (tname) {
14 return tname.trim()
15 }).filter(function (tname) {
16 return (tname !== '')
17 })
18}
19
20function exec (cmd, opts = {}) {
21 debug(cmd)
22 opts = Object.assign({}, opts, { stdio: 'inherit' })
23 try {
24 return cp.execSync(cmd, opts)
25 } catch (err) {
26 if (err.status) process.exit(err.status)
27 throw err
28 }
29}
30
31const prepare = async function (target) {
32 if (target.host === 'localhost' || !target.host) {
33 exec(`createdb ${connArgs(target, true).join(' ')}`)
34 } else {
35 // N.B.: we don't have a proper postgres driver and we don't want to rely on overriding
36 // possible .psqlrc output configurations, so we generate a random marker that is returned
37 // from the query. We avoid including it verbatim in the query text in case the equivalent
38 // of --echo-all is set.
39 const num = Math.random()
40 const emptyMarker = `${num}${num}`
41 let result = await psql.exec(target, `SELECT CASE count(*) WHEN 0 THEN '${num}' || '${num}' END FROM pg_stat_user_tables`)
42 if (!result.includes(emptyMarker)) throw new Error(`Remote database is not empty. Please create a new database or use ${cli.color.cmd('heroku pg:reset')}`)
43 }
44}
45
46function connArgs (uri, skipDFlag) {
47 const args = []
48
49 if (uri.user) args.push('-U', uri.user)
50 if (uri.host) args.push('-h', uri.host)
51 if (uri.port) args.push('-p', `${uri.port}`)
52 if (!skipDFlag) args.push('-d')
53 args.push(uri.database)
54
55 return args
56}
57
58const verifyExtensionsMatch = async function (source, target) {
59 // It's pretty common for local DBs to not have extensions available that
60 // are used by the remote app, so take the final precaution of warning if
61 // the extensions available in the local database don't match. We don't
62 // report it if the difference is solely in the version of an extension
63 // used, though.
64 let sql = 'SELECT extname FROM pg_extension ORDER BY extname;'
65
66 let [extensionTarget, extensionSource] = await Promise.all([
67 psql.exec(target, sql),
68 psql.exec(source, sql)
69 ])
70
71 let extensions = {
72 target: extensionTarget,
73 source: extensionSource
74 }
75
76 // TODO: it shouldn't matter if the target has *more* extensions than the source
77 if (extensions.target !== extensions.source) {
78 cli.warn(`WARNING: Extensions in newly created target database differ from existing source database.
79Target extensions:
80${extensions.target}
81Source extensions:
82${extensions.source}
83HINT: You should review output to ensure that any errors
84ignored are acceptable - entire tables may have been missed, where a dependency
85could not be resolved. You may need to to install a postgresql-contrib package
86and retry.`)
87 }
88}
89
90const maybeTunnel = async function (herokuDb) {
91 // TODO defend against side effects, should find altering code & fix
92 herokuDb = Object.assign({}, herokuDb)
93
94 const configs = bastion.getConfigs(herokuDb)
95 const tunnel = await bastion.sshTunnel(herokuDb, configs.dbTunnelConfig)
96 if (tunnel) {
97 const tunnelHost = {
98 host: 'localhost',
99 port: configs.dbTunnelConfig.localPort,
100 _tunnel: tunnel
101 }
102
103 herokuDb = Object.assign(herokuDb, tunnelHost)
104 }
105 return herokuDb
106}
107
108function spawnPipe (pgDump, pgRestore) {
109 return new Promise((resolve, reject) => {
110 pgDump.stdout.pipe(pgRestore.stdin)
111 pgDump.on('close', code => code ? reject(new Error(`pg_dump errored with ${code}`)) : pgRestore.stdin.end())
112 pgRestore.on('close', code => code ? reject(new Error(`pg_restore errored with ${code}`)) : resolve())
113 })
114}
115
116const run = async function (sourceIn, targetIn, exclusions) {
117 await prepare(targetIn)
118
119 const source = await maybeTunnel(sourceIn)
120 const target = await maybeTunnel(targetIn)
121 const exclude = exclusions.map(function (e) { return '--exclude-table-data=' + e }).join(' ')
122
123 let dumpFlags = ['--verbose', '-F', 'c', '-Z', '0', '-N', '_heroku', ...connArgs(source, true)]
124 if (exclude !== '') dumpFlags.push(exclude)
125
126 const dumpOptions = {
127 env: {
128 PGSSLMODE: 'prefer',
129 ...env
130 },
131 stdio: ['pipe', 'pipe', 2],
132 encoding: 'utf8',
133 shell: true
134 }
135 if (source.password) dumpOptions.env.PGPASSWORD = source.password
136
137 const restoreFlags = ['--verbose', '-F', 'c', '--no-acl', '--no-owner', ...connArgs(target)]
138 const restoreOptions = {
139 env: { ...env },
140 stdio: ['pipe', 'pipe', 2],
141 encoding: 'utf8',
142 shell: true
143 }
144 if (target.password) restoreOptions.env.PGPASSWORD = target.password
145
146 const pgDump = cp.spawn('pg_dump', dumpFlags, dumpOptions)
147 const pgRestore = cp.spawn('pg_restore', restoreFlags, restoreOptions)
148
149 await spawnPipe(pgDump, pgRestore)
150
151 if (source._tunnel) source._tunnel.close()
152 if (target._tunnel) target._tunnel.close()
153
154 await verifyExtensionsMatch(sourceIn, targetIn)
155}
156
157async function push(context, heroku) {
158 const fetcher = require('../lib/fetcher')(heroku)
159 const { app, args } = context
160 const flags = context.flags
161 const exclusions = parseExclusions(flags['exclude-table-data'])
162
163 const source = util.parsePostgresConnectionString(args.source)
164 const target = await fetcher.database(app, args.target)
165
166 cli.log(`heroku-cli: Pushing ${cli.color.cyan(args.source)} ---> ${cli.color.addon(target.attachment.addon.name)}`)
167 await run(source, target, exclusions)
168 cli.log('heroku-cli: Pushing complete.')
169}
170
171async function pull(context, heroku) {
172 const fetcher = require('../lib/fetcher')(heroku)
173 const { app, args } = context
174 const flags = context.flags
175 const exclusions = parseExclusions(flags['exclude-table-data'])
176
177 const source = await fetcher.database(app, args.source)
178 const target = util.parsePostgresConnectionString(args.target)
179
180 cli.log(`heroku-cli: Pulling ${cli.color.addon(source.attachment.addon.name)} ---> ${cli.color.cyan(args.target)}`)
181 await run(source, target, exclusions)
182 cli.log('heroku-cli: Pulling complete.')
183}
184
185let cmd = {
186 topic: 'pg',
187 needsApp: true,
188 needsAuth: true,
189 args: [{ name: 'source' }, { name: 'target' }],
190 flags: [
191 { name: 'exclude-table-data', hasValue: true, description: 'tables for which data should be excluded (use \';\' to split multiple names)' }
192 ]
193}
194
195module.exports = [
196 Object.assign({
197 command: 'push',
198 description: 'push local or remote into Heroku database',
199 help: `Push from SOURCE into TARGET. TARGET must be empty.
200
201To empty a Heroku database for push run \`heroku pg:reset\`
202
203SOURCE must be either the name of a database existing on your localhost or the
204fully qualified URL of a remote database.
205
206Examples:
207
208 # push mylocaldb into a Heroku DB named postgresql-swimmingly-100
209 $ heroku pg:push mylocaldb postgresql-swimmingly-100
210
211 # push remote DB at postgres://myhost/mydb into a Heroku DB named postgresql-swimmingly-100
212 $ heroku pg:push postgres://myhost/mydb postgresql-swimmingly-100
213`,
214 run: cli.command({ preauth: true }, push)
215 }, cmd),
216 Object.assign({
217 command: 'pull',
218 description: 'pull Heroku database into local or remote database',
219 help: `Pull from SOURCE into TARGET.
220
221TARGET must be one of:
222 * a database name (i.e. on a local PostgreSQL server) => TARGET must not exist and will be created
223 * a fully qualified URL to a local PostgreSQL server => TARGET must not exist and will be created
224 * a fully qualified URL to a remote PostgreSQL server => TARGET must exist and be empty
225
226To delete a local database run \`dropdb TARGET\`
227To create an empty remote database, run \`createdb\` with connection command-line options (run \`createdb --help\` for details).
228
229Examples:
230
231 # pull Heroku DB named postgresql-swimmingly-100 into local DB mylocaldb that must not exist
232 $ heroku pg:pull postgresql-swimmingly-100 mylocaldb --app sushi
233
234 # pull Heroku DB named postgresql-swimmingly-100 into empty remote DB at postgres://myhost/mydb
235 $ heroku pg:pull postgresql-swimmingly-100 postgres://myhost/mydb --app sushi
236`,
237 run: cli.command({ preauth: true }, pull)
238 }, cmd)
239]