forked from meteor/meteor
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathdeploy.js
787 lines (693 loc) · 24.3 KB
/
deploy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
// URL parsing and validation
// RPC to server (endpoint, arguments)
// see if RPC requires password
// prompt for password
// send RPC with or without password as required
var qs = require('querystring');
var files = require('./files.js');
var httpHelpers = require('./http-helpers.js');
var buildmessage = require('./buildmessage.js');
var config = require('./config.js');
var auth = require('./auth.js');
var utils = require('./utils.js');
var _ = require('underscore');
var Future = require('fibers/future');
var stats = require('./stats.js');
var Console = require('./console.js').Console;
// Make a synchronous RPC to the "classic" MDG deploy API. The deploy
// API has the following contract:
//
// - Parameters are always sent in the query string.
// - A tarball can be sent in the body (when deploying an app).
// - On success, all calls return HTTP 200. Those that return a value
// either return a JSON payload or a plaintext payload and the
// Content-Type header is set appropriately.
// - On failure, calls return some non-200 HTTP status code and
// provide a human-readable error message in the body.
// - URLs are of the form "/[operation]/[site]".
// - Body encodings are always utf8.
// - Meteor Accounts auth is possible using first-party MDG cookies
// (rather than OAuth).
//
// Options include:
// - method: GET, POST, or DELETE. default GET
// - operation: "info", "logs", "mongo", "deploy", "authorized-apps"
// - site: site name
// - expectPayload: an array of key names. if present, then we expect
// the server to return JSON content on success and to return an
// object with all of these key names.
// - expectMessage: if true, then we expect the server to return text
// content on success.
// - bodyStream: if provided, a stream to use as the request body
// - any other parameters accepted by the node 'request' module, for example
// 'qs' to set query string parameters
//
// Waits until server responds, then returns an object with the
// following keys:
//
// - statusCode: HTTP status code, or null if the server couldn't be
// contacted
// - payload: if successful, and the server returned a JSON body, the
// parsed JSON body
// - message: if successful, and the server returned a text body, the
// body as a string
// - errorMessage: if unsuccessful, a human-readable error message,
// derived from either a transport-level exception, the response
// body, or a generic 'try again later' message, as appropriate
var deployRpc = function (options) {
var genericError = "Server error (please try again later)";
options = _.clone(options);
options.headers = _.clone(options.headers || {});
if (options.headers.cookie)
throw new Error("sorry, can't combine cookie headers yet");
// XXX: Reintroduce progress for upload
try {
var result = httpHelpers.request(_.extend(options, {
url: config.getDeployUrl() + '/' + options.operation +
(options.site ? ('/' + options.site) : ''),
method: options.method || 'GET',
bodyStream: options.bodyStream,
useAuthHeader: true,
encoding: 'utf8' // Hack, but good enough for the deploy server..
}));
} catch (e) {
return {
statusCode: null,
errorMessage: "Connection error (" + e.message + ")"
};
}
var response = result.response;
var body = result.body;
var ret = { statusCode: response.statusCode };
if (response.statusCode !== 200) {
ret.errorMessage = body.length > 0 ? body : genericError;
return ret;
}
var contentType = response.headers["content-type"] || '';
if (contentType === "application/json; charset=utf-8") {
try {
ret.payload = JSON.parse(body);
} catch (e) {
ret.errorMessage = genericError;
return ret;
}
} else if (contentType === "text/plain; charset=utf-8") {
ret.message = body;
}
var hasAllExpectedKeys = _.all(_.map(
options.expectPayload || [], function (key) {
return ret.payload && _.has(ret.payload, key);
}));
if ((options.expectPayload && ! _.has(ret, 'payload')) ||
(options.expectMessage && ! _.has(ret, 'message')) ||
! hasAllExpectedKeys) {
delete ret.payload;
delete ret.message;
ret.errorMessage = genericError;
}
return ret;
};
// Just like deployRpc, but also presents authentication. It will
// prompt the user for a password, or use a Meteor Accounts
// credential, as necessary.
//
// Additional options (beyond deployRpc):
//
// - preflight: if true, do everything but the actual RPC. The only
// other necessary option is 'site'. On failure, returns an object
// with errorMessage (just like deployRpc). On success, returns an
// object without an errorMessage key and with possible keys
// 'protection' (value either 'password' or 'account') and
// 'authorized' (true if the current user is an authorized user on
// this app).
// - promptIfAuthFails: if true, then we think we are logged in with the
// accounts server but our authentication actually fails, then prompt
// the user to log in with a username and password and then resend the
// RPC.
var authedRpc = function (options) {
var rpcOptions = _.clone(options);
var preflight = rpcOptions.preflight;
delete rpcOptions.preflight;
// Fetch auth info
var infoResult = deployRpc({
operation: 'info',
site: rpcOptions.site,
expectPayload: []
});
if (infoResult.statusCode === 401 && rpcOptions.promptIfAuthFails) {
// Our authentication didn't validate, so prompt the user to log in
// again, and resend the RPC if the login succeeds.
var username = Console.readLine({
prompt: "Username: ",
stream: process.stderr
});
var loginOptions = {
username: username,
suppressErrorMessage: true
};
if (auth.doInteractivePasswordLogin(loginOptions)) {
return authedRpc(options);
} else {
return {
statusCode: 403,
errorMessage: "login failed."
};
}
}
if (infoResult.statusCode === 404) {
// Doesn't exist, therefore not protected.
return preflight ? { } : deployRpc(rpcOptions);
}
if (infoResult.errorMessage)
return infoResult;
var info = infoResult.payload;
if (! _.has(info, 'protection')) {
// Not protected.
//
// XXX should prompt the user to claim the app (only if deploying?)
return preflight ? { } : deployRpc(rpcOptions);
}
if (info.protection === "password") {
if (preflight) {
return { protection: info.protection };
}
// Password protected. Read a password, hash it, and include the
// hashed password as a query parameter when doing the RPC.
var password;
password = Console.readLine({
echo: false,
prompt: "Password: ",
stream: process.stderr
});
// Hash the password so we never send plaintext over the
// wire. Doesn't actually make us more secure, but it means we
// won't leak a user's password, which they might use on other
// sites too.
var crypto = require('crypto');
var hash = crypto.createHash('sha1');
hash.update('S3krit Salt!');
hash.update(password);
password = hash.digest('hex');
rpcOptions = _.clone(rpcOptions);
rpcOptions.qs = _.clone(rpcOptions.qs || {});
rpcOptions.qs.password = password;
return deployRpc(rpcOptions);
}
if (info.protection === "account") {
if (! _.has(info, 'authorized')) {
// Absence of this implies that we are not an authorized user on
// this app
if (preflight) {
return { protection: info.protection };
} else {
return {
statusCode: null,
errorMessage: auth.isLoggedIn() ?
// XXX better error message (probably need to break out of
// the 'errorMessage printed with brief prefix' pattern)
"Not an authorized user on this site" :
"Not logged in"
};
}
}
// Sweet, we're an authorized user.
if (preflight) {
return {
protection: info.protection,
authorized: info.authorized
};
} else {
return deployRpc(rpcOptions);
}
}
return {
statusCode: null,
errorMessage: "You need a newer version of Meteor to work with this site"
};
};
// When the user is trying to do something with a legacy
// password-protected app, instruct them to claim it with 'meteor
// claim'.
var printLegacyPasswordMessage = function (site) {
Console.error(
"\nThis site was deployed with an old version of Meteor that used " +
"site passwords instead of user accounts. Now we have a much better " +
"system, Meteor developer accounts.");
Console.error();
Console.error("If this is your site, please claim it into your account with");
Console.error(
Console.command("meteor claim " + site),
Console.options({ indent: 2 }));
};
// When the user is trying to do something with an app that they are not
// authorized for, instruct them to get added via 'meteor authorized
// --add' or switch accounts.
var printUnauthorizedMessage = function () {
var username = auth.loggedInUsername();
Console.error("Sorry, that site belongs to a different user.");
if (username) {
Console.error("You are currently logged in as " + username + ".");
}
Console.error();
Console.error(
"Either have the site owner use " +
Console.command("'meteor authorized --add'") + " to add you as an " +
"authorized developer for the site, or switch to an authorized account " +
"with " + Console.command("'meteor login'") + ".");
};
// Take a proposed sitename for deploying to. If it looks
// syntactically good, canonicalize it (this essentially means
// stripping 'http://' or a trailing '/' if present) and return it. If
// not, print an error message to stderr and return null.
var canonicalizeSite = function (site) {
// There are actually two different bugs here. One is that the meteor deploy
// server does not support apps whose total site length is greater than 63
// (because of how it generates Mongo database names); that can be fixed on
// the server. After that, this check will be too strong, but we still will
// want to check that each *component* of the hostname is at most 63
// characters (url.parse will do something very strange if a component is
// larger than 63, which is the maximum legal length).
if (site.length > 63) {
Console.error(
"The maximum hostname length currently supported is 63 characters: " +
site + " is too long. " +
"Please try again with a shorter URL for your site.");
return false;
}
var url = site;
if (!url.match(':\/\/'))
url = 'http://' + url;
var parsed = require('url').parse(url);
if (! parsed.hostname) {
Console.info(
"Please specify a domain to connect to, such as www.example.com or " +
"http://www.example.com/");
return false;
}
if (parsed.pathname != '/' || parsed.hash || parsed.query) {
Console.info(
"Sorry, Meteor does not yet support specific path URLs, such as " +
Console.url("http://www.example.com/blog") + " . Please specify the root of a domain.");
return false;
}
return parsed.hostname;
};
// Run the bundler and deploy the result. Print progress
// messages. Return a command exit code.
//
// Options:
// - projectContext: the ProjectContext for the app
// - site: site to deploy as
// - settingsFile: file from which to read deploy settings (undefined
// to leave unchanged from previous deploy of the app, if any)
// - recordPackageUsage: (defaults to true) if set to false, don't
// send information about packages used by this app to the package
// stats server.
// - buildOptions: the 'buildOptions' argument to the bundler
var bundleAndDeploy = function (options) {
if (options.recordPackageUsage === undefined)
options.recordPackageUsage = true;
var site = canonicalizeSite(options.site);
if (! site)
return 1;
// We should give a username/password prompt if the user was logged in
// but the credentials are expired, unless the user is logged in but
// doesn't have a username (in which case they should hit the email
// prompt -- a user without a username shouldn't be given a username
// prompt). There's an edge case where things happen in the following
// order: user creates account, user sets username, credential expires
// or is revoked, user comes back to deploy again. In that case,
// they'll get an email prompt instead of a username prompt because
// the command-line tool didn't have time to learn about their
// username before the credential was expired.
auth.pollForRegistrationCompletion({
noLogout: true
});
var promptIfAuthFails = (auth.loggedInUsername() !== null);
// Check auth up front, rather than after the (potentially lengthy)
// bundling process.
var preflight = authedRpc({
site: site,
preflight: true,
promptIfAuthFails: promptIfAuthFails
});
if (preflight.errorMessage) {
Console.error("Error deploying application: " + preflight.errorMessage);
return 1;
}
if (preflight.protection === "password") {
printLegacyPasswordMessage(site);
Console.error("If it's not your site, please try a different name!");
return 1;
} else if (preflight.protection === "account" &&
! preflight.authorized) {
printUnauthorizedMessage();
return 1;
}
var buildDir = options.projectContext.getProjectLocalDirectory('build_tar');
var bundlePath = files.pathJoin(buildDir, 'bundle');
Console.info('Deploying to ' + site + '.');
var settings = null;
var messages = buildmessage.capture({
title: "preparing to deploy",
rootPath: process.cwd()
}, function () {
if (options.settingsFile)
settings = files.getSettings(options.settingsFile);
});
if (! messages.hasMessages()) {
var bundler = require('./bundler.js');
var bundleResult = bundler.bundle({
projectContext: options.projectContext,
outputPath: bundlePath,
buildOptions: options.buildOptions
});
if (bundleResult.errors)
messages = bundleResult.errors;
}
if (messages.hasMessages()) {
Console.info("\nErrors prevented deploying:");
Console.info(messages.formatMessages());
return 1;
}
if (options.recordPackageUsage) {
stats.recordPackages({
what: "sdk.deploy",
projectContext: options.projectContext,
site: site
});
}
var result = buildmessage.enterJob({ title: "uploading" }, function () {
return authedRpc({
method: 'POST',
operation: 'deploy',
site: site,
qs: settings !== null ? {settings: settings} : {},
bodyStream: files.createTarGzStream(files.pathJoin(buildDir, 'bundle')),
expectPayload: ['url'],
preflightPassword: preflight.preflightPassword
});
});
if (result.errorMessage) {
Console.error("\nError deploying application: " + result.errorMessage);
return 1;
}
var deployedAt = require('url').parse(result.payload.url);
var hostname = deployedAt.hostname;
Console.info('Now serving at http://' + hostname);
files.rm_recursive(buildDir);
if (! hostname.match(/meteor\.com$/)) {
var dns = require('dns');
dns.resolve(hostname, 'CNAME', function (err, cnames) {
if (err || cnames[0] !== 'origin.meteor.com') {
dns.resolve(hostname, 'A', function (err, addresses) {
if (err || addresses[0] !== '107.22.210.133') {
Console.info('-------------');
Console.info(
"You've deployed to a custom domain.",
"Please be sure to CNAME your hostname",
"to origin.meteor.com, or set an A record to 107.22.210.133.");
Console.info('-------------');
}
});
}
});
}
return 0;
};
var deleteApp = function (site) {
site = canonicalizeSite(site);
if (! site)
return 1;
var result = authedRpc({
method: 'DELETE',
operation: 'deploy',
site: site,
promptIfAuthFails: true
});
if (result.errorMessage) {
Console.error("Couldn't delete application: " + result.errorMessage);
return 1;
}
Console.info("Deleted.");
return 0;
};
// Helper that does a preflight request to check auth, and prints the
// appropriate error message if auth fails or if this is a legacy
// password-protected app. If auth succeeds, then it runs the actual
// RPC. 'site' and 'operation' are the site and operation for the
// RPC. 'what' is a string describing the operation, for use in error
// messages. Returns the result of the RPC if successful, or null
// otherwise (including if auth failed or if the user is not authorized
// for this site).
var checkAuthThenSendRpc = function (site, operation, what) {
var preflight = authedRpc({
operation: operation,
site: site,
preflight: true,
promptIfAuthFails: true
});
if (preflight.errorMessage) {
Console.error("Couldn't " + what + ": " + preflight.errorMessage);
return null;
}
if (preflight.protection === "password") {
printLegacyPasswordMessage(site);
return null;
} else if (preflight.protection === "account" &&
! preflight.authorized) {
if (! auth.isLoggedIn()) {
// Maybe the user is authorized for this app but not logged in
// yet, so give them a login prompt.
var loginResult = auth.doUsernamePasswordLogin({ retry: true });
if (loginResult) {
// Once we've logged in, retry the whole operation. We need to
// do the preflight request again instead of immediately moving
// on to the real RPC because we don't yet know if the newly
// logged-in user is authorized for this app, and if they
// aren't, then we want to print the nice unauthorized error
// message.
return checkAuthThenSendRpc(site, operation, what);
} else {
// Shouldn't ever get here because we set the retry flag on the
// login, but just in case.
Console.error(
"\nYou must be logged in to " + what + " for this app. Use " +
Console.command("'meteor login'") + "to log in.");
Console.error();
Console.error(
"If you don't have a Meteor developer account yet, you can quickly " +
"create one at www.meteor.com.");
return null;
}
} else { // User is logged in but not authorized for this app
Console.error();
printUnauthorizedMessage();
return null;
}
}
// User is authorized for the app; go ahead and do the actual RPC.
var result = authedRpc({
operation: operation,
site: site,
expectMessage: true,
promptIfAuthFails: true
});
if (result.errorMessage) {
Console.error("Couldn't " + what + ": " + result.errorMessage);
return null;
}
return result;
};
// On failure, prints a message to stderr and returns null. Otherwise,
// returns a temporary authenticated Mongo URL allowing access to this
// site's database.
var temporaryMongoUrl = function (site) {
site = canonicalizeSite(site);
if (! site)
// canonicalizeSite printed an error
return null;
var result = checkAuthThenSendRpc(site, 'mongo', 'open a mongo connection');
if (result !== null) {
return result.message;
} else {
return null;
}
};
var logs = function (site) {
site = canonicalizeSite(site);
if (! site)
return 1;
var result = checkAuthThenSendRpc(site, 'logs', 'view logs');
if (result === null) {
return 1;
} else {
Console.info(result.message);
auth.maybePrintRegistrationLink({ leadingNewline: true });
return 0;
}
};
var listAuthorized = function (site) {
site = canonicalizeSite(site);
if (! site)
return 1;
var result = deployRpc({
operation: 'info',
site: site,
expectPayload: []
});
if (result.errorMessage) {
Console.error("Couldn't get authorized users list: " + result.errorMessage);
return 1;
}
var info = result.payload;
if (! _.has(info, 'protection')) {
Console.info("<anyone>");
return 0;
}
if (info.protection === "password") {
Console.info("<password>");
return 0;
}
if (info.protection === "account") {
if (! _.has(info, 'authorized')) {
Console.error("Couldn't get authorized users list: " +
"You are not authorized");
return 1;
}
Console.info((auth.loggedInUsername() || "<you>"));
_.each(info.authorized, function (username) {
if (username)
// Current username rules don't let you register anything that we might
// want to split over multiple lines (ex: containing a space), but we
// don't want confusion if we ever change some implementation detail.
Console.rawInfo(username + "\n");
});
return 0;
}
};
// action is "add" or "remove"
var changeAuthorized = function (site, action, username) {
site = canonicalizeSite(site);
if (! site)
// canonicalizeSite will have already printed an error
return 1;
var result = authedRpc({
method: 'POST',
operation: 'authorized',
site: site,
qs: action === "add" ? { add: username } : { remove: username },
promptIfAuthFails: true
});
if (result.errorMessage) {
Console.error("Couldn't change authorized users: " + result.errorMessage);
return 1;
}
Console.info(site + ": " +
(action === "add" ? "added " : "removed ")
+ username);
return 0;
};
var claim = function (site) {
site = canonicalizeSite(site);
if (! site)
// canonicalizeSite will have already printed an error
return 1;
// Check to see if it's even a claimable site, so that we can print
// a more appropriate message than we'd get if we called authedRpc
// straight away (at a cost of an extra REST call)
var infoResult = deployRpc({
operation: 'info',
site: site
});
if (infoResult.statusCode === 404) {
Console.error(
"There isn't a site deployed at that address. Use " +
Console.command("'meteor deploy'") + " " +
"if you'd like to deploy your app here.");
return 1;
}
if (infoResult.payload && infoResult.payload.protection === "account") {
if (infoResult.payload.authorized)
Console.error("That site already belongs to you.\n");
else
Console.error("Sorry, that site belongs to someone else.\n");
return 1;
}
if (infoResult.payload &&
infoResult.payload.protection === "password") {
Console.info(
"To claim this site and transfer it to your account, enter the",
"site password one last time.");
Console.info();
}
var result = authedRpc({
method: 'POST',
operation: 'claim',
site: site,
promptIfAuthFails: true
});
if (result.errorMessage) {
auth.pollForRegistrationCompletion();
if (! auth.loggedInUsername() &&
auth.registrationUrl()) {
Console.error(
"You need to set a password on your Meteor developer account before",
"you can claim sites. You can do that here in under a minute:");
Console.error(Console.url(auth.registrationUrl()));
Console.error();
} else {
Console.error("Couldn't claim site: " + result.errorMessage);
}
return 1;
}
Console.info(site + ": " + "successfully transferred to your account.");
Console.info();
Console.info("Show authorized users with:");
Console.info(
Console.command("meteor authorized " + site),
Console.options({ indent: 2 }));
Console.info();
Console.info("Add authorized users with:");
Console.info(
Console.command("meteor authorized " + site + " --add <username>"),
Console.options({ indent: 2 }));
Console.info();
Console.info("Remove authorized users with:");
Console.info(
Console.command("meteor authorized " + site + " --remove <username>"),
Console.options({ indent: 2 }));
Console.info();
return 0;
};
var listSites = function () {
var result = deployRpc({
method: "GET",
operation: "authorized-apps",
promptIfAuthFails: true,
expectPayload: ["sites"]
});
if (result.errorMessage) {
Console.error("Couldn't list sites: " + result.errorMessage);
return 1;
}
if (! result.payload ||
! result.payload.sites ||
! result.payload.sites.length) {
Console.info("You don't have any sites yet.");
} else {
result.payload.sites.sort();
_.each(result.payload.sites, function (site) {
Console.info(site);
});
}
return 0;
};
exports.bundleAndDeploy = bundleAndDeploy;
exports.deleteApp = deleteApp;
exports.temporaryMongoUrl = temporaryMongoUrl;
exports.logs = logs;
exports.listAuthorized = listAuthorized;
exports.changeAuthorized = changeAuthorized;
exports.claim = claim;
exports.listSites = listSites;