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(resourceLimits
);
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 * @param resourceLimits is the collection of resource limits for AppCfg.
401 * @return A list of pathnames that should be uploaded using uploadFile()
402 * before calling commit().
404 private Collection
<FileInfo
> beginTransaction(ResourceLimits resourceLimits
)
407 throw new IllegalStateException("Already in a transaction.");
410 if (backend
== null) {
411 app
.statusUpdate("Initiating update.");
413 app
.statusUpdate("Initiating update of backend " + backend
+ ".");
415 send("/api/appversion/create", app
.getAppYaml());
416 inTransaction
= true;
417 Collection
<FileInfo
> blobsToClone
= new ArrayList
<FileInfo
>(files
.size());
418 Collection
<FileInfo
> filesToClone
= new ArrayList
<FileInfo
>(files
.size());
420 for (FileInfo f
: files
.values()) {
421 if (f
.mimeType
== null) {
428 TreeMap
<String
, FileInfo
> filesToUpload
= new TreeMap
<String
, FileInfo
>();
429 cloneFiles("/api/appversion/cloneblobs", blobsToClone
, "static",
430 filesToUpload
, resourceLimits
.maxFilesToClone());
431 cloneFiles("/api/appversion/clonefiles", filesToClone
, "application",
432 filesToUpload
, resourceLimits
.maxFilesToClone());
434 logger
.fine("Files to upload :");
435 for (FileInfo f
: filesToUpload
.values()) {
436 logger
.fine("\t" + f
);
439 this.files
= filesToUpload
;
440 return new ArrayList
<FileInfo
>(filesToUpload
.values());
443 private static final String LIST_DELIMITER
= "\n";
446 * Sends files to the given url.
448 * @param url server URL to use.
449 * @param filesParam List of files to clone.
450 * @param type Type of files ( "static" or "application")
451 * @param filesToUpload Files that need to be uploaded are added to this
453 * @param maxFilesToClone Max number of files to clone at a single time.
455 private void cloneFiles(String url
, Collection
<FileInfo
> filesParam
,
456 String type
, Map
<String
, FileInfo
> filesToUpload
, long maxFilesToClone
)
458 if (filesParam
.isEmpty()) {
461 app
.statusUpdate("Cloning " + filesParam
.size() + " " + type
+ " files.");
464 int remaining
= filesParam
.size();
465 ArrayList
<FileInfo
> chunk
= new ArrayList
<FileInfo
>((int) maxFilesToClone
);
466 for (FileInfo file
: filesParam
) {
468 if (--remaining
== 0 || chunk
.size() >= maxFilesToClone
) {
470 app
.statusUpdate("Cloned " + cloned
+ " files.");
472 String result
= send(url
, buildClonePayload(chunk
));
473 if (result
!= null && result
.length() > 0) {
474 for (String path
: result
.split(LIST_DELIMITER
)) {
475 if (path
== null || path
.length() == 0) {
478 FileInfo info
= this.files
.get(path
);
480 logger
.warning("Skipping " + path
+ ": missing FileInfo");
483 filesToUpload
.put(path
, info
);
486 cloned
+= chunk
.size();
493 * Uploads a file to the hosting service.
495 * Must only be called after beginTransaction(). The file provided must be on
496 * of those that were returned by beginTransaction();
498 * @param file FileInfo for the file to upload.
500 private void uploadFile(FileInfo file
) throws IOException
{
501 if (!inTransaction
) {
502 throw new IllegalStateException(
503 "beginTransaction() must be called before uploadFile().");
505 if (!files
.containsKey(file
.path
)) {
506 throw new IllegalArgumentException("File " + file
.path
507 + " is not in the list of files to be uploaded.");
510 files
.remove(file
.path
);
511 if (file
.mimeType
== null) {
512 fileBatcher
.addToBatch(file
);
514 blobBatcher
.addToBatch(file
);
519 * Commits the transaction, making the new app version available.
521 * All the files returned by beginTransaction must have been uploaded with
522 * uploadFile() before commit() may be called.
524 private void commit() throws IOException
{
527 boolean ready
= retryWithBackoff(1, 2, 60, 20, new Callable
<Boolean
>() {
529 public Boolean
call() throws Exception
{
537 logger
.severe("Version still not ready to serve, aborting.");
538 throw new RuntimeException("Version not ready.");
540 } catch (IOException ioe
) {
542 } catch (RuntimeException e
) {
544 } catch (Exception e
) {
545 throw new RuntimeException(e
);
550 * Deploys the new app version but does not make it default.
552 * All the files returned by beginTransaction must have been uploaded with
553 * uploadFile() before commit() may be called.
555 private void deploy() throws IOException
{
556 if (!inTransaction
) {
557 throw new IllegalStateException(
558 "beginTransaction() must be called before uploadFile().");
560 if (files
.size() > 0) {
561 throw new IllegalStateException(
562 "Some required files have not been uploaded.");
564 app
.statusUpdate("Deploying new version.", 20);
565 send("/api/appversion/deploy", "");
570 * Check if the new app version is ready to serve traffic.
572 * @return true if the server returned that the app is ready to serve.
574 private boolean isReady() throws IOException
{
576 throw new IllegalStateException(
577 "deploy() must be called before isReady()");
579 String result
= send("/api/appversion/isready", "");
580 return "1".equals(result
.trim());
583 private void startServing() throws IOException
{
585 throw new IllegalStateException(
586 "deploy() must be called before startServing()");
588 app
.statusUpdate("Closing update: new version is ready to start serving.");
589 send("/api/appversion/startserving", "");
590 inTransaction
= false;
593 public void forceRollback() throws IOException
{
594 app
.statusUpdate("Rolling back the update" + this.backend
== null ?
"."
595 : " on backend " + this.backend
+ ".");
596 send("/api/appversion/rollback", "");
599 private void rollback() throws IOException
{
600 if (!inTransaction
) {
607 String
send(String url
, String payload
, String
... args
)
609 return connection
.post(url
, payload
, addVersionToArgs(args
));
613 String
send(String url
, File payload
, String mimeType
, String
... args
)
615 return connection
.post(url
, payload
, mimeType
, addVersionToArgs(args
));
618 private String
[] addVersionToArgs(String
... args
) {
619 List
<String
> result
= new ArrayList
<String
>();
620 result
.addAll(Arrays
.asList(args
));
621 result
.add("app_id");
622 result
.add(app
.getAppId());
623 if (backend
!= null) {
624 result
.add("backend");
626 } else if (app
.getVersion() != null) {
627 result
.add("version");
628 result
.add(app
.getVersion());
630 if (app
.getServer() != null) {
631 result
.add("server");
632 result
.add(app
.getServer());
634 return result
.toArray(new String
[result
.size()]);
638 * Calls a function multiple times, backing off more and more each time.
640 * @param initialDelay Inital delay after the first try, in seconds.
641 * @param backoffFactor Delay will be multiplied by this factor after each
643 * @param maxDelay Maximum delay factor.
644 * @param maxTries Maximum number of tries.
645 * @param callable Callable to call.
646 * @return true if the Callable returned true in one of its tries.
648 private boolean retryWithBackoff(double initialDelay
, double backoffFactor
,
649 double maxDelay
, int maxTries
, Callable
<Boolean
> callable
)
651 long delayMillis
= (long) (initialDelay
* 1000);
652 long maxDelayMillis
= (long) (maxDelay
* 1000);
653 if (callable
.call()) {
656 while (maxTries
> 1) {
657 app
.statusUpdate("Will check again in " + (delayMillis
/ 1000)
659 Thread
.sleep(delayMillis
);
660 delayMillis
*= backoffFactor
;
661 if (delayMillis
> maxDelayMillis
) {
662 delayMillis
= maxDelayMillis
;
665 if (callable
.call()) {
672 private static final String TUPLE_DELIMITER
= "|";
675 * Build the post body for a clone request.
677 * @param files List of FileInfos for the files to clone.
678 * @return A string containing the properly delimited tuples.
680 private static String
buildClonePayload(Collection
<FileInfo
> files
) {
681 StringBuffer data
= new StringBuffer();
682 boolean first
= true;
683 for (FileInfo file
: files
) {
687 data
.append(LIST_DELIMITER
);
689 data
.append(file
.path
);
690 data
.append(TUPLE_DELIMITER
);
691 data
.append(file
.hash
);
692 if (file
.mimeType
!= null) {
693 data
.append(TUPLE_DELIMITER
);
694 data
.append(file
.mimeType
);
698 return data
.toString();
701 static class FileInfo
implements Comparable
<FileInfo
> {
705 public String mimeType
;
707 public FileInfo(File f
, File base
) throws IOException
{
709 this.path
= Utility
.calculatePath(f
, base
);
710 this.hash
= calculateHash();
713 public void setMimeType(GenericApplication app
) {
714 mimeType
= app
.getMimeTypeIfStatic(path
);
718 public String
toString() {
719 return (mimeType
== null ?
"" : mimeType
) + '\t' + hash
+ "\t" + path
;
723 public int compareTo(FileInfo other
) {
724 return path
.compareTo(other
.path
);
728 public int hashCode() {
729 return path
.hashCode();
733 public boolean equals(Object obj
) {
734 if (obj
instanceof FileInfo
) {
735 return path
.equals(((FileInfo
) obj
).path
);
740 private static final Pattern FILE_PATH_POSITIVE_RE
=
741 Pattern
.compile("^[ 0-9a-zA-Z._+/$-]{1,256}$");
743 private static final Pattern FILE_PATH_NEGATIVE_RE_1
=
744 Pattern
.compile("[.][.]|^[.]/|[.]$|/[.]/|^-");
746 private static final Pattern FILE_PATH_NEGATIVE_RE_2
=
747 Pattern
.compile("//|/$");
749 private static final Pattern FILE_PATH_NEGATIVE_RE_3
=
750 Pattern
.compile("^ | $|/ | /");
752 private String
checkValidFilename() {
753 if (!FILE_PATH_POSITIVE_RE
.matcher(path
).matches()) {
754 return "Invalid character in filename: " + path
;
756 if (FILE_PATH_NEGATIVE_RE_1
.matcher(path
).find()) {
757 return "Filname cannot contain '.' or '..' or start with '-': " + path
;
759 if (FILE_PATH_NEGATIVE_RE_2
.matcher(path
).find()) {
760 return "Filname cannot have trailing / or contain //: " + path
;
762 if (FILE_PATH_NEGATIVE_RE_3
.matcher(path
).find()) {
763 return "Any spaces must be in the middle of a filename: '" + path
+ "'";
768 private static final char[] HEX
=
769 {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd',
772 public String
calculateHash() throws IOException
{
773 InputStream s
= new FileInputStream(file
);
774 byte[] buf
= new byte[4096];
776 MessageDigest digest
= MessageDigest
.getInstance("SHA-1");
777 for (int numRead
; (numRead
= s
.read(buf
)) != -1;) {
778 digest
.update(buf
, 0, numRead
);
780 StringBuffer hashValue
= new StringBuffer(40);
782 for (byte b
: digest
.digest()) {
783 if ((i
> 0) && ((i
% 4) == 0)) {
784 hashValue
.append('_');
786 hashValue
.append(HEX
[(b
>> 4) & 0xf]);
787 hashValue
.append(HEX
[b
& 0xf]);
791 return hashValue
.toString();
792 } catch (NoSuchAlgorithmException e
) {
793 throw new RuntimeException(e
);
797 } catch (IOException ex
) {
804 class UploadBatcher
{
806 private static final int MAX_BATCH_SIZE
= 3200000;
807 private static final int MAX_BATCH_COUNT
= 100;
808 private static final int MAX_BATCH_FILE_SIZE
= 200000;
809 private static final int BATCH_OVERHEAD
= 500;
814 boolean batching
= true;
815 List
<FileInfo
> batch
= new ArrayList
<FileInfo
>();
819 * @param what Either "file" or "blob" or "errorblob" indicating what kind of objects this
820 * batcher uploads. Used in messages and URLs.
821 * @param batching whether or not we want to really do batch.
823 public UploadBatcher(String what
, boolean batching
) {
825 this.singleUrl
= "/api/appversion/add" + what
;
826 this.batchUrl
= singleUrl
+ "s";
827 this.batching
= batching
;
831 * Send the current batch on its way and reset the batrch buffer when done
833 public void sendBatch() throws IOException
{
836 "Sending batch containing " + batch
.size() + " "+ what
+"(s) totaling " +
837 batchSize
/ 1000 + "KB.");
838 connection
.post(batchUrl
, batch
, addVersionToArgs("", ""));
839 batch
= new ArrayList
<FileInfo
>();
844 * """Flush the current batch.
846 * This first attempts to send the batch as a single request; if that fails because the server
847 * doesn"t support batching, the files are sent one by one, and self.batching is reset to
850 * At the end, self.batch and self.batchSize are reset
852 public void flush() throws IOException
{
853 if (batch
.isEmpty()) {
858 } catch (Exception e
) {
859 app
.statusUpdate("Exception in flushing batch payload, so sending 1 by 1..."
862 for (FileInfo fileInfo
: batch
) {
863 send(singleUrl
, fileInfo
.file
, fileInfo
.mimeType
, "path", fileInfo
.path
);
865 batch
= new ArrayList
<FileInfo
>();
871 * Batch a file, possibly flushing first, or perhaps upload it directly.
873 * Args: path: The name of the file. payload: The contents of the file. mime_type: The MIME
874 * Content-type of the file, or None.
876 * If mime_type is None, application/octet-stream is substituted. """
878 public void addToBatch(FileInfo fileInfo
) throws IOException
{
880 long size
= fileInfo
.file
.length();
882 if (size
<= MAX_BATCH_FILE_SIZE
) {
883 if ((batch
.size() >= MAX_BATCH_COUNT
) ||
884 (batchSize
+ size
> MAX_BATCH_SIZE
)) {
889 batchSize
+= size
+ BATCH_OVERHEAD
;
893 send(singleUrl
, fileInfo
.file
, fileInfo
.mimeType
, "path", fileInfo
.path
);