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
.Join
;
11 import java
.io
.FileInputStream
;
12 import java
.io
.IOException
;
13 import java
.io
.InputStream
;
14 import java
.net
.HttpURLConnection
;
15 import java
.security
.MessageDigest
;
16 import java
.security
.NoSuchAlgorithmException
;
17 import java
.text
.MessageFormat
;
18 import java
.util
.ArrayList
;
19 import java
.util
.Arrays
;
20 import java
.util
.Collection
;
21 import java
.util
.Collections
;
22 import java
.util
.HashMap
;
23 import java
.util
.List
;
25 import java
.util
.TreeMap
;
26 import java
.util
.concurrent
.Callable
;
27 import java
.util
.logging
.Logger
;
28 import java
.util
.regex
.Pattern
;
31 * Uploads a new appversion to the hosting service.
34 public class AppVersionUpload
{
36 * Don't try to precompile more than this number of files in one request.
38 private static final int MAX_FILES_PER_PRECOMPILE
= 50;
40 protected ServerConnection connection
;
41 protected GenericApplication app
;
42 protected final String backend
;
43 private final Logger logger
= Logger
.getLogger(AppVersionUpload
.class.getName());
44 private boolean inTransaction
= false;
45 private Map
<String
, FileInfo
> files
= new HashMap
<String
, FileInfo
>();
46 private boolean deployed
= false;
47 private final UploadBatcher fileBatcher
;
48 private final UploadBatcher blobBatcher
;
50 public AppVersionUpload(ServerConnection connection
, GenericApplication app
) {
51 this(connection
, app
, null, Boolean
.TRUE
);
55 * Create a new {@link AppVersionUpload} instance that can deploy a new
56 * versions of {@code app} via {@code connection}.
58 * @param connection to connect to the server
59 * @param app that contains the code to be deployed
60 * @param backend if supplied and non-{@code null}, a particular backend is
63 public AppVersionUpload(ServerConnection connection
, GenericApplication app
,
64 String backend
, boolean batchMode
) {
65 this.connection
= connection
;
67 this.backend
= backend
;
68 fileBatcher
= new UploadBatcher("file", batchMode
);
69 blobBatcher
= new UploadBatcher("blob", batchMode
);
73 * Uploads a new appversion to the server.
75 * @throws IOException if a problem occurs in the upload.
77 public void doUpload(ResourceLimits resourceLimits
) throws IOException
{
79 File basepath
= getBasepath();
81 app
.statusUpdate("Scanning files on local disk.", 20);
83 long resourceTotal
= 0;
84 for (File f
: new FileIterator(basepath
)) {
85 FileInfo fileInfo
= new FileInfo(f
, basepath
);
86 fileInfo
.setMimeType(app
);
87 logger
.fine("Processing file '" + f
+ "'.");
88 long maxFileBlobSize
= fileInfo
.mimeType
!= null ?
89 resourceLimits
.maxBlobSize() : resourceLimits
.maxFileSize();
90 if (f
.length() > maxFileBlobSize
) {
92 if (f
.getName().toLowerCase().endsWith(".jar")) {
93 message
= "Jar " + f
.getPath() + " is too large. Consider "
94 + "using --enable_jar_splitting.";
96 message
= "File " + f
.getPath() + " is too large (limit "
97 + maxFileBlobSize
+ " bytes).";
99 throw new IOException(message
);
101 resourceTotal
+= addFile(fileInfo
);
103 if (++numFiles
% 250 == 0) {
104 app
.statusUpdate("Scanned " + numFiles
+ " files.");
107 if (numFiles
> resourceLimits
.maxFileCount()) {
108 throw new IOException("Applications are limited to "
109 + resourceLimits
.maxFileCount() + " files, you have " + numFiles
112 if (resourceTotal
> resourceLimits
.maxTotalFileSize()) {
113 throw new IOException("Applications are limited to "
114 + resourceLimits
.maxTotalFileSize() + " bytes of resource files, "
115 + "you have " + resourceTotal
+ ".");
118 Collection
<FileInfo
> missingFiles
= beginTransaction();
119 app
.statusUpdate("Uploading " + missingFiles
.size() + " files.", 50);
120 if (missingFiles
.size() > 0) {
122 int quarter
= Math
.max(1, missingFiles
.size() / 4);
123 for (FileInfo missingFile
: missingFiles
) {
124 logger
.fine("Uploading file '" + missingFile
+ "'");
125 uploadFile(missingFile
);
126 if (++numFiles
% quarter
== 0) {
127 app
.statusUpdate("Uploaded " + numFiles
+ " files.");
131 uploadErrorHandlers(app
.getErrorHandlers(), basepath
);
132 if (app
.isPrecompilationEnabled()) {
149 private void uploadErrorHandlers(List
<ErrorHandler
> errorHandlers
, File basepath
)
151 if (!errorHandlers
.isEmpty()) {
152 app
.statusUpdate("Uploading " + errorHandlers
.size() + " file(s) "
153 + "for static error handlers.");
154 for (ErrorHandler handler
: errorHandlers
) {
155 File file
= new File(basepath
, handler
.getFile());
156 FileInfo info
= new FileInfo(file
, basepath
);
157 String error
= info
.checkValidFilename();
159 throw new IOException("Could not find static error handler: " + error
);
161 info
.mimeType
= handler
.getMimeType();
162 String errorType
= handler
.getErrorCode();
163 if (errorType
== null) {
164 errorType
= "default";
166 send("/api/appversion/adderrorblob", info
.file
, info
.mimeType
, "path",
172 public void precompile() throws IOException
{
173 app
.statusUpdate("Initializing precompilation...");
174 List
<String
> filesToCompile
= new ArrayList
<String
>();
179 filesToCompile
.addAll(sendPrecompileRequest(Collections
180 .<String
> emptyList()));
182 } catch (IOException ex
) {
183 if (errorCount
< 3) {
187 } catch (InterruptedException ex2
) {
189 new IOException("Interrupted during precompilation.");
196 "Precompilation failed. Consider adding <precompilation-enabled>false"
197 + "</precompilation-enabled> to your appengine-web.xml and trying again.");
205 IOException lastError
= null;
206 while (!filesToCompile
.isEmpty()) {
208 if (precompileChunk(filesToCompile
)) {
211 } catch (IOException ex
) {
214 Collections
.shuffle(filesToCompile
);
217 } catch (InterruptedException ex2
) {
219 new IOException("Interrupted during precompilation.");
225 if (errorCount
> 3) {
227 new IOException("Precompilation failed with "
228 + filesToCompile
.size() + " file(s) remaining. "
230 + " <precompilation-enabled>false</precompilation-enabled>"
231 + " to your " + "appengine-web.xml and trying again.");
232 ex2
.initCause(lastError
);
239 * Attempt to precompile up to {@code MAX_FILES_PER_PRECOMPILE} files from
240 * {@code filesToCompile}.
242 * @param filesToCompile a list of file names, which will be mutated to remove
243 * any files that were successfully compiled.
245 * @return true if filesToCompile was reduced in size (i.e. progress was
248 private boolean precompileChunk(List
<String
> filesToCompile
)
250 int filesLeft
= filesToCompile
.size();
251 if (filesLeft
== 0) {
252 app
.statusUpdate("Initializing precompilation...");
254 app
.statusUpdate(MessageFormat
.format(
255 "Precompiling... {0} file(s) left.", filesLeft
));
258 List
<String
> subset
=
260 .subList(0, Math
.min(filesLeft
, MAX_FILES_PER_PRECOMPILE
));
261 List
<String
> remainingFiles
= sendPrecompileRequest(subset
);
263 filesToCompile
.addAll(remainingFiles
);
264 return filesToCompile
.size() < filesLeft
;
267 private List
<String
> sendPrecompileRequest(List
<String
> filesToCompile
)
270 send("/api/appversion/precompile", Join
.join("\n", filesToCompile
));
271 if (response
.length() > 0) {
272 return Arrays
.asList(response
.split("\n"));
274 return Collections
.emptyList();
278 public void updateIndexes() throws IOException
{
279 if (app
.getIndexesXml() != null) {
280 app
.statusUpdate("Uploading index definitions.");
281 send("/api/datastore/index/add", getIndexYaml());
286 public void updateCron() throws IOException
{
287 String yaml
= getCronYaml();
289 app
.statusUpdate("Uploading cron jobs.");
290 send("/api/datastore/cron/update", yaml
);
294 public void updateQueue() throws IOException
{
295 String yaml
= getQueueYaml();
297 app
.statusUpdate("Uploading task queues.");
298 send("/api/queue/update", yaml
);
302 public void updateDos() throws IOException
{
303 String yaml
= getDosYaml();
305 app
.statusUpdate("Uploading DoS entries.");
306 send("/api/dos/update", yaml
);
310 public void updatePagespeed() throws IOException
{
311 String yaml
= getPagespeedYaml();
313 app
.statusUpdate("Uploading PageSpeed entries.");
314 send("/api/appversion/updatepagespeed", yaml
);
317 send("/api/appversion/updatepagespeed", "");
318 } catch (HttpIoException exc
) {
319 if (exc
.getResponseCode() != HttpURLConnection
.HTTP_NOT_FOUND
) {
326 public void setDefaultVersion() throws IOException
{
327 app
.statusUpdate("Setting default version to " + app
.getVersion() + ".");
328 send("/api/appversion/setdefault", "");
331 protected String
getIndexYaml() {
332 return app
.getIndexesXml().toYaml();
335 protected String
getCronYaml() {
336 if (app
.getCronXml() != null) {
337 return app
.getCronXml().toYaml();
343 protected String
getQueueYaml() {
344 if (app
.getQueueXml() != null) {
345 return app
.getQueueXml().toYaml();
351 protected String
getDosYaml() {
352 if (app
.getDosXml() != null) {
353 return app
.getDosXml().toYaml();
359 protected String
getPagespeedYaml() {
360 return app
.getPagespeedYaml();
363 private File
getBasepath() {
364 File path
= app
.getStagingDir();
366 path
= new File(app
.getPath());
372 * Adds a file for uploading, returning the bytes counted against the total
376 * @return 0 for a static file, or file.length() for a resource file.
377 * @throws IOException
379 private long addFile(FileInfo info
) throws IOException
{
381 throw new IllegalStateException("Already in a transaction.");
384 String error
= info
.checkValidFilename();
386 logger
.severe(error
);
390 files
.put(info
.path
, info
);
392 return info
.mimeType
!= null ?
0 : info
.file
.length();
396 * Begins the transaction, returning a list of files that need uploading.
398 * All calls to addFile must be made before calling beginTransaction().
400 * @return A list of pathnames that should be uploaded using uploadFile()
401 * before calling commit().
403 private Collection
<FileInfo
> beginTransaction() throws IOException
{
405 throw new IllegalStateException("Already in a transaction.");
408 if (backend
== null) {
409 app
.statusUpdate("Initiating update.");
411 app
.statusUpdate("Initiating update of backend " + backend
+ ".");
413 send("/api/appversion/create", app
.getAppYaml());
414 inTransaction
= true;
415 Collection
<FileInfo
> blobsToClone
= new ArrayList
<FileInfo
>(files
.size());
416 Collection
<FileInfo
> filesToClone
= new ArrayList
<FileInfo
>(files
.size());
418 for (FileInfo f
: files
.values()) {
419 if (f
.mimeType
== null) {
426 TreeMap
<String
, FileInfo
> filesToUpload
= new TreeMap
<String
, FileInfo
>();
427 cloneFiles("/api/appversion/cloneblobs", blobsToClone
, "static",
429 cloneFiles("/api/appversion/clonefiles", filesToClone
, "application",
432 logger
.fine("Files to upload :");
433 for (FileInfo f
: filesToUpload
.values()) {
434 logger
.fine("\t" + f
);
437 this.files
= filesToUpload
;
438 return new ArrayList
<FileInfo
>(filesToUpload
.values());
441 private static final int MAX_FILES_TO_CLONE
= 100;
442 private static final String LIST_DELIMITER
= "\n";
445 * Sends files to the given url.
447 * @param url server URL to use.
448 * @param filesParam List of files to clone.
449 * @param type Type of files ( "static" or "application")
450 * @param filesToUpload Files that need to be uploaded are added to this
453 private void cloneFiles(String url
, Collection
<FileInfo
> filesParam
,
454 String type
, Map
<String
, FileInfo
> filesToUpload
) throws IOException
{
455 if (filesParam
.isEmpty()) {
458 app
.statusUpdate("Cloning " + filesParam
.size() + " " + type
+ " files.");
461 int remaining
= filesParam
.size();
462 ArrayList
<FileInfo
> chunk
= new ArrayList
<FileInfo
>(MAX_FILES_TO_CLONE
);
463 for (FileInfo file
: filesParam
) {
465 if (--remaining
== 0 || chunk
.size() >= MAX_FILES_TO_CLONE
) {
467 app
.statusUpdate("Cloned " + cloned
+ " files.");
469 String result
= send(url
, buildClonePayload(chunk
));
470 if (result
!= null && result
.length() > 0) {
471 for (String path
: result
.split(LIST_DELIMITER
)) {
472 if (path
== null || path
.length() == 0) {
475 FileInfo info
= this.files
.get(path
);
477 logger
.warning("Skipping " + path
+ ": missing FileInfo");
480 filesToUpload
.put(path
, info
);
483 cloned
+= chunk
.size();
490 * Uploads a file to the hosting service.
492 * Must only be called after beginTransaction(). The file provided must be on
493 * of those that were returned by beginTransaction();
495 * @param file FileInfo for the file to upload.
497 private void uploadFile(FileInfo file
) throws IOException
{
498 if (!inTransaction
) {
499 throw new IllegalStateException(
500 "beginTransaction() must be called before uploadFile().");
502 if (!files
.containsKey(file
.path
)) {
503 throw new IllegalArgumentException("File " + file
.path
504 + " is not in the list of files to be uploaded.");
507 files
.remove(file
.path
);
508 if (file
.mimeType
== null) {
509 fileBatcher
.addToBatch(file
);
511 blobBatcher
.addToBatch(file
);
516 * Commits the transaction, making the new app version available.
518 * All the files returned by beginTransaction must have been uploaded with
519 * uploadFile() before commit() may be called.
521 private void commit() throws IOException
{
524 boolean ready
= retryWithBackoff(1, 2, 60, 20, new Callable
<Boolean
>() {
526 public Boolean
call() throws Exception
{
534 logger
.severe("Version still not ready to serve, aborting.");
535 throw new RuntimeException("Version not ready.");
537 } catch (IOException ioe
) {
539 } catch (RuntimeException e
) {
541 } catch (Exception e
) {
542 throw new RuntimeException(e
);
547 * Deploys the new app version but does not make it default.
549 * All the files returned by beginTransaction must have been uploaded with
550 * uploadFile() before commit() may be called.
552 private void deploy() throws IOException
{
553 if (!inTransaction
) {
554 throw new IllegalStateException(
555 "beginTransaction() must be called before uploadFile().");
557 if (files
.size() > 0) {
558 throw new IllegalStateException(
559 "Some required files have not been uploaded.");
561 app
.statusUpdate("Deploying new version.", 20);
562 send("/api/appversion/deploy", "");
567 * Check if the new app version is ready to serve traffic.
569 * @return true if the server returned that the app is ready to serve.
571 private boolean isReady() throws IOException
{
573 throw new IllegalStateException(
574 "deploy() must be called before isReady()");
576 String result
= send("/api/appversion/isready", "");
577 return "1".equals(result
.trim());
580 private void startServing() throws IOException
{
582 throw new IllegalStateException(
583 "deploy() must be called before startServing()");
585 app
.statusUpdate("Closing update: new version is ready to start serving.");
586 send("/api/appversion/startserving", "");
587 inTransaction
= false;
590 public void forceRollback() throws IOException
{
591 app
.statusUpdate("Rolling back the update" + this.backend
== null ?
"."
592 : " on backend " + this.backend
+ ".");
593 send("/api/appversion/rollback", "");
596 private void rollback() throws IOException
{
597 if (!inTransaction
) {
604 String
send(String url
, String payload
, String
... args
)
606 return connection
.post(url
, payload
, addVersionToArgs(args
));
610 String
send(String url
, File payload
, String mimeType
, String
... args
)
612 return connection
.post(url
, payload
, mimeType
, addVersionToArgs(args
));
615 private String
[] addVersionToArgs(String
... args
) {
616 List
<String
> result
= new ArrayList
<String
>();
617 result
.addAll(Arrays
.asList(args
));
618 result
.add("app_id");
619 result
.add(app
.getAppId());
620 if (backend
!= null) {
621 result
.add("backend");
623 } else if (app
.getVersion() != null) {
624 result
.add("version");
625 result
.add(app
.getVersion());
627 if (app
.getServer() != null) {
628 result
.add("server");
629 result
.add(app
.getServer());
631 return result
.toArray(new String
[result
.size()]);
635 * Calls a function multiple times, backing off more and more each time.
637 * @param initialDelay Inital delay after the first try, in seconds.
638 * @param backoffFactor Delay will be multiplied by this factor after each
640 * @param maxDelay Maximum delay factor.
641 * @param maxTries Maximum number of tries.
642 * @param callable Callable to call.
643 * @return true if the Callable returned true in one of its tries.
645 private boolean retryWithBackoff(double initialDelay
, double backoffFactor
,
646 double maxDelay
, int maxTries
, Callable
<Boolean
> callable
)
648 long delayMillis
= (long) (initialDelay
* 1000);
649 long maxDelayMillis
= (long) (maxDelay
* 1000);
650 if (callable
.call()) {
653 while (maxTries
> 1) {
654 app
.statusUpdate("Will check again in " + (delayMillis
/ 1000)
656 Thread
.sleep(delayMillis
);
657 delayMillis
*= backoffFactor
;
658 if (delayMillis
> maxDelayMillis
) {
659 delayMillis
= maxDelayMillis
;
662 if (callable
.call()) {
669 private static final String TUPLE_DELIMITER
= "|";
672 * Build the post body for a clone request.
674 * @param files List of FileInfos for the files to clone.
675 * @return A string containing the properly delimited tuples.
677 private static String
buildClonePayload(Collection
<FileInfo
> files
) {
678 StringBuffer data
= new StringBuffer();
679 boolean first
= true;
680 for (FileInfo file
: files
) {
684 data
.append(LIST_DELIMITER
);
686 data
.append(file
.path
);
687 data
.append(TUPLE_DELIMITER
);
688 data
.append(file
.hash
);
689 if (file
.mimeType
!= null) {
690 data
.append(TUPLE_DELIMITER
);
691 data
.append(file
.mimeType
);
695 return data
.toString();
698 static class FileInfo
implements Comparable
<FileInfo
> {
702 public String mimeType
;
704 public FileInfo(File f
, File base
) throws IOException
{
706 this.path
= Utility
.calculatePath(f
, base
);
707 this.hash
= calculateHash();
710 public void setMimeType(GenericApplication app
) {
711 mimeType
= app
.getMimeTypeIfStatic(path
);
715 public String
toString() {
716 return (mimeType
== null ?
"" : mimeType
) + '\t' + hash
+ "\t" + path
;
720 public int compareTo(FileInfo other
) {
721 return path
.compareTo(other
.path
);
725 public int hashCode() {
726 return path
.hashCode();
730 public boolean equals(Object obj
) {
731 if (obj
instanceof FileInfo
) {
732 return path
.equals(((FileInfo
) obj
).path
);
737 private static final Pattern FILE_PATH_POSITIVE_RE
=
738 Pattern
.compile("^[ 0-9a-zA-Z._+/$-]{1,256}$");
740 private static final Pattern FILE_PATH_NEGATIVE_RE_1
=
741 Pattern
.compile("[.][.]|^[.]/|[.]$|/[.]/|^-");
743 private static final Pattern FILE_PATH_NEGATIVE_RE_2
=
744 Pattern
.compile("//|/$");
746 private static final Pattern FILE_PATH_NEGATIVE_RE_3
=
747 Pattern
.compile("^ | $|/ | /");
749 private String
checkValidFilename() {
750 if (!FILE_PATH_POSITIVE_RE
.matcher(path
).matches()) {
751 return "Invalid character in filename: " + path
;
753 if (FILE_PATH_NEGATIVE_RE_1
.matcher(path
).find()) {
754 return "Filname cannot contain '.' or '..' or start with '-': " + path
;
756 if (FILE_PATH_NEGATIVE_RE_2
.matcher(path
).find()) {
757 return "Filname cannot have trailing / or contain //: " + path
;
759 if (FILE_PATH_NEGATIVE_RE_3
.matcher(path
).find()) {
760 return "Any spaces must be in the middle of a filename: '" + path
+ "'";
765 private static final char[] HEX
=
766 {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd',
769 public String
calculateHash() throws IOException
{
770 InputStream s
= new FileInputStream(file
);
771 byte[] buf
= new byte[4096];
773 MessageDigest digest
= MessageDigest
.getInstance("SHA-1");
774 for (int numRead
; (numRead
= s
.read(buf
)) != -1;) {
775 digest
.update(buf
, 0, numRead
);
777 StringBuffer hashValue
= new StringBuffer(40);
779 for (byte b
: digest
.digest()) {
780 if ((i
> 0) && ((i
% 4) == 0)) {
781 hashValue
.append('_');
783 hashValue
.append(HEX
[(b
>> 4) & 0xf]);
784 hashValue
.append(HEX
[b
& 0xf]);
788 return hashValue
.toString();
789 } catch (NoSuchAlgorithmException e
) {
790 throw new RuntimeException(e
);
794 } catch (IOException ex
) {
801 class UploadBatcher
{
803 private static final int MAX_BATCH_SIZE
= 3200000;
804 private static final int MAX_BATCH_COUNT
= 100;
805 private static final int MAX_BATCH_FILE_SIZE
= 200000;
806 private static final int BATCH_OVERHEAD
= 500;
811 boolean batching
= true;
812 List
<FileInfo
> batch
= new ArrayList
<FileInfo
>();
816 * @param what Either "file" or "blob" or "errorblob" indicating what kind of objects this
817 * batcher uploads. Used in messages and URLs.
818 * @param batching whether or not we want to really do batch.
820 public UploadBatcher(String what
, boolean batching
) {
822 this.singleUrl
= "/api/appversion/add" + what
;
823 this.batchUrl
= singleUrl
+ "s";
824 this.batching
= batching
;
828 * Send the current batch on its way and reset the batrch buffer when done
830 public void sendBatch() throws IOException
{
833 "Sending batch containing " + batch
.size() + " "+ what
+"(s) totaling " +
834 batchSize
/ 1000 + "KB.");
835 connection
.post(batchUrl
, batch
, addVersionToArgs("", ""));
836 batch
= new ArrayList
<FileInfo
>();
841 * """Flush the current batch.
843 * This first attempts to send the batch as a single request; if that fails because the server
844 * doesn"t support batching, the files are sent one by one, and self.batching is reset to
847 * At the end, self.batch and self.batchSize are reset
849 public void flush() throws IOException
{
850 if (batch
.isEmpty()) {
855 } catch (Exception e
) {
856 app
.statusUpdate("Exception in flushing batch payload, so sending 1 by 1..."
859 for (FileInfo fileInfo
: batch
) {
860 send(singleUrl
, fileInfo
.file
, fileInfo
.mimeType
, "path", fileInfo
.path
);
862 batch
= new ArrayList
<FileInfo
>();
868 * Batch a file, possibly flushing first, or perhaps upload it directly.
870 * Args: path: The name of the file. payload: The contents of the file. mime_type: The MIME
871 * Content-type of the file, or None.
873 * If mime_type is None, application/octet-stream is substituted. """
875 public void addToBatch(FileInfo fileInfo
) throws IOException
{
877 long size
= fileInfo
.file
.length();
879 if (size
<= MAX_BATCH_FILE_SIZE
) {
880 if ((batch
.size() >= MAX_BATCH_COUNT
) ||
881 (batchSize
+ size
> MAX_BATCH_SIZE
)) {
886 batchSize
+= size
+ BATCH_OVERHEAD
;
890 send(singleUrl
, fileInfo
.file
, fileInfo
.mimeType
, "path", fileInfo
.path
);