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.
17 """Appcfg logic specific to Java apps."""
18 from __future__
import with_statement
29 from google
.appengine
.datastore
import datastore_index
30 from google
.appengine
.datastore
import datastore_index_xml
31 from google
.appengine
.tools
import app_engine_web_xml_parser
32 from google
.appengine
.tools
import backends_xml_parser
33 from google
.appengine
.tools
import cron_xml_parser
34 from google
.appengine
.tools
import dispatch_xml_parser
35 from google
.appengine
.tools
import dos_xml_parser
36 from google
.appengine
.tools
import jarfile
37 from google
.appengine
.tools
import queue_xml_parser
38 from google
.appengine
.tools
import web_xml_parser
39 from google
.appengine
.tools
import yaml_translator
42 _CLASSES_JAR_NAME_PREFIX
= '_ah_webinf_classes'
43 _COMPILED_JSP_JAR_NAME_PREFIX
= '_ah_compiled_jsps'
44 _LOCAL_JSPC_CLASS
= 'com.google.appengine.tools.development.LocalJspC'
45 _MAX_COMPILED_JSP_JAR_SIZE
= 1024 * 1024 * 5
48 class Error(Exception):
52 class ConfigurationError(Error
):
53 """There was a configuration error in the application being uploaded."""
57 class CompileError(Error
):
58 """There was a compilation error in a JSP file or its generated Java code."""
62 def IsWarFileWithoutYaml(dir_path
):
63 if os
.path
.isfile(os
.path
.join(dir_path
, 'app.yaml')):
65 web_inf
= os
.path
.join(dir_path
, 'WEB-INF')
66 return (os
.path
.isdir(web_inf
) and
67 set(['appengine-web.xml', 'web.xml']).issubset(os
.listdir(web_inf
)))
70 def AddUpdateOptions(parser
):
71 """Adds options specific to the 'update' command on Java apps to 'parser'.
74 parser: An instance of OptionsParser.
76 parser
.add_option('--retain_upload_dir', action
='store_true',
77 dest
='retain_upload_dir', default
=False,
78 help='Do not delete temporary (staging) directory used '
79 'in uploading Java apps')
80 parser
.add_option('--no_symlinks', action
='store_true',
81 dest
='no_symlinks', default
=False,
82 help='Do not use symbolic links when making the temporary '
83 '(staging) directory for uploading Java apps')
84 parser
.add_option('--compile_encoding', action
='store',
85 dest
='compile_encoding', default
='UTF-8',
86 help='Set the encoding to be used when compiling Java '
87 'source files (default "UTF-8").')
88 parser
.add_option('--disable_jar_jsps', action
='store_false',
89 dest
='jar_jsps', default
=True,
90 help='Do not jar the classes generated from JSPs.')
91 parser
.add_option('--delete_jsps', action
='store_true',
92 dest
='delete_jsps', default
=False,
93 help='Delete the JSP source files after compilation.')
94 parser
.add_option('--enable_jar_classes', action
='store_true',
95 dest
='do_jar_classes', default
=False,
96 help='Jar the WEB-INF/classes content.')
97 parser
.add_option('--enable_jar_splitting', action
='store_true',
98 dest
='do_jar_splitting', default
=False,
99 help='Split large jar files (> 32M) into smaller '
101 parser
.add_option('--jar_splitting_excludes', action
='store',
102 dest
='jar_splitting_exclude_suffixes', default
='',
103 help='When --enable_jar_splitting is specified and '
104 '--jar_splitting_excludes specifies a comma-separated list '
105 'of suffixes, a file in a jar whose name ends with one '
106 'of the suffixes will not be included in the split jar '
110 class JavaAppUpdate(object):
111 """Performs Java-specific update configurations."""
113 _JSP_REGEX
= re
.compile('.*\\.jspx?')
115 _xml_parser
= collections
.namedtuple(
116 '_xml_parser', ['xml_name', 'yaml_name', 'xml_to_yaml_function'])
119 _xml_parser('backends.xml', 'backends.yaml',
120 backends_xml_parser
.GetBackendsYaml
),
121 _xml_parser('cron.xml', 'cron.yaml', cron_xml_parser
.GetCronYaml
),
122 _xml_parser('dispatch.xml', 'dispatch.yaml',
123 dispatch_xml_parser
.GetDispatchYaml
),
124 _xml_parser('dos.xml', 'dos.yaml', dos_xml_parser
.GetDosYaml
),
125 _xml_parser('queue.xml', 'queue.yaml', queue_xml_parser
.GetQueueYaml
),
128 _XML_VALIDATOR_CLASS
= 'com.google.appengine.tools.admin.XmlValidator'
130 def __init__(self
, basepath
, options
):
131 self
.basepath
= os
.path
.abspath(basepath
)
132 self
.options
= options
133 if not hasattr(self
.options
, 'no_symlinks'):
135 self
.options
.no_symlinks
= True
137 java_home
, exec_suffix
= JavaHomeAndSuffix()
138 self
.java_command
= os
.path
.join(java_home
, 'bin', 'java' + exec_suffix
)
139 self
.javac_command
= os
.path
.join(java_home
, 'bin', 'javac' + exec_suffix
)
141 self
._ValidateXmlFiles
()
143 self
.app_engine_web_xml
= self
._ReadAppEngineWebXml
()
144 self
.app_engine_web_xml
.app_root
= self
.basepath
145 if self
.options
.app_id
:
146 self
.app_engine_web_xml
.app_id
= self
.options
.app_id
147 if self
.options
.version
:
148 self
.app_engine_web_xml
.version_id
= self
.options
.version
149 self
.web_xml
= self
._ReadWebXml
()
151 def _ValidateXmlFiles(self
):
160 sdk_dir
= os
.path
.dirname(jarfile
.__file
__)
161 xml_validator_jar
= os
.path
.join(
162 sdk_dir
, 'java', 'lib', 'impl', 'libxmlvalidator.jar')
163 if not os
.path
.exists(xml_validator_jar
):
165 print >>sys
.stderr
, ('Not validating XML files because %s does not '
166 'exist' % xml_validator_jar
)
169 schema_dir
= os
.path
.join(sdk_dir
, 'java', 'docs')
170 for schema_name
in os
.listdir(schema_dir
):
171 basename
, extension
= os
.path
.splitext(schema_name
)
172 if extension
== '.xsd':
173 schema_file
= os
.path
.join(schema_dir
, schema_name
)
174 xml_file
= os
.path
.join(self
.basepath
, 'WEB-INF', basename
+ '.xml')
175 if os
.path
.exists(xml_file
):
176 validator_args
+= [xml_file
, schema_file
]
182 self
._XML
_VALIDATOR
_CLASS
,
184 status
= subprocess
.call(command_and_args
)
187 raise ConfigurationError('XML validation failed')
189 def _ReadAppEngineWebXml(self
):
190 return self
._ReadAndParseXml
(
191 basepath
=self
.basepath
,
192 file_name
='appengine-web.xml',
193 parser
=app_engine_web_xml_parser
.AppEngineWebXmlParser
)
195 def _ReadWebXml(self
, basepath
=None):
197 basepath
= self
.basepath
198 return self
._ReadAndParseXml
(
201 parser
=web_xml_parser
.WebXmlParser
)
203 def _ReadAndParseXml(self
, basepath
, file_name
, parser
):
204 with
open(os
.path
.join(basepath
, 'WEB-INF', file_name
)) as file_handle
:
205 return parser().ProcessXml(file_handle
.read())
207 def CreateStagingDirectory(self
, tools_dir
):
208 """Creates a staging directory for uploading.
210 This is where we perform the necessary actions to create an application
211 directory for the update command to work properly - files are organized
212 into the static folder, and yaml files are generated where they can be
216 tools_dir: Path to the SDK tools directory
217 (typically .../google/appengine/tools)
220 The path to a new temporary directory which contains generated yaml files
221 and a static file directory. For the most part, the rest of the update and
222 upload flow can resume identically to Python/PHP/Go applications.
225 CompileError: if compilation of JSPs failed.
226 ConfigurationError: if the app to be staged has a configuration error.
227 IOError: if there was an I/O problem, for example when scanning jar files.
229 stage_dir
= tempfile
.mkdtemp(prefix
='appcfgpy')
230 static_dir
= os
.path
.join(stage_dir
, '__static__')
232 self
._CopyOrLink
(self
.basepath
, stage_dir
, static_dir
, False)
233 self
.app_engine_web_xml
.app_root
= stage_dir
235 if self
.options
.compile_jsps
:
236 self
._CompileJspsIfAny
(tools_dir
, stage_dir
)
238 web_inf
= os
.path
.join(stage_dir
, 'WEB-INF')
239 web_inf_lib
= os
.path
.join(web_inf
, 'lib')
240 api_jar_dict
= _FindApiJars(web_inf_lib
)
241 api_versions
= set(api_jar_dict
.values())
244 elif len(api_versions
) == 1:
245 api_version
= api_versions
.pop()
247 raise ConfigurationError('API jars have inconsistent versions: %s' %
251 for staged_api_jar
in api_jar_dict
:
252 os
.remove(staged_api_jar
)
254 appengine_generated
= os
.path
.join(
255 stage_dir
, 'WEB-INF', 'appengine-generated')
256 self
._GenerateAppYaml
(stage_dir
, api_version
, appengine_generated
)
258 app_id
= self
.options
.app_id
or self
.app_engine_web_xml
.app_id
259 assert app_id
, 'Missing app id'
262 for parser
in self
._XML
_PARSERS
:
263 xml_name
= os
.path
.join(web_inf
, parser
.xml_name
)
264 if os
.path
.exists(xml_name
):
265 with
open(xml_name
) as xml_file
:
266 xml_string
= xml_file
.read()
267 yaml_string
= parser
.xml_to_yaml_function(app_id
, xml_string
)
268 yaml_file
= os
.path
.join(appengine_generated
, parser
.yaml_name
)
269 with
open(yaml_file
, 'w') as yaml
:
270 yaml
.write(yaml_string
)
279 'datastore-indexes.xml',
280 os
.path
.join('appengine-generated', 'datastore-indexes-auto.xml')):
281 xml_name
= os
.path
.join(self
.basepath
, 'WEB-INF', xml_name
)
282 if os
.path
.exists(xml_name
):
283 with
open(xml_name
) as xml_file
:
284 xml_string
= xml_file
.read()
285 index_definitions
= datastore_index_xml
.IndexesXmlToIndexDefinitions(
287 indexes
.extend(index_definitions
.indexes
)
289 yaml_string
= datastore_index
.IndexDefinitions(indexes
=indexes
).ToYAML()
290 yaml_file
= os
.path
.join(appengine_generated
, 'index.yaml')
291 with
open(yaml_file
, 'w') as yaml
:
292 yaml
.write(yaml_string
)
296 def GenerateAppYamlString(self
, static_file_list
, api_version
=None):
297 """Constructs an app.yaml string equivalent to the XML files under WEB-INF.
300 static_file_list: a list of strings that are the absolute path names of
301 static file resources.
302 api_version: a string that is the Java API version number, or None if
303 not known or relevant.
306 A string that would have the same effect as the XML files under WEB-INF
307 if it were the contents of an app.yaml file.
309 return yaml_translator
.AppYamlTranslator(
310 self
.app_engine_web_xml
,
313 api_version
).GetYaml()
315 def _GenerateAppYaml(self
, stage_dir
, api_version
, appengine_generated
):
316 """Creates the app.yaml file in WEB-INF/appengine-generated/.
319 The path to the WEB-INF/appengine-generated directory.
321 static_file_list
= self
._GetStaticFileList
(stage_dir
)
322 yaml_str
= self
.GenerateAppYamlString(static_file_list
, api_version
)
323 if not os
.path
.isdir(appengine_generated
):
324 os
.mkdir(appengine_generated
)
325 with
open(os
.path
.join(appengine_generated
, 'app.yaml'), 'w') as handle
:
326 handle
.write(yaml_str
)
328 def _CopyOrLink(self
, source_dir
, stage_dir
, static_dir
, inside_web_inf
):
329 source_dir
= os
.path
.abspath(source_dir
)
330 stage_dir
= os
.path
.abspath(stage_dir
)
331 static_dir
= os
.path
.abspath(static_dir
)
332 for file_name
in os
.listdir(source_dir
):
333 file_path
= os
.path
.join(source_dir
, file_name
)
335 if file_name
.startswith('.') or file_name
== 'appengine-generated':
338 if os
.path
.isdir(file_path
):
341 os
.path
.join(stage_dir
, file_name
),
342 os
.path
.join(static_dir
, file_name
),
343 inside_web_inf
or file_name
== 'WEB-INF')
346 or self
.app_engine_web_xml
.IncludesResource(file_path
)
347 or (self
.options
.compile_jsps
348 and file_path
.lower().endswith('.jsp'))):
349 self
._CopyOrLinkFile
(file_path
, os
.path
.join(stage_dir
, file_name
))
350 if (not inside_web_inf
351 and self
.app_engine_web_xml
.IncludesStatic(file_path
)):
352 self
._CopyOrLinkFile
(file_path
, os
.path
.join(static_dir
, file_name
))
354 def _CopyOrLinkFile(self
, source
, dest
):
356 destdir
= os
.path
.dirname(dest
)
357 if not os
.path
.exists(destdir
):
359 if self
._ShouldSplitJar
(source
):
360 self
._SplitJar
(source
, destdir
)
361 elif source
.endswith('web.xml'):
362 shutil
.copy(source
, dest
)
363 os
.chmod(dest
, os
.stat(dest
).st_mode | stat
.S_IWRITE
)
365 elif self
.options
.no_symlinks
:
366 shutil
.copy(source
, dest
)
368 os
.symlink(source
, dest
)
370 def _MoveDirectoryContents(self
, source_dir
, dest_dir
):
371 """Move the contents of source_dir to dest_dir, which might not exist.
374 IOError: if the dest_dir hierarchy already contains a file where the
375 source_dir hierarchy has a file or directory of the same name, or if
376 the dest_dir hierarchy already contains a directory where the source_dir
377 hierarchy has a file of the same name.
379 if not os
.path
.exists(dest_dir
):
381 for entry
in os
.listdir(source_dir
):
382 source_entry
= os
.path
.join(source_dir
, entry
)
383 dest_entry
= os
.path
.join(dest_dir
, entry
)
384 if os
.path
.exists(dest_entry
):
385 if os
.path
.isdir(source_entry
) and os
.path
.isdir(dest_entry
):
386 self
._MoveDirectoryContents
(source_entry
, dest_entry
)
388 raise IOError('Cannot overwrite existing %s' % dest_entry
)
390 shutil
.move(source_entry
, dest_entry
)
392 _MAX_SIZE
= 32 * 1000 * 1000
395 def _ShouldSplitJar(self
, path
):
396 return (path
.lower().endswith('.jar') and self
.options
.do_jar_splitting
and
397 os
.path
.getsize(path
) >= self
._MAX
_SIZE
)
399 def _SplitJar(self
, jar_path
, dest_dir
):
400 """Split a source jar into two or more jars in the given dest_dir.
403 jar_path: string that is the path to jar to be split. The contents of this
404 jar will be copied into the output jars, but the jar itself will not be
406 dest_dir: directory into which to put the jars that result from splitting
410 IOError: if the jar cannot be split.
414 set(self
.options
.jar_splitting_exclude_suffixes
.split(',')) - set(['']))
415 include
= lambda name
: not any(name
.endswith(s
) for s
in exclude_suffixes
)
416 jarfile
.SplitJar(jar_path
, dest_dir
, self
._MAX
_SIZE
, include
)
419 def _GetStaticFileList(staging_dir
):
420 return _FilesMatching(os
.path
.join(staging_dir
, '__static__'))
422 def _CompileJspsIfAny(self
, tools_dir
, staging_dir
):
423 """Compiles JSP files, if any, into .class files.."""
424 if self
._MatchingFileExists
(self
._JSP
_REGEX
, staging_dir
):
425 gen_dir
= tempfile
.mkdtemp()
427 self
._CompileJspsWithGenDir
(tools_dir
, staging_dir
, gen_dir
)
429 shutil
.rmtree(gen_dir
)
431 def _CompileJspsWithGenDir(self
, tools_dir
, staging_dir
, gen_dir
):
432 staging_web_inf
= os
.path
.join(staging_dir
, 'WEB-INF')
433 lib_dir
= os
.path
.join(staging_web_inf
, 'lib')
435 for jar_file
in GetUserJspLibFiles(tools_dir
):
436 self
._CopyOrLinkFile
(
437 jar_file
, os
.path
.join(lib_dir
, os
.path
.basename(jar_file
)))
438 for jar_file
in GetSharedJspLibFiles(tools_dir
):
439 self
._CopyOrLinkFile
(
440 jar_file
, os
.path
.join(lib_dir
, os
.path
.basename(jar_file
)))
442 classes_dir
= os
.path
.join(staging_web_inf
, 'classes')
443 generated_web_xml
= os
.path
.join(staging_web_inf
, 'generated_web.xml')
445 classpath
= self
._GetJspClasspath
(tools_dir
, classes_dir
, gen_dir
)
449 '-classpath', classpath
,
451 '-uriroot', staging_dir
,
452 '-p', 'org.apache.jsp',
455 '-webinc', generated_web_xml
,
457 '-javaEncoding', self
.options
.compile_encoding
,
460 status
= subprocess
.call(command_and_args
)
463 'Compilation of JSPs exited with status %d' % status
)
465 self
._CompileJavaFiles
(classpath
, staging_web_inf
, gen_dir
)
468 self
.web_xml
= self
._ReadWebXml
(staging_dir
)
470 def _CompileJavaFiles(self
, classpath
, web_inf
, jsp_class_dir
):
471 """Compile all *.java files found under jsp_class_dir."""
472 java_files
= _FilesMatching(jsp_class_dir
, lambda f
: f
.endswith('.java'))
478 '-classpath', classpath
,
480 '-encoding', self
.options
.compile_encoding
,
483 status
= subprocess
.call(command_and_args
)
486 'Compilation of JSP-generated code exited with status %d' % status
)
488 if self
.options
.jar_jsps
:
489 self
._ZipJasperGeneratedFiles
(web_inf
, jsp_class_dir
)
491 web_inf_classes
= os
.path
.join(web_inf
, 'classes')
492 self
._MoveDirectoryContents
(jsp_class_dir
, web_inf_classes
)
494 if self
.options
.delete_jsps
:
495 jsps
= _FilesMatching(os
.path
.dirname(web_inf
),
496 lambda f
: f
.endswith('.jsp'))
500 if self
.options
.do_jar_classes
:
501 self
._ZipWebInfClassesFiles
(web_inf
)
504 def _ZipJasperGeneratedFiles(web_inf
, jsp_class_dir
):
505 lib_dir
= os
.path
.join(web_inf
, 'lib')
506 jarfile
.Make(jsp_class_dir
, lib_dir
, _COMPILED_JSP_JAR_NAME_PREFIX
,
507 maximum_size
=_MAX_COMPILED_JSP_JAR_SIZE
,
508 include_predicate
=lambda name
: not name
.endswith('.java'))
511 def _ZipWebInfClassesFiles(web_inf
):
514 lib_dir
= os
.path
.join(web_inf
, 'lib')
515 classes_dir
= os
.path
.join(web_inf
, 'classes')
516 jarfile
.Make(classes_dir
, lib_dir
, _CLASSES_JAR_NAME_PREFIX
,
517 maximum_size
=_MAX_COMPILED_JSP_JAR_SIZE
)
518 shutil
.rmtree(classes_dir
)
520 os
.mkdir(classes_dir
)
523 def _GetJspClasspath(tools_dir
, classes_dir
, gen_dir
):
524 """Builds the classpath for the JSP Compilation system call."""
525 lib_dir
= os
.path
.join(os
.path
.dirname(classes_dir
), 'lib')
527 GetImplLibs(tools_dir
) + GetSharedLibFiles(tools_dir
) +
528 [classes_dir
, gen_dir
] +
530 lib_dir
, lambda f
: f
.endswith('.jar') or f
.endswith('.zip')))
532 return (os
.pathsep
).join(elements
)
535 def _MatchingFileExists(regex
, dir_path
):
536 for _
, _
, files
in os
.walk(dir_path
):
538 if re
.search(regex
, f
):
543 def GetImplLibs(tools_dir
):
544 return _GetLibsShallow(os
.path
.join(tools_dir
, 'java', 'lib', 'impl'))
547 def GetSharedLibFiles(tools_dir
):
548 return _GetLibsRecursive(os
.path
.join(tools_dir
, 'java', 'lib', 'shared'))
551 def GetUserJspLibFiles(tools_dir
):
552 return _GetLibsRecursive(
553 os
.path
.join(tools_dir
, 'java', 'lib', 'tools', 'jsp'))
556 def GetSharedJspLibFiles(tools_dir
):
557 return _GetLibsRecursive(
558 os
.path
.join(tools_dir
, 'java', 'lib', 'shared', 'jsp'))
561 def _GetLibsRecursive(dir_path
):
562 return _FilesMatching(dir_path
, lambda f
: f
.endswith('.jar'))
565 def _GetLibsShallow(dir_path
):
567 for f
in os
.listdir(dir_path
):
568 if os
.path
.isfile(os
.path
.join(dir_path
, f
)) and f
.endswith('.jar'):
569 libs
.append(os
.path
.join(dir_path
, f
))
573 def _FilesMatching(root
, predicate
=lambda f
: True):
574 """Finds all files under the given root that match the given predicate.
577 root: a string that is the absolute or relative path to a directory.
578 predicate: a function that takes a file name (without a directory) and
579 returns a truth value.
582 A list of strings that are the paths of every file under the given root
583 that satisfies the given predicate. The paths are absolute if and only if
584 the input root is absolute.
587 for path
, _
, files
in os
.walk(root
):
588 matches
+= [os
.path
.join(path
, f
) for f
in files
if predicate(f
)]
592 def JavaHomeAndSuffix():
593 """Find the directory that the JDK is installed in.
595 The JDK install directory is expected to have a bin directory that contains
596 at a minimum the java and javac executables. If the environment variable
597 JAVA_HOME is set then it must point to such a directory. Otherwise, we look
598 for javac on the PATH and check that it is inside a JDK install directory.
601 A tuple where the first element is the JDK install directory and the second
602 element is a suffix that must be appended to executables in that directory
603 ('' on Unix-like systems, '.exe' on Windows).
606 RuntimeError: If JAVA_HOME is set but is not a JDK install directory, or
607 otherwise if a JDK install directory cannot be found based on the PATH.
609 def ResultForJdkAt(path
):
610 """Return (path, suffix) if path is a JDK install directory, else None."""
611 def IsExecutable(binary
):
612 return os
.path
.isfile(binary
) and os
.access(binary
, os
.X_OK
)
615 for suffix
in ['', '.exe']:
616 if all(IsExecutable(os
.path
.join(path
, 'bin', binary
+ suffix
))
617 for binary
in ['java', 'javac', 'jar']):
618 return (path
, suffix
)
621 result
= ResultFor(path
)
625 head
, tail
= os
.path
.split(path
)
627 result
= ResultFor(head
)
630 java_home
= os
.getenv('JAVA_HOME')
632 result
= ResultForJdkAt(java_home
)
637 'JAVA_HOME is set but does not reference a valid JDK: %s' % java_home
)
638 for path_dir
in os
.environ
['PATH'].split(os
.pathsep
):
639 maybe_root
, last
= os
.path
.split(path_dir
)
641 result
= ResultForJdkAt(maybe_root
)
644 raise RuntimeError('Did not find JDK in PATH and JAVA_HOME is not set')
647 def _FindApiJars(lib_dir
):
648 """Find the appengine-api-*.jar and its version.
650 The version of an appengine-api-*.jar is the Specification-Version attribute
651 in the jar's manifest section whose Name is 'com/google/appengine/api/'.
654 lib_dir: the base directory under which jars are to be found.
657 A dict from string to string, mapping all found API jars to their
658 corresponding versions.
661 IOError: if there was a problem reading the jars.
664 for jar_file
in _FilesMatching(lib_dir
, lambda f
: f
.endswith('.jar')):
665 manifest
= jarfile
.ReadManifest(jar_file
)
667 section
= manifest
.sections
.get('com/google/appengine/api/')
668 if section
and 'Specification-Version' in section
:
669 result
[jar_file
] = section
['Specification-Version']