1 | "use strict";
|
2 | Object.defineProperty(exports, "__esModule", { value: true });
|
3 | const color_1 = require("@heroku-cli/color");
|
4 | const notifications_1 = require("@heroku-cli/notifications");
|
5 | const child_process_1 = require("child_process");
|
6 | const cli_ux_1 = require("cli-ux");
|
7 | const debug_1 = require("debug");
|
8 | const http = require("http");
|
9 | const net = require("net");
|
10 | const stream_1 = require("stream");
|
11 | const tls = require("tls");
|
12 | const tty = require("tty");
|
13 | const url = require("url");
|
14 | const helpers_1 = require("../lib/helpers");
|
15 | const debug = debug_1.default('heroku:run');
|
16 | const wait = (ms) => new Promise(resolve => setTimeout(() => resolve(), ms));
|
17 | class 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 |
|
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 |
|
72 |
|
73 |
|
74 |
|
75 |
|
76 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
216 | params.push('-t');
|
217 | }
|
218 | }
|
219 | let sshProc = child_process_1.spawn('ssh', params, { stdio });
|
220 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
282 | if (!process.stdout.isTTY) {
|
283 |
|
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 |
|
309 |
|
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 |
|
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 |
|
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 |
|
366 | };
|
367 | notifications_1.notify(notification);
|
368 | }
|
369 | catch (err) {
|
370 | cli_ux_1.default.warn(err);
|
371 | }
|
372 | }
|
373 | }
|
374 | exports.default = Dyno;
|