1 | 'use strict'
|
2 |
|
3 | const cli = require('heroku-cli-util')
|
4 | const debug = require('debug')('push')
|
5 | const psql = require('../lib/psql')
|
6 | const bastion = require('../lib/bastion')
|
7 | const cp = require('child_process')
|
8 | const util = require('../lib/util')
|
9 |
|
10 | const env = process.env
|
11 |
|
12 | function parseExclusions (rawExcludeList) {
|
13 | return (rawExcludeList || '').split(';').map(function (tname) {
|
14 | return tname.trim()
|
15 | }).filter(function (tname) {
|
16 | return (tname !== '')
|
17 | })
|
18 | }
|
19 |
|
20 | function 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 |
|
31 | const prepare = async function (target) {
|
32 | if (target.host === 'localhost' || !target.host) {
|
33 | exec(`createdb ${connArgs(target, true).join(' ')}`)
|
34 | } else {
|
35 |
|
36 |
|
37 |
|
38 |
|
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 |
|
46 | function 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 |
|
58 | const verifyExtensionsMatch = async function (source, target) {
|
59 |
|
60 |
|
61 |
|
62 |
|
63 |
|
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 |
|
77 | if (extensions.target !== extensions.source) {
|
78 | cli.warn(`WARNING: Extensions in newly created target database differ from existing source database.
|
79 | Target extensions:
|
80 | ${extensions.target}
|
81 | Source extensions:
|
82 | ${extensions.source}
|
83 | HINT: You should review output to ensure that any errors
|
84 | ignored are acceptable - entire tables may have been missed, where a dependency
|
85 | could not be resolved. You may need to to install a postgresql-contrib package
|
86 | and retry.`)
|
87 | }
|
88 | }
|
89 |
|
90 | const maybeTunnel = async function (herokuDb) {
|
91 |
|
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 |
|
108 | function 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 |
|
116 | const 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 |
|
157 | async 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 |
|
171 | async 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 |
|
185 | let 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 |
|
195 | module.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 |
|
201 | To empty a Heroku database for push run \`heroku pg:reset\`
|
202 |
|
203 | SOURCE must be either the name of a database existing on your localhost or the
|
204 | fully qualified URL of a remote database.
|
205 |
|
206 | Examples:
|
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 |
|
221 | TARGET 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 |
|
226 | To delete a local database run \`dropdb TARGET\`
|
227 | To create an empty remote database, run \`createdb\` with connection command-line options (run \`createdb --help\` for details).
|
228 |
|
229 | Examples:
|
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 | ]
|