UNPKG

11.8 kBJavaScriptView Raw
1'use strict'
2
3let tls = require('tls')
4let url = require('url')
5let tty = require('tty')
6let {Duplex, Transform} = require('stream')
7let cli = require('heroku-cli-util')
8let helpers = require('../lib/helpers')
9
10const http = require('https')
11const net = require('net')
12const spawn = require('child_process').spawn
13const debug = require('debug')('heroku:run')
14const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
15
16/** Represents a dyno process */
17class Dyno extends Duplex {
18 /**
19 * @param {Object} options
20 * @param {Object} options.heroku - instance of heroku-client
21 * @param {boolean} options.exit-code - get exit code from process
22 * @param {string} options.command - command to run
23 * @param {string} options.app - app to run dyno on
24 * @param {string} options.attach - attach to dyno
25 * @param {string} options.size - size of dyno to create
26 * @param {string} options.type - type of dyno to create
27 * @param {boolean} options.no-tty - force not to use a tty
28 * @param {Object} options.env - dyno environment variables
29 * @param {boolean} options.notify - show notifications or not
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 * Starts the dyno
41 * @returns {Promise} promise resolved when dyno process is created
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 // Currently the runtime API sends back a 409 in the event the
77 // release isn't found yet. API just forwards this response back to
78 // the client, so we'll need to retry these. This usually
79 // happens when you create an app and immediately try to run a
80 // one-off dyno. No pause between attempts since this is
81 // typically a very short-lived condition.
82 if (err.statusCode === 409 && retries > 0) {
83 return this._doStart(retries - 1)
84 } else {
85 throw err
86 }
87 })
88 }
89
90 /**
91 * Attaches stdin/stdout to dyno
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 // the API sometimes responds with a 404 when the dyno is not yet ready
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 // abort the request when the local pipe server is closed
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 // does not actually uncork but allows error to be displayed when attempting to read
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 // force tty
222 params.push('-t')
223 }
224 }
225 let sshProc = spawn('ssh', params, {stdio})
226
227 // only receives stdout with --exit-code
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 // supress host key and permission denied messages
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 // there was a problem connecting with the ssh key
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 // cleanup local server
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 // discard first line
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 // carriage returns break json parsing of output
295 // eslint-disable-next-line
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 // without this the CLI will hang on rake db:migrate
321 // until a character is pressed
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 // do not need to do anything to handle Readable interface
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 // only show notifications if dyno took longer than 20 seconds to start
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 // sound: true
374 })
375 } catch (err) {
376 cli.warn(err)
377 }
378 }
379}
380
381module.exports = Dyno