1 | 'use strict'
|
2 |
|
3 | let tls = require('tls')
|
4 | let url = require('url')
|
5 | let tty = require('tty')
|
6 | let {Duplex, Transform} = require('stream')
|
7 | let cli = require('heroku-cli-util')
|
8 | let helpers = require('../lib/helpers')
|
9 |
|
10 | const http = require('https')
|
11 | const net = require('net')
|
12 | const spawn = require('child_process').spawn
|
13 | const debug = require('debug')('heroku:run')
|
14 | const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
15 |
|
16 |
|
17 | class Dyno extends Duplex {
|
18 | |
19 |
|
20 |
|
21 |
|
22 |
|
23 |
|
24 |
|
25 |
|
26 |
|
27 |
|
28 |
|
29 |
|
30 |
|
31 | constructor (opts) {
|
32 | super()
|
33 | this.cork()
|
34 | this.opts = opts
|
35 | this.heroku = opts.heroku
|
36 | if (this.opts.showStatus === undefined) this.opts.showStatus = true
|
37 | }
|
38 |
|
39 | |
40 |
|
41 |
|
42 |
|
43 | start () {
|
44 | this._startedAt = new Date()
|
45 | const dynoStart = this._doStart()
|
46 | if (this.opts.showStatus) {
|
47 | return cli.action(`Running ${cli.color.cyan.bold(this.opts.command)} on ${cli.color.app(this.opts.app)}`, {success: false}, dynoStart)
|
48 | } else return dynoStart
|
49 | }
|
50 |
|
51 | _doStart (retries = 2) {
|
52 | let command = this.opts['exit-code'] ? `${this.opts.command}; echo "\uFFFF heroku-command-exit-status: $?"` : this.opts.command
|
53 | return this.heroku.post(this.opts.dyno ? `/apps/${this.opts.app}/dynos/${this.opts.dyno}` : `/apps/${this.opts.app}/dynos`, {
|
54 | headers: {
|
55 | Accept: this.opts.dyno ? 'application/vnd.heroku+json; version=3.run-inside' : 'application/vnd.heroku+json; version=3'
|
56 | },
|
57 | body: {
|
58 | command: command,
|
59 | attach: this.opts.attach,
|
60 | size: this.opts.size,
|
61 | type: this.opts.type,
|
62 | env: this._env(),
|
63 | force_no_tty: this.opts['no-tty']
|
64 | }
|
65 | })
|
66 | .then(dyno => {
|
67 | this.dyno = dyno
|
68 | if (this.opts.attach || this.opts.dyno) {
|
69 | if (this.dyno.name && this.opts.dyno === undefined) this.opts.dyno = this.dyno.name
|
70 | return this.attach()
|
71 | } else if (this.opts.showStatus) {
|
72 | cli.action.done(this._status('done'))
|
73 | }
|
74 | })
|
75 | .catch(err => {
|
76 |
|
77 |
|
78 |
|
79 |
|
80 |
|
81 |
|
82 | if (err.statusCode === 409 && retries > 0) {
|
83 | return this._doStart(retries - 1)
|
84 | } else {
|
85 | throw err
|
86 | }
|
87 | })
|
88 | }
|
89 |
|
90 | |
91 |
|
92 |
|
93 | attach () {
|
94 | this.pipe(process.stdout)
|
95 | this.uri = url.parse(this.dyno.attach_url)
|
96 | if (this._useSSH) {
|
97 | this.p = this._ssh()
|
98 | } else {
|
99 | this.p = this._rendezvous()
|
100 | }
|
101 | return this.p.then(() => {
|
102 | this.end()
|
103 | })
|
104 | }
|
105 |
|
106 | get _useSSH () {
|
107 | return this.uri.protocol === 'http:' || this.uri.protocol === 'https:'
|
108 | }
|
109 |
|
110 | _rendezvous () {
|
111 | return new Promise((resolve, reject) => {
|
112 | this.resolve = resolve
|
113 | this.reject = reject
|
114 |
|
115 | if (this.opts.showStatus) cli.action.status(this._status('starting'))
|
116 | let c = tls.connect(this.uri.port, this.uri.hostname, {rejectUnauthorized: this.heroku.options.rejectUnauthorized})
|
117 | c.setTimeout(1000 * 60 * 60)
|
118 | c.setEncoding('utf8')
|
119 | c.on('connect', () => {
|
120 | debug('connect')
|
121 | c.write(this.uri.path.substr(1) + '\r\n', () => {
|
122 | if (this.opts.showStatus) cli.action.status(this._status('connecting'))
|
123 | })
|
124 | })
|
125 | c.on('data', this._readData(c))
|
126 | c.on('close', () => {
|
127 | debug('close')
|
128 | this.opts['exit-code'] ? this.reject('No exit code returned') : this.resolve()
|
129 | if (this.unpipeStdin) this.unpipeStdin()
|
130 | })
|
131 | c.on('error', this.reject)
|
132 | c.on('timeout', () => {
|
133 | debug('timeout')
|
134 | c.end()
|
135 | this.reject(new Error('timed out'))
|
136 | })
|
137 | process.once('SIGINT', () => c.end())
|
138 | })
|
139 | }
|
140 |
|
141 | async _ssh (retries = 20) {
|
142 | const interval = 1000
|
143 |
|
144 | try {
|
145 | const dyno = await this.heroku.get(`/apps/${this.opts.app}/dynos/${this.opts.dyno}`)
|
146 | this.dyno = dyno
|
147 | cli.action.done(this._status(this.dyno.state))
|
148 |
|
149 | if (this.dyno.state === 'starting' || this.dyno.state === 'up') {
|
150 | return this._connect()
|
151 | } else {
|
152 | await wait(interval)
|
153 | return this._ssh()
|
154 | }
|
155 | } catch (err) {
|
156 |
|
157 | if (err.statusCode === 404 && retries > 0) {
|
158 | return this._ssh(retries - 1)
|
159 | } else {
|
160 | throw err
|
161 | }
|
162 | }
|
163 | }
|
164 |
|
165 | _connect () {
|
166 | return new Promise((resolve, reject) => {
|
167 | this.resolve = resolve
|
168 | this.reject = reject
|
169 |
|
170 | let options = this.uri
|
171 | options.headers = {'Connection': 'Upgrade', 'Upgrade': 'tcp'}
|
172 | options.rejectUnauthorized = false
|
173 | let r = http.request(options)
|
174 | r.end()
|
175 |
|
176 | r.on('error', this.reject)
|
177 | r.on('upgrade', (_, remote, head) => {
|
178 | let s = net.createServer((client) => {
|
179 | client.on('end', () => {
|
180 | s.close()
|
181 | this.resolve()
|
182 | })
|
183 | client.on('connect', () => s.close())
|
184 |
|
185 | client.on('error', () => this.reject)
|
186 | remote.on('error', () => this.reject)
|
187 |
|
188 | client.setNoDelay(true)
|
189 | remote.setNoDelay(true)
|
190 |
|
191 | remote.on('data', (data) => client.write(data))
|
192 | client.on('data', (data) => remote.write(data))
|
193 | })
|
194 |
|
195 | s.listen(0, 'localhost', () => this._handle(s))
|
196 |
|
197 | s.on('close', () => {
|
198 | r.abort()
|
199 | })
|
200 | })
|
201 | })
|
202 | }
|
203 |
|
204 | _handle (localServer) {
|
205 | let addr = localServer.address()
|
206 | let host = addr.address
|
207 | let port = addr.port
|
208 | let lastErr = ''
|
209 |
|
210 |
|
211 | this.uncork()
|
212 | if (this.opts.listen) {
|
213 | cli.log(`listening on port ${host}:${port} for ssh client`)
|
214 | } else {
|
215 | let params = [host, '-p', port, '-oStrictHostKeyChecking=no', '-oUserKnownHostsFile=/dev/null', '-oServerAliveInterval=20']
|
216 |
|
217 | const stdio = [0, 1, 'pipe']
|
218 | if (this.opts['exit-code']) {
|
219 | stdio[1] = 'pipe'
|
220 | if (process.stdout.isTTY) {
|
221 |
|
222 | params.push('-t')
|
223 | }
|
224 | }
|
225 | let sshProc = spawn('ssh', params, {stdio})
|
226 |
|
227 |
|
228 | if (sshProc.stdout) {
|
229 | sshProc.stdout.setEncoding('utf8')
|
230 | sshProc.stdout.on('data', this._readData())
|
231 | }
|
232 |
|
233 | sshProc.stderr.on('data', (data) => {
|
234 | lastErr = data
|
235 |
|
236 |
|
237 | if (this._isDebug() || (data.includes("Warning: Permanently added '[127.0.0.1]") && data.includes('Permission denied (publickey).'))) {
|
238 | process.stderr.write(data)
|
239 | }
|
240 | })
|
241 | sshProc.on('close', (code, signal) => {
|
242 |
|
243 | if (lastErr.length > 0 && lastErr.includes('Permission denied')) {
|
244 | cli.error('There was a problem connecting to the dyno.')
|
245 | if (process.env.SSH_AUTH_SOCK) {
|
246 | cli.error('Confirm that your ssh key is added to your agent by running `ssh-add`.')
|
247 | }
|
248 | cli.error('Check that your ssh key has been uploaded to heroku with `heroku keys:add`.')
|
249 | cli.error(`See ${cli.color.cyan('https://devcenter.heroku.com/articles/one-off-dynos#shield-private-spaces')}`)
|
250 | }
|
251 |
|
252 | localServer.close()
|
253 | })
|
254 | this.p
|
255 | .then(() => sshProc.kill())
|
256 | .catch(() => sshProc.kill())
|
257 | }
|
258 | this._notify()
|
259 | }
|
260 |
|
261 | _isDebug () {
|
262 | let debug = process.env.HEROKU_DEBUG
|
263 | return debug && (debug === '1' || debug.toUpperCase() === 'TRUE')
|
264 | }
|
265 |
|
266 | _env () {
|
267 | let c = this.opts.env ? helpers.buildEnvFromFlag(this.opts.env) : {}
|
268 | c.TERM = process.env.TERM
|
269 | if (tty.isatty(1)) {
|
270 | c.COLUMNS = process.stdout.columns
|
271 | c.LINES = process.stdout.rows
|
272 | }
|
273 | return c
|
274 | }
|
275 |
|
276 | _status (status) {
|
277 | let size = this.dyno.size ? ` (${this.dyno.size})` : ''
|
278 | return `${status}, ${this.dyno.name || this.opts.dyno}${size}`
|
279 | }
|
280 |
|
281 | _readData (c) {
|
282 | let firstLine = true
|
283 | return data => {
|
284 | debug('input: %o', data)
|
285 |
|
286 | if (c && firstLine) {
|
287 | if (this.opts.showStatus) cli.action.done(this._status('up'))
|
288 | firstLine = false
|
289 | this._readStdin(c)
|
290 | return
|
291 | }
|
292 | this._notify()
|
293 |
|
294 |
|
295 |
|
296 | if (!process.stdout.isTTY) data = data.replace(new RegExp('\r\n', 'g'), '\n')
|
297 |
|
298 | let exitCode = data.match(/\uFFFF heroku-command-exit-status: (\d+)/m)
|
299 | if (exitCode) {
|
300 | debug('got exit code: %d', exitCode[1])
|
301 | this.push(data.replace(/^\uFFFF heroku-command-exit-status: \d+$\n?/m, ''))
|
302 | let code = parseInt(exitCode[1])
|
303 | if (code === 0) this.resolve()
|
304 | else {
|
305 | let err = new Error(`Process exited with code ${cli.color.red(code)}`)
|
306 | err.exitCode = code
|
307 | this.reject(err)
|
308 | }
|
309 | return
|
310 | }
|
311 | this.push(data)
|
312 | }
|
313 | }
|
314 |
|
315 | _readStdin (c) {
|
316 | this.input = c
|
317 | let stdin = process.stdin
|
318 | stdin.setEncoding('utf8')
|
319 |
|
320 |
|
321 |
|
322 | if (stdin.unref) stdin.unref()
|
323 |
|
324 | if (!this.opts['no-tty'] && tty.isatty(0)) {
|
325 | stdin.setRawMode(true)
|
326 | stdin.pipe(c)
|
327 | let sigints = []
|
328 | stdin.on('data', function (c) {
|
329 | if (c === '\u0003') sigints.push(new Date())
|
330 | sigints = sigints.filter(d => d > new Date() - 1000)
|
331 | if (sigints.length >= 4) {
|
332 | cli.error('forcing dyno disconnect')
|
333 | process.exit(1)
|
334 | }
|
335 | })
|
336 | } else {
|
337 | stdin.pipe(new Transform({
|
338 | objectMode: true,
|
339 | transform: (chunk, _, next) => c.write(chunk, next),
|
340 | flush: done => c.write('\x04', done)
|
341 | }))
|
342 | }
|
343 | this.uncork()
|
344 | }
|
345 |
|
346 | _read () {
|
347 | if (this.useSSH) {
|
348 | throw new Error('Cannot read stream from ssh dyno')
|
349 | }
|
350 |
|
351 | }
|
352 |
|
353 | _write (chunk, encoding, callback) {
|
354 | if (this.useSSH) {
|
355 | throw new Error('Cannot write stream to ssh dyno')
|
356 | }
|
357 | if (!this.input) throw new Error('no input')
|
358 | this.input.write(chunk, encoding, callback)
|
359 | }
|
360 |
|
361 | _notify () {
|
362 | try {
|
363 | if (this._notified) return
|
364 | this._notified = true
|
365 | if (!this.opts.notify) return
|
366 |
|
367 | if (new Date() - this._startedAt < 1000 * 20) return
|
368 | const {notify} = require('@heroku-cli/notifications')
|
369 | notify({
|
370 | title: this.opts.app,
|
371 | subtitle: `heroku run ${this.opts.command}`,
|
372 | message: 'dyno is up'
|
373 |
|
374 | })
|
375 | } catch (err) {
|
376 | cli.warn(err)
|
377 | }
|
378 | }
|
379 | }
|
380 |
|
381 | module.exports = Dyno
|