App Engine Python SDK version 1.8.1
[gae.git] / python / google / appengine / tools / appcfg.py
blobf8f0eef96d5f17c72a2e17cfb9c7e08f181237f2
1 #!/usr/bin/env python
3 # Copyright 2007 Google Inc.
5 # Licensed under the Apache License, Version 2.0 (the "License");
6 # you may not use this file except in compliance with the License.
7 # You may obtain a copy of the License at
9 # http://www.apache.org/licenses/LICENSE-2.0
11 # Unless required by applicable law or agreed to in writing, software
12 # distributed under the License is distributed on an "AS IS" BASIS,
13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 # See the License for the specific language governing permissions and
15 # limitations under the License.
21 """Tool for deploying apps to an app server.
23 Currently, the application only uploads new appversions. To do this, it first
24 walks the directory tree rooted at the path the user specifies, adding all the
25 files it finds to a list. It then uploads the application configuration
26 (app.yaml) to the server using HTTP, followed by uploading each of the files.
27 It then commits the transaction with another request.
29 The bulk of this work is handled by the AppVersionUpload class, which exposes
30 methods to add to the list of files, fetch a list of modified files, upload
31 files, and commit or rollback the transaction.
32 """
35 import calendar
36 import copy
37 import datetime
38 import errno
39 import getpass
40 import hashlib
41 import logging
42 import mimetypes
43 import optparse
44 import os
45 import random
46 import re
47 import subprocess
48 import sys
49 import tempfile
50 import time
51 import urllib
52 import urllib2
56 import google
57 import yaml
59 from google.appengine.cron import groctimespecification
60 from google.appengine.api import appinfo
61 from google.appengine.api import appinfo_includes
62 from google.appengine.api import backendinfo
63 from google.appengine.api import croninfo
64 from google.appengine.api import dispatchinfo
65 from google.appengine.api import dosinfo
66 from google.appengine.api import queueinfo
67 from google.appengine.api import yaml_errors
68 from google.appengine.api import yaml_object
69 from google.appengine.datastore import datastore_index
70 from google.appengine.tools import appengine_rpc
71 try:
74 from google.appengine.tools import appengine_rpc_httplib2
75 except ImportError:
76 appengine_rpc_httplib2 = None
77 from google.appengine.tools import bulkloader
78 from google.appengine.tools import sdk_update_checker
81 LIST_DELIMITER = '\n'
82 TUPLE_DELIMITER = '|'
83 BACKENDS_ACTION = 'backends'
86 MAX_LOG_LEVEL = 4
89 MAX_BATCH_SIZE = 3200000
90 MAX_BATCH_COUNT = 100
91 MAX_BATCH_FILE_SIZE = 200000
92 BATCH_OVERHEAD = 500
99 verbosity = 1
102 PREFIXED_BY_ADMIN_CONSOLE_RE = '^(?:admin-console)(.*)'
105 SDK_PRODUCT = 'appcfg_py'
108 DAY = 24*3600
109 SUNDAY = 6
111 SUPPORTED_RUNTIMES = ('go', 'python', 'python27')
116 MEGA = 1024 * 1024
117 MILLION = 1000 * 1000
118 DEFAULT_RESOURCE_LIMITS = {
119 'max_file_size': 32 * MILLION,
120 'max_blob_size': 32 * MILLION,
121 'max_files_to_clone': 100,
122 'max_total_file_size': 150 * MEGA,
123 'max_file_count': 10000,
126 # Client ID and secrets are managed in the Google API console.
132 APPCFG_CLIENT_ID = '550516889912.apps.googleusercontent.com'
133 APPCFG_CLIENT_NOTSOSECRET = 'ykPq-0UYfKNprLRjVx1hBBar'
134 APPCFG_SCOPES = ['https://www.googleapis.com/auth/appengine.admin']
137 STATIC_FILE_PREFIX = '__static__'
140 class Error(Exception):
141 pass
144 class OAuthNotAvailable(Error):
145 """The appengine_rpc_httplib2 module could not be imported."""
146 pass
149 class CannotStartServingError(Error):
150 """We could not start serving the version being uploaded."""
151 pass
154 def PrintUpdate(msg):
155 """Print a message to stderr.
157 If 'verbosity' is greater than 0, print the message.
159 Args:
160 msg: The string to print.
162 if verbosity > 0:
163 timestamp = datetime.datetime.now()
164 print >>sys.stderr, '%s' % datetime.datetime.now().strftime('%I:%M %p'),
165 print >>sys.stderr, msg
168 def StatusUpdate(msg):
169 """Print a status message to stderr."""
170 PrintUpdate(msg)
173 def ErrorUpdate(msg):
174 """Print an error message to stderr."""
175 PrintUpdate(msg)
178 def _PrintErrorAndExit(stream, msg, exit_code=2):
179 """Prints the given error message and exists the program.
181 Args:
182 stream: The stream (e.g. StringIO or file) to write the message to.
183 msg: The error message to display as a string.
184 exit_code: The integer code to pass to sys.exit().
186 stream.write(msg)
187 sys.exit(exit_code)
190 class FileClassification(object):
191 """A class to hold a file's classification.
193 This class both abstracts away the details of how we determine
194 whether a file is a regular, static or error file as well as acting
195 as a container for various metadata about the file.
198 def __init__(self, config, filename):
199 """Initializes a FileClassification instance.
201 Args:
202 config: The app.yaml object to check the filename against.
203 filename: The name of the file.
205 self.__static_mime_type = self.__GetMimeTypeIfStaticFile(config, filename)
206 self.__static_app_readable = self.__GetAppReadableIfStaticFile(config,
207 filename)
208 self.__error_mime_type, self.__error_code = self.__LookupErrorBlob(config,
209 filename)
211 @staticmethod
212 def __GetMimeTypeIfStaticFile(config, filename):
213 """Looks up the mime type for 'filename'.
215 Uses the handlers in 'config' to determine if the file should
216 be treated as a static file.
218 Args:
219 config: The app.yaml object to check the filename against.
220 filename: The name of the file.
222 Returns:
223 The mime type string. For example, 'text/plain' or 'image/gif'.
224 None if this is not a static file.
226 for handler in config.handlers:
227 handler_type = handler.GetHandlerType()
228 if handler_type in ('static_dir', 'static_files'):
229 if handler_type == 'static_dir':
230 regex = os.path.join(re.escape(handler.GetHandler()), '.*')
231 else:
232 regex = handler.upload
233 if re.match(regex, filename):
234 return handler.mime_type or FileClassification.__MimeType(filename)
235 return None
237 @staticmethod
238 def __GetAppReadableIfStaticFile(config, filename):
239 """Looks up whether a static file is readable by the application.
241 Uses the handlers in 'config' to determine if the file should
242 be treated as a static file and if so, if the file should be readable by the
243 application.
245 Args:
246 config: The AppInfoExternal object to check the filename against.
247 filename: The name of the file.
249 Returns:
250 True if the file is static and marked as app readable, False otherwise.
252 for handler in config.handlers:
253 handler_type = handler.GetHandlerType()
254 if handler_type in ('static_dir', 'static_files'):
255 if handler_type == 'static_dir':
256 regex = os.path.join(re.escape(handler.GetHandler()), '.*')
257 else:
258 regex = handler.upload
259 if re.match(regex, filename):
260 return handler.application_readable
261 return False
263 @staticmethod
264 def __LookupErrorBlob(config, filename):
265 """Looks up the mime type and error_code for 'filename'.
267 Uses the error handlers in 'config' to determine if the file should
268 be treated as an error blob.
270 Args:
271 config: The app.yaml object to check the filename against.
272 filename: The name of the file.
274 Returns:
276 A tuple of (mime_type, error_code), or (None, None) if this is not an
277 error blob. For example, ('text/plain', default) or ('image/gif',
278 timeout) or (None, None).
280 if not config.error_handlers:
281 return (None, None)
282 for error_handler in config.error_handlers:
283 if error_handler.file == filename:
284 error_code = error_handler.error_code
285 error_code = error_code or 'default'
286 if error_handler.mime_type:
287 return (error_handler.mime_type, error_code)
288 else:
289 return (FileClassification.__MimeType(filename), error_code)
290 return (None, None)
292 @staticmethod
293 def __MimeType(filename, default='application/octet-stream'):
294 guess = mimetypes.guess_type(filename)[0]
295 if guess is None:
296 print >>sys.stderr, ('Could not guess mimetype for %s. Using %s.'
297 % (filename, default))
298 return default
299 return guess
301 def IsApplicationFile(self):
302 return bool((not self.IsStaticFile() or self.__static_app_readable) and
303 not self.IsErrorFile())
305 def IsStaticFile(self):
306 return bool(self.__static_mime_type)
308 def StaticMimeType(self):
309 return self.__static_mime_type
311 def IsErrorFile(self):
312 return bool(self.__error_mime_type)
314 def ErrorMimeType(self):
315 return self.__error_mime_type
317 def ErrorCode(self):
318 return self.__error_code
321 def BuildClonePostBody(file_tuples):
322 """Build the post body for the /api/clone{files,blobs,errorblobs} urls.
324 Args:
325 file_tuples: A list of tuples. Each tuple should contain the entries
326 appropriate for the endpoint in question.
328 Returns:
329 A string containing the properly delimited tuples.
331 file_list = []
332 for tup in file_tuples:
333 path = tup[1]
334 tup = tup[2:]
335 file_list.append(TUPLE_DELIMITER.join([path] + list(tup)))
336 return LIST_DELIMITER.join(file_list)
339 def GetRemoteResourceLimits(rpcserver, config):
340 """Get the resource limit as reported by the admin console.
342 Get the resource limits by querying the admin_console/appserver. The
343 actual limits returned depends on the server we are talking to and
344 could be missing values we expect or include extra values.
346 Args:
347 rpcserver: The RPC server to use.
348 config: The appyaml configuration.
350 Returns:
351 A dictionary.
353 try:
354 StatusUpdate('Getting current resource limits.')
355 yaml_data = rpcserver.Send('/api/appversion/getresourcelimits',
356 app_id=config.application,
357 version=config.version)
359 except urllib2.HTTPError, err:
363 if err.code != 404:
364 raise
365 return {}
367 return yaml.safe_load(yaml_data)
370 def GetResourceLimits(rpcserver, config):
371 """Gets the resource limits.
373 Gets the resource limits that should be applied to apps. Any values
374 that the server does not know about will have their default value
375 reported (although it is also possible for the server to report
376 values we don't know about).
378 Args:
379 rpcserver: The RPC server to use.
380 config: The appyaml configuration.
382 Returns:
383 A dictionary.
385 resource_limits = DEFAULT_RESOURCE_LIMITS.copy()
386 resource_limits.update(GetRemoteResourceLimits(rpcserver, config))
387 logging.debug('Using resource limits: %s', resource_limits)
388 return resource_limits
391 def RetryWithBackoff(callable_func, retry_notify_func,
392 initial_delay=1, backoff_factor=2,
393 max_delay=60, max_tries=20):
394 """Calls a function multiple times, backing off more and more each time.
396 Args:
397 callable_func: A function that performs some operation that should be
398 retried a number of times up on failure. Signature: () -> (done, value)
399 If 'done' is True, we'll immediately return (True, value)
400 If 'done' is False, we'll delay a bit and try again, unless we've
401 hit the 'max_tries' limit, in which case we'll return (False, value).
402 retry_notify_func: This function will be called immediately before the
403 next retry delay. Signature: (value, delay) -> None
404 'value' is the value returned by the last call to 'callable_func'
405 'delay' is the retry delay, in seconds
406 initial_delay: Initial delay after first try, in seconds.
407 backoff_factor: Delay will be multiplied by this factor after each try.
408 max_delay: Maximum delay, in seconds.
409 max_tries: Maximum number of tries (the first one counts).
411 Returns:
412 What the last call to 'callable_func' returned, which is of the form
413 (done, value). If 'done' is True, you know 'callable_func' returned True
414 before we ran out of retries. If 'done' is False, you know 'callable_func'
415 kept returning False and we ran out of retries.
417 Raises:
418 Whatever the function raises--an exception will immediately stop retries.
421 delay = initial_delay
422 num_tries = 0
424 while True:
425 done, opaque_value = callable_func()
426 num_tries += 1
428 if done:
429 return True, opaque_value
431 if num_tries >= max_tries:
432 return False, opaque_value
434 retry_notify_func(opaque_value, delay)
435 time.sleep(delay)
436 delay = min(delay * backoff_factor, max_delay)
439 def MigratePython27Notice():
440 """Tells the user that Python 2.5 runtime is deprecated.
442 Encourages the user to migrate from Python 2.5 to Python 2.7.
444 Prints a message to sys.stdout. The caller should have tested that the user is
445 using Python 2.5, so as not to spuriously display this message.
447 print (
448 'WARNING: This application is using the Python 2.5 runtime, which is '
449 'deprecated! It should be updated to the Python 2.7 runtime as soon as '
450 'possible, which offers performance improvements and many new features. '
451 'Learn how simple it is to migrate your application to Python 2.7 at '
452 'https://developers.google.com/appengine/docs/python/python25/migrate27.')
455 class IndexDefinitionUpload(object):
456 """Provides facilities to upload index definitions to the hosting service."""
458 def __init__(self, rpcserver, config, definitions):
459 """Creates a new DatastoreIndexUpload.
461 Args:
462 rpcserver: The RPC server to use. Should be an instance of HttpRpcServer
463 or TestRpcServer.
464 config: The AppInfoExternal object derived from the app.yaml file.
465 definitions: An IndexDefinitions object.
467 self.rpcserver = rpcserver
468 self.config = config
469 self.definitions = definitions
471 def DoUpload(self):
472 """Uploads the index definitions."""
473 StatusUpdate('Uploading index definitions.')
474 self.rpcserver.Send('/api/datastore/index/add',
475 app_id=self.config.application,
476 version=self.config.version,
477 payload=self.definitions.ToYAML())
480 class CronEntryUpload(object):
481 """Provides facilities to upload cron entries to the hosting service."""
483 def __init__(self, rpcserver, config, cron):
484 """Creates a new CronEntryUpload.
486 Args:
487 rpcserver: The RPC server to use. Should be an instance of a subclass of
488 AbstractRpcServer
489 config: The AppInfoExternal object derived from the app.yaml file.
490 cron: The CronInfoExternal object loaded from the cron.yaml file.
492 self.rpcserver = rpcserver
493 self.config = config
494 self.cron = cron
496 def DoUpload(self):
497 """Uploads the cron entries."""
498 StatusUpdate('Uploading cron entries.')
499 self.rpcserver.Send('/api/cron/update',
500 app_id=self.config.application,
501 version=self.config.version,
502 payload=self.cron.ToYAML())
505 class QueueEntryUpload(object):
506 """Provides facilities to upload task queue entries to the hosting service."""
508 def __init__(self, rpcserver, config, queue):
509 """Creates a new QueueEntryUpload.
511 Args:
512 rpcserver: The RPC server to use. Should be an instance of a subclass of
513 AbstractRpcServer
514 config: The AppInfoExternal object derived from the app.yaml file.
515 queue: The QueueInfoExternal object loaded from the queue.yaml file.
517 self.rpcserver = rpcserver
518 self.config = config
519 self.queue = queue
521 def DoUpload(self):
522 """Uploads the task queue entries."""
523 StatusUpdate('Uploading task queue entries.')
524 self.rpcserver.Send('/api/queue/update',
525 app_id=self.config.application,
526 version=self.config.version,
527 payload=self.queue.ToYAML())
530 class DosEntryUpload(object):
531 """Provides facilities to upload dos entries to the hosting service."""
533 def __init__(self, rpcserver, config, dos):
534 """Creates a new DosEntryUpload.
536 Args:
537 rpcserver: The RPC server to use. Should be an instance of a subclass of
538 AbstractRpcServer.
539 config: The AppInfoExternal object derived from the app.yaml file.
540 dos: The DosInfoExternal object loaded from the dos.yaml file.
542 self.rpcserver = rpcserver
543 self.config = config
544 self.dos = dos
546 def DoUpload(self):
547 """Uploads the dos entries."""
548 StatusUpdate('Uploading DOS entries.')
549 self.rpcserver.Send('/api/dos/update',
550 app_id=self.config.application,
551 version=self.config.version,
552 payload=self.dos.ToYAML())
555 class PagespeedEntryUpload(object):
556 """Provides facilities to upload pagespeed configs to the hosting service."""
558 def __init__(self, rpcserver, config, pagespeed):
559 """Creates a new PagespeedEntryUpload.
561 Args:
562 rpcserver: The RPC server to use. Should be an instance of a subclass of
563 AbstractRpcServer.
564 config: The AppInfoExternal object derived from the app.yaml file.
565 pagespeed: The PagespeedEntry object from config.
567 self.rpcserver = rpcserver
568 self.config = config
569 self.pagespeed = pagespeed
571 def DoUpload(self):
572 """Uploads the pagespeed entries."""
574 pagespeed_yaml = ''
575 if self.pagespeed:
576 StatusUpdate('Uploading PageSpeed configuration.')
577 pagespeed_yaml = self.pagespeed.ToYAML()
578 try:
579 self.rpcserver.Send('/api/appversion/updatepagespeed',
580 app_id=self.config.application,
581 version=self.config.version,
582 payload=pagespeed_yaml)
583 except urllib2.HTTPError, err:
593 if err.code != 404 or self.pagespeed is not None:
594 raise
597 class DefaultVersionSet(object):
598 """Provides facilities to set the default (serving) version."""
600 def __init__(self, rpcserver, app_id, server, version):
601 """Creates a new DefaultVersionSet.
603 Args:
604 rpcserver: The RPC server to use. Should be an instance of a subclass of
605 AbstractRpcServer.
606 app_id: The application to make the change to.
607 server: The server to set the default version of (if any).
608 version: The version to set as the default.
610 self.rpcserver = rpcserver
611 self.app_id = app_id
612 self.server = server
613 self.version = version
615 def SetVersion(self):
616 """Sets the default version."""
617 if self.server:
618 StatusUpdate('Setting default version of server %s of application %s '
619 'to %s.' % (self.app_id, self.server, self.version))
620 else:
621 StatusUpdate('Setting default version of application %s to %s.'
622 % (self.app_id, self.version))
623 self.rpcserver.Send('/api/appversion/setdefault',
624 app_id=self.app_id,
625 server=self.server,
626 version=self.version)
629 class IndexOperation(object):
630 """Provide facilities for writing Index operation commands."""
632 def __init__(self, rpcserver, config):
633 """Creates a new IndexOperation.
635 Args:
636 rpcserver: The RPC server to use. Should be an instance of HttpRpcServer
637 or TestRpcServer.
638 config: appinfo.AppInfoExternal configuration object.
640 self.rpcserver = rpcserver
641 self.config = config
643 def DoDiff(self, definitions):
644 """Retrieve diff file from the server.
646 Args:
647 definitions: datastore_index.IndexDefinitions as loaded from users
648 index.yaml file.
650 Returns:
651 A pair of datastore_index.IndexDefinitions objects. The first record
652 is the set of indexes that are present in the index.yaml file but missing
653 from the server. The second record is the set of indexes that are
654 present on the server but missing from the index.yaml file (indicating
655 that these indexes should probably be vacuumed).
657 StatusUpdate('Fetching index definitions diff.')
658 response = self.rpcserver.Send('/api/datastore/index/diff',
659 app_id=self.config.application,
660 payload=definitions.ToYAML())
661 return datastore_index.ParseMultipleIndexDefinitions(response)
663 def DoDelete(self, definitions):
664 """Delete indexes from the server.
666 Args:
667 definitions: Index definitions to delete from datastore.
669 Returns:
670 A single datstore_index.IndexDefinitions containing indexes that were
671 not deleted, probably because they were already removed. This may
672 be normal behavior as there is a potential race condition between fetching
673 the index-diff and sending deletion confirmation through.
675 StatusUpdate('Deleting selected index definitions.')
676 response = self.rpcserver.Send('/api/datastore/index/delete',
677 app_id=self.config.application,
678 payload=definitions.ToYAML())
679 return datastore_index.ParseIndexDefinitions(response)
682 class VacuumIndexesOperation(IndexOperation):
683 """Provide facilities to request the deletion of datastore indexes."""
685 def __init__(self, rpcserver, config, force,
686 confirmation_fn=raw_input):
687 """Creates a new VacuumIndexesOperation.
689 Args:
690 rpcserver: The RPC server to use. Should be an instance of HttpRpcServer
691 or TestRpcServer.
692 config: appinfo.AppInfoExternal configuration object.
693 force: True to force deletion of indexes, else False.
694 confirmation_fn: Function used for getting input form user.
696 super(VacuumIndexesOperation, self).__init__(rpcserver, config)
697 self.force = force
698 self.confirmation_fn = confirmation_fn
700 def GetConfirmation(self, index):
701 """Get confirmation from user to delete an index.
703 This method will enter an input loop until the user provides a
704 response it is expecting. Valid input is one of three responses:
706 y: Confirm deletion of index.
707 n: Do not delete index.
708 a: Delete all indexes without asking for further confirmation.
710 If the user enters nothing at all, the default action is to skip
711 that index and do not delete.
713 If the user selects 'a', as a side effect, the 'force' flag is set.
715 Args:
716 index: Index to confirm.
718 Returns:
719 True if user enters 'y' or 'a'. False if user enter 'n'.
721 while True:
723 print 'This index is no longer defined in your index.yaml file.'
724 print
725 print index.ToYAML()
726 print
729 confirmation = self.confirmation_fn(
730 'Are you sure you want to delete this index? (N/y/a): ')
731 confirmation = confirmation.strip().lower()
734 if confirmation == 'y':
735 return True
736 elif confirmation == 'n' or not confirmation:
737 return False
738 elif confirmation == 'a':
739 self.force = True
740 return True
741 else:
742 print 'Did not understand your response.'
744 def DoVacuum(self, definitions):
745 """Vacuum indexes in datastore.
747 This method will query the server to determine which indexes are not
748 being used according to the user's local index.yaml file. Once it has
749 made this determination, it confirms with the user which unused indexes
750 should be deleted. Once confirmation for each index is receives, it
751 deletes those indexes.
753 Because another user may in theory delete the same indexes at the same
754 time as the user, there is a potential race condition. In this rare cases,
755 some of the indexes previously confirmed for deletion will not be found.
756 The user is notified which indexes these were.
758 Args:
759 definitions: datastore_index.IndexDefinitions as loaded from users
760 index.yaml file.
763 unused_new_indexes, notused_indexes = self.DoDiff(definitions)
766 deletions = datastore_index.IndexDefinitions(indexes=[])
767 if notused_indexes.indexes is not None:
768 for index in notused_indexes.indexes:
769 if self.force or self.GetConfirmation(index):
770 deletions.indexes.append(index)
773 if deletions.indexes:
774 not_deleted = self.DoDelete(deletions)
777 if not_deleted.indexes:
778 not_deleted_count = len(not_deleted.indexes)
779 if not_deleted_count == 1:
780 warning_message = ('An index was not deleted. Most likely this is '
781 'because it no longer exists.\n\n')
782 else:
783 warning_message = ('%d indexes were not deleted. Most likely this '
784 'is because they no longer exist.\n\n'
785 % not_deleted_count)
786 for index in not_deleted.indexes:
787 warning_message += index.ToYAML()
788 logging.warning(warning_message)
791 class LogsRequester(object):
792 """Provide facilities to export request logs."""
794 def __init__(self,
795 rpcserver,
796 app_id,
797 server,
798 version_id,
799 output_file,
800 num_days,
801 append,
802 severity,
803 end,
804 vhost,
805 include_vhost,
806 include_all=None,
807 time_func=time.time):
808 """Constructor.
810 Args:
811 rpcserver: The RPC server to use. Should be an instance of HttpRpcServer
812 or TestRpcServer.
813 app_id: The application to fetch logs from.
814 server: The server of the app to fetch logs from, optional.
815 version_id: The version of the app to fetch logs for.
816 output_file: Output file name.
817 num_days: Number of days worth of logs to export; 0 for all available.
818 append: True if appending to an existing file.
819 severity: App log severity to request (0-4); None for no app logs.
820 end: date object representing last day of logs to return.
821 vhost: The virtual host of log messages to get. None for all hosts.
822 include_vhost: If true, the virtual host is included in log messages.
823 include_all: If true, we add to the log message everything we know
824 about the request.
825 time_func: Method that return a timestamp representing now (for testing).
828 self.rpcserver = rpcserver
829 self.app_id = app_id
830 self.output_file = output_file
831 self.append = append
832 self.num_days = num_days
833 self.severity = severity
834 self.vhost = vhost
835 self.include_vhost = include_vhost
836 self.include_all = include_all
838 self.server = server
839 self.version_id = version_id
840 self.sentinel = None
841 self.write_mode = 'w'
842 if self.append:
843 self.sentinel = FindSentinel(self.output_file)
844 self.write_mode = 'a'
847 self.skip_until = False
848 now = PacificDate(time_func())
849 if end < now:
850 self.skip_until = end
851 else:
853 end = now
855 self.valid_dates = None
856 if self.num_days:
857 start = end - datetime.timedelta(self.num_days - 1)
858 self.valid_dates = (start, end)
860 def DownloadLogs(self):
861 """Download the requested logs.
863 This will write the logs to the file designated by
864 self.output_file, or to stdout if the filename is '-'.
865 Multiple roundtrips to the server may be made.
867 if self.server:
868 StatusUpdate('Downloading request logs for app %s server %s version %s.' %
869 (self.app_id, self.server, self.version_id))
870 else:
871 StatusUpdate('Downloading request logs for app %s version %s.' %
872 (self.app_id, self.version_id))
878 tf = tempfile.TemporaryFile()
879 last_offset = None
880 try:
881 while True:
882 try:
883 new_offset = self.RequestLogLines(tf, last_offset)
884 if not new_offset or new_offset == last_offset:
885 break
886 last_offset = new_offset
887 except KeyboardInterrupt:
888 StatusUpdate('Keyboard interrupt; saving data downloaded so far.')
889 break
890 StatusUpdate('Copying request logs to %r.' % self.output_file)
891 if self.output_file == '-':
892 of = sys.stdout
893 else:
894 try:
895 of = open(self.output_file, self.write_mode)
896 except IOError, err:
897 StatusUpdate('Can\'t write %r: %s.' % (self.output_file, err))
898 sys.exit(1)
899 try:
900 line_count = CopyReversedLines(tf, of)
901 finally:
902 of.flush()
903 if of is not sys.stdout:
904 of.close()
905 finally:
906 tf.close()
907 StatusUpdate('Copied %d records.' % line_count)
909 def RequestLogLines(self, tf, offset):
910 """Make a single roundtrip to the server.
912 Args:
913 tf: Writable binary stream to which the log lines returned by
914 the server are written, stripped of headers, and excluding
915 lines skipped due to self.sentinel or self.valid_dates filtering.
916 offset: Offset string for a continued request; None for the first.
918 Returns:
919 The offset string to be used for the next request, if another
920 request should be issued; or None, if not.
922 logging.info('Request with offset %r.', offset)
923 kwds = {'app_id': self.app_id,
924 'version': self.version_id,
925 'limit': 1000,
927 if self.server:
928 kwds['server'] = self.server
929 if offset:
930 kwds['offset'] = offset
931 if self.severity is not None:
932 kwds['severity'] = str(self.severity)
933 if self.vhost is not None:
934 kwds['vhost'] = str(self.vhost)
935 if self.include_vhost is not None:
936 kwds['include_vhost'] = str(self.include_vhost)
937 if self.include_all is not None:
938 kwds['include_all'] = str(self.include_all)
939 response = self.rpcserver.Send('/api/request_logs', payload=None, **kwds)
940 response = response.replace('\r', '\0')
941 lines = response.splitlines()
942 logging.info('Received %d bytes, %d records.', len(response), len(lines))
943 offset = None
944 if lines and lines[0].startswith('#'):
945 match = re.match(r'^#\s*next_offset=(\S+)\s*$', lines[0])
946 del lines[0]
947 if match:
948 offset = match.group(1)
949 if lines and lines[-1].startswith('#'):
950 del lines[-1]
952 valid_dates = self.valid_dates
953 sentinel = self.sentinel
954 skip_until = self.skip_until
955 len_sentinel = None
956 if sentinel:
957 len_sentinel = len(sentinel)
958 for line in lines:
959 if (sentinel and
960 line.startswith(sentinel) and
961 line[len_sentinel : len_sentinel+1] in ('', '\0')):
962 return None
964 linedate = DateOfLogLine(line)
966 if not linedate:
967 continue
969 if skip_until:
970 if linedate > skip_until:
971 continue
972 else:
974 self.skip_until = skip_until = False
976 if valid_dates and not valid_dates[0] <= linedate <= valid_dates[1]:
977 return None
978 tf.write(line + '\n')
979 if not lines:
980 return None
981 return offset
984 def DateOfLogLine(line):
985 """Returns a date object representing the log line's timestamp.
987 Args:
988 line: a log line string.
989 Returns:
990 A date object representing the timestamp or None if parsing fails.
992 m = re.compile(r'[^[]+\[(\d+/[A-Za-z]+/\d+):[^\d]*').match(line)
993 if not m:
994 return None
995 try:
996 return datetime.date(*time.strptime(m.group(1), '%d/%b/%Y')[:3])
997 except ValueError:
998 return None
1001 def PacificDate(now):
1002 """For a UTC timestamp, return the date in the US/Pacific timezone.
1004 Args:
1005 now: A posix timestamp giving current UTC time.
1007 Returns:
1008 A date object representing what day it is in the US/Pacific timezone.
1011 return datetime.date(*time.gmtime(PacificTime(now))[:3])
1014 def PacificTime(now):
1015 """Helper to return the number of seconds between UTC and Pacific time.
1017 This is needed to compute today's date in Pacific time (more
1018 specifically: Mountain View local time), which is how request logs
1019 are reported. (Google servers always report times in Mountain View
1020 local time, regardless of where they are physically located.)
1022 This takes (post-2006) US DST into account. Pacific time is either
1023 8 hours or 7 hours west of UTC, depending on whether DST is in
1024 effect. Since 2007, US DST starts on the Second Sunday in March
1025 March, and ends on the first Sunday in November. (Reference:
1026 http://aa.usno.navy.mil/faq/docs/daylight_time.php.)
1028 Note that the server doesn't report its local time (the HTTP Date
1029 header uses UTC), and the client's local time is irrelevant.
1031 Args:
1032 now: A posix timestamp giving current UTC time.
1034 Returns:
1035 A pseudo-posix timestamp giving current Pacific time. Passing
1036 this through time.gmtime() will produce a tuple in Pacific local
1037 time.
1039 now -= 8*3600
1040 if IsPacificDST(now):
1041 now += 3600
1042 return now
1045 def IsPacificDST(now):
1046 """Helper for PacificTime to decide whether now is Pacific DST (PDT).
1048 Args:
1049 now: A pseudo-posix timestamp giving current time in PST.
1051 Returns:
1052 True if now falls within the range of DST, False otherwise.
1054 pst = time.gmtime(now)
1055 year = pst[0]
1056 assert year >= 2007
1058 begin = calendar.timegm((year, 3, 8, 2, 0, 0, 0, 0, 0))
1059 while time.gmtime(begin).tm_wday != SUNDAY:
1060 begin += DAY
1062 end = calendar.timegm((year, 11, 1, 2, 0, 0, 0, 0, 0))
1063 while time.gmtime(end).tm_wday != SUNDAY:
1064 end += DAY
1065 return begin <= now < end
1068 def CopyReversedLines(instream, outstream, blocksize=2**16):
1069 r"""Copy lines from input stream to output stream in reverse order.
1071 As a special feature, null bytes in the input are turned into
1072 newlines followed by tabs in the output, but these 'sub-lines'
1073 separated by null bytes are not reversed. E.g. If the input is
1074 'A\0B\nC\0D\n', the output is 'C\n\tD\nA\n\tB\n'.
1076 Args:
1077 instream: A seekable stream open for reading in binary mode.
1078 outstream: A stream open for writing; doesn't have to be seekable or binary.
1079 blocksize: Optional block size for buffering, for unit testing.
1081 Returns:
1082 The number of lines copied.
1084 line_count = 0
1085 instream.seek(0, 2)
1086 last_block = instream.tell() // blocksize
1087 spillover = ''
1088 for iblock in xrange(last_block + 1, -1, -1):
1089 instream.seek(iblock * blocksize)
1090 data = instream.read(blocksize)
1091 lines = data.splitlines(True)
1092 lines[-1:] = ''.join(lines[-1:] + [spillover]).splitlines(True)
1093 if lines and not lines[-1].endswith('\n'):
1095 lines[-1] += '\n'
1096 lines.reverse()
1097 if lines and iblock > 0:
1098 spillover = lines.pop()
1099 if lines:
1100 line_count += len(lines)
1101 data = ''.join(lines).replace('\0', '\n\t')
1102 outstream.write(data)
1103 return line_count
1106 def FindSentinel(filename, blocksize=2**16):
1107 """Return the sentinel line from the output file.
1109 Args:
1110 filename: The filename of the output file. (We'll read this file.)
1111 blocksize: Optional block size for buffering, for unit testing.
1113 Returns:
1114 The contents of the last line in the file that doesn't start with
1115 a tab, with its trailing newline stripped; or None if the file
1116 couldn't be opened or no such line could be found by inspecting
1117 the last 'blocksize' bytes of the file.
1119 if filename == '-':
1120 StatusUpdate('Can\'t combine --append with output to stdout.')
1121 sys.exit(2)
1122 try:
1123 fp = open(filename, 'rb')
1124 except IOError, err:
1125 StatusUpdate('Append mode disabled: can\'t read %r: %s.' % (filename, err))
1126 return None
1127 try:
1128 fp.seek(0, 2)
1129 fp.seek(max(0, fp.tell() - blocksize))
1130 lines = fp.readlines()
1131 del lines[:1]
1132 sentinel = None
1133 for line in lines:
1134 if not line.startswith('\t'):
1135 sentinel = line
1136 if not sentinel:
1138 StatusUpdate('Append mode disabled: can\'t find sentinel in %r.' %
1139 filename)
1140 return None
1141 return sentinel.rstrip('\n')
1142 finally:
1143 fp.close()
1146 class UploadBatcher(object):
1147 """Helper to batch file uploads."""
1149 def __init__(self, what, rpcserver, params):
1150 """Constructor.
1152 Args:
1153 what: Either 'file' or 'blob' or 'errorblob' indicating what kind of
1154 objects this batcher uploads. Used in messages and URLs.
1155 rpcserver: The RPC server.
1156 params: A dictionary object containing URL params to add to HTTP requests.
1158 assert what in ('file', 'blob', 'errorblob'), repr(what)
1159 self.what = what
1160 self.params = params
1161 self.rpcserver = rpcserver
1162 self.single_url = '/api/appversion/add' + what
1163 self.batch_url = self.single_url + 's'
1164 self.batching = True
1165 self.batch = []
1166 self.batch_size = 0
1168 def SendBatch(self):
1169 """Send the current batch on its way.
1171 If successful, resets self.batch and self.batch_size.
1173 Raises:
1174 HTTPError with code=404 if the server doesn't support batching.
1176 boundary = 'boundary'
1177 parts = []
1178 for path, payload, mime_type in self.batch:
1179 while boundary in payload:
1180 boundary += '%04x' % random.randint(0, 0xffff)
1181 assert len(boundary) < 80, 'Unexpected error, please try again.'
1182 part = '\n'.join(['',
1183 'X-Appcfg-File: %s' % urllib.quote(path),
1184 'X-Appcfg-Hash: %s' % _Hash(payload),
1185 'Content-Type: %s' % mime_type,
1186 'Content-Length: %d' % len(payload),
1187 'Content-Transfer-Encoding: 8bit',
1189 payload,
1191 parts.append(part)
1192 parts.insert(0,
1193 'MIME-Version: 1.0\n'
1194 'Content-Type: multipart/mixed; boundary="%s"\n'
1195 '\n'
1196 'This is a message with multiple parts in MIME format.' %
1197 boundary)
1198 parts.append('--\n')
1199 delimiter = '\n--%s' % boundary
1200 payload = delimiter.join(parts)
1201 logging.info('Uploading batch of %d %ss to %s with boundary="%s".',
1202 len(self.batch), self.what, self.batch_url, boundary)
1203 self.rpcserver.Send(self.batch_url,
1204 payload=payload,
1205 content_type='message/rfc822',
1206 **self.params)
1207 self.batch = []
1208 self.batch_size = 0
1210 def SendSingleFile(self, path, payload, mime_type):
1211 """Send a single file on its way."""
1212 logging.info('Uploading %s %s (%s bytes, type=%s) to %s.',
1213 self.what, path, len(payload), mime_type, self.single_url)
1214 self.rpcserver.Send(self.single_url,
1215 payload=payload,
1216 content_type=mime_type,
1217 path=path,
1218 **self.params)
1220 def Flush(self):
1221 """Flush the current batch.
1223 This first attempts to send the batch as a single request; if that
1224 fails because the server doesn't support batching, the files are
1225 sent one by one, and self.batching is reset to False.
1227 At the end, self.batch and self.batch_size are reset.
1229 if not self.batch:
1230 return
1231 try:
1232 self.SendBatch()
1233 except urllib2.HTTPError, err:
1234 if err.code != 404:
1235 raise
1238 logging.info('Old server detected; turning off %s batching.', self.what)
1239 self.batching = False
1242 for path, payload, mime_type in self.batch:
1243 self.SendSingleFile(path, payload, mime_type)
1246 self.batch = []
1247 self.batch_size = 0
1249 def AddToBatch(self, path, payload, mime_type):
1250 """Batch a file, possibly flushing first, or perhaps upload it directly.
1252 Args:
1253 path: The name of the file.
1254 payload: The contents of the file.
1255 mime_type: The MIME Content-type of the file, or None.
1257 If mime_type is None, application/octet-stream is substituted.
1259 if not mime_type:
1260 mime_type = 'application/octet-stream'
1261 size = len(payload)
1262 if size <= MAX_BATCH_FILE_SIZE:
1263 if (len(self.batch) >= MAX_BATCH_COUNT or
1264 self.batch_size + size > MAX_BATCH_SIZE):
1265 self.Flush()
1266 if self.batching:
1267 logging.info('Adding %s %s (%s bytes, type=%s) to batch.',
1268 self.what, path, size, mime_type)
1269 self.batch.append((path, payload, mime_type))
1270 self.batch_size += size + BATCH_OVERHEAD
1271 return
1272 self.SendSingleFile(path, payload, mime_type)
1275 def _FormatHash(h):
1276 """Return a string representation of a hash.
1278 The hash is a sha1 hash. It is computed both for files that need to be
1279 pushed to App Engine and for data payloads of requests made to App Engine.
1281 Args:
1282 h: The hash
1284 Returns:
1285 The string representation of the hash.
1287 return '%s_%s_%s_%s_%s' % (h[0:8], h[8:16], h[16:24], h[24:32], h[32:40])
1290 def _Hash(content):
1291 """Compute the sha1 hash of the content.
1293 Args:
1294 content: The data to hash as a string.
1296 Returns:
1297 The string representation of the hash.
1299 h = hashlib.sha1(content).hexdigest()
1300 return _FormatHash(h)
1303 def _HashFromFileHandle(file_handle):
1304 """Compute the hash of the content of the file pointed to by file_handle.
1306 Args:
1307 file_handle: File-like object which provides seek, read and tell.
1309 Returns:
1310 The string representation of the hash.
1319 pos = file_handle.tell()
1320 content_hash = _Hash(file_handle.read())
1321 file_handle.seek(pos, 0)
1322 return content_hash
1325 def EnsureDir(path):
1326 """Makes sure that a directory exists at the given path.
1328 If a directory already exists at that path, nothing is done.
1329 Otherwise, try to create a directory at that path with os.makedirs.
1330 If that fails, propagate the resulting OSError exception.
1332 Args:
1333 path: The path that you want to refer to a directory.
1335 try:
1336 os.makedirs(path)
1337 except OSError, exc:
1340 if not (exc.errno == errno.EEXIST and os.path.isdir(path)):
1341 raise
1344 def DoDownloadApp(rpcserver, out_dir, app_id, server, app_version):
1345 """Downloads the files associated with a particular app version.
1347 Args:
1348 rpcserver: The RPC server to use to download.
1349 out_dir: The directory the files should be downloaded to.
1350 app_id: The app ID of the app whose files we want to download.
1351 server: The server we want to download from. Can be:
1352 - None: We'll download from the default server.
1353 - <server>: We'll download from the specified server.
1354 app_version: The version number we want to download. Can be:
1355 - None: We'll download the latest default version.
1356 - <major>: We'll download the latest minor version.
1357 - <major>/<minor>: We'll download that exact version.
1360 StatusUpdate('Fetching file list...')
1362 url_args = {'app_id': app_id}
1363 if server:
1364 url_args['server'] = server
1365 if app_version is not None:
1366 url_args['version_match'] = app_version
1368 result = rpcserver.Send('/api/files/list', **url_args)
1370 StatusUpdate('Fetching files...')
1372 lines = result.splitlines()
1374 if len(lines) < 1:
1375 logging.error('Invalid response from server: empty')
1376 return
1378 full_version = lines[0]
1379 file_lines = lines[1:]
1381 current_file_number = 0
1382 num_files = len(file_lines)
1384 num_errors = 0
1386 for line in file_lines:
1387 parts = line.split('|', 2)
1388 if len(parts) != 3:
1389 logging.error('Invalid response from server: expecting '
1390 '"<id>|<size>|<path>", found: "%s"\n', line)
1391 return
1393 current_file_number += 1
1395 file_id, size_str, path = parts
1396 try:
1397 size = int(size_str)
1398 except ValueError:
1399 logging.error('Invalid file list entry from server: invalid size: '
1400 '"%s"', size_str)
1401 return
1403 StatusUpdate('[%d/%d] %s' % (current_file_number, num_files, path))
1405 def TryGet():
1406 """A request to /api/files/get which works with the RetryWithBackoff."""
1407 try:
1408 contents = rpcserver.Send('/api/files/get', app_id=app_id,
1409 version=full_version, id=file_id)
1410 return True, contents
1411 except urllib2.HTTPError, exc:
1414 if exc.code == 503:
1415 return False, exc
1416 else:
1417 raise
1419 def PrintRetryMessage(_, delay):
1420 StatusUpdate('Server busy. Will try again in %d seconds.' % delay)
1422 success, contents = RetryWithBackoff(TryGet, PrintRetryMessage)
1423 if not success:
1424 logging.error('Unable to download file "%s".', path)
1425 num_errors += 1
1426 continue
1428 if len(contents) != size:
1429 logging.error('File "%s": server listed as %d bytes but served '
1430 '%d bytes.', path, size, len(contents))
1431 num_errors += 1
1433 full_path = os.path.join(out_dir, path)
1435 if os.path.exists(full_path):
1436 logging.error('Unable to create file "%s": path conflicts with '
1437 'an existing file or directory', path)
1438 num_errors += 1
1439 continue
1441 full_dir = os.path.dirname(full_path)
1442 try:
1443 EnsureDir(full_dir)
1444 except OSError, exc:
1445 logging.error('Couldn\'t create directory "%s": %s', full_dir, exc)
1446 num_errors += 1
1447 continue
1449 try:
1450 out_file = open(full_path, 'wb')
1451 except IOError, exc:
1452 logging.error('Couldn\'t open file "%s": %s', full_path, exc)
1453 num_errors += 1
1454 continue
1456 try:
1457 try:
1458 out_file.write(contents)
1459 except IOError, exc:
1460 logging.error('Couldn\'t write to file "%s": %s', full_path, exc)
1461 num_errors += 1
1462 continue
1463 finally:
1464 out_file.close()
1466 if num_errors > 0:
1467 logging.error('Number of errors: %d. See output for details.', num_errors)
1470 class AppVersionUpload(object):
1471 """Provides facilities to upload a new appversion to the hosting service.
1473 Attributes:
1474 rpcserver: The AbstractRpcServer to use for the upload.
1475 config: The AppInfoExternal object derived from the app.yaml file.
1476 app_id: The application string from 'config'.
1477 version: The version string from 'config'.
1478 backend: The backend to update, if any.
1479 files: A dictionary of files to upload to the rpcserver, mapping path to
1480 hash of the file contents.
1481 in_transaction: True iff a transaction with the server has started.
1482 An AppVersionUpload can do only one transaction at a time.
1483 deployed: True iff the Deploy method has been called.
1484 started: True iff the StartServing method has been called.
1487 def __init__(self, rpcserver, config, server_yaml_path='app.yaml',
1488 backend=None,
1489 error_fh=None):
1490 """Creates a new AppVersionUpload.
1492 Args:
1493 rpcserver: The RPC server to use. Should be an instance of HttpRpcServer
1494 or TestRpcServer.
1495 config: An AppInfoExternal object that specifies the configuration for
1496 this application.
1497 server_yaml_path: The (string) path to the yaml file corresponding to
1498 <config>, relative to the bundle directory.
1499 backend: If specified, indicates the update applies to the given backend.
1500 The backend name must match an entry in the backends: stanza.
1501 error_fh: Unexpected HTTPErrors are printed to this file handle.
1503 self.rpcserver = rpcserver
1504 self.config = config
1505 self.app_id = self.config.application
1506 self.server = self.config.server
1507 self.backend = backend
1508 self.error_fh = error_fh or sys.stderr
1510 self.version = self.config.version
1512 self.params = {}
1513 if self.app_id:
1514 self.params['app_id'] = self.app_id
1515 if self.server:
1516 self.params['server'] = self.server
1517 if self.backend:
1518 self.params['backend'] = self.backend
1519 elif self.version:
1520 self.params['version'] = self.version
1525 self.files = {}
1528 self.all_files = set()
1530 self.in_transaction = False
1531 self.deployed = False
1532 self.started = False
1533 self.batching = True
1534 self.file_batcher = UploadBatcher('file', self.rpcserver, self.params)
1535 self.blob_batcher = UploadBatcher('blob', self.rpcserver, self.params)
1536 self.errorblob_batcher = UploadBatcher('errorblob', self.rpcserver,
1537 self.params)
1539 if not self.config.vm_settings:
1540 self.config.vm_settings = appinfo.VmSettings()
1541 self.config.vm_settings['server_yaml_path'] = server_yaml_path
1543 def Send(self, url, payload=''):
1544 """Sends a request to the server, with common params."""
1545 logging.info('Send: %s, params=%s', url, self.params)
1546 return self.rpcserver.Send(url, payload=payload, **self.params)
1548 def AddFile(self, path, file_handle):
1549 """Adds the provided file to the list to be pushed to the server.
1551 Args:
1552 path: The path the file should be uploaded as.
1553 file_handle: A stream containing data to upload.
1555 assert not self.in_transaction, 'Already in a transaction.'
1556 assert file_handle is not None
1558 reason = appinfo.ValidFilename(path)
1559 if reason:
1560 logging.error(reason)
1561 return
1563 content_hash = _HashFromFileHandle(file_handle)
1565 self.files[path] = content_hash
1566 self.all_files.add(path)
1568 def Describe(self):
1569 """Returns a string describing the object being updated."""
1570 result = 'app: %s' % self.app_id
1571 if self.server is not None and self.server != appinfo.DEFAULT_SERVER:
1572 result += ', server: %s' % self.server
1573 if self.backend:
1574 result += ', backend: %s' % self.backend
1575 elif self.version:
1576 result += ', version: %s' % self.version
1577 return result
1579 @staticmethod
1580 def _ValidateBeginYaml(resp):
1581 """Validates the given /api/appversion/create response string."""
1582 response_dict = yaml.safe_load(resp)
1583 if not response_dict or 'warnings' not in response_dict:
1584 return False
1585 return response_dict
1587 def Begin(self):
1588 """Begins the transaction, returning a list of files that need uploading.
1590 All calls to AddFile must be made before calling Begin().
1592 Returns:
1593 A list of pathnames for files that should be uploaded using UploadFile()
1594 before Commit() can be called.
1596 assert not self.in_transaction, 'Already in a transaction.'
1601 config_copy = copy.deepcopy(self.config)
1602 for url in config_copy.handlers:
1603 handler_type = url.GetHandlerType()
1604 if url.application_readable:
1605 if handler_type == 'static_dir':
1606 url.static_dir = os.path.join(STATIC_FILE_PREFIX, url.static_dir)
1607 elif handler_type == 'static_files':
1608 url.static_files = os.path.join(STATIC_FILE_PREFIX, url.static_files)
1609 url.upload = os.path.join(STATIC_FILE_PREFIX, url.upload)
1611 response = self.Send(
1612 '/api/appversion/create',
1613 payload=config_copy.ToYAML())
1615 result = self._ValidateBeginYaml(response)
1616 if result:
1617 warnings = result.get('warnings')
1618 for warning in warnings:
1619 StatusUpdate('WARNING: %s' % warning)
1621 self.in_transaction = True
1623 files_to_clone = []
1624 blobs_to_clone = []
1625 errorblobs = {}
1626 for path, content_hash in self.files.iteritems():
1627 file_classification = FileClassification(self.config, path)
1629 if file_classification.IsStaticFile():
1630 upload_path = path
1631 if file_classification.IsApplicationFile():
1632 upload_path = os.path.join(STATIC_FILE_PREFIX, path)
1633 blobs_to_clone.append((path, upload_path, content_hash,
1634 file_classification.StaticMimeType()))
1638 if file_classification.IsErrorFile():
1642 errorblobs[path] = content_hash
1644 if file_classification.IsApplicationFile():
1645 files_to_clone.append((path, path, content_hash))
1647 files_to_upload = {}
1649 def CloneFiles(url, files, file_type):
1650 """Sends files to the given url.
1652 Args:
1653 url: the server URL to use.
1654 files: a list of files
1655 file_type: the type of the files
1657 if not files:
1658 return
1660 StatusUpdate('Cloning %d %s file%s.' %
1661 (len(files), file_type, len(files) != 1 and 's' or ''))
1663 max_files = self.resource_limits['max_files_to_clone']
1664 for i in xrange(0, len(files), max_files):
1665 if i > 0 and i % max_files == 0:
1666 StatusUpdate('Cloned %d files.' % i)
1668 chunk = files[i:min(len(files), i + max_files)]
1669 result = self.Send(url, payload=BuildClonePostBody(chunk))
1670 if result:
1671 to_upload = {}
1672 for f in result.split(LIST_DELIMITER):
1673 for entry in files:
1674 real_path, upload_path = entry[:2]
1675 if f == upload_path:
1676 to_upload[real_path] = self.files[real_path]
1677 break
1678 files_to_upload.update(to_upload)
1680 CloneFiles('/api/appversion/cloneblobs', blobs_to_clone, 'static')
1681 CloneFiles('/api/appversion/clonefiles', files_to_clone, 'application')
1683 logging.debug('Files to upload: %s', files_to_upload)
1685 for (path, content_hash) in errorblobs.iteritems():
1686 files_to_upload[path] = content_hash
1687 self.files = files_to_upload
1688 return sorted(files_to_upload.iterkeys())
1690 def UploadFile(self, path, file_handle):
1691 """Uploads a file to the hosting service.
1693 Must only be called after Begin().
1694 The path provided must be one of those that were returned by Begin().
1696 Args:
1697 path: The path the file is being uploaded as.
1698 file_handle: A file-like object containing the data to upload.
1700 Raises:
1701 KeyError: The provided file is not amongst those to be uploaded.
1703 assert self.in_transaction, 'Begin() must be called before UploadFile().'
1704 if path not in self.files:
1705 raise KeyError('File \'%s\' is not in the list of files to be uploaded.'
1706 % path)
1708 del self.files[path]
1710 file_classification = FileClassification(self.config, path)
1711 payload = file_handle.read()
1712 if file_classification.IsStaticFile():
1713 upload_path = path
1714 if file_classification.IsApplicationFile():
1715 upload_path = os.path.join(STATIC_FILE_PREFIX, path)
1716 self.blob_batcher.AddToBatch(upload_path, payload,
1717 file_classification.StaticMimeType())
1721 if file_classification.IsErrorFile():
1724 self.errorblob_batcher.AddToBatch(file_classification.ErrorCode(),
1725 payload,
1726 file_classification.ErrorMimeType())
1728 if file_classification.IsApplicationFile():
1730 self.file_batcher.AddToBatch(path, payload, None)
1732 def Precompile(self):
1733 """Handle bytecode precompilation."""
1735 StatusUpdate('Compilation starting.')
1737 files = []
1738 if self.config.runtime == 'go':
1741 for f in self.all_files:
1742 if f.endswith('.go') and not self.config.nobuild_files.match(f):
1743 files.append(f)
1745 while True:
1746 if files:
1747 StatusUpdate('Compilation: %d files left.' % len(files))
1748 files = self.PrecompileBatch(files)
1749 if not files:
1750 break
1751 StatusUpdate('Compilation completed.')
1753 def PrecompileBatch(self, files):
1754 """Precompile a batch of files.
1756 Args:
1757 files: Either an empty list (for the initial request) or a list
1758 of files to be precompiled.
1760 Returns:
1761 Either an empty list (if no more files need to be precompiled)
1762 or a list of files to be precompiled subsequently.
1764 payload = LIST_DELIMITER.join(files)
1765 response = self.Send('/api/appversion/precompile', payload=payload)
1766 if not response:
1767 return []
1768 return response.split(LIST_DELIMITER)
1770 def Commit(self):
1771 """Commits the transaction, making the new app version available.
1773 All the files returned by Begin() must have been uploaded with UploadFile()
1774 before Commit() can be called.
1776 This tries the new 'deploy' method; if that fails it uses the old 'commit'.
1778 Returns:
1779 An appinfo.AppInfoSummary if one was returned from the Deploy, None
1780 otherwise.
1782 Raises:
1783 Exception: Some required files were not uploaded.
1785 assert self.in_transaction, 'Begin() must be called before Commit().'
1786 if self.files:
1787 raise Exception('Not all required files have been uploaded.')
1789 def PrintRetryMessage(_, delay):
1790 StatusUpdate('Will check again in %s seconds.' % delay)
1792 app_summary = None
1794 app_summary = self.Deploy()
1797 success, unused_contents = RetryWithBackoff(
1798 lambda: (self.IsReady(), None), PrintRetryMessage, 1, 2, 60, 20)
1799 if not success:
1801 logging.warning('Version still not ready to serve, aborting.')
1802 raise Exception('Version not ready.')
1804 result = self.StartServing()
1805 if not result:
1808 self.in_transaction = False
1809 else:
1810 if result == '0':
1811 raise CannotStartServingError(
1812 'Another operation on this version is in progress.')
1813 success, unused_contents = RetryWithBackoff(
1814 lambda: (self.IsServing(), None), PrintRetryMessage, 1, 2, 60, 20)
1815 if not success:
1817 logging.warning('Version still not serving, aborting.')
1818 raise Exception('Version not ready.')
1819 self.in_transaction = False
1821 return app_summary
1823 def Deploy(self):
1824 """Deploys the new app version but does not make it default.
1826 All the files returned by Begin() must have been uploaded with UploadFile()
1827 before Deploy() can be called.
1829 Returns:
1830 An appinfo.AppInfoSummary if one was returned from the Deploy, None
1831 otherwise.
1833 Raises:
1834 Exception: Some required files were not uploaded.
1836 assert self.in_transaction, 'Begin() must be called before Deploy().'
1837 if self.files:
1838 raise Exception('Not all required files have been uploaded.')
1840 StatusUpdate('Starting deployment.')
1841 result = self.Send('/api/appversion/deploy')
1842 self.deployed = True
1844 if result:
1845 return yaml_object.BuildSingleObject(appinfo.AppInfoSummary, result)
1846 else:
1847 return None
1849 def IsReady(self):
1850 """Check if the new app version is ready to serve traffic.
1852 Raises:
1853 Exception: Deploy has not yet been called.
1855 Returns:
1856 True if the server returned the app is ready to serve.
1858 assert self.deployed, 'Deploy() must be called before IsReady().'
1860 StatusUpdate('Checking if deployment succeeded.')
1861 result = self.Send('/api/appversion/isready')
1862 return result == '1'
1864 def StartServing(self):
1865 """Start serving with the newly created version.
1867 Raises:
1868 Exception: Deploy has not yet been called.
1870 Returns:
1871 The response body, as a string.
1873 assert self.deployed, 'Deploy() must be called before StartServing().'
1875 StatusUpdate('Deployment successful.')
1876 self.params['willcheckserving'] = '1'
1877 result = self.Send('/api/appversion/startserving')
1878 del self.params['willcheckserving']
1879 self.started = True
1880 return result
1882 @staticmethod
1883 def _ValidateIsServingYaml(resp):
1884 """Validates the given /isserving YAML string.
1886 Returns the resulting dictionary if the response is valid.
1888 response_dict = yaml.safe_load(resp)
1889 if 'serving' not in response_dict:
1890 return False
1891 return response_dict
1893 def IsServing(self):
1894 """Check if the new app version is serving.
1896 Raises:
1897 Exception: Deploy has not yet been called.
1899 Returns:
1900 True if the deployed app version is serving.
1902 assert self.started, 'StartServing() must be called before IsServing().'
1904 StatusUpdate('Checking if updated app version is serving.')
1906 self.params['new_serving_resp'] = '1'
1907 result = self.Send('/api/appversion/isserving')
1908 del self.params['new_serving_resp']
1909 if result in ['0', '1']:
1910 return result == '1'
1911 result = AppVersionUpload._ValidateIsServingYaml(result)
1912 if not result:
1913 raise CannotStartServingError(
1914 'Internal error: Could not parse IsServing response.')
1915 message = result.get('message')
1916 fatal = result.get('fatal')
1917 if message:
1918 StatusUpdate(message)
1919 if fatal:
1920 raise CannotStartServingError(fatal)
1921 return result['serving']
1923 def Rollback(self):
1924 """Rolls back the transaction if one is in progress."""
1925 if not self.in_transaction:
1926 return
1927 StatusUpdate('Rolling back the update.')
1928 self.Send('/api/appversion/rollback')
1929 self.in_transaction = False
1930 self.files = {}
1932 def DoUpload(self, paths, openfunc):
1933 """Uploads a new appversion with the given config and files to the server.
1935 Args:
1936 paths: An iterator that yields the relative paths of the files to upload.
1937 openfunc: A function that takes a path and returns a file-like object.
1939 Returns:
1940 An appinfo.AppInfoSummary if one was returned from the server, None
1941 otherwise.
1943 logging.info('Reading app configuration.')
1945 StatusUpdate('\nStarting update of %s' % self.Describe())
1948 path = ''
1949 try:
1950 self.resource_limits = GetResourceLimits(self.rpcserver, self.config)
1952 StatusUpdate('Scanning files on local disk.')
1953 num_files = 0
1954 for path in paths:
1955 file_handle = openfunc(path)
1956 file_classification = FileClassification(self.config, path)
1957 try:
1958 file_length = GetFileLength(file_handle)
1959 if file_classification.IsApplicationFile():
1960 max_size = self.resource_limits['max_file_size']
1961 else:
1962 max_size = self.resource_limits['max_blob_size']
1963 if file_length > max_size:
1964 logging.error('Ignoring file \'%s\': Too long '
1965 '(max %d bytes, file is %d bytes)',
1966 path, max_size, file_length)
1967 else:
1968 logging.info('Processing file \'%s\'', path)
1969 self.AddFile(path, file_handle)
1970 finally:
1971 file_handle.close()
1972 num_files += 1
1973 if num_files % 500 == 0:
1974 StatusUpdate('Scanned %d files.' % num_files)
1975 except KeyboardInterrupt:
1976 logging.info('User interrupted. Aborting.')
1977 raise
1978 except EnvironmentError, e:
1979 logging.error('An error occurred processing file \'%s\': %s. Aborting.',
1980 path, e)
1981 raise
1983 app_summary = None
1984 try:
1986 missing_files = self.Begin()
1987 if missing_files:
1988 StatusUpdate('Uploading %d files and blobs.' % len(missing_files))
1989 num_files = 0
1990 for missing_file in missing_files:
1991 file_handle = openfunc(missing_file)
1992 try:
1993 self.UploadFile(missing_file, file_handle)
1994 finally:
1995 file_handle.close()
1996 num_files += 1
1997 if num_files % 500 == 0:
1998 StatusUpdate('Processed %d out of %s.' %
1999 (num_files, len(missing_files)))
2001 self.file_batcher.Flush()
2002 self.blob_batcher.Flush()
2003 self.errorblob_batcher.Flush()
2004 StatusUpdate('Uploaded %d files and blobs' % num_files)
2007 if (self.config.derived_file_type and
2008 appinfo.PYTHON_PRECOMPILED in self.config.derived_file_type):
2009 try:
2010 self.Precompile()
2011 except urllib2.HTTPError, e:
2013 ErrorUpdate('Error %d: --- begin server output ---\n'
2014 '%s\n--- end server output ---' %
2015 (e.code, e.read().rstrip('\n')))
2016 if e.code == 422 or self.config.runtime == 'go':
2023 raise
2024 print >>self.error_fh, (
2025 'Precompilation failed. Your app can still serve but may '
2026 'have reduced startup performance. You can retry the update '
2027 'later to retry the precompilation step.')
2030 app_summary = self.Commit()
2031 StatusUpdate('Completed update of %s' % self.Describe())
2033 except KeyboardInterrupt:
2035 logging.info('User interrupted. Aborting.')
2036 self.Rollback()
2037 raise
2038 except urllib2.HTTPError, err:
2040 logging.info('HTTP Error (%s)', err)
2041 self.Rollback()
2042 raise
2043 except CannotStartServingError, err:
2045 logging.error(err.message)
2046 self.Rollback()
2047 raise
2048 except:
2049 logging.exception('An unexpected error occurred. Aborting.')
2050 self.Rollback()
2051 raise
2053 logging.info('Done!')
2054 return app_summary
2057 def FileIterator(base, skip_files, runtime, separator=os.path.sep):
2058 """Walks a directory tree, returning all the files. Follows symlinks.
2060 Args:
2061 base: The base path to search for files under.
2062 skip_files: A regular expression object for files/directories to skip.
2063 runtime: The name of the runtime e.g. "python". If "python27" then .pyc
2064 files with matching .py files will be skipped.
2065 separator: Path separator used by the running system's platform.
2067 Yields:
2068 Paths of files found, relative to base.
2070 dirs = ['']
2071 while dirs:
2072 current_dir = dirs.pop()
2073 entries = set(os.listdir(os.path.join(base, current_dir)))
2074 for entry in sorted(entries):
2075 name = os.path.join(current_dir, entry)
2076 fullname = os.path.join(base, name)
2081 if separator == '\\':
2082 name = name.replace('\\', '/')
2084 if runtime == 'python27' and not skip_files.match(name):
2085 root, extension = os.path.splitext(entry)
2086 if extension == '.pyc' and (root + '.py') in entries:
2087 logging.warning('Ignoring file \'%s\': Cannot upload both '
2088 '<filename>.py and <filename>.pyc', name)
2089 continue
2091 if os.path.isfile(fullname):
2092 if skip_files.match(name):
2093 logging.info('Ignoring file \'%s\': File matches ignore regex.', name)
2094 else:
2095 yield name
2096 elif os.path.isdir(fullname):
2097 if skip_files.match(name):
2098 logging.info(
2099 'Ignoring directory \'%s\': Directory matches ignore regex.',
2100 name)
2101 else:
2102 dirs.append(name)
2105 def GetFileLength(fh):
2106 """Returns the length of the file represented by fh.
2108 This function is capable of finding the length of any seekable stream,
2109 unlike os.fstat, which only works on file streams.
2111 Args:
2112 fh: The stream to get the length of.
2114 Returns:
2115 The length of the stream.
2117 pos = fh.tell()
2119 fh.seek(0, 2)
2120 length = fh.tell()
2121 fh.seek(pos, 0)
2122 return length
2125 def GetUserAgent(get_version=sdk_update_checker.GetVersionObject,
2126 get_platform=appengine_rpc.GetPlatformToken,
2127 sdk_product=SDK_PRODUCT):
2128 """Determines the value of the 'User-agent' header to use for HTTP requests.
2130 If the 'APPCFG_SDK_NAME' environment variable is present, that will be
2131 used as the first product token in the user-agent.
2133 Args:
2134 get_version: Used for testing.
2135 get_platform: Used for testing.
2136 sdk_product: Used as part of sdk/version product token.
2138 Returns:
2139 String containing the 'user-agent' header value, which includes the SDK
2140 version, the platform information, and the version of Python;
2141 e.g., 'appcfg_py/1.0.1 Darwin/9.2.0 Python/2.5.2'.
2143 product_tokens = []
2146 sdk_name = os.environ.get('APPCFG_SDK_NAME')
2147 if sdk_name:
2148 product_tokens.append(sdk_name)
2149 else:
2150 version = get_version()
2151 if version is None:
2152 release = 'unknown'
2153 else:
2154 release = version['release']
2156 product_tokens.append('%s/%s' % (sdk_product, release))
2159 product_tokens.append(get_platform())
2162 python_version = '.'.join(str(i) for i in sys.version_info)
2163 product_tokens.append('Python/%s' % python_version)
2165 return ' '.join(product_tokens)
2168 def GetSourceName(get_version=sdk_update_checker.GetVersionObject):
2169 """Gets the name of this source version."""
2170 version = get_version()
2171 if version is None:
2172 release = 'unknown'
2173 else:
2174 release = version['release']
2175 return 'Google-appcfg-%s' % (release,)
2178 class AppCfgApp(object):
2179 """Singleton class to wrap AppCfg tool functionality.
2181 This class is responsible for parsing the command line and executing
2182 the desired action on behalf of the user. Processing files and
2183 communicating with the server is handled by other classes.
2185 Attributes:
2186 actions: A dictionary mapping action names to Action objects.
2187 action: The Action specified on the command line.
2188 parser: An instance of optparse.OptionParser.
2189 options: The command line options parsed by 'parser'.
2190 argv: The original command line as a list.
2191 args: The positional command line args left over after parsing the options.
2192 raw_input_fn: Function used for getting raw user input, like email.
2193 password_input_fn: Function used for getting user password.
2194 error_fh: Unexpected HTTPErrors are printed to this file handle.
2196 Attributes for testing:
2197 parser_class: The class to use for parsing the command line. Because
2198 OptionsParser will exit the program when there is a parse failure, it
2199 is nice to subclass OptionsParser and catch the error before exiting.
2202 def __init__(self, argv, parser_class=optparse.OptionParser,
2203 rpc_server_class=None,
2204 raw_input_fn=raw_input,
2205 password_input_fn=getpass.getpass,
2206 out_fh=sys.stdout,
2207 error_fh=sys.stderr,
2208 update_check_class=sdk_update_checker.SDKUpdateChecker,
2209 throttle_class=None,
2210 opener=open,
2211 file_iterator=FileIterator,
2212 time_func=time.time,
2213 wrap_server_error_message=True,
2214 oauth_client_id=APPCFG_CLIENT_ID,
2215 oauth_client_secret=APPCFG_CLIENT_NOTSOSECRET,
2216 oauth_scopes=APPCFG_SCOPES):
2217 """Initializer. Parses the cmdline and selects the Action to use.
2219 Initializes all of the attributes described in the class docstring.
2220 Prints help or error messages if there is an error parsing the cmdline.
2222 Args:
2223 argv: The list of arguments passed to this program.
2224 parser_class: Options parser to use for this application.
2225 rpc_server_class: RPC server class to use for this application.
2226 raw_input_fn: Function used for getting user email.
2227 password_input_fn: Function used for getting user password.
2228 out_fh: All normal output is printed to this file handle.
2229 error_fh: Unexpected HTTPErrors are printed to this file handle.
2230 update_check_class: sdk_update_checker.SDKUpdateChecker class (can be
2231 replaced for testing).
2232 throttle_class: A class to use instead of ThrottledHttpRpcServer
2233 (only used in the bulkloader).
2234 opener: Function used for opening files.
2235 file_iterator: Callable that takes (basepath, skip_files, file_separator)
2236 and returns a generator that yields all filenames in the file tree
2237 rooted at that path, skipping files that match the skip_files compiled
2238 regular expression.
2239 time_func: Function which provides the current time (can be replaced for
2240 testing).
2241 wrap_server_error_message: If true, the error messages from
2242 urllib2.HTTPError exceptions in Run() are wrapped with
2243 '--- begin server output ---' and '--- end server output ---',
2244 otherwise the error message is printed as is.
2245 oauth_client_id: The client ID of the project providing Auth. Defaults to
2246 the SDK default project client ID, the constant APPCFG_CLIENT_ID.
2247 oauth_client_secret: The client secret of the project providing Auth.
2248 Defaults to the SDK default project client secret, the constant
2249 APPCFG_CLIENT_NOTSOSECRET.
2250 oauth_scopes: The scope or set of scopes to be accessed by the OAuth2
2251 token retrieved. Defaults to APPCFG_SCOPES. Can be a string or list of
2252 strings, representing the scope(s) to request.
2254 self.parser_class = parser_class
2255 self.argv = argv
2256 self.rpc_server_class = rpc_server_class
2257 self.raw_input_fn = raw_input_fn
2258 self.password_input_fn = password_input_fn
2259 self.out_fh = out_fh
2260 self.error_fh = error_fh
2261 self.update_check_class = update_check_class
2262 self.throttle_class = throttle_class
2263 self.time_func = time_func
2264 self.wrap_server_error_message = wrap_server_error_message
2265 self.oauth_client_id = oauth_client_id
2266 self.oauth_client_secret = oauth_client_secret
2267 self.oauth_scopes = oauth_scopes
2273 self.parser = self._GetOptionParser()
2274 for action in self.actions.itervalues():
2275 action.options(self, self.parser)
2278 self.options, self.args = self.parser.parse_args(argv[1:])
2280 if len(self.args) < 1:
2281 self._PrintHelpAndExit()
2283 if self.options.allow_any_runtime:
2287 appinfo.AppInfoExternal._skip_runtime_checks = True
2288 else:
2289 if self.options.runtime:
2290 if self.options.runtime not in SUPPORTED_RUNTIMES:
2291 _PrintErrorAndExit(self.error_fh,
2292 '"%s" is not a supported runtime\n' %
2293 self.options.runtime)
2294 else:
2295 appinfo.AppInfoExternal.ATTRIBUTES[appinfo.RUNTIME] = (
2296 '|'.join(SUPPORTED_RUNTIMES))
2298 action = self.args.pop(0)
2300 def RaiseParseError(actionname, action):
2303 self.parser, self.options = self._MakeSpecificParser(action)
2304 error_desc = action.error_desc
2305 if not error_desc:
2306 error_desc = "Expected a <directory> argument after '%s'." % (
2307 actionname.split(' ')[0])
2308 self.parser.error(error_desc)
2313 if action == BACKENDS_ACTION:
2314 if len(self.args) < 1:
2315 RaiseParseError(action, self.actions[BACKENDS_ACTION])
2317 backend_action_first = BACKENDS_ACTION + ' ' + self.args[0]
2318 if backend_action_first in self.actions:
2319 self.args.pop(0)
2320 action = backend_action_first
2322 elif len(self.args) > 1:
2323 backend_directory_first = BACKENDS_ACTION + ' ' + self.args[1]
2324 if backend_directory_first in self.actions:
2325 self.args.pop(1)
2326 action = backend_directory_first
2329 if len(self.args) < 1 or action == BACKENDS_ACTION:
2330 RaiseParseError(action, self.actions[action])
2332 if action not in self.actions:
2333 self.parser.error("Unknown action: '%s'\n%s" %
2334 (action, self.parser.get_description()))
2337 self.action = self.actions[action]
2342 if not self.action.uses_basepath or self.options.help:
2343 self.basepath = None
2344 else:
2345 if not self.args:
2346 RaiseParseError(action, self.action)
2347 self.basepath = self.args.pop(0)
2353 self.parser, self.options = self._MakeSpecificParser(self.action)
2357 if self.options.help:
2358 self._PrintHelpAndExit()
2360 if self.options.verbose == 2:
2361 logging.getLogger().setLevel(logging.INFO)
2362 elif self.options.verbose == 3:
2363 logging.getLogger().setLevel(logging.DEBUG)
2368 global verbosity
2369 verbosity = self.options.verbose
2372 if self.options.oauth2_refresh_token:
2373 self.options.oauth2 = True
2376 if self.options.oauth2_client_id:
2377 self.oauth_client_id = self.options.oauth2_client_id
2378 if self.options.oauth2_client_secret:
2379 self.oauth_client_secret = self.options.oauth2_client_secret
2384 self.opener = opener
2385 self.file_iterator = file_iterator
2387 def Run(self):
2388 """Executes the requested action.
2390 Catches any HTTPErrors raised by the action and prints them to stderr.
2392 Returns:
2393 1 on error, 0 if successful.
2395 try:
2396 self.action(self)
2397 except urllib2.HTTPError, e:
2398 body = e.read()
2399 if self.wrap_server_error_message:
2400 error_format = ('Error %d: --- begin server output ---\n'
2401 '%s\n--- end server output ---')
2402 else:
2403 error_format = 'Error %d: %s'
2405 print >>self.error_fh, (error_format % (e.code, body.rstrip('\n')))
2406 return 1
2407 except yaml_errors.EventListenerError, e:
2408 print >>self.error_fh, ('Error parsing yaml file:\n%s' % e)
2409 return 1
2410 except CannotStartServingError:
2411 print >>self.error_fh, 'Could not start serving the given version.'
2412 return 1
2413 return 0
2415 def _GetActionDescriptions(self):
2416 """Returns a formatted string containing the short_descs for all actions."""
2417 action_names = self.actions.keys()
2418 action_names.sort()
2419 desc = ''
2420 for action_name in action_names:
2421 if not self.actions[action_name].hidden:
2422 desc += ' %s: %s\n' % (action_name,
2423 self.actions[action_name].short_desc)
2424 return desc
2426 def _GetOptionParser(self):
2427 """Creates an OptionParser with generic usage and description strings.
2429 Returns:
2430 An OptionParser instance.
2433 class Formatter(optparse.IndentedHelpFormatter):
2434 """Custom help formatter that does not reformat the description."""
2436 def format_description(self, description):
2437 """Very simple formatter."""
2438 return description + '\n'
2440 desc = self._GetActionDescriptions()
2441 desc = ('Action must be one of:\n%s'
2442 'Use \'help <action>\' for a detailed description.') % desc
2446 parser = self.parser_class(usage='%prog [options] <action>',
2447 description=desc,
2448 formatter=Formatter(),
2449 conflict_handler='resolve')
2454 parser.add_option('-h', '--help', action='store_true',
2455 dest='help', help='Show the help message and exit.')
2456 parser.add_option('-q', '--quiet', action='store_const', const=0,
2457 dest='verbose', help='Print errors only.')
2458 parser.add_option('-v', '--verbose', action='store_const', const=2,
2459 dest='verbose', default=1,
2460 help='Print info level logs.')
2461 parser.add_option('--noisy', action='store_const', const=3,
2462 dest='verbose', help='Print all logs.')
2463 parser.add_option('-s', '--server', action='store', dest='server',
2464 default='appengine.google.com',
2465 metavar='SERVER', help='The App Engine server.')
2466 parser.add_option('--secure', action='store_true', dest='secure',
2467 default=True, help=optparse.SUPPRESS_HELP)
2468 parser.add_option('--ignore_bad_cert', action='store_true',
2469 dest='ignore_certs', default=False,
2470 help=optparse.SUPPRESS_HELP)
2471 parser.add_option('--insecure', action='store_false', dest='secure',
2472 help=optparse.SUPPRESS_HELP)
2473 parser.add_option('-e', '--email', action='store', dest='email',
2474 metavar='EMAIL', default=None,
2475 help='The username to use. Will prompt if omitted.')
2476 parser.add_option('-H', '--host', action='store', dest='host',
2477 metavar='HOST', default=None,
2478 help='Overrides the Host header sent with all RPCs.')
2479 parser.add_option('--no_cookies', action='store_false',
2480 dest='save_cookies', default=True,
2481 help='Do not save authentication cookies to local disk.')
2482 parser.add_option('--skip_sdk_update_check', action='store_true',
2483 dest='skip_sdk_update_check', default=False,
2484 help='Do not check for SDK updates.')
2485 parser.add_option('--passin', action='store_true',
2486 dest='passin', default=False,
2487 help='Read the login password from stdin.')
2488 parser.add_option('-A', '--application', action='store', dest='app_id',
2489 help=('Set the application, overriding the application '
2490 'value from app.yaml file.'))
2491 parser.add_option('-S', '--server_id', action='store', dest='server_id',
2492 help=optparse.SUPPRESS_HELP)
2493 parser.add_option('-V', '--version', action='store', dest='version',
2494 help=('Set the (major) version, overriding the version '
2495 'value from app.yaml file.'))
2496 parser.add_option('-r', '--runtime', action='store', dest='runtime',
2497 help='Override runtime from app.yaml file.')
2498 parser.add_option('-R', '--allow_any_runtime', action='store_true',
2499 dest='allow_any_runtime', default=False,
2500 help='Do not validate the runtime in app.yaml')
2501 parser.add_option('--oauth2', action='store_true', dest='oauth2',
2502 default=False,
2503 help='Use OAuth2 instead of password auth.')
2504 parser.add_option('--oauth2_refresh_token', action='store',
2505 dest='oauth2_refresh_token', default=None,
2506 help='An existing OAuth2 refresh token to use. Will '
2507 'not attempt interactive OAuth approval.')
2508 parser.add_option('--oauth2_client_id', action='store',
2509 dest='oauth2_client_id', default=None,
2510 help=optparse.SUPPRESS_HELP)
2511 parser.add_option('--oauth2_client_secret', action='store',
2512 dest='oauth2_client_secret', default=None,
2513 help=optparse.SUPPRESS_HELP)
2514 parser.add_option('--oauth2_credential_file', action='store',
2515 dest='oauth2_credential_file', default=None,
2516 help=optparse.SUPPRESS_HELP)
2517 parser.add_option('--noauth_local_webserver', action='store_false',
2518 dest='auth_local_webserver', default=True,
2519 help='Do not run a local web server to handle redirects '
2520 'during OAuth authorization.')
2521 return parser
2523 def _MakeSpecificParser(self, action):
2524 """Creates a new parser with documentation specific to 'action'.
2526 Args:
2527 action: An Action instance to be used when initializing the new parser.
2529 Returns:
2530 A tuple containing:
2531 parser: An instance of OptionsParser customized to 'action'.
2532 options: The command line options after re-parsing.
2534 parser = self._GetOptionParser()
2535 parser.set_usage(action.usage)
2536 parser.set_description('%s\n%s' % (action.short_desc, action.long_desc))
2537 action.options(self, parser)
2538 options, unused_args = parser.parse_args(self.argv[1:])
2539 return parser, options
2541 def _PrintHelpAndExit(self, exit_code=2):
2542 """Prints the parser's help message and exits the program.
2544 Args:
2545 exit_code: The integer code to pass to sys.exit().
2547 self.parser.print_help()
2548 sys.exit(exit_code)
2550 def _GetRpcServer(self):
2551 """Returns an instance of an AbstractRpcServer.
2553 Returns:
2554 A new AbstractRpcServer, on which RPC calls can be made.
2556 Raises:
2557 OAuthNotAvailable: Oauth is requested but the dependecies aren't imported.
2560 def GetUserCredentials():
2561 """Prompts the user for a username and password."""
2562 email = self.options.email
2563 if email is None:
2564 email = self.raw_input_fn('Email: ')
2566 password_prompt = 'Password for %s: ' % email
2569 if self.options.passin:
2570 password = self.raw_input_fn(password_prompt)
2571 else:
2572 password = self.password_input_fn(password_prompt)
2574 return (email, password)
2576 StatusUpdate('Host: %s' % self.options.server)
2580 dev_appserver = self.options.host == 'localhost'
2581 if self.options.oauth2 and not dev_appserver:
2582 if not appengine_rpc_httplib2:
2584 raise OAuthNotAvailable()
2585 if not self.rpc_server_class:
2586 self.rpc_server_class = appengine_rpc_httplib2.HttpRpcServerOauth2
2588 get_user_credentials = self.options.oauth2_refresh_token
2590 source = (self.oauth_client_id,
2591 self.oauth_client_secret,
2592 self.oauth_scopes,
2593 self.options.oauth2_credential_file)
2595 appengine_rpc_httplib2.tools.FLAGS.auth_local_webserver = (
2596 self.options.auth_local_webserver)
2597 else:
2598 if not self.rpc_server_class:
2599 self.rpc_server_class = appengine_rpc.HttpRpcServerWithOAuth2Suggestion
2600 get_user_credentials = GetUserCredentials
2601 source = GetSourceName()
2604 if dev_appserver:
2605 email = self.options.email
2606 if email is None:
2607 email = 'test@example.com'
2608 logging.info('Using debug user %s. Override with --email', email)
2609 rpcserver = self.rpc_server_class(
2610 self.options.server,
2611 lambda: (email, 'password'),
2612 GetUserAgent(),
2613 source,
2614 host_override=self.options.host,
2615 save_cookies=self.options.save_cookies,
2617 secure=False)
2619 rpcserver.authenticated = True
2620 return rpcserver
2623 if self.options.passin:
2624 auth_tries = 1
2625 else:
2626 auth_tries = 3
2628 return self.rpc_server_class(self.options.server, get_user_credentials,
2629 GetUserAgent(), source,
2630 host_override=self.options.host,
2631 save_cookies=self.options.save_cookies,
2632 auth_tries=auth_tries,
2633 account_type='HOSTED_OR_GOOGLE',
2634 secure=self.options.secure,
2635 ignore_certs=self.options.ignore_certs)
2637 def _FindYaml(self, basepath, file_name):
2638 """Find yaml files in application directory.
2640 Args:
2641 basepath: Base application directory.
2642 file_name: Relative file path from basepath, without extension, to search
2643 for.
2645 Returns:
2646 Path to located yaml file if one exists, else None.
2648 if not os.path.isdir(basepath):
2649 self.parser.error('Not a directory: %s' % basepath)
2653 alt_basepath = os.path.join(basepath, 'WEB-INF', 'appengine-generated')
2655 for yaml_basepath in (basepath, alt_basepath):
2656 for yaml_file in (file_name + '.yaml', file_name + '.yml'):
2657 yaml_path = os.path.join(yaml_basepath, yaml_file)
2658 if os.path.isfile(yaml_path):
2659 return yaml_path
2661 return None
2663 def _ParseAppInfoFromYaml(self, basepath, basename='app'):
2664 """Parses the app.yaml file.
2666 Args:
2667 basepath: The directory of the application.
2668 basename: The relative file path, from basepath, to search for.
2670 Returns:
2671 An AppInfoExternal object.
2673 appyaml = self._ParseYamlFile(basepath, basename, appinfo_includes.Parse)
2674 if appyaml is None:
2675 self.parser.error('Directory does not contain an %s.yaml '
2676 'configuration file.' % basename)
2678 orig_application = appyaml.application
2679 orig_server = appyaml.server
2680 orig_version = appyaml.version
2681 if self.options.app_id:
2682 appyaml.application = self.options.app_id
2683 if self.options.server_id:
2684 appyaml.server = self.options.server_id
2685 if self.options.version:
2686 appyaml.version = self.options.version
2687 if self.options.runtime:
2688 appyaml.runtime = self.options.runtime
2690 msg = 'Application: %s' % appyaml.application
2691 if appyaml.application != orig_application:
2692 msg += ' (was: %s)' % orig_application
2693 if self.action.function is 'Update':
2695 if (appyaml.server is not None and
2696 appyaml.server != appinfo.DEFAULT_SERVER):
2697 msg += '; server: %s' % appyaml.server
2698 if appyaml.server != orig_server:
2699 msg += ' (was: %s)' % orig_server
2701 msg += '; version: %s' % appyaml.version
2702 if appyaml.version != orig_version:
2703 msg += ' (was: %s)' % orig_version
2704 StatusUpdate(msg)
2705 return appyaml
2707 def _ParseYamlFile(self, basepath, basename, parser):
2708 """Parses a yaml file.
2710 Args:
2711 basepath: The base directory of the application.
2712 basename: The relative file path, from basepath, (with the '.yaml'
2713 stripped off).
2714 parser: the function or method used to parse the file.
2716 Returns:
2717 A single parsed yaml file or None if the file does not exist.
2719 file_name = self._FindYaml(basepath, basename)
2720 if file_name is not None:
2721 fh = self.opener(file_name, 'r')
2722 try:
2723 defns = parser(fh, open_fn=self.opener)
2724 finally:
2725 fh.close()
2726 return defns
2727 return None
2729 def _ParseBackendsYaml(self, basepath):
2730 """Parses the backends.yaml file.
2732 Args:
2733 basepath: the directory of the application.
2735 Returns:
2736 A BackendsInfoExternal object or None if the file does not exist.
2738 return self._ParseYamlFile(basepath, 'backends',
2739 backendinfo.LoadBackendInfo)
2741 def _ParseIndexYaml(self, basepath):
2742 """Parses the index.yaml file.
2744 Args:
2745 basepath: the directory of the application.
2747 Returns:
2748 A single parsed yaml file or None if the file does not exist.
2750 return self._ParseYamlFile(basepath, 'index',
2751 datastore_index.ParseIndexDefinitions)
2753 def _ParseCronYaml(self, basepath):
2754 """Parses the cron.yaml file.
2756 Args:
2757 basepath: the directory of the application.
2759 Returns:
2760 A CronInfoExternal object or None if the file does not exist.
2762 return self._ParseYamlFile(basepath, 'cron', croninfo.LoadSingleCron)
2764 def _ParseQueueYaml(self, basepath):
2765 """Parses the queue.yaml file.
2767 Args:
2768 basepath: the directory of the application.
2770 Returns:
2771 A QueueInfoExternal object or None if the file does not exist.
2773 return self._ParseYamlFile(basepath, 'queue', queueinfo.LoadSingleQueue)
2775 def _ParseDispatchYaml(self, basepath):
2776 """Parses the dispatch.yaml file.
2778 Args:
2779 basepath: the directory of the application.
2781 Returns:
2782 A DispatchInfoExternal object or None if the file does not exist.
2784 return self._ParseYamlFile(basepath, 'dispatch',
2785 dispatchinfo.LoadSingleDispatch)
2787 def _ParseDosYaml(self, basepath):
2788 """Parses the dos.yaml file.
2790 Args:
2791 basepath: the directory of the application.
2793 Returns:
2794 A DosInfoExternal object or None if the file does not exist.
2796 return self._ParseYamlFile(basepath, 'dos', dosinfo.LoadSingleDos)
2798 def Help(self, action=None):
2799 """Prints help for a specific action.
2801 Args:
2802 action: If provided, print help for the action provided.
2804 Expects self.args[0], or 'action', to contain the name of the action in
2805 question. Exits the program after printing the help message.
2807 if not action:
2808 if len(self.args) > 1:
2809 self.args = [' '.join(self.args)]
2811 if len(self.args) != 1 or self.args[0] not in self.actions:
2812 self.parser.error('Expected a single action argument. '
2813 ' Must be one of:\n' +
2814 self._GetActionDescriptions())
2815 action = self.args[0]
2816 action = self.actions[action]
2817 self.parser, unused_options = self._MakeSpecificParser(action)
2818 self._PrintHelpAndExit(exit_code=0)
2820 def DownloadApp(self):
2821 """Downloads the given app+version."""
2822 if len(self.args) != 1:
2823 self.parser.error('\"download_app\" expects one non-option argument, '
2824 'found ' + str(len(self.args)) + '.')
2826 out_dir = self.args[0]
2828 app_id = self.options.app_id
2829 if app_id is None:
2830 self.parser.error('You must specify an app ID via -A or --application.')
2832 server = self.options.server_id
2833 app_version = self.options.version
2837 if os.path.exists(out_dir):
2838 if not os.path.isdir(out_dir):
2839 self.parser.error('Cannot download to path "%s": '
2840 'there\'s a file in the way.' % out_dir)
2841 elif os.listdir(out_dir):
2842 self.parser.error('Cannot download to path "%s": directory already '
2843 'exists and it isn\'t empty.' % out_dir)
2845 rpcserver = self._GetRpcServer()
2847 DoDownloadApp(rpcserver, out_dir, app_id, server, app_version)
2849 def UpdateVersion(self, rpcserver, basepath, appyaml, server_yaml_path,
2850 backend=None):
2851 """Updates and deploys a new appversion.
2853 Args:
2854 rpcserver: An AbstractRpcServer instance on which RPC calls can be made.
2855 basepath: The root directory of the version to update.
2856 appyaml: The AppInfoExternal object parsed from an app.yaml-like file.
2857 server_yaml_path: The (string) path to the yaml file, relative to the
2858 bundle directory.
2859 backend: The name of the backend to update, if any.
2861 Returns:
2862 An appinfo.AppInfoSummary if one was returned from the Deploy, None
2863 otherwise.
2866 if self.options.precompilation:
2867 if not appyaml.derived_file_type:
2868 appyaml.derived_file_type = []
2869 if appinfo.PYTHON_PRECOMPILED not in appyaml.derived_file_type:
2870 appyaml.derived_file_type.append(appinfo.PYTHON_PRECOMPILED)
2872 paths = self.file_iterator(basepath, appyaml.skip_files, appyaml.runtime)
2873 openfunc = lambda path: self.opener(os.path.join(basepath, path), 'rb')
2875 if appyaml.runtime == 'go':
2878 goroot = os.path.join(os.path.dirname(google.appengine.__file__),
2879 '../../goroot')
2880 gopath = os.environ.get('GOPATH')
2881 if os.path.isdir(goroot) and gopath:
2882 app_paths = list(paths)
2883 go_files = [f for f in app_paths
2884 if f.endswith('.go') and not appyaml.nobuild_files.match(f)]
2885 if not go_files:
2886 raise Exception('no Go source files to upload '
2887 '(-nobuild_files applied)')
2888 gab_argv = [
2889 os.path.join(goroot, 'bin', 'go-app-builder'),
2890 '-app_base', self.basepath,
2891 '-arch', '6',
2892 '-gopath', gopath,
2893 '-goroot', goroot,
2894 '-print_extras',
2895 ] + go_files
2896 try:
2897 p = subprocess.Popen(gab_argv, stdout=subprocess.PIPE,
2898 stderr=subprocess.PIPE, env={})
2899 rc = p.wait()
2900 except Exception, e:
2901 raise Exception('failed running go-app-builder', e)
2902 if rc != 0:
2903 raise Exception(p.stderr.read())
2908 overlay = dict([l.split('|') for l in p.stdout.read().split('\n') if l])
2909 logging.info('GOPATH overlay: %s', overlay)
2911 def ofunc(path):
2912 if path in overlay:
2913 return self.opener(overlay[path], 'rb')
2914 return self.opener(os.path.join(basepath, path), 'rb')
2915 paths = app_paths + overlay.keys()
2916 openfunc = ofunc
2918 appversion = AppVersionUpload(rpcserver,
2919 appyaml,
2920 server_yaml_path=server_yaml_path,
2921 backend=backend,
2922 error_fh=self.error_fh)
2923 return appversion.DoUpload(paths, openfunc)
2925 def UpdateUsingSpecificFiles(self):
2926 """Updates and deploys new app versions based on given config files."""
2927 rpcserver = self._GetRpcServer()
2928 all_files = [self.basepath] + self.args
2929 has_python25_version = False
2931 for yaml_path in all_files:
2932 file_name = os.path.basename(yaml_path)
2933 self.basepath = os.path.dirname(yaml_path)
2934 if not self.basepath:
2935 self.basepath = '.'
2936 server_yaml = self._ParseAppInfoFromYaml(self.basepath,
2937 os.path.splitext(file_name)[0])
2938 if server_yaml.runtime == 'python':
2939 has_python25_version = True
2943 if not server_yaml.server and file_name != 'app.yaml':
2944 ErrorUpdate("Error: 'server' parameter not specified in %s" %
2945 yaml_path)
2946 continue
2947 self.UpdateVersion(rpcserver, self.basepath, server_yaml, file_name)
2948 if has_python25_version:
2949 MigratePython27Notice()
2951 def Update(self):
2952 """Updates and deploys a new appversion and global app configs."""
2953 appyaml = None
2954 rpcserver = self._GetRpcServer()
2955 if not os.path.isdir(self.basepath):
2957 self.UpdateUsingSpecificFiles()
2958 return
2961 yaml_file_basename = 'app.yaml'
2962 appyaml = self._ParseAppInfoFromYaml(
2963 self.basepath,
2964 basename=os.path.splitext(yaml_file_basename)[0])
2969 if self.options.skip_sdk_update_check:
2970 logging.info('Skipping update check')
2971 else:
2972 updatecheck = self.update_check_class(rpcserver, appyaml)
2973 updatecheck.CheckForUpdates()
2974 self.UpdateVersion(rpcserver, self.basepath, appyaml, yaml_file_basename)
2976 if appyaml.runtime == 'python':
2977 MigratePython27Notice()
2980 if self.options.backends:
2981 self.BackendsUpdate()
2988 index_defs = self._ParseIndexYaml(self.basepath)
2989 if index_defs:
2990 index_upload = IndexDefinitionUpload(rpcserver, appyaml, index_defs)
2991 try:
2992 index_upload.DoUpload()
2993 except urllib2.HTTPError, e:
2994 ErrorUpdate('Error %d: --- begin server output ---\n'
2995 '%s\n--- end server output ---' %
2996 (e.code, e.read().rstrip('\n')))
2997 print >> self.error_fh, (
2998 'Your app was updated, but there was an error updating your '
2999 'indexes. Please retry later with appcfg.py update_indexes.')
3002 cron_yaml = self._ParseCronYaml(self.basepath)
3003 if cron_yaml:
3004 cron_upload = CronEntryUpload(rpcserver, appyaml, cron_yaml)
3005 cron_upload.DoUpload()
3008 queue_yaml = self._ParseQueueYaml(self.basepath)
3009 if queue_yaml:
3010 queue_upload = QueueEntryUpload(rpcserver, appyaml, queue_yaml)
3011 queue_upload.DoUpload()
3014 dos_yaml = self._ParseDosYaml(self.basepath)
3015 if dos_yaml:
3016 dos_upload = DosEntryUpload(rpcserver, appyaml, dos_yaml)
3017 dos_upload.DoUpload()
3020 if appyaml:
3021 pagespeed_upload = PagespeedEntryUpload(
3022 rpcserver, appyaml, appyaml.pagespeed)
3023 try:
3024 pagespeed_upload.DoUpload()
3025 except urllib2.HTTPError, e:
3026 ErrorUpdate('Error %d: --- begin server output ---\n'
3027 '%s\n--- end server output ---' %
3028 (e.code, e.read().rstrip('\n')))
3029 print >> self.error_fh, (
3030 'Your app was updated, but there was an error updating PageSpeed. '
3031 'Please try the update again later.')
3033 def _UpdateOptions(self, parser):
3034 """Adds update-specific options to 'parser'.
3036 Args:
3037 parser: An instance of OptionsParser.
3039 parser.add_option('--no_precompilation', action='store_false',
3040 dest='precompilation', default=True,
3041 help='Disable automatic Python precompilation.')
3042 parser.add_option('--backends', action='store_true',
3043 dest='backends', default=False,
3044 help='Update backends when performing appcfg update.')
3046 def VacuumIndexes(self):
3047 """Deletes unused indexes."""
3048 if self.args:
3049 self.parser.error('Expected a single <directory> argument.')
3051 appyaml = self._ParseAppInfoFromYaml(self.basepath)
3054 index_defs = self._ParseIndexYaml(self.basepath)
3055 if index_defs is None:
3056 index_defs = datastore_index.IndexDefinitions()
3058 rpcserver = self._GetRpcServer()
3059 vacuum = VacuumIndexesOperation(rpcserver,
3060 appyaml,
3061 self.options.force_delete)
3062 vacuum.DoVacuum(index_defs)
3064 def _VacuumIndexesOptions(self, parser):
3065 """Adds vacuum_indexes-specific options to 'parser'.
3067 Args:
3068 parser: An instance of OptionsParser.
3070 parser.add_option('-f', '--force', action='store_true', dest='force_delete',
3071 default=False,
3072 help='Force deletion without being prompted.')
3074 def UpdateCron(self):
3075 """Updates any new or changed cron definitions."""
3076 if self.args:
3077 self.parser.error('Expected a single <directory> argument.')
3079 appyaml = self._ParseAppInfoFromYaml(self.basepath)
3080 rpcserver = self._GetRpcServer()
3083 cron_yaml = self._ParseCronYaml(self.basepath)
3084 if cron_yaml:
3085 cron_upload = CronEntryUpload(rpcserver, appyaml, cron_yaml)
3086 cron_upload.DoUpload()
3087 else:
3088 print >>sys.stderr, 'Could not find cron configuration. No action taken.'
3090 def UpdateIndexes(self):
3091 """Updates indexes."""
3092 if self.args:
3093 self.parser.error('Expected a single <directory> argument.')
3096 appyaml = self._ParseAppInfoFromYaml(self.basepath)
3097 rpcserver = self._GetRpcServer()
3100 index_defs = self._ParseIndexYaml(self.basepath)
3101 if index_defs:
3102 index_upload = IndexDefinitionUpload(rpcserver, appyaml, index_defs)
3103 index_upload.DoUpload()
3104 else:
3105 print >>sys.stderr, 'Could not find index configuration. No action taken.'
3107 def UpdateQueues(self):
3108 """Updates any new or changed task queue definitions."""
3109 if self.args:
3110 self.parser.error('Expected a single <directory> argument.')
3112 appyaml = self._ParseAppInfoFromYaml(self.basepath)
3113 rpcserver = self._GetRpcServer()
3116 queue_yaml = self._ParseQueueYaml(self.basepath)
3117 if queue_yaml:
3118 queue_upload = QueueEntryUpload(rpcserver, appyaml, queue_yaml)
3119 queue_upload.DoUpload()
3120 else:
3121 print >>sys.stderr, 'Could not find queue configuration. No action taken.'
3123 def UpdateDispatch(self):
3124 """Updates new or changed dispatch definitions."""
3125 if self.args:
3126 self.parser.error('Expected a single <directory> argument.')
3128 rpcserver = self._GetRpcServer()
3131 dispatch_yaml = self._ParseDispatchYaml(self.basepath)
3132 if dispatch_yaml:
3133 if self.options.app_id:
3134 dispatch_yaml.application = self.options.app_id
3135 if not dispatch_yaml.application:
3136 self.parser.error('Expected -A app_id when dispatch.yaml.application'
3137 ' is not set.')
3138 StatusUpdate('Uploading dispatch entries.')
3139 rpcserver.Send('/api/dispatch/update',
3140 app_id=dispatch_yaml.application,
3141 payload=dispatch_yaml.ToYAML())
3142 else:
3143 print >>sys.stderr, ('Could not find dispatch configuration. No action'
3144 ' taken.')
3146 def UpdateDos(self):
3147 """Updates any new or changed dos definitions."""
3148 if self.args:
3149 self.parser.error('Expected a single <directory> argument.')
3151 appyaml = self._ParseAppInfoFromYaml(self.basepath)
3152 rpcserver = self._GetRpcServer()
3155 dos_yaml = self._ParseDosYaml(self.basepath)
3156 if dos_yaml:
3157 dos_upload = DosEntryUpload(rpcserver, appyaml, dos_yaml)
3158 dos_upload.DoUpload()
3159 else:
3160 print >>sys.stderr, 'Could not find dos configuration. No action taken.'
3162 def BackendsAction(self):
3163 """Placeholder; we never expect this action to be invoked."""
3164 pass
3166 def BackendsYamlCheck(self, appyaml, backend=None):
3167 """Check the backends.yaml file is sane and which backends to update."""
3170 if appyaml.backends:
3171 self.parser.error('Backends are not allowed in app.yaml.')
3173 backends_yaml = self._ParseBackendsYaml(self.basepath)
3174 appyaml.backends = backends_yaml.backends
3176 if not appyaml.backends:
3177 self.parser.error('No backends found in backends.yaml.')
3179 backends = []
3180 for backend_entry in appyaml.backends:
3181 entry = backendinfo.LoadBackendEntry(backend_entry.ToYAML())
3182 if entry.name in backends:
3183 self.parser.error('Duplicate entry for backend: %s.' % entry.name)
3184 else:
3185 backends.append(entry.name)
3187 backends_to_update = []
3189 if backend:
3191 if backend in backends:
3192 backends_to_update = [backend]
3193 else:
3194 self.parser.error("Backend '%s' not found in backends.yaml." %
3195 backend)
3196 else:
3198 backends_to_update = backends
3200 return backends_to_update
3202 def BackendsUpdate(self):
3203 """Updates a backend."""
3204 self.backend = None
3205 if len(self.args) == 1:
3206 self.backend = self.args[0]
3207 elif len(self.args) > 1:
3208 self.parser.error('Expected an optional <backend> argument.')
3210 yaml_file_basename = 'app'
3211 appyaml = self._ParseAppInfoFromYaml(self.basepath,
3212 basename=yaml_file_basename)
3213 rpcserver = self._GetRpcServer()
3215 backends_to_update = self.BackendsYamlCheck(appyaml, self.backend)
3216 for backend in backends_to_update:
3217 self.UpdateVersion(rpcserver, self.basepath, appyaml, yaml_file_basename,
3218 backend=backend)
3220 def BackendsList(self):
3221 """Lists all backends for an app."""
3222 if self.args:
3223 self.parser.error('Expected no arguments.')
3228 appyaml = self._ParseAppInfoFromYaml(self.basepath)
3229 rpcserver = self._GetRpcServer()
3230 response = rpcserver.Send('/api/backends/list', app_id=appyaml.application)
3231 print >> self.out_fh, response
3233 def BackendsRollback(self):
3234 """Does a rollback of an existing transaction on this backend."""
3235 if len(self.args) != 1:
3236 self.parser.error('Expected a single <backend> argument.')
3238 self._Rollback(self.args[0])
3240 def BackendsStart(self):
3241 """Starts a backend."""
3242 if len(self.args) != 1:
3243 self.parser.error('Expected a single <backend> argument.')
3245 backend = self.args[0]
3246 appyaml = self._ParseAppInfoFromYaml(self.basepath)
3247 rpcserver = self._GetRpcServer()
3248 response = rpcserver.Send('/api/backends/start',
3249 app_id=appyaml.application,
3250 backend=backend)
3251 print >> self.out_fh, response
3253 def BackendsStop(self):
3254 """Stops a backend."""
3255 if len(self.args) != 1:
3256 self.parser.error('Expected a single <backend> argument.')
3258 backend = self.args[0]
3259 appyaml = self._ParseAppInfoFromYaml(self.basepath)
3260 rpcserver = self._GetRpcServer()
3261 response = rpcserver.Send('/api/backends/stop',
3262 app_id=appyaml.application,
3263 backend=backend)
3264 print >> self.out_fh, response
3266 def BackendsDelete(self):
3267 """Deletes a backend."""
3268 if len(self.args) != 1:
3269 self.parser.error('Expected a single <backend> argument.')
3271 backend = self.args[0]
3272 appyaml = self._ParseAppInfoFromYaml(self.basepath)
3273 rpcserver = self._GetRpcServer()
3274 response = rpcserver.Send('/api/backends/delete',
3275 app_id=appyaml.application,
3276 backend=backend)
3277 print >> self.out_fh, response
3279 def BackendsConfigure(self):
3280 """Changes the configuration of an existing backend."""
3281 if len(self.args) != 1:
3282 self.parser.error('Expected a single <backend> argument.')
3284 backend = self.args[0]
3285 appyaml = self._ParseAppInfoFromYaml(self.basepath)
3286 backends_yaml = self._ParseBackendsYaml(self.basepath)
3287 rpcserver = self._GetRpcServer()
3288 response = rpcserver.Send('/api/backends/configure',
3289 app_id=appyaml.application,
3290 backend=backend,
3291 payload=backends_yaml.ToYAML())
3292 print >> self.out_fh, response
3294 def _ParseAndValidateServerYamls(self, yaml_paths):
3295 """Validates given yaml paths and returns the parsed yaml objects.
3297 Args:
3298 yaml_paths: List of paths to AppInfo yaml files.
3300 Returns:
3301 List of parsed AppInfo yamls.
3303 results = []
3304 app_id = None
3305 last_yaml_path = None
3306 for yaml_path in yaml_paths:
3307 if not os.path.isfile(yaml_path):
3308 _PrintErrorAndExit(
3309 self.error_fh,
3310 ("Error: The given path '%s' is not to a YAML configuration "
3311 "file.\n") % yaml_path)
3312 file_name = os.path.basename(yaml_path)
3313 base_path = os.path.dirname(yaml_path)
3314 if not base_path:
3315 base_path = '.'
3316 server_yaml = self._ParseAppInfoFromYaml(base_path,
3317 os.path.splitext(file_name)[0])
3319 if not server_yaml.server and file_name != 'app.yaml':
3320 _PrintErrorAndExit(
3321 self.error_fh,
3322 "Error: 'server' parameter not specified in %s" % yaml_path)
3326 if app_id is not None and server_yaml.application != app_id:
3327 _PrintErrorAndExit(
3328 self.error_fh,
3329 "Error: 'application' value '%s' in %s does not match the value "
3330 "'%s', found in %s" % (server_yaml.application,
3331 yaml_path,
3332 app_id,
3333 last_yaml_path))
3334 app_id = server_yaml.application
3335 last_yaml_path = yaml_path
3336 results.append(server_yaml)
3338 return results
3340 def _ServerAction(self, action_path):
3341 """Process flags and yaml files and make a call to the given path.
3343 The 'start' and 'stop' actions are extremely similar in how they process
3344 input to appcfg.py and only really differ in what path they hit on the
3345 RPCServer.
3347 Args:
3348 action_path: Path on the RPCServer to send the call to.
3351 servers_to_process = []
3352 if len(self.args) == 0:
3354 if not (self.options.app_id and
3355 self.options.server_id and
3356 self.options.version):
3357 _PrintErrorAndExit(self.error_fh,
3358 'Expected at least one <file> argument or the '
3359 '--application, --server_id and --version flags to'
3360 ' be set.')
3361 else:
3362 servers_to_process.append((self.options.app_id,
3363 self.options.server_id,
3364 self.options.version))
3365 else:
3368 if self.options.server_id:
3370 _PrintErrorAndExit(self.error_fh,
3371 'You may not specify a <file> argument with the '
3372 '--server_id flag.')
3374 server_yamls = self._ParseAndValidateServerYamls(self.args)
3375 for serv_yaml in server_yamls:
3378 app_id = serv_yaml.application
3379 servers_to_process.append((self.options.app_id or serv_yaml.application,
3380 serv_yaml.server or appinfo.DEFAULT_SERVER,
3381 self.options.version or serv_yaml.version))
3383 rpcserver = self._GetRpcServer()
3386 for app_id, server, version in servers_to_process:
3387 response = rpcserver.Send(action_path,
3388 app_id=app_id,
3389 server=server,
3390 version=version)
3391 print >> self.out_fh, response
3393 def Start(self):
3394 """Starts one or more servers."""
3395 self._ServerAction('/api/servers/start')
3397 def Stop(self):
3398 """Stops one or more servers."""
3399 self._ServerAction('/api/servers/stop')
3401 def Rollback(self):
3402 """Does a rollback of an existing transaction for this app version."""
3403 if self.args:
3404 self.parser.error('Expected a single <directory> or <file> argument.')
3405 self._Rollback()
3407 def _Rollback(self, backend=None):
3408 """Does a rollback of an existing transaction.
3410 Args:
3411 backend: name of a backend to rollback, or None
3413 If a backend is specified the rollback will affect only that backend, if no
3414 backend is specified the rollback will affect the current app version.
3416 if os.path.isdir(self.basepath):
3417 server_yaml = self._ParseAppInfoFromYaml(self.basepath)
3418 else:
3420 file_name = os.path.basename(self.basepath)
3421 self.basepath = os.path.dirname(self.basepath)
3422 if not self.basepath:
3423 self.basepath = '.'
3424 server_yaml = self._ParseAppInfoFromYaml(self.basepath,
3425 os.path.splitext(file_name)[0])
3427 appversion = AppVersionUpload(self._GetRpcServer(), server_yaml,
3428 server_yaml_path='app.yaml',
3429 backend=backend)
3431 appversion.in_transaction = True
3432 appversion.Rollback()
3434 def SetDefaultVersion(self):
3435 """Sets the default version."""
3436 server = ''
3437 if len(self.args) == 1:
3438 appyaml = self._ParseAppInfoFromYaml(self.args[0])
3439 app_id = appyaml.application
3440 server = appyaml.server or ''
3441 version = appyaml.version
3442 elif len(self.args) == 0:
3443 if not (self.options.app_id and self.options.version):
3444 self.parser.error(
3445 ('Expected a <directory> argument or both --application and '
3446 '--version flags.'))
3447 else:
3448 self._PrintHelpAndExit()
3451 if self.options.app_id:
3452 app_id = self.options.app_id
3453 if self.options.server_id:
3454 server = self.options.server_id
3455 if self.options.version:
3456 version = self.options.version
3458 version_setter = DefaultVersionSet(self._GetRpcServer(),
3459 app_id,
3460 server,
3461 version)
3462 version_setter.SetVersion()
3464 def RequestLogs(self):
3465 """Write request logs to a file."""
3467 args_length = len(self.args)
3468 server = ''
3469 if args_length == 2:
3470 appyaml = self._ParseAppInfoFromYaml(self.args.pop(0))
3471 app_id = appyaml.application
3472 server = appyaml.server or ''
3473 version = appyaml.version
3474 elif args_length == 1:
3475 if not (self.options.app_id and self.options.version):
3476 self.parser.error(
3477 ('Expected the --application and --version flags if <directory> '
3478 'argument is not specified.'))
3479 else:
3480 self._PrintHelpAndExit()
3483 if self.options.app_id:
3484 app_id = self.options.app_id
3485 if self.options.server_id:
3486 server = self.options.server_id
3487 if self.options.version:
3488 version = self.options.version
3490 if (self.options.severity is not None and
3491 not 0 <= self.options.severity <= MAX_LOG_LEVEL):
3492 self.parser.error(
3493 'Severity range is 0 (DEBUG) through %s (CRITICAL).' % MAX_LOG_LEVEL)
3495 if self.options.num_days is None:
3496 self.options.num_days = int(not self.options.append)
3498 try:
3499 end_date = self._ParseEndDate(self.options.end_date)
3500 except (TypeError, ValueError):
3501 self.parser.error('End date must be in the format YYYY-MM-DD.')
3503 rpcserver = self._GetRpcServer()
3505 logs_requester = LogsRequester(rpcserver,
3506 app_id,
3507 server,
3508 version,
3509 self.args[0],
3510 self.options.num_days,
3511 self.options.append,
3512 self.options.severity,
3513 end_date,
3514 self.options.vhost,
3515 self.options.include_vhost,
3516 self.options.include_all,
3517 time_func=self.time_func)
3518 logs_requester.DownloadLogs()
3520 @staticmethod
3521 def _ParseEndDate(date, time_func=time.time):
3522 """Translates an ISO 8601 date to a date object.
3524 Args:
3525 date: A date string as YYYY-MM-DD.
3526 time_func: time.time() function for testing.
3528 Returns:
3529 A date object representing the last day of logs to get.
3530 If no date is given, returns today in the US/Pacific timezone.
3532 if not date:
3533 return PacificDate(time_func())
3534 return datetime.date(*[int(i) for i in date.split('-')])
3536 def _RequestLogsOptions(self, parser):
3537 """Adds request_logs-specific options to 'parser'.
3539 Args:
3540 parser: An instance of OptionsParser.
3542 parser.add_option('-n', '--num_days', type='int', dest='num_days',
3543 action='store', default=None,
3544 help='Number of days worth of log data to get. '
3545 'The cut-off point is midnight US/Pacific. '
3546 'Use 0 to get all available logs. '
3547 'Default is 1, unless --append is also given; '
3548 'then the default is 0.')
3549 parser.add_option('-a', '--append', dest='append',
3550 action='store_true', default=False,
3551 help='Append to existing file.')
3552 parser.add_option('--severity', type='int', dest='severity',
3553 action='store', default=None,
3554 help='Severity of app-level log messages to get. '
3555 'The range is 0 (DEBUG) through 4 (CRITICAL). '
3556 'If omitted, only request logs are returned.')
3557 parser.add_option('--vhost', type='string', dest='vhost',
3558 action='store', default=None,
3559 help='The virtual host of log messages to get. '
3560 'If omitted, all log messages are returned.')
3561 parser.add_option('--include_vhost', dest='include_vhost',
3562 action='store_true', default=False,
3563 help='Include virtual host in log messages.')
3564 parser.add_option('--include_all', dest='include_all',
3565 action='store_true', default=None,
3566 help='Include everything in log messages.')
3567 parser.add_option('--end_date', dest='end_date',
3568 action='store', default='',
3569 help='End date (as YYYY-MM-DD) of period for log data. '
3570 'Defaults to today.')
3572 def CronInfo(self, now=None, output=sys.stdout):
3573 """Displays information about cron definitions.
3575 Args:
3576 now: used for testing.
3577 output: Used for testing.
3579 if self.args:
3580 self.parser.error('Expected a single <directory> argument.')
3581 if now is None:
3582 now = datetime.datetime.utcnow()
3584 cron_yaml = self._ParseCronYaml(self.basepath)
3585 if cron_yaml and cron_yaml.cron:
3586 for entry in cron_yaml.cron:
3587 description = entry.description
3588 if not description:
3589 description = '<no description>'
3590 if not entry.timezone:
3591 entry.timezone = 'UTC'
3593 print >>output, '\n%s:\nURL: %s\nSchedule: %s (%s)' % (description,
3594 entry.url,
3595 entry.schedule,
3596 entry.timezone)
3597 if entry.timezone != 'UTC':
3598 print >>output, ('Note: Schedules with timezones won\'t be calculated'
3599 ' correctly here')
3600 schedule = groctimespecification.GrocTimeSpecification(entry.schedule)
3602 matches = schedule.GetMatches(now, self.options.num_runs)
3603 for match in matches:
3604 print >>output, '%s, %s from now' % (
3605 match.strftime('%Y-%m-%d %H:%M:%SZ'), match - now)
3607 def _CronInfoOptions(self, parser):
3608 """Adds cron_info-specific options to 'parser'.
3610 Args:
3611 parser: An instance of OptionsParser.
3613 parser.add_option('-n', '--num_runs', type='int', dest='num_runs',
3614 action='store', default=5,
3615 help='Number of runs of each cron job to display'
3616 'Default is 5')
3618 def _CheckRequiredLoadOptions(self):
3619 """Checks that upload/download options are present."""
3620 for option in ['filename']:
3621 if getattr(self.options, option) is None:
3622 self.parser.error('Option \'%s\' is required.' % option)
3623 if not self.options.url:
3624 self.parser.error('You must have google.appengine.ext.remote_api.handler '
3625 'assigned to an endpoint in app.yaml, or provide '
3626 'the url of the handler via the \'url\' option.')
3628 def InferRemoteApiUrl(self, appyaml):
3629 """Uses app.yaml to determine the remote_api endpoint.
3631 Args:
3632 appyaml: A parsed app.yaml file.
3634 Returns:
3635 The url of the remote_api endpoint as a string, or None
3638 handlers = appyaml.handlers
3639 handler_suffixes = ['remote_api/handler.py',
3640 'remote_api.handler.application']
3641 app_id = appyaml.application
3642 for handler in handlers:
3643 if hasattr(handler, 'script') and handler.script:
3644 if any(handler.script.endswith(suffix) for suffix in handler_suffixes):
3645 server = self.options.server
3646 url = handler.url
3647 if url.endswith('(/.*)?'):
3650 url = url[:-6]
3651 if server == 'appengine.google.com':
3652 return 'http://%s.appspot.com%s' % (app_id, url)
3653 else:
3654 match = re.match(PREFIXED_BY_ADMIN_CONSOLE_RE, server)
3655 if match:
3656 return 'http://%s%s%s' % (app_id, match.group(1), url)
3657 else:
3658 return 'http://%s%s' % (server, url)
3659 return None
3661 def RunBulkloader(self, arg_dict):
3662 """Invokes the bulkloader with the given keyword arguments.
3664 Args:
3665 arg_dict: Dictionary of arguments to pass to bulkloader.Run().
3668 try:
3670 import sqlite3
3671 except ImportError:
3672 logging.error('upload_data action requires SQLite3 and the python '
3673 'sqlite3 module (included in python since 2.5).')
3674 sys.exit(1)
3676 sys.exit(bulkloader.Run(arg_dict))
3678 def _SetupLoad(self):
3679 """Performs common verification and set up for upload and download."""
3681 if len(self.args) != 1 and not self.options.url:
3682 self.parser.error('Expected either --url or a single <directory> '
3683 'argument.')
3685 if len(self.args) == 1:
3686 self.basepath = self.args[0]
3687 appyaml = self._ParseAppInfoFromYaml(self.basepath)
3689 self.options.app_id = appyaml.application
3691 if not self.options.url:
3692 url = self.InferRemoteApiUrl(appyaml)
3693 if url is not None:
3694 self.options.url = url
3696 self._CheckRequiredLoadOptions()
3698 if self.options.batch_size < 1:
3699 self.parser.error('batch_size must be 1 or larger.')
3703 if verbosity == 1:
3704 logging.getLogger().setLevel(logging.INFO)
3705 self.options.debug = False
3706 else:
3707 logging.getLogger().setLevel(logging.DEBUG)
3708 self.options.debug = True
3710 def _MakeLoaderArgs(self):
3711 args = dict([(arg_name, getattr(self.options, arg_name, None)) for
3712 arg_name in (
3713 'url',
3714 'filename',
3715 'batch_size',
3716 'kind',
3717 'num_threads',
3718 'bandwidth_limit',
3719 'rps_limit',
3720 'http_limit',
3721 'db_filename',
3722 'config_file',
3723 'auth_domain',
3724 'has_header',
3725 'loader_opts',
3726 'log_file',
3727 'passin',
3728 'email',
3729 'debug',
3730 'exporter_opts',
3731 'mapper_opts',
3732 'result_db_filename',
3733 'mapper_opts',
3734 'dry_run',
3735 'dump',
3736 'restore',
3737 'namespace',
3738 'create_config',
3740 args['application'] = self.options.app_id
3741 args['throttle_class'] = self.throttle_class
3742 return args
3744 def PerformDownload(self, run_fn=None):
3745 """Performs a datastore download via the bulkloader.
3747 Args:
3748 run_fn: Function to invoke the bulkloader, used for testing.
3750 if run_fn is None:
3751 run_fn = self.RunBulkloader
3752 self._SetupLoad()
3754 StatusUpdate('Downloading data records.')
3756 args = self._MakeLoaderArgs()
3757 args['download'] = bool(args['config_file'])
3758 args['has_header'] = False
3759 args['map'] = False
3760 args['dump'] = not args['config_file']
3761 args['restore'] = False
3762 args['create_config'] = False
3764 run_fn(args)
3766 def PerformUpload(self, run_fn=None):
3767 """Performs a datastore upload via the bulkloader.
3769 Args:
3770 run_fn: Function to invoke the bulkloader, used for testing.
3772 if run_fn is None:
3773 run_fn = self.RunBulkloader
3774 self._SetupLoad()
3776 StatusUpdate('Uploading data records.')
3778 args = self._MakeLoaderArgs()
3779 args['download'] = False
3780 args['map'] = False
3781 args['dump'] = False
3782 args['restore'] = not args['config_file']
3783 args['create_config'] = False
3785 run_fn(args)
3787 def CreateBulkloadConfig(self, run_fn=None):
3788 """Create a bulkloader config via the bulkloader wizard.
3790 Args:
3791 run_fn: Function to invoke the bulkloader, used for testing.
3793 if run_fn is None:
3794 run_fn = self.RunBulkloader
3795 self._SetupLoad()
3797 StatusUpdate('Creating bulkloader configuration.')
3799 args = self._MakeLoaderArgs()
3800 args['download'] = False
3801 args['has_header'] = False
3802 args['map'] = False
3803 args['dump'] = False
3804 args['restore'] = False
3805 args['create_config'] = True
3807 run_fn(args)
3809 def _PerformLoadOptions(self, parser):
3810 """Adds options common to 'upload_data' and 'download_data'.
3812 Args:
3813 parser: An instance of OptionsParser.
3815 parser.add_option('--url', type='string', dest='url',
3816 action='store',
3817 help='The location of the remote_api endpoint.')
3818 parser.add_option('--batch_size', type='int', dest='batch_size',
3819 action='store', default=10,
3820 help='Number of records to post in each request.')
3821 parser.add_option('--bandwidth_limit', type='int', dest='bandwidth_limit',
3822 action='store', default=250000,
3823 help='The maximum bytes/second bandwidth for transfers.')
3824 parser.add_option('--rps_limit', type='int', dest='rps_limit',
3825 action='store', default=20,
3826 help='The maximum records/second for transfers.')
3827 parser.add_option('--http_limit', type='int', dest='http_limit',
3828 action='store', default=8,
3829 help='The maximum requests/second for transfers.')
3830 parser.add_option('--db_filename', type='string', dest='db_filename',
3831 action='store',
3832 help='Name of the progress database file.')
3833 parser.add_option('--auth_domain', type='string', dest='auth_domain',
3834 action='store', default='gmail.com',
3835 help='The name of the authorization domain to use.')
3836 parser.add_option('--log_file', type='string', dest='log_file',
3837 help='File to write bulkloader logs. If not supplied '
3838 'then a new log file will be created, named: '
3839 'bulkloader-log-TIMESTAMP.')
3840 parser.add_option('--dry_run', action='store_true',
3841 dest='dry_run', default=False,
3842 help='Do not execute any remote_api calls')
3843 parser.add_option('--namespace', type='string', dest='namespace',
3844 action='store', default='',
3845 help='Namespace to use when accessing datastore.')
3846 parser.add_option('--num_threads', type='int', dest='num_threads',
3847 action='store', default=10,
3848 help='Number of threads to transfer records with.')
3850 def _PerformUploadOptions(self, parser):
3851 """Adds 'upload_data' specific options to the 'parser' passed in.
3853 Args:
3854 parser: An instance of OptionsParser.
3856 self._PerformLoadOptions(parser)
3857 parser.add_option('--filename', type='string', dest='filename',
3858 action='store',
3859 help='The name of the file containing the input data.'
3860 ' (Required)')
3861 parser.add_option('--kind', type='string', dest='kind',
3862 action='store',
3863 help='The kind of the entities to store.')
3864 parser.add_option('--has_header', dest='has_header',
3865 action='store_true', default=False,
3866 help='Whether the first line of the input file should be'
3867 ' skipped')
3868 parser.add_option('--loader_opts', type='string', dest='loader_opts',
3869 help='A string to pass to the Loader.initialize method.')
3870 parser.add_option('--config_file', type='string', dest='config_file',
3871 action='store',
3872 help='Name of the configuration file.')
3874 def _PerformDownloadOptions(self, parser):
3875 """Adds 'download_data' specific options to the 'parser' passed in.
3877 Args:
3878 parser: An instance of OptionsParser.
3880 self._PerformLoadOptions(parser)
3881 parser.add_option('--filename', type='string', dest='filename',
3882 action='store',
3883 help='The name of the file where output data is to be'
3884 ' written. (Required)')
3885 parser.add_option('--kind', type='string', dest='kind',
3886 action='store',
3887 help='The kind of the entities to retrieve.')
3888 parser.add_option('--exporter_opts', type='string', dest='exporter_opts',
3889 help='A string to pass to the Exporter.initialize method.'
3891 parser.add_option('--result_db_filename', type='string',
3892 dest='result_db_filename',
3893 action='store',
3894 help='Database to write entities to for download.')
3895 parser.add_option('--config_file', type='string', dest='config_file',
3896 action='store',
3897 help='Name of the configuration file.')
3899 def _CreateBulkloadConfigOptions(self, parser):
3900 """Adds 'download_data' specific options to the 'parser' passed in.
3902 Args:
3903 parser: An instance of OptionsParser.
3905 self._PerformLoadOptions(parser)
3906 parser.add_option('--filename', type='string', dest='filename',
3907 action='store',
3908 help='The name of the file where the generated template'
3909 ' is to be written. (Required)')
3911 def ResourceLimitsInfo(self, output=None):
3912 """Outputs the current resource limits.
3914 Args:
3915 output: The file handle to write the output to (used for testing).
3917 appyaml = self._ParseAppInfoFromYaml(self.basepath)
3918 resource_limits = GetResourceLimits(self._GetRpcServer(), appyaml)
3921 for attr_name in sorted(resource_limits):
3922 print >>output, '%s: %s' % (attr_name, resource_limits[attr_name])
3924 class Action(object):
3925 """Contains information about a command line action.
3927 Attributes:
3928 function: The name of a function defined on AppCfg or its subclasses
3929 that will perform the appropriate action.
3930 usage: A command line usage string.
3931 short_desc: A one-line description of the action.
3932 long_desc: A detailed description of the action. Whitespace and
3933 formatting will be preserved.
3934 error_desc: An error message to display when the incorrect arguments are
3935 given.
3936 options: A function that will add extra options to a given OptionParser
3937 object.
3938 uses_basepath: Does the action use a basepath/app-directory (and hence
3939 app.yaml).
3940 hidden: Should this command be shown in the help listing.
3948 def __init__(self, function, usage, short_desc, long_desc='',
3949 error_desc=None, options=lambda obj, parser: None,
3950 uses_basepath=True, hidden=False):
3951 """Initializer for the class attributes."""
3952 self.function = function
3953 self.usage = usage
3954 self.short_desc = short_desc
3955 self.long_desc = long_desc
3956 self.error_desc = error_desc
3957 self.options = options
3958 self.uses_basepath = uses_basepath
3959 self.hidden = hidden
3961 def __call__(self, appcfg):
3962 """Invoke this Action on the specified AppCfg.
3964 This calls the function of the appropriate name on AppCfg, and
3965 respects polymophic overrides.
3967 Args:
3968 appcfg: The appcfg to use.
3969 Returns:
3970 The result of the function call.
3972 method = getattr(appcfg, self.function)
3973 return method()
3975 actions = {
3977 'help': Action(
3978 function='Help',
3979 usage='%prog help <action>',
3980 short_desc='Print help for a specific action.',
3981 uses_basepath=False),
3983 'update': Action(
3984 function='Update',
3985 usage='%prog [options] update <directory>',
3986 options=_UpdateOptions,
3987 short_desc='Create or update an app version.',
3988 long_desc="""
3989 Specify a directory that contains all of the files required by
3990 the app, and appcfg.py will create/update the app version referenced
3991 in the app.yaml file at the top level of that directory. appcfg.py
3992 will follow symlinks and recursively upload all files to the server.
3993 Temporary or source control files (e.g. foo~, .svn/*) will be skipped."""),
3995 'download_app': Action(
3996 function='DownloadApp',
3997 usage='%prog [options] download_app -A app_id [ -V version ]'
3998 ' <out-dir>',
3999 short_desc='Download a previously-uploaded app.',
4000 long_desc="""
4001 Download a previously-uploaded app to the specified directory. The app
4002 ID is specified by the \"-A\" option. The optional version is specified
4003 by the \"-V\" option.""",
4004 uses_basepath=False),
4006 'update_cron': Action(
4007 function='UpdateCron',
4008 usage='%prog [options] update_cron <directory>',
4009 short_desc='Update application cron definitions.',
4010 long_desc="""
4011 The 'update_cron' command will update any new, removed or changed cron
4012 definitions from the optional cron.yaml file."""),
4014 'update_indexes': Action(
4015 function='UpdateIndexes',
4016 usage='%prog [options] update_indexes <directory>',
4017 short_desc='Update application indexes.',
4018 long_desc="""
4019 The 'update_indexes' command will add additional indexes which are not currently
4020 in production as well as restart any indexes that were not completed."""),
4022 'update_queues': Action(
4023 function='UpdateQueues',
4024 usage='%prog [options] update_queues <directory>',
4025 short_desc='Update application task queue definitions.',
4026 long_desc="""
4027 The 'update_queue' command will update any new, removed or changed task queue
4028 definitions from the optional queue.yaml file."""),
4030 'update_dispatch': Action(
4031 function='UpdateDispatch',
4032 hidden=True,
4033 usage='%prog [options] update_dispatch <directory>',
4034 short_desc='Update application dispatch definitions.',
4035 long_desc="""
4036 The 'update_dispatch' command will update any new, removed or changed dispatch
4037 definitions from the optional dispatch.yaml file."""),
4039 'update_dos': Action(
4040 function='UpdateDos',
4041 usage='%prog [options] update_dos <directory>',
4042 short_desc='Update application dos definitions.',
4043 long_desc="""
4044 The 'update_dos' command will update any new, removed or changed dos
4045 definitions from the optional dos.yaml file."""),
4047 'backends': Action(
4048 function='BackendsAction',
4049 usage='%prog [options] backends <directory> <action>',
4050 short_desc='Perform a backend action.',
4051 long_desc="""
4052 The 'backends' command will perform a backends action.""",
4053 error_desc="""\
4054 Expected a <directory> and <action> argument."""),
4056 'backends list': Action(
4057 function='BackendsList',
4058 usage='%prog [options] backends <directory> list',
4059 short_desc='List all backends configured for the app.',
4060 long_desc="""
4061 The 'backends list' command will list all backends configured for the app."""),
4063 'backends update': Action(
4064 function='BackendsUpdate',
4065 usage='%prog [options] backends <directory> update [<backend>]',
4066 options=_UpdateOptions,
4067 short_desc='Update one or more backends.',
4068 long_desc="""
4069 The 'backends update' command updates one or more backends. This command
4070 updates backend configuration settings and deploys new code to the server. Any
4071 existing instances will stop and be restarted. Updates all backends, or a
4072 single backend if the <backend> argument is provided."""),
4074 'backends rollback': Action(
4075 function='BackendsRollback',
4076 usage='%prog [options] backends <directory> rollback <backend>',
4077 short_desc='Roll back an update of a backend.',
4078 long_desc="""
4079 The 'backends update' command requires a server-side transaction.
4080 Use 'backends rollback' if you experience an error during 'backends update'
4081 and want to start the update over again."""),
4083 'backends start': Action(
4084 function='BackendsStart',
4085 usage='%prog [options] backends <directory> start <backend>',
4086 short_desc='Start a backend.',
4087 long_desc="""
4088 The 'backends start' command will put a backend into the START state."""),
4090 'backends stop': Action(
4091 function='BackendsStop',
4092 usage='%prog [options] backends <directory> stop <backend>',
4093 short_desc='Stop a backend.',
4094 long_desc="""
4095 The 'backends start' command will put a backend into the STOP state."""),
4097 'backends delete': Action(
4098 function='BackendsDelete',
4099 usage='%prog [options] backends <directory> delete <backend>',
4100 short_desc='Delete a backend.',
4101 long_desc="""
4102 The 'backends delete' command will delete a backend."""),
4104 'backends configure': Action(
4105 function='BackendsConfigure',
4106 usage='%prog [options] backends <directory> configure <backend>',
4107 short_desc='Reconfigure a backend without stopping it.',
4108 long_desc="""
4109 The 'backends configure' command performs an online update of a backend, without
4110 stopping instances that are currently running. No code or handlers are updated,
4111 only certain configuration settings specified in backends.yaml. Valid settings
4112 are: instances, options: public, and options: failfast."""),
4114 'vacuum_indexes': Action(
4115 function='VacuumIndexes',
4116 usage='%prog [options] vacuum_indexes <directory>',
4117 options=_VacuumIndexesOptions,
4118 short_desc='Delete unused indexes from application.',
4119 long_desc="""
4120 The 'vacuum_indexes' command will help clean up indexes which are no longer
4121 in use. It does this by comparing the local index configuration with
4122 indexes that are actually defined on the server. If any indexes on the
4123 server do not exist in the index configuration file, the user is given the
4124 option to delete them."""),
4126 'rollback': Action(
4127 function='Rollback',
4128 usage='%prog [options] rollback <directory> | <file>',
4129 short_desc='Rollback an in-progress update.',
4130 long_desc="""
4131 The 'update' command requires a server-side transaction.
4132 Use 'rollback' if you experience an error during 'update'
4133 and want to begin a new update transaction."""),
4135 'request_logs': Action(
4136 function='RequestLogs',
4137 usage='%prog [options] request_logs [<directory>] <output_file>',
4138 options=_RequestLogsOptions,
4139 uses_basepath=False,
4140 short_desc='Write request logs in Apache common log format.',
4141 long_desc="""
4142 The 'request_logs' command exports the request logs from your application
4143 to a file. It will write Apache common log format records ordered
4144 chronologically. If output file is '-' stdout will be written.""",
4145 error_desc="""\
4146 Expected an optional <directory> and mandatory <output_file> argument."""),
4148 'cron_info': Action(
4149 function='CronInfo',
4150 usage='%prog [options] cron_info <directory>',
4151 options=_CronInfoOptions,
4152 short_desc='Display information about cron jobs.',
4153 long_desc="""
4154 The 'cron_info' command will display the next 'number' runs (default 5) for
4155 each cron job defined in the cron.yaml file."""),
4159 'start': Action(
4160 function='Start',
4161 hidden=True,
4162 uses_basepath=False,
4163 usage='%prog [options] start [file, ...]',
4164 short_desc='Start a server version.',
4165 long_desc="""
4166 The 'start' command will put a server version into the START state."""),
4168 'stop': Action(
4169 function='Stop',
4170 hidden=True,
4171 uses_basepath=False,
4172 usage='%prog [options] stop [file, ...]',
4173 short_desc='Stop a server version.',
4174 long_desc="""
4175 The 'stop' command will put a server version into the STOP state."""),
4181 'upload_data': Action(
4182 function='PerformUpload',
4183 usage='%prog [options] upload_data <directory>',
4184 options=_PerformUploadOptions,
4185 short_desc='Upload data records to datastore.',
4186 long_desc="""
4187 The 'upload_data' command translates input records into datastore entities and
4188 uploads them into your application's datastore.""",
4189 uses_basepath=False),
4191 'download_data': Action(
4192 function='PerformDownload',
4193 usage='%prog [options] download_data <directory>',
4194 options=_PerformDownloadOptions,
4195 short_desc='Download entities from datastore.',
4196 long_desc="""
4197 The 'download_data' command downloads datastore entities and writes them to
4198 file as CSV or developer defined format.""",
4199 uses_basepath=False),
4201 'create_bulkloader_config': Action(
4202 function='CreateBulkloadConfig',
4203 usage='%prog [options] create_bulkload_config <directory>',
4204 options=_CreateBulkloadConfigOptions,
4205 short_desc='Create a bulkloader.yaml from a running application.',
4206 long_desc="""
4207 The 'create_bulkloader_config' command creates a bulkloader.yaml configuration
4208 template for use with upload_data or download_data.""",
4209 uses_basepath=False),
4212 'set_default_version': Action(
4213 function='SetDefaultVersion',
4214 usage='%prog [options] set_default_version [directory]',
4215 short_desc='Set the default (serving) version.',
4216 long_desc="""
4217 The 'set_default_version' command sets the default (serving) version of the app.
4218 Defaults to using the application and version specified in app.yaml; use the
4219 --application and --version flags to override these values.""",
4220 uses_basepath=False),
4222 'resource_limits_info': Action(
4223 function='ResourceLimitsInfo',
4224 usage='%prog [options] resource_limits_info <directory>',
4225 short_desc='Get the resource limits.',
4226 long_desc="""
4227 The 'resource_limits_info' command prints the current resource limits that
4228 are enforced."""),
4234 def main(argv):
4235 logging.basicConfig(format=('%(asctime)s %(levelname)s %(filename)s:'
4236 '%(lineno)s %(message)s '))
4237 try:
4238 result = AppCfgApp(argv).Run()
4239 if result:
4240 sys.exit(result)
4241 except KeyboardInterrupt:
4242 StatusUpdate('Interrupted.')
4243 sys.exit(1)
4246 if __name__ == '__main__':
4247 main(sys.argv)