UNPKG

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