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
;
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
;
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
;
38 * Uploads a new appversion to the hosting service.
41 public class AppVersionUpload
{
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);
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
82 public AppVersionUpload(ServerConnection connection
, GenericApplication app
,
83 String backend
, boolean batchMode
) {
84 this.connection
= connection
;
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();
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
);
106 uploadFilesTransaction(resourceLimits
, failOnPrecompilationError
);
108 clientDeploySender
= originalClientDeploySender
;
110 if (updateGlobalConfigurations
) {
116 reportIfSkippingDispatchConfiguration();
118 reportSkippingGlobalConfiguration();
122 private void uploadFilesTransaction(
123 ResourceLimits resourceLimits
,
124 boolean failOnPrecompilationError
) throws LocalIOException
, RemoteIOException
{
127 File basepath
= getBasepath();
128 scanFiles(basepath
, resourceLimits
);
129 Collection
<FileInfo
> missingFiles
= beginTransaction(resourceLimits
);
130 uploadFiles(failOnPrecompilationError
, basepath
, missingFiles
);
132 clientDeploySender
.logClientDeploy(true, addVersionToArgs());
136 } catch (HttpIoException e
) {
137 if (e
.isSlaError()) {
138 clientDeploySender
.logClientDeploy(false, addVersionToArgs());
141 } catch (RuntimeException e
) {
142 clientDeploySender
.logClientDeploy(false, addVersionToArgs());
147 private void uploadFiles(boolean failOnPrecompilationError
, File basepath
,
148 Collection
<FileInfo
> missingFiles
)
149 throws LocalIOException
, RemoteIOException
{
151 app
.statusUpdate("Uploading " + missingFiles
.size() + " files.", 50);
152 if (!missingFiles
.isEmpty()) {
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
);
171 private void scanFiles(File basepath
, ResourceLimits resourceLimits
)
172 throws LocalIOException
{
174 app
.statusUpdate("Scanning files on local disk.", 20);
176 long resourceTotal
= 0;
177 List
<Pattern
> skipFiles
= loadSkipFiles(app
.getAppYaml());
178 for (File f
: new FileIterator(basepath
)) {
179 if (shouldSkip(f
.getName(), skipFiles
)) {
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
) {
190 if (f
.getName().toLowerCase().endsWith(".jar")) {
191 message
= "Jar " + f
.getPath() + " is too large. Consider "
192 + "using --enable_jar_splitting.";
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
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) {
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
);
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",
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) {
290 } catch (InterruptedException e
) {
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;
324 filesToCompile
.addAll(sendPrecompileRequest(Collections
.<String
>emptyList()));
326 } catch (RemoteIOException ex
) {
328 if (!sleepIfShouldRetry
.sleepIfShouldRetry(errorCount
)) {
329 if (failOnPrecompilationError
) {
330 throw precompilationFailedException("", ex
);
332 logger
.warning(PRECOMPILATION_FAILED_WARNING_MESSAGE
);
340 while (!filesToCompile
.isEmpty()) {
342 if (precompileChunk(filesToCompile
)) {
345 } catch (RemoteIOException ex
) {
346 Collections
.shuffle(filesToCompile
);
348 if (!sleepIfShouldRetry
.sleepIfShouldRetry(errorCount
)) {
349 if (failOnPrecompilationError
) {
350 String messageFragment
= " with " + filesToCompile
.size() + " file(s) remaining";
351 throw precompilationFailedException(messageFragment
, ex
);
353 logger
.warning(PRECOMPILATION_FAILED_WARNING_MESSAGE
);
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
);
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
384 private boolean precompileChunk(List
<String
> filesToCompile
)
385 throws RemoteIOException
{
386 int filesLeft
= filesToCompile
.size();
387 if (filesLeft
== 0) {
388 app
.statusUpdate("Initializing precompilation...");
390 app
.statusUpdate(MessageFormat
.format(
391 "Precompiling... {0} file(s) left.", filesLeft
));
394 List
<String
> subset
=
396 .subList(0, Math
.min(filesLeft
, MAX_FILES_PER_PRECOMPILE
));
397 List
<String
> remainingFiles
= sendPrecompileRequest(subset
);
399 filesToCompile
.addAll(remainingFiles
);
400 return filesToCompile
.size() < filesLeft
;
403 private List
<String
> sendPrecompileRequest(List
<String
> filesToCompile
)
404 throws RemoteIOException
{
406 send("/api/appversion/precompile", Joiner
.on("\n").useForNull("null").join(filesToCompile
));
407 if (response
.length() > 0) {
408 return Arrays
.asList(response
.split("\n"));
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();
425 app
.statusUpdate("Uploading cron jobs.");
426 send("/api/datastore/cron/update", yaml
);
430 public void updateQueue() throws RemoteIOException
{
431 String yaml
= getQueueYaml();
433 app
.statusUpdate("Uploading task queues.");
434 send("/api/queue/update", yaml
);
438 public void updateDispatch() throws RemoteIOException
{
439 String yaml
= getDispatchYaml();
441 app
.statusUpdate("Uploading dispatch entries.");
442 send("/api/dispatch/update", yaml
);
446 public void updateDos() throws RemoteIOException
{
447 String yaml
= getDosYaml();
449 app
.statusUpdate("Uploading DoS entries.");
450 send("/api/dos/update", yaml
);
454 public void updatePagespeed() throws RemoteIOException
{
455 String yaml
= getPagespeedYaml();
457 app
.statusUpdate("Uploading PageSpeed entries.");
458 send("/api/appversion/updatepagespeed", yaml
);
461 send("/api/appversion/updatepagespeed", "");
462 } catch (HttpIoException exc
) {
463 if (exc
.getResponseCode() != HttpURLConnection
.HTTP_NOT_FOUND
) {
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
>();
480 args
.add(app
.getAppId());
482 args
.add(app
.getVersion());
483 for (String mod
: modules
) {
487 connection
.post(url
, "", args
.toArray(new String
[args
.size()]));
490 app
.statusUpdate("Setting the default version of module " + module
+ " of application " +
491 app
.getAppId() + " to " + app
.getVersion());
494 app
.statusUpdate("Setting the default version of application " + app
.getAppId() +
495 " to " + app
.getVersion());
500 protected String
getIndexYaml() {
501 return app
.getIndexesXml().toYaml();
504 protected String
getCronYaml() {
505 if (app
.getCronXml() != null) {
506 return app
.getCronXml().toYaml();
512 protected String
getQueueYaml() {
513 if (app
.getQueueXml() != null) {
514 return app
.getQueueXml().toYaml();
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();
532 protected String
getPagespeedYaml() {
533 return app
.getPagespeedYaml();
537 protected boolean getInTransaction() {
538 return this.inTransaction
;
542 protected void setInTransaction(boolean newValue
) {
543 this.inTransaction
= newValue
;
546 private File
getBasepath() {
547 File path
= app
.getStagingDir();
549 path
= new File(app
.getPath());
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.
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());
571 url
.append(app
.getVersion());
573 return url
.toString();
577 * Adds a file for uploading, returning the bytes counted against the total
581 * @return 0 for a static file, or file.length() for a resource file.
584 long addFile(FileInfo info
) {
586 throw new IllegalStateException("Already in a transaction.");
589 String error
= FileInfo
.checkValidFilename(info
.path
);
591 logger
.severe(error
);
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
));
609 Object obj
= yaml
.read();
611 @SuppressWarnings("unchecked")
612 Map
<String
, Object
> responseMap
= (Map
<String
, Object
>) obj
;
613 if (responseMap
!= null) {
614 obj
= responseMap
.get("warnings");
616 @SuppressWarnings("unchecked")
617 ArrayList
<String
> warnings
= (ArrayList
<String
>) obj
;
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().
638 Collection
<FileInfo
> beginTransaction(ResourceLimits resourceLimits
) throws RemoteIOException
{
640 throw new IllegalStateException("Already in a transaction.");
643 if (backend
== null) {
644 app
.statusUpdate("Initiating update.");
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) {
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
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()) {
698 app
.statusUpdate("Cloning " + filesParam
.size() + " " + type
+ " files.");
701 int remaining
= filesParam
.size();
702 ArrayList
<FileInfo
> chunk
= new ArrayList
<FileInfo
>((int) maxFilesToClone
);
703 for (FileInfo file
: filesParam
) {
705 if (--remaining
== 0 || chunk
.size() >= maxFilesToClone
) {
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) {
715 FileInfo info
= this.files
.get(path
);
717 logger
.warning("Skipping " + path
+ ": missing FileInfo");
720 filesToUpload
.put(path
, info
);
723 cloned
+= chunk
.size();
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
);
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.
762 void commit() throws RemoteIOException
{
765 boolean ready
= retryWithBackoff(1, 2, 60, 20, new Callable
<Boolean
>() {
767 public Boolean
call() throws Exception
{
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
>() {
781 public Boolean
call() throws Exception
{
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
>() {
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
) {
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", "");
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
{
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
{
849 throw new IllegalStateException(
850 "deploy() must be called before startServing()");
852 send("/api/appversion/startserving", "", "willcheckserving", "1");
857 protected Map
<String
, String
> parseIsServingResponse(String isServingResp
) {
858 ImmutableMap
.Builder
<String
, String
> result
= ImmutableMap
.builder();
859 if (isServingResp
.isEmpty()) {
860 return result
.build();
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
{
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"));
904 throw new RuntimeException(
905 "Fatal problem encountered during deployment. Unexpected response when " +
906 "checking for serving status. Response: " + result
);
911 Map
<String
, String
> parseIsConfigUpdatedResponse(String isConfigUpdatedResp
) {
912 ImmutableMap
.Builder
<String
, String
> result
= ImmutableMap
.builder();
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
{
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"));
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
) {
962 String
send(String url
, String payload
, String
... args
)
963 throws RemoteIOException
{
965 return clientDeploySender
.send(url
, payload
, addVersionToArgs(args
));
966 } catch (IOException e
) {
967 throw RemoteIOException
.from(e
);
972 String
send(String url
, File payload
, String mimeType
, String
... args
)
973 throws RemoteIOException
{
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");
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
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
)
1014 long delayMillis
= (long) (initialDelay
* 1000);
1015 long maxDelayMillis
= (long) (maxDelay
* 1000);
1016 if (callable
.call()) {
1019 while (maxTries
> 1) {
1020 app
.statusUpdate("Will check again in " + (delayMillis
/ 1000)
1022 Thread
.sleep(delayMillis
);
1023 delayMillis
*= backoffFactor
;
1024 if (delayMillis
> maxDelayMillis
) {
1025 delayMillis
= maxDelayMillis
;
1028 if (callable
.call()) {
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
) {
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();
1065 static String
getRuntime(String appYaml
) {
1066 String result
= "?";
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());
1080 static List
<Pattern
> loadSkipFiles(String appYaml
) {
1081 List
<Pattern
> skipFiles
= new ArrayList
<>();
1082 if (appYaml
== null) {
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());
1100 static boolean shouldSkip(String name
, List
<Pattern
> skipFiles
) {
1101 for (Pattern skipPattern
: skipFiles
) {
1102 if (skipPattern
.matcher(name
).matches()) {
1109 static class FileInfo
implements Comparable
<FileInfo
> {
1113 public String mimeType
;
1115 private FileInfo(String path
) {
1120 public FileInfo(File f
, File base
) throws LocalIOException
{
1122 this.path
= Utility
.calculatePath(f
, base
);
1123 this.hash
= calculateHash();
1127 static FileInfo
newForTesting(String path
) {
1128 return new FileInfo(path
);
1131 public void setMimeType(GenericApplication app
) {
1132 mimeType
= app
.getMimeTypeIfStatic(path
);
1136 public String
toString() {
1137 return (mimeType
== null ?
"" : mimeType
) + '\t' + hash
+ "\t" + path
;
1141 public int compareTo(FileInfo other
) {
1142 return path
.compareTo(other
.path
);
1146 public int hashCode() {
1147 return path
.hashCode();
1151 public boolean equals(Object obj
) {
1152 if (obj
instanceof FileInfo
) {
1153 return path
.equals(((FileInfo
) obj
).path
);
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("^ | $|/ | /");
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
+ "'";
1187 private static final BaseEncoding SEPARATED_HEX
=
1188 BaseEncoding
.base16().lowerCase().withSeparator("_", 8);
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
{
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;
1215 boolean batching
= true;
1216 List
<FileInfo
> batch
= new ArrayList
<FileInfo
>();
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
) {
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
{
1237 "Sending batch containing " + batch
.size() + " "+ what
+"(s) totaling " +
1238 batchSize
/ 1000 + "KB.");
1239 clientDeploySender
.sendBatch(batchUrl
, batch
, batchSize
, addVersionToArgs("", ""));
1240 batch
= new ArrayList
<>();
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
1251 * At the end, self.batch and self.batchSize are reset
1253 public void flush() throws RemoteIOException
{
1254 if (batch
.isEmpty()) {
1259 } catch (Exception e
) {
1260 app
.statusUpdate("Exception in flushing batch payload, so sending 1 by 1..."
1263 for (FileInfo fileInfo
: batch
) {
1264 send(singleUrl
, fileInfo
.file
, fileInfo
.mimeType
, "path", fileInfo
.path
);
1266 batch
= new ArrayList
<FileInfo
>();
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
)) {
1289 batch
.add(fileInfo
);
1290 batchSize
+= size
+ BATCH_OVERHEAD
;
1294 send(singleUrl
, fileInfo
.file
, fileInfo
.mimeType
, "path", fileInfo
.path
);