1.9.5
[gae.git] / java / src / main / com / google / appengine / tools / admin / AppVersionUpload.java
blob5b9f1ca40102a602577bbc8f9e2bb4c722cf0382
1 // Copyright 2009 Google Inc. All rights reserved.
3 package com.google.appengine.tools.admin;
5 import com.google.appengine.tools.admin.GenericApplication.ErrorHandler;
6 import com.google.appengine.tools.util.FileIterator;
7 import com.google.common.annotations.VisibleForTesting;
8 import com.google.common.base.Joiner;
9 import com.google.common.base.Preconditions;
10 import com.google.common.collect.ImmutableMap;
11 import com.google.common.hash.Hashing;
12 import com.google.common.io.BaseEncoding;
13 import com.google.common.io.ByteSource;
14 import com.google.common.io.Files;
16 import net.sourceforge.yamlbeans.YamlException;
17 import net.sourceforge.yamlbeans.YamlReader;
19 import java.io.File;
20 import java.io.IOException;
21 import java.io.StringReader;
22 import java.net.HttpURLConnection;
23 import java.text.MessageFormat;
24 import java.util.ArrayList;
25 import java.util.Arrays;
26 import java.util.Collection;
27 import java.util.Collections;
28 import java.util.HashMap;
29 import java.util.List;
30 import java.util.Map;
31 import java.util.TreeMap;
32 import java.util.TreeSet;
33 import java.util.concurrent.Callable;
34 import java.util.logging.Logger;
35 import java.util.regex.Pattern;
37 /**
38 * Uploads a new appversion to the hosting service.
41 public class AppVersionUpload {
42 /**
43 * Don't try to precompile more than this number of files in one request.
45 private static final int MAX_FILES_PER_PRECOMPILE = 50;
47 private static final String YAML_EMPTY_STRING = "null";
49 private static final String PRECOMPILATION_FAILED_WARNING_MESSAGE =
50 "Precompilation failed. Consider retrying the update later, or add"
51 + " <precompilation-enabled>false</precompilation-enabled> to your appengine-web.xml"
52 + " to disable precompilation.";
54 private static final Logger logger = Logger.getLogger(AppVersionUpload.class.getName());
56 protected ServerConnection connection;
57 protected GenericApplication app;
58 protected final String backend;
59 private boolean inTransaction = false;
60 private Map<String, FileInfo> files = new HashMap<String, FileInfo>();
61 private boolean deployed = false;
62 private boolean started = false;
63 private boolean checkConfigUpdated = false;
64 private final UploadBatcher fileBatcher;
65 private final UploadBatcher blobBatcher;
66 private ClientDeploySender clientDeploySender;
67 private SleepIfShouldRetry sleepIfShouldRetry;
69 public AppVersionUpload(ServerConnection connection, GenericApplication app) {
70 this(connection, app, null, true);
73 /**
74 * Create a new {@link AppVersionUpload} instance that can deploy a new
75 * versions of {@code app} via {@code connection}.
77 * @param connection to connect to the server
78 * @param app that contains the code to be deployed
79 * @param backend if supplied and non-{@code null}, a particular backend is
80 * being updated
82 public AppVersionUpload(ServerConnection connection, GenericApplication app,
83 String backend, boolean batchMode) {
84 this.connection = connection;
85 this.app = app;
86 this.backend = backend;
87 this.clientDeploySender = new NoLoggingClientDeploySender(connection);
88 fileBatcher = new UploadBatcher("file", batchMode);
89 blobBatcher = new UploadBatcher("blob", batchMode);
90 sleepIfShouldRetry = new DefaultSleepAndRetry();
93 /***
94 * Uploads a new appversion to the server.
96 * @throws LocalIOException if a problem occurs with local files.
97 * @throws RemoteIOException if a problem occurs communicating with the server.
99 public void doUpload(ResourceLimits resourceLimits, boolean updateGlobalConfigurations,
100 boolean failOnPrecompilationError, ClientDeploySender clientDeploySender)
101 throws LocalIOException, RemoteIOException {
103 ClientDeploySender originalClientDeploySender = this.clientDeploySender;
104 this.clientDeploySender = Preconditions.checkNotNull(clientDeploySender);
105 try {
106 uploadFilesTransaction(resourceLimits, failOnPrecompilationError);
107 } finally {
108 clientDeploySender = originalClientDeploySender;
110 if (updateGlobalConfigurations) {
111 updateIndexes();
112 updateCron();
113 updateQueue();
114 updateDos();
115 updatePagespeed();
116 reportIfSkippingDispatchConfiguration();
117 } else {
118 reportSkippingGlobalConfiguration();
122 private void uploadFilesTransaction(
123 ResourceLimits resourceLimits,
124 boolean failOnPrecompilationError) throws LocalIOException, RemoteIOException {
125 try {
126 try {
127 File basepath = getBasepath();
128 scanFiles(basepath, resourceLimits);
129 Collection<FileInfo> missingFiles = beginTransaction(resourceLimits);
130 uploadFiles(failOnPrecompilationError, basepath, missingFiles);
131 commit();
132 clientDeploySender.logClientDeploy(true, addVersionToArgs());
133 } finally {
134 rollback();
136 } catch (HttpIoException e) {
137 if (e.isSlaError()) {
138 clientDeploySender.logClientDeploy(false, addVersionToArgs());
140 throw e;
141 } catch (RuntimeException e) {
142 clientDeploySender.logClientDeploy(false, addVersionToArgs());
143 throw e;
147 private void uploadFiles(boolean failOnPrecompilationError, File basepath,
148 Collection<FileInfo> missingFiles)
149 throws LocalIOException, RemoteIOException {
150 int numFiles;
151 app.statusUpdate("Uploading " + missingFiles.size() + " files.", 50);
152 if (!missingFiles.isEmpty()) {
153 numFiles = 0;
154 int quarter = Math.max(1, missingFiles.size() / 4);
155 for (FileInfo missingFile : missingFiles) {
156 logger.fine("Uploading file '" + missingFile + "'");
157 uploadFile(missingFile);
158 if (++numFiles % quarter == 0) {
159 app.statusUpdate("Uploaded " + numFiles + " files.");
163 uploadErrorHandlers(app.getErrorHandlers(), basepath);
164 if (app.isPrecompilationEnabled()) {
165 precompile(failOnPrecompilationError);
167 fileBatcher.flush();
168 blobBatcher.flush();
171 private void scanFiles(File basepath, ResourceLimits resourceLimits)
172 throws LocalIOException {
174 app.statusUpdate("Scanning files on local disk.", 20);
175 int numFiles = 0;
176 long resourceTotal = 0;
177 List<Pattern> skipFiles = loadSkipFiles(app.getAppYaml());
178 for (File f : new FileIterator(basepath)) {
179 if (shouldSkip(f.getName(), skipFiles)) {
180 continue;
182 FileInfo fileInfo = new FileInfo(f, basepath);
183 fileInfo.setMimeType(app);
185 logger.fine("Processing file '" + f + "'.");
186 long maxFileBlobSize = fileInfo.mimeType != null ?
187 resourceLimits.maxBlobSize() : resourceLimits.maxFileSize();
188 if (f.length() > maxFileBlobSize) {
189 String message;
190 if (f.getName().toLowerCase().endsWith(".jar")) {
191 message = "Jar " + f.getPath() + " is too large. Consider "
192 + "using --enable_jar_splitting.";
193 } else {
194 message = "File " + f.getPath() + " is too large (limit "
195 + maxFileBlobSize + " bytes).";
197 throw new LocalIOException(message);
199 resourceTotal += addFile(fileInfo);
201 if (++numFiles % 250 == 0) {
202 app.statusUpdate("Scanned " + numFiles + " files.");
205 if (numFiles > resourceLimits.maxFileCount()) {
206 throw new LocalIOException("Applications are limited to "
207 + resourceLimits.maxFileCount() + " files, you have " + numFiles
208 + ".");
210 if (resourceTotal > resourceLimits.maxTotalFileSize()) {
211 throw new LocalIOException("Applications are limited to "
212 + resourceLimits.maxTotalFileSize() + " bytes of resource files, "
213 + "you have " + resourceTotal + ".");
217 private void reportSkippingGlobalConfiguration() {
218 TreeSet<String> skipSet = new TreeSet<String>();
219 if (app.getIndexesXml() != null) {
220 skipSet.add("indexes.xml");
222 if (app.getCronXml() != null) {
223 skipSet.add("cron.xml");
225 if (app.getQueueXml() != null) {
226 skipSet.add("queue.xml");
228 if (app.getDispatchXml() != null) {
229 skipSet.add("dispatch.xml");
231 if (app.getDosXml() != null) {
232 skipSet.add("dos.xml");
234 if (app.getPagespeedYaml() != null) {
235 skipSet.add("pagespeed");
237 if (!skipSet.isEmpty()) {
238 app.statusUpdate("Skipping global configurations: " + Joiner.on(", ").join(skipSet));
242 private void reportIfSkippingDispatchConfiguration() {
243 if (app.getDispatchXml() != null) {
244 app.statusUpdate(
245 "Skipping dispatch.xml - consider running \"appcfg.sh update_dispatch <war-dir>\"");
249 private void uploadErrorHandlers(List<ErrorHandler> errorHandlers, File basepath)
250 throws LocalIOException, RemoteIOException {
251 if (!errorHandlers.isEmpty()) {
252 app.statusUpdate("Uploading " + errorHandlers.size() + " file(s) "
253 + "for static error handlers.");
254 for (ErrorHandler handler : errorHandlers) {
255 File file = new File(basepath, handler.getFile());
256 FileInfo info = new FileInfo(file, basepath);
257 String error = FileInfo.checkValidFilename(info.path);
258 if (error != null) {
259 throw new LocalIOException("Could not find static error handler: " + error);
261 info.mimeType = handler.getMimeType();
262 String errorType = handler.getErrorCode();
263 if (errorType == null) {
264 errorType = "default";
266 send("/api/appversion/adderrorblob", info.file, info.mimeType, "path",
267 errorType);
272 @VisibleForTesting
273 interface SleepIfShouldRetry {
275 * If precompilation should be retried given the number of errors so far then sleep and return
276 * true; otherwise return false.
277 * @param errorCount the number of precompilation errors seen so far.
278 * @return true if precompilation should be tried.
280 boolean sleepIfShouldRetry(int errorCount);
283 private static class DefaultSleepAndRetry implements SleepIfShouldRetry {
284 @Override public boolean sleepIfShouldRetry(int errorCount) {
285 if (errorCount > 3) {
286 return false;
287 } else {
288 try {
289 Thread.sleep(1000);
290 } catch (InterruptedException e) {
292 return true;
297 @VisibleForTesting
298 void setSleepIfShouldRetry(SleepIfShouldRetry sleepAndRetry) {
299 this.sleepIfShouldRetry = sleepAndRetry;
302 public void precompile(boolean failOnPrecompilationError) throws RemoteIOException {
303 app.statusUpdate("Initializing precompilation...");
304 List<String> filesToCompile = new ArrayList<String>();
306 boolean containsGoFiles = false;
307 for (String f : this.files.keySet()) {
308 boolean isGoFile = f.toLowerCase().endsWith(".go");
309 if (isGoFile && !containsGoFiles) {
310 containsGoFiles = true;
312 if (isGoFile || f.toLowerCase().endsWith(".py")) {
313 filesToCompile.add(f);
316 Collections.sort(filesToCompile);
317 if (containsGoFiles) {
318 failOnPrecompilationError = true;
321 int errorCount = 0;
322 while (true) {
323 try {
324 filesToCompile.addAll(sendPrecompileRequest(Collections.<String>emptyList()));
325 break;
326 } catch (RemoteIOException ex) {
327 errorCount++;
328 if (!sleepIfShouldRetry.sleepIfShouldRetry(errorCount)) {
329 if (failOnPrecompilationError) {
330 throw precompilationFailedException("", ex);
331 } else {
332 logger.warning(PRECOMPILATION_FAILED_WARNING_MESSAGE);
333 return;
339 errorCount = 0;
340 while (!filesToCompile.isEmpty()) {
341 try {
342 if (precompileChunk(filesToCompile)) {
343 errorCount = 0;
345 } catch (RemoteIOException ex) {
346 Collections.shuffle(filesToCompile);
347 errorCount++;
348 if (!sleepIfShouldRetry.sleepIfShouldRetry(errorCount)) {
349 if (failOnPrecompilationError) {
350 String messageFragment = " with " + filesToCompile.size() + " file(s) remaining";
351 throw precompilationFailedException(messageFragment, ex);
352 } else {
353 logger.warning(PRECOMPILATION_FAILED_WARNING_MESSAGE);
354 return;
361 private static RemoteIOException precompilationFailedException(
362 String messageFragment, RemoteIOException cause) {
363 String message = "Precompilation failed" + messageFragment + ". Consider adding"
364 + " <precompilation-enabled>false</precompilation-enabled> to your appengine-web.xml"
365 + " and trying again.";
366 if (cause instanceof HttpIoException) {
367 HttpIoException httpCause = (HttpIoException) cause;
368 return new HttpIoException(message, httpCause.getResponseCode(), httpCause);
369 } else {
370 return RemoteIOException.from(cause, message);
375 * Attempt to precompile up to {@code MAX_FILES_PER_PRECOMPILE} files from
376 * {@code filesToCompile}.
378 * @param filesToCompile a list of file names, which will be mutated to remove
379 * any files that were successfully compiled.
381 * @return true if filesToCompile was reduced in size (i.e. progress was
382 * made).
384 private boolean precompileChunk(List<String> filesToCompile)
385 throws RemoteIOException {
386 int filesLeft = filesToCompile.size();
387 if (filesLeft == 0) {
388 app.statusUpdate("Initializing precompilation...");
389 } else {
390 app.statusUpdate(MessageFormat.format(
391 "Precompiling... {0} file(s) left.", filesLeft));
394 List<String> subset =
395 filesToCompile
396 .subList(0, Math.min(filesLeft, MAX_FILES_PER_PRECOMPILE));
397 List<String> remainingFiles = sendPrecompileRequest(subset);
398 subset.clear();
399 filesToCompile.addAll(remainingFiles);
400 return filesToCompile.size() < filesLeft;
403 private List<String> sendPrecompileRequest(List<String> filesToCompile)
404 throws RemoteIOException {
405 String response =
406 send("/api/appversion/precompile", Joiner.on("\n").useForNull("null").join(filesToCompile));
407 if (response.length() > 0) {
408 return Arrays.asList(response.split("\n"));
409 } else {
410 return Collections.emptyList();
414 public void updateIndexes() throws RemoteIOException {
415 if (app.getIndexesXml() != null) {
416 app.statusUpdate("Uploading index definitions.");
417 send("/api/datastore/index/add", getIndexYaml());
422 public void updateCron() throws RemoteIOException {
423 String yaml = getCronYaml();
424 if (yaml != null) {
425 app.statusUpdate("Uploading cron jobs.");
426 send("/api/datastore/cron/update", yaml);
430 public void updateQueue() throws RemoteIOException {
431 String yaml = getQueueYaml();
432 if (yaml != null) {
433 app.statusUpdate("Uploading task queues.");
434 send("/api/queue/update", yaml);
438 public void updateDispatch() throws RemoteIOException {
439 String yaml = getDispatchYaml();
440 if (yaml != null) {
441 app.statusUpdate("Uploading dispatch entries.");
442 send("/api/dispatch/update", yaml);
446 public void updateDos() throws RemoteIOException {
447 String yaml = getDosYaml();
448 if (yaml != null) {
449 app.statusUpdate("Uploading DoS entries.");
450 send("/api/dos/update", yaml);
454 public void updatePagespeed() throws RemoteIOException {
455 String yaml = getPagespeedYaml();
456 if (yaml != null) {
457 app.statusUpdate("Uploading PageSpeed entries.");
458 send("/api/appversion/updatepagespeed", yaml);
459 } else {
460 try {
461 send("/api/appversion/updatepagespeed", "");
462 } catch (HttpIoException exc) {
463 if (exc.getResponseCode() != HttpURLConnection.HTTP_NOT_FOUND) {
464 throw exc;
470 public void setDefaultVersion() throws IOException {
471 String module = app.getModule();
472 String url = "/api/appversion/setdefault";
473 if (module != null) {
474 String[] modules = module.split(",");
475 if (modules.length > 1) {
476 app.statusUpdate("Setting the default version of modules " + Joiner.on(", ").join(modules) +
477 " of application " + app.getAppId() + " to " + app.getVersion());
478 List<String> args = new ArrayList<String>();
479 args.add("app_id");
480 args.add(app.getAppId());
481 args.add("version");
482 args.add(app.getVersion());
483 for (String mod: modules) {
484 args.add("module");
485 args.add(mod);
487 connection.post(url, "", args.toArray(new String[args.size()]));
488 return;
489 } else {
490 app.statusUpdate("Setting the default version of module " + module + " of application " +
491 app.getAppId() + " to " + app.getVersion());
493 } else {
494 app.statusUpdate("Setting the default version of application " + app.getAppId() +
495 " to " + app.getVersion());
497 send(url, "");
500 protected String getIndexYaml() {
501 return app.getIndexesXml().toYaml();
504 protected String getCronYaml() {
505 if (app.getCronXml() != null) {
506 return app.getCronXml().toYaml();
507 } else {
508 return null;
512 protected String getQueueYaml() {
513 if (app.getQueueXml() != null) {
514 return app.getQueueXml().toYaml();
515 } else {
516 return null;
520 protected String getDispatchYaml() {
521 return app.getDispatchXml() == null ? null : app.getDispatchXml().toYaml();
524 protected String getDosYaml() {
525 if (app.getDosXml() != null) {
526 return app.getDosXml().toYaml();
527 } else {
528 return null;
532 protected String getPagespeedYaml() {
533 return app.getPagespeedYaml();
536 @VisibleForTesting
537 protected boolean getInTransaction() {
538 return this.inTransaction;
541 @VisibleForTesting
542 protected void setInTransaction(boolean newValue) {
543 this.inTransaction = newValue;
546 private File getBasepath() {
547 File path = app.getStagingDir();
548 if (path == null) {
549 path = new File(app.getPath());
551 return path;
555 * Get the URL that the user would go to for their app's logs. This string is intended to be
556 * provided to the user, to show them where to go to find an error.
558 * @return A URL that the user can use to find their app's logs.
560 @VisibleForTesting
561 String getLogUrl() {
562 StringBuilder url = new StringBuilder();
563 url.append("https://appengine.google.com/logs?app_id=");
564 url.append(app.getAppId());
565 if (app.getVersion() != null) {
566 url.append("&version_id=");
567 if (app.getModule() != null) {
568 url.append(app.getModule());
569 url.append("%3A");
571 url.append(app.getVersion());
573 return url.toString();
577 * Adds a file for uploading, returning the bytes counted against the total
578 * resource quota.
580 * @param info
581 * @return 0 for a static file, or file.length() for a resource file.
583 @VisibleForTesting
584 long addFile(FileInfo info) {
585 if (inTransaction) {
586 throw new IllegalStateException("Already in a transaction.");
589 String error = FileInfo.checkValidFilename(info.path);
590 if (error != null) {
591 logger.severe(error);
592 return 0;
595 files.put(info.path, info);
597 return info.mimeType != null ? 0 : info.file.length();
601 * Parses the response from /api/appversion/create into a Map.
603 * @param response String returned from the /api/appversion/create call.
604 * @return YAML parsed into Map.
606 private ArrayList<String> validateBeginYaml(String response) {
607 YamlReader yaml = new YamlReader(new StringReader(response));
608 try {
609 Object obj = yaml.read();
610 if (obj != null) {
611 @SuppressWarnings("unchecked")
612 Map<String, Object> responseMap = (Map<String, Object>) obj;
613 if (responseMap != null) {
614 obj = responseMap.get("warnings");
615 if (obj != null) {
616 @SuppressWarnings("unchecked")
617 ArrayList<String> warnings = (ArrayList<String>) obj;
618 return warnings;
622 } catch (YamlException exc) {
623 } catch (ClassCastException exc) {
625 return new ArrayList<String>();
629 * Begins the transaction, returning a list of files that need uploading.
631 * All calls to addFile must be made before calling beginTransaction().
633 * @param resourceLimits is the collection of resource limits for AppCfg.
634 * @return A list of pathnames that should be uploaded using uploadFile()
635 * before calling commit().
637 @VisibleForTesting
638 Collection<FileInfo> beginTransaction(ResourceLimits resourceLimits) throws RemoteIOException {
639 if (inTransaction) {
640 throw new IllegalStateException("Already in a transaction.");
643 if (backend == null) {
644 app.statusUpdate("Initiating update.");
645 } else {
646 app.statusUpdate("Initiating update of backend " + backend + ".");
648 String response = send("/api/appversion/create", app.getAppYaml());
649 ArrayList<String> warnings = validateBeginYaml(response);
650 for (String warning : warnings) {
651 app.statusUpdate("WARNING: " + warning);
653 inTransaction = true;
654 Collection<FileInfo> blobsToClone = new ArrayList<FileInfo>(files.size());
655 Collection<FileInfo> filesToClone = new ArrayList<FileInfo>(files.size());
657 for (FileInfo f : files.values()) {
658 if (f.mimeType == null) {
659 filesToClone.add(f);
660 } else {
661 blobsToClone.add(f);
665 TreeMap<String, FileInfo> filesToUpload = new TreeMap<String, FileInfo>();
666 cloneFiles("/api/appversion/cloneblobs", blobsToClone, "static",
667 filesToUpload, resourceLimits.maxFilesToClone());
668 cloneFiles("/api/appversion/clonefiles", filesToClone, "application",
669 filesToUpload, resourceLimits.maxFilesToClone());
671 logger.fine("Files to upload :");
672 for (FileInfo f : filesToUpload.values()) {
673 logger.fine("\t" + f);
676 this.files = filesToUpload;
677 return new ArrayList<FileInfo>(filesToUpload.values());
680 private static final String LIST_DELIMITER = "\n";
683 * Sends files to the given url.
685 * @param url server URL to use.
686 * @param filesParam List of files to clone.
687 * @param type Type of files ( "static" or "application")
688 * @param filesToUpload Files that need to be uploaded are added to this
689 * Collection.
690 * @param maxFilesToClone Max number of files to clone at a single time.
692 private void cloneFiles(String url, Collection<FileInfo> filesParam,
693 String type, Map<String, FileInfo> filesToUpload, long maxFilesToClone)
694 throws RemoteIOException {
695 if (filesParam.isEmpty()) {
696 return;
698 app.statusUpdate("Cloning " + filesParam.size() + " " + type + " files.");
700 int cloned = 0;
701 int remaining = filesParam.size();
702 ArrayList<FileInfo> chunk = new ArrayList<FileInfo>((int) maxFilesToClone);
703 for (FileInfo file : filesParam) {
704 chunk.add(file);
705 if (--remaining == 0 || chunk.size() >= maxFilesToClone) {
706 if (cloned > 0) {
707 app.statusUpdate("Cloned " + cloned + " files.");
709 String result = send(url, buildClonePayload(chunk));
710 if (result != null && result.length() > 0) {
711 for (String path : result.split(LIST_DELIMITER)) {
712 if (path == null || path.length() == 0) {
713 continue;
715 FileInfo info = this.files.get(path);
716 if (info == null) {
717 logger.warning("Skipping " + path + ": missing FileInfo");
718 continue;
720 filesToUpload.put(path, info);
723 cloned += chunk.size();
724 chunk.clear();
730 * Uploads a file to the hosting service.
732 * Must only be called after beginTransaction(). The file provided must be on
733 * of those that were returned by beginTransaction();
735 * @param file FileInfo for the file to upload.
737 private void uploadFile(FileInfo file) throws RemoteIOException {
738 if (!inTransaction) {
739 throw new IllegalStateException(
740 "beginTransaction() must be called before uploadFile().");
742 if (!files.containsKey(file.path)) {
743 throw new IllegalArgumentException("File " + file.path
744 + " is not in the list of files to be uploaded.");
747 files.remove(file.path);
748 if (file.mimeType == null) {
749 fileBatcher.addToBatch(file);
750 } else {
751 blobBatcher.addToBatch(file);
756 * Commits the transaction, making the new app version available.
758 * All the files returned by beginTransaction must have been uploaded with
759 * uploadFile() before commit() may be called.
761 @VisibleForTesting
762 void commit() throws RemoteIOException {
763 deploy();
764 try {
765 boolean ready = retryWithBackoff(1, 2, 60, 20, new Callable<Boolean>() {
766 @Override
767 public Boolean call() throws Exception {
768 return isReady();
772 if (ready) {
773 startServing();
774 } else {
775 logger.severe("Version still not ready to serve, aborting.");
776 throw new RemoteIOException("Version not ready.");
779 boolean versionIsServing = retryWithBackoff(1, 2, 60, 20, new Callable<Boolean>() {
780 @Override
781 public Boolean call() throws Exception {
782 return isServing();
785 if (!versionIsServing) {
786 logger.severe("Version still not serving, aborting.");
787 throw new RemoteIOException("Version not ready.");
789 if (checkConfigUpdated) {
790 boolean configIsUpdated = retryWithBackoff(1, 2, 60, 20, new Callable<Boolean>() {
791 @Override
792 public Boolean call() throws Exception {
793 return isConfigUpdated();
796 if (!configIsUpdated) {
797 final String errorMessage = "Endpoints configuration not updated. Check the app's " +
798 "AppEngine logs for errors: " + getLogUrl();
799 app.statusUpdate(errorMessage);
800 logger.severe(errorMessage);
801 throw new RuntimeException(errorMessage);
804 app.statusUpdate("Closing update: new version is ready to start serving.");
805 inTransaction = false;
806 } catch (RemoteIOException | RuntimeException e) {
807 throw e;
808 } catch (Exception e) {
809 throw new RuntimeException(e);
814 * Deploys the new app version but does not make it default.
816 * All the files returned by beginTransaction must have been uploaded with
817 * uploadFile() before commit() may be called.
819 private void deploy() throws RemoteIOException {
820 if (!inTransaction) {
821 throw new IllegalStateException(
822 "beginTransaction() must be called before deploy().");
824 if (!files.isEmpty()) {
825 throw new IllegalStateException(
826 "Some required files have not been uploaded.");
828 app.statusUpdate("Deploying new version.", 20);
829 send("/api/appversion/deploy", "");
830 deployed = true;
834 * Check if the new app version is ready to serve traffic.
836 * @return true if the server returned that the app is ready to serve.
838 private boolean isReady() throws IOException {
839 if (!deployed) {
840 throw new IllegalStateException(
841 "deploy() must be called before isReady()");
843 String result = send("/api/appversion/isready", "");
844 return "1".equals(result.trim());
847 private void startServing() throws IOException {
848 if (!deployed) {
849 throw new IllegalStateException(
850 "deploy() must be called before startServing()");
852 send("/api/appversion/startserving", "", "willcheckserving", "1");
853 started = true;
856 @VisibleForTesting
857 protected Map<String, String> parseIsServingResponse(String isServingResp) {
858 ImmutableMap.Builder<String, String> result = ImmutableMap.builder();
859 if (isServingResp.isEmpty()) {
860 return result.build();
863 try {
864 YamlReader yamlReader = new YamlReader(isServingResp);
865 @SuppressWarnings("unchecked")
866 Map<Object, Object> resultMap = yamlReader.read(Map.class, String.class);
867 for (Object key : resultMap.keySet()) {
868 result.put((String) key, (String) resultMap.get(key));
870 } catch (YamlException e) {
871 logger.severe("Unable to parse Yaml from response: " + result);
872 throw new RuntimeException(e);
874 return result.build();
877 private boolean isServing() throws IOException {
878 if (!started) {
879 throw new IllegalStateException(
880 "startServing() must be called before isServing().");
882 String result = send("/api/appversion/isserving", "", "new_serving_resp", "1");
883 if ("1".equals(result.trim()) || "0".equals(result.trim())) {
884 return "1".equals(result.trim());
887 Map<String, String> resultMap = parseIsServingResponse(result.trim());
888 if (resultMap.containsKey("message") &&
889 !YAML_EMPTY_STRING.equals(resultMap.get("message"))) {
890 app.statusUpdate(resultMap.get("message"));
892 if (resultMap.containsKey("fatal") &&
893 Boolean.parseBoolean(resultMap.get("fatal").toLowerCase())) {
894 throw new RuntimeException(
895 "Fatal problem encountered during deployment. Please refer to the logs" +
896 " for more information.");
898 if (resultMap.containsKey("check_endpoints_config")) {
899 checkConfigUpdated = Boolean.parseBoolean(resultMap.get("check_endpoints_config"));
901 if (resultMap.containsKey("serving")) {
902 return Boolean.parseBoolean(resultMap.get("serving"));
903 } else {
904 throw new RuntimeException(
905 "Fatal problem encountered during deployment. Unexpected response when " +
906 "checking for serving status. Response: " + result);
910 @VisibleForTesting
911 Map<String, String> parseIsConfigUpdatedResponse(String isConfigUpdatedResp) {
912 ImmutableMap.Builder<String, String> result = ImmutableMap.builder();
913 try {
914 YamlReader yamlReader = new YamlReader(isConfigUpdatedResp);
915 @SuppressWarnings("unchecked")
916 Map<Object, Object> resultMap = yamlReader.read(Map.class, String.class);
917 if (resultMap == null) {
918 return result.build();
921 for (Object key : resultMap.keySet()) {
922 result.put((String) key, (String) resultMap.get(key));
924 } catch (YamlException e) {
925 logger.severe("Unable to parse Yaml from response: " + result);
926 throw new RuntimeException(e);
928 return result.build();
931 private boolean isConfigUpdated() throws IOException {
932 if (!started) {
933 throw new IllegalStateException(
934 "startServing() must be called before isConfigUpdated().");
936 String result = send("/api/isconfigupdated", "");
938 Map<String, String> resultMap = parseIsConfigUpdatedResponse(result.trim());
939 if (resultMap.containsKey("updated")) {
940 return Boolean.parseBoolean(resultMap.get("updated"));
941 } else {
942 throw new RuntimeException(
943 "Fatal problem encountered during deployment. Unexpected response when " +
944 "checking for configuration update status. Response: " + result);
948 public void forceRollback() throws RemoteIOException {
949 app.statusUpdate("Rolling back the update" + (this.backend == null ? "."
950 : " on backend " + this.backend + "."));
951 send("/api/appversion/rollback", "");
954 private void rollback() throws RemoteIOException {
955 if (!inTransaction) {
956 return;
958 forceRollback();
961 @VisibleForTesting
962 String send(String url, String payload, String... args)
963 throws RemoteIOException {
964 try {
965 return clientDeploySender.send(url, payload, addVersionToArgs(args));
966 } catch (IOException e) {
967 throw RemoteIOException.from(e);
971 @VisibleForTesting
972 String send(String url, File payload, String mimeType, String... args)
973 throws RemoteIOException {
974 try {
975 return clientDeploySender.send(url, payload, mimeType, addVersionToArgs(args));
976 } catch (IOException e) {
977 throw RemoteIOException.from(e);
981 private String[] addVersionToArgs(String... args) {
982 List<String> result = new ArrayList<String>();
983 Collections.addAll(result, args);
984 result.add("app_id");
985 result.add(app.getAppId());
986 if (backend != null) {
987 result.add("backend");
988 result.add(backend);
989 } else if (app.getVersion() != null) {
990 result.add("version");
991 result.add(app.getVersion());
993 if (app.getModule() != null) {
994 result.add("module");
995 result.add(app.getModule());
997 return result.toArray(new String[result.size()]);
1001 * Calls a function multiple times, backing off more and more each time.
1003 * @param initialDelay Inital delay after the first try, in seconds.
1004 * @param backoffFactor Delay will be multiplied by this factor after each
1005 * try.
1006 * @param maxDelay Maximum delay factor.
1007 * @param maxTries Maximum number of tries.
1008 * @param callable Callable to call.
1009 * @return true if the Callable returned true in one of its tries.
1011 private boolean retryWithBackoff(double initialDelay, double backoffFactor,
1012 double maxDelay, int maxTries, Callable<Boolean> callable)
1013 throws Exception {
1014 long delayMillis = (long) (initialDelay * 1000);
1015 long maxDelayMillis = (long) (maxDelay * 1000);
1016 if (callable.call()) {
1017 return true;
1019 while (maxTries > 1) {
1020 app.statusUpdate("Will check again in " + (delayMillis / 1000)
1021 + " seconds.");
1022 Thread.sleep(delayMillis);
1023 delayMillis *= backoffFactor;
1024 if (delayMillis > maxDelayMillis) {
1025 delayMillis = maxDelayMillis;
1027 maxTries--;
1028 if (callable.call()) {
1029 return true;
1032 return false;
1035 private static final String TUPLE_DELIMITER = "|";
1038 * Build the post body for a clone request.
1040 * @param files List of FileInfos for the files to clone.
1041 * @return A string containing the properly delimited tuples.
1043 private static String buildClonePayload(Collection<FileInfo> files) {
1044 StringBuffer data = new StringBuffer();
1045 boolean first = true;
1046 for (FileInfo file : files) {
1047 if (first) {
1048 first = false;
1049 } else {
1050 data.append(LIST_DELIMITER);
1052 data.append(file.path);
1053 data.append(TUPLE_DELIMITER);
1054 data.append(file.hash);
1055 if (file.mimeType != null) {
1056 data.append(TUPLE_DELIMITER);
1057 data.append(file.mimeType);
1061 return data.toString();
1064 @VisibleForTesting
1065 static String getRuntime(String appYaml) {
1066 String result = "?";
1067 try {
1068 Map<?, ?> yaml = (Map<?, ?>) new YamlReader(appYaml).read();
1069 Object runtime = yaml.get("runtime");
1070 if (runtime instanceof String) {
1071 result = (String) runtime;
1073 } catch (YamlException ex) {
1074 logger.severe(ex.toString());
1076 return result;
1079 @VisibleForTesting
1080 static List<Pattern> loadSkipFiles(String appYaml) {
1081 List<Pattern> skipFiles = new ArrayList<>();
1082 if (appYaml == null) {
1083 return skipFiles;
1085 try {
1086 Map<?, ?> yaml = (Map<?, ?>) new YamlReader(appYaml).read();
1087 List<?> skipFileList = (List<?>) yaml.get("skip_files");
1088 if (skipFileList != null) {
1089 for (Object skipFile : skipFileList) {
1090 skipFiles.add(Pattern.compile(skipFile.toString()));
1093 } catch (YamlException ex) {
1094 logger.severe(ex.toString());
1096 return skipFiles;
1099 @VisibleForTesting
1100 static boolean shouldSkip(String name, List<Pattern> skipFiles) {
1101 for (Pattern skipPattern : skipFiles) {
1102 if (skipPattern.matcher(name).matches()) {
1103 return true;
1106 return false;
1109 static class FileInfo implements Comparable<FileInfo> {
1110 public File file;
1111 public String path;
1112 public String hash;
1113 public String mimeType;
1115 private FileInfo(String path) {
1116 this.path = path;
1117 this.mimeType = "";
1120 public FileInfo(File f, File base) throws LocalIOException {
1121 this.file = f;
1122 this.path = Utility.calculatePath(f, base);
1123 this.hash = calculateHash();
1126 @VisibleForTesting
1127 static FileInfo newForTesting(String path) {
1128 return new FileInfo(path);
1131 public void setMimeType(GenericApplication app) {
1132 mimeType = app.getMimeTypeIfStatic(path);
1135 @Override
1136 public String toString() {
1137 return (mimeType == null ? "" : mimeType) + '\t' + hash + "\t" + path;
1140 @Override
1141 public int compareTo(FileInfo other) {
1142 return path.compareTo(other.path);
1145 @Override
1146 public int hashCode() {
1147 return path.hashCode();
1150 @Override
1151 public boolean equals(Object obj) {
1152 if (obj instanceof FileInfo) {
1153 return path.equals(((FileInfo) obj).path);
1155 return false;
1158 private static final Pattern FILE_PATH_POSITIVE_RE =
1159 Pattern.compile("^[ 0-9a-zA-Z._+/@$-]{1,256}$");
1161 private static final Pattern FILE_PATH_NEGATIVE_RE_1 =
1162 Pattern.compile("[.][.]|^[.]/|[.]$|/[.]/|^-|^_ah/|^/");
1164 private static final Pattern FILE_PATH_NEGATIVE_RE_2 =
1165 Pattern.compile("//|/$");
1167 private static final Pattern FILE_PATH_NEGATIVE_RE_3 =
1168 Pattern.compile("^ | $|/ | /");
1170 @VisibleForTesting
1171 static String checkValidFilename(String path) {
1172 if (!FILE_PATH_POSITIVE_RE.matcher(path).matches()) {
1173 return "Invalid character in filename: " + path;
1175 if (FILE_PATH_NEGATIVE_RE_1.matcher(path).find()) {
1176 return "Filname cannot contain '.' or '..' or start with '-', '_ah/' or '/' : " + path;
1178 if (FILE_PATH_NEGATIVE_RE_2.matcher(path).find()) {
1179 return "Filname cannot have trailing / or contain //: " + path;
1181 if (FILE_PATH_NEGATIVE_RE_3.matcher(path).find()) {
1182 return "Any spaces must be in the middle of a filename: '" + path + "'";
1184 return null;
1187 private static final BaseEncoding SEPARATED_HEX =
1188 BaseEncoding.base16().lowerCase().withSeparator("_", 8);
1190 @VisibleForTesting
1191 static String calculateHash(ByteSource source) throws IOException {
1192 byte[] hash = source.hash(Hashing.sha1()).asBytes();
1193 return SEPARATED_HEX.encode(hash);
1196 public String calculateHash() throws LocalIOException {
1197 try {
1198 return calculateHash(Files.asByteSource(file));
1199 } catch (IOException e) {
1200 throw LocalIOException.from(e);
1205 class UploadBatcher {
1207 private static final int MAX_BATCH_SIZE = 3200000;
1208 private static final int MAX_BATCH_COUNT = 100;
1209 private static final int MAX_BATCH_FILE_SIZE = 200000;
1210 private static final int BATCH_OVERHEAD = 500;
1212 String what;
1213 String singleUrl;
1214 String batchUrl;
1215 boolean batching = true;
1216 List<FileInfo> batch = new ArrayList<FileInfo>();
1217 long batchSize = 0;
1220 * @param what Either "file" or "blob" or "errorblob" indicating what kind of objects this
1221 * batcher uploads. Used in messages and URLs.
1222 * @param batching whether or not we want to really do batch.
1224 public UploadBatcher(String what, boolean batching) {
1225 this.what = what;
1226 this.singleUrl = "/api/appversion/add" + what;
1227 this.batchUrl = singleUrl + "s";
1228 this.batching = batching;
1232 * Send the current batch on its way and reset the batch buffer when done
1234 public void sendBatch() throws IOException {
1236 app.statusUpdate(
1237 "Sending batch containing " + batch.size() + " "+ what +"(s) totaling " +
1238 batchSize / 1000 + "KB.");
1239 clientDeploySender.sendBatch(batchUrl, batch, batchSize, addVersionToArgs("", ""));
1240 batch = new ArrayList<>();
1241 batchSize = 0;
1245 * Flush the current batch.
1247 * This first attempts to send the batch as a single request; if that fails because the server
1248 * doesn"t support batching, the files are sent one by one, and self.batching is reset to
1249 * False.
1251 * At the end, self.batch and self.batchSize are reset
1253 public void flush() throws RemoteIOException {
1254 if (batch.isEmpty()) {
1255 return;
1257 try {
1258 sendBatch();
1259 } catch (Exception e) {
1260 app.statusUpdate("Exception in flushing batch payload, so sending 1 by 1..."
1261 + e.getMessage());
1262 batching = false;
1263 for (FileInfo fileInfo : batch) {
1264 send(singleUrl, fileInfo.file, fileInfo.mimeType, "path", fileInfo.path);
1266 batch = new ArrayList<FileInfo>();
1267 batchSize = 0;
1272 * Batch a file, possibly flushing first, or perhaps upload it directly.
1274 * Args: path: The name of the file. payload: The contents of the file. mime_type: The MIME
1275 * Content-type of the file, or None.
1277 * If mime_type is None, application/octet-stream is substituted. """
1279 public void addToBatch(FileInfo fileInfo) throws RemoteIOException {
1281 long size = fileInfo.file.length();
1283 if (size <= MAX_BATCH_FILE_SIZE) {
1284 if ((batch.size() >= MAX_BATCH_COUNT) ||
1285 (batchSize + size > MAX_BATCH_SIZE)) {
1286 flush();
1288 if (batching) {
1289 batch.add(fileInfo);
1290 batchSize += size + BATCH_OVERHEAD;
1291 return;
1294 send(singleUrl, fileInfo.file, fileInfo.mimeType, "path", fileInfo.path);