1# -*- coding: utf-8 -*- # 2# Copyright 2017 Google LLC. All Rights Reserved. 3# 4# Licensed under the Apache License, Version 2.0 (the "License"); 5# you may not use this file except in compliance with the License. 6# You may obtain a copy of the License at 7# 8# http://www.apache.org/licenses/LICENSE-2.0 9# 10# Unless required by applicable law or agreed to in writing, software 11# distributed under the License is distributed on an "AS IS" BASIS, 12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13# See the License for the specific language governing permissions and 14# limitations under the License. 15"""Utilities for deriving services and configs from paths. 16 17Paths are typically given as positional params, like 18`gcloud app deploy <path1> <path2>...`. 19""" 20 21from __future__ import absolute_import 22from __future__ import division 23from __future__ import unicode_literals 24 25import collections 26import os 27 28from googlecloudsdk.api_lib.app import env 29from googlecloudsdk.api_lib.app import yaml_parsing 30from googlecloudsdk.command_lib.app import exceptions 31from googlecloudsdk.core import log 32from googlecloudsdk.core.util import files 33 34_STANDARD_APP_YAML_URL = ( 35 'https://cloud.google.com/appengine/docs/standard/python/config/appref') 36_FLEXIBLE_APP_YAML_URL = ( 37 'https://cloud.google.com/' 38 'appengine/docs/flexible/python/configuring-your-app-with-app-yaml') 39 40APP_YAML_INSTRUCTIONS = ( 41 'using the directions at {flex} (App Engine flexible environment) or {std} ' 42 '(App Engine standard environment) under the tab for your language.' 43).format(flex=_FLEXIBLE_APP_YAML_URL, std=_STANDARD_APP_YAML_URL) 44 45FINGERPRINTING_WARNING = ( 46 'As an alternative, create an app.yaml file yourself ' + 47 APP_YAML_INSTRUCTIONS) 48NO_YAML_ERROR = ( 49 'An app.yaml (or appengine-web.xml) file is required to deploy this ' 50 'directory as an App Engine application. Create an app.yaml file ' 51 + APP_YAML_INSTRUCTIONS) 52 53 54class Service(object): 55 """Represents data around a deployable service. 56 57 Attributes: 58 descriptor: str, File path to the original deployment descriptor, which is 59 either a `<service>.yaml` or an `appengine-web.xml`. 60 source: str, Path to the original deployable artifact or directory, which 61 is typically the original source directory, but could also be an artifact 62 such as a fat JAR file. 63 service_info: yaml_parsing.ServiceYamlInfo, Info parsed from the 64 `<service>.yaml` file. Note that service_info.file may point to a 65 file in a staged directory. 66 upload_dir: str, Path to the source directory. If staging is required, this 67 points to the staged directory. 68 service_id: str, the service id. 69 path: str, File path to the staged deployment `<service>.yaml` descriptor 70 or to the original one, if no staging is used. 71 """ 72 73 def __init__(self, descriptor, source, service_info, upload_dir): 74 self.descriptor = descriptor 75 self.source = source 76 self.service_info = service_info 77 self.upload_dir = upload_dir 78 79 @property 80 def service_id(self): 81 return self.service_info.module 82 83 @property 84 def path(self): 85 return self.service_info.file 86 87 @classmethod 88 def FromPath(cls, path, stager, path_matchers, appyaml): 89 """Return a Service from a path using staging if necessary. 90 91 Args: 92 path: str, Unsanitized absolute path, may point to a directory or a file 93 of any type. There is no guarantee that it exists. 94 stager: staging.Stager, stager that will be invoked if there is a runtime 95 and environment match. 96 path_matchers: List[Function], ordered list of functions on the form 97 fn(path, stager), where fn returns a Service or None if no match. 98 appyaml: str or None, the app.yaml location to used for deployment. 99 100 Returns: 101 Service, if one can be derived, else None. 102 """ 103 for matcher in path_matchers: 104 service = matcher(path, stager, appyaml) 105 if service: 106 return service 107 return None 108 109 110def ServiceYamlMatcher(path, stager, appyaml): 111 """Generate a Service from an <service>.yaml source path. 112 113 This function is a path matcher that returns if and only if: 114 - `path` points to either a `<service>.yaml` or `<app-dir>` where 115 `<app-dir>/app.yaml` exists. 116 - the yaml-file is a valid <service>.yaml file. 117 118 If the runtime and environment match an entry in the stager, the service will 119 be staged into a directory. 120 121 Args: 122 path: str, Unsanitized absolute path, may point to a directory or a file of 123 any type. There is no guarantee that it exists. 124 stager: staging.Stager, stager that will be invoked if there is a runtime 125 and environment match. 126 appyaml: str or None, the app.yaml location to used for deployment. 127 128 Raises: 129 staging.StagingCommandFailedError, staging command failed. 130 131 Returns: 132 Service, fully populated with entries that respect a potentially 133 staged deployable service, or None if the path does not match the 134 pattern described. 135 """ 136 descriptor = path if os.path.isfile(path) else os.path.join(path, 137 'app.yaml') 138 _, ext = os.path.splitext(descriptor) 139 if os.path.exists(descriptor) and ext in ['.yaml', '.yml']: 140 app_dir = os.path.dirname(descriptor) 141 service_info = yaml_parsing.ServiceYamlInfo.FromFile(descriptor) 142 staging_dir = stager.Stage(descriptor, app_dir, service_info.runtime, 143 service_info.env, appyaml) 144 # If staging, stage, get stage_dir 145 return Service(descriptor, app_dir, service_info, staging_dir or app_dir) 146 return None 147 148 149def JarMatcher(jar_path, stager, appyaml): 150 """Generate a Service from a Java fatjar path. 151 152 This function is a path matcher that returns if and only if: 153 - `jar_path` points to a jar file . 154 155 The service will be staged according to the stager as a jar runtime, 156 which is defined in staging.py. 157 158 Args: 159 jar_path: str, Unsanitized absolute path pointing to a file of jar type. 160 stager: staging.Stager, stager that will be invoked if there is a runtime 161 and environment match. 162 appyaml: str or None, the app.yaml location to used for deployment. 163 164 Raises: 165 staging.StagingCommandFailedError, staging command failed. 166 167 Returns: 168 Service, fully populated with entries that respect a staged deployable 169 service, or None if the path does not match the pattern described. 170 """ 171 _, ext = os.path.splitext(jar_path) 172 if os.path.exists(jar_path) and ext in ['.jar']: 173 app_dir = os.path.abspath(os.path.join(jar_path, os.pardir)) 174 descriptor = jar_path 175 staging_dir = stager.Stage(descriptor, app_dir, 'java-jar', env.STANDARD, 176 appyaml) 177 yaml_path = os.path.join(staging_dir, 'app.yaml') 178 service_info = yaml_parsing.ServiceYamlInfo.FromFile(yaml_path) 179 return Service(descriptor, app_dir, service_info, staging_dir) 180 return None 181 182 183def PomXmlMatcher(path, stager, appyaml): 184 """Generate a Service from an Maven project source path. 185 186 This function is a path matcher that returns true if and only if: 187 - `path` points to either a Maven `pom.xml` or `<maven=project-dir>` where 188 `<maven-project-dir>/pom.xml` exists. 189 190 If the runtime and environment match an entry in the stager, the service will 191 be staged into a directory. 192 193 Args: 194 path: str, Unsanitized absolute path, may point to a directory or a file of 195 any type. There is no guarantee that it exists. 196 stager: staging.Stager, stager that will be invoked if there is a runtime 197 and environment match. 198 appyaml: str or None, the app.yaml location to used for deployment. 199 200 Raises: 201 staging.StagingCommandFailedError, staging command failed. 202 203 Returns: 204 Service, fully populated with entries that respect a potentially 205 staged deployable service, or None if the path does not match the 206 pattern described. 207 """ 208 descriptor = path if os.path.isfile(path) else os.path.join(path, 'pom.xml') 209 filename = os.path.basename(descriptor) 210 if os.path.exists(descriptor) and filename == 'pom.xml': 211 app_dir = os.path.dirname(descriptor) 212 staging_dir = stager.Stage(descriptor, app_dir, 'java-maven-project', 213 env.STANDARD, appyaml) 214 yaml_path = os.path.join(staging_dir, 'app.yaml') 215 service_info = yaml_parsing.ServiceYamlInfo.FromFile(yaml_path) 216 return Service(descriptor, app_dir, service_info, staging_dir) 217 return None 218 219 220def BuildGradleMatcher(path, stager, appyaml): 221 """Generate a Service from an Gradle project source path. 222 223 This function is a path matcher that returns true if and only if: 224 - `path` points to either a Gradle `build.gradle` or `<gradle-project-dir>` 225 where `<gradle-project-dir>/build.gradle` exists. 226 227 If the runtime and environment match an entry in the stager, the service will 228 be staged into a directory. 229 230 Args: 231 path: str, Unsanitized absolute path, may point to a directory or a file of 232 any type. There is no guarantee that it exists. 233 stager: staging.Stager, stager that will be invoked if there is a runtime 234 and environment match. 235 appyaml: str or None, the app.yaml location to used for deployment. 236 237 Raises: 238 staging.StagingCommandFailedError, staging command failed. 239 240 Returns: 241 Service, fully populated with entries that respect a potentially 242 staged deployable service, or None if the path does not match the 243 pattern described. 244 """ 245 descriptor = path if os.path.isfile(path) else os.path.join( 246 path, 'build.gradle') 247 filename = os.path.basename(descriptor) 248 if os.path.exists(descriptor) and filename == 'build.gradle': 249 app_dir = os.path.dirname(descriptor) 250 staging_dir = stager.Stage(descriptor, app_dir, 'java-gradle-project', 251 env.STANDARD, appyaml) 252 yaml_path = os.path.join(staging_dir, 'app.yaml') 253 service_info = yaml_parsing.ServiceYamlInfo.FromFile(yaml_path) 254 return Service(descriptor, app_dir, service_info, staging_dir) 255 return None 256 257 258def AppengineWebMatcher(path, stager, appyaml): 259 """Generate a Service from an appengine-web.xml source path. 260 261 This function is a path matcher that returns if and only if: 262 - `path` points to either `.../WEB-INF/appengine-web.xml` or `<app-dir>` where 263 `<app-dir>/WEB-INF/appengine-web.xml` exists. 264 - the xml-file is a valid appengine-web.xml file according to the Java stager. 265 266 The service will be staged according to the stager as a java-xml runtime, 267 which is defined in staging.py. 268 269 Args: 270 path: str, Unsanitized absolute path, may point to a directory or a file of 271 any type. There is no guarantee that it exists. 272 stager: staging.Stager, stager that will be invoked if there is a runtime 273 and environment match. 274 appyaml: str or None, the app.yaml location to used for deployment. 275 276 Raises: 277 staging.StagingCommandFailedError, staging command failed. 278 279 Returns: 280 Service, fully populated with entries that respect a staged deployable 281 service, or None if the path does not match the pattern described. 282 """ 283 suffix = os.path.join(os.sep, 'WEB-INF', 'appengine-web.xml') 284 app_dir = path[:-len(suffix)] if path.endswith(suffix) else path 285 descriptor = os.path.join(app_dir, 'WEB-INF', 'appengine-web.xml') 286 if not os.path.isfile(descriptor): 287 return None 288 289 xml_file = files.ReadFileContents(descriptor) 290 if '<application>' in xml_file or '<version>' in xml_file: 291 log.warning('<application> and <version> elements in ' + 292 '`appengine-web.xml` are not respected') 293 294 staging_dir = stager.Stage(descriptor, app_dir, 'java-xml', env.STANDARD, 295 appyaml) 296 if not staging_dir: 297 # After GA launch of appengine-web.xml support, this should never occur. 298 return None 299 yaml_path = os.path.join(staging_dir, 'app.yaml') 300 service_info = yaml_parsing.ServiceYamlInfo.FromFile(yaml_path) 301 return Service(descriptor, app_dir, service_info, staging_dir) 302 303 304def ExplicitAppYamlMatcher(path, stager, appyaml): 305 """Use optional app.yaml with a directory or a file the user wants to deploy. 306 307 Args: 308 path: str, Unsanitized absolute path, may point to a directory or a file of 309 any type. There is no guarantee that it exists. 310 stager: staging.Stager, stager that will not be invoked. 311 appyaml: str or None, the app.yaml location to used for deployment. 312 313 Returns: 314 Service, fully populated with entries that respect a staged deployable 315 service, or None if there is no optional --appyaml flag usage. 316 """ 317 318 if appyaml: 319 service_info = yaml_parsing.ServiceYamlInfo.FromFile(appyaml) 320 staging_dir = stager.Stage(appyaml, path, 'generic-copy', service_info.env, 321 appyaml) 322 return Service(appyaml, path, service_info, staging_dir) 323 return None 324 325 326def UnidentifiedDirMatcher(path, stager, appyaml): 327 """Points out to the user that they need an app.yaml to deploy. 328 329 Args: 330 path: str, Unsanitized absolute path, may point to a directory or a file of 331 any type. There is no guarantee that it exists. 332 stager: staging.Stager, stager that will not be invoked. 333 appyaml: str or None, the app.yaml location to used for deployment. 334 Returns: 335 None 336 """ 337 del stager, appyaml 338 if os.path.isdir(path): 339 log.error(NO_YAML_ERROR) 340 return None 341 342 343def GetPathMatchers(): 344 """Get list of path matchers ordered by descending precedence. 345 346 Returns: 347 List[Function], ordered list of functions on the form fn(path, stager), 348 where fn returns a Service or None if no match. 349 """ 350 return [ 351 ServiceYamlMatcher, AppengineWebMatcher, JarMatcher, PomXmlMatcher, 352 BuildGradleMatcher, ExplicitAppYamlMatcher, UnidentifiedDirMatcher 353 ] 354 355 356class Services(object): 357 """Collection of deployable services.""" 358 359 def __init__(self, services=None): 360 """Instantiate a set of deployable services. 361 362 Args: 363 services: List[Service], optional list of services for quick 364 initialization. 365 366 Raises: 367 DuplicateServiceError: Two or more services have the same service id. 368 """ 369 self._services = collections.OrderedDict() 370 if services: 371 for d in services: 372 self.Add(d) 373 374 def Add(self, service): 375 """Add a deployable service to the set. 376 377 Args: 378 service: Service, to add. 379 380 Raises: 381 DuplicateServiceError: Two or more services have the same service id. 382 """ 383 existing = self._services.get(service.service_id) 384 if existing: 385 raise exceptions.DuplicateServiceError(existing.path, service.path, 386 service.service_id) 387 self._services[service.service_id] = service 388 389 def GetAll(self): 390 """Retrieve the service info objects in the order they were added. 391 392 Returns: 393 List[Service], list of services. 394 """ 395 return list(self._services.values()) 396 397 398class Configs(object): 399 """Collection of config files.""" 400 401 def __init__(self): 402 self._configs = collections.OrderedDict() 403 404 def Add(self, config): 405 """Add a ConfigYamlInfo to the set of configs. 406 407 Args: 408 config: ConfigYamlInfo, the config to add. 409 410 Raises: 411 exceptions.DuplicateConfigError, the config type is already in the set. 412 """ 413 config_type = config.config 414 existing = self._configs.get(config_type) 415 if existing: 416 raise exceptions.DuplicateConfigError(existing.file, config.file, 417 config_type) 418 self._configs[config_type] = config 419 420 def GetAll(self): 421 """Retreive the config file objects in the order they were added. 422 423 Returns: 424 List[ConfigYamlInfo], list of config file objects. 425 """ 426 return list(self._configs.values()) 427 428 429def GetDeployables(args, stager, path_matchers, appyaml=None): 430 """Given a list of args, infer the deployable services and configs. 431 432 Given a deploy command, e.g. `gcloud app deploy ./dir other/service.yaml 433 cron.yaml WEB-INF/appengine-web.xml`, the deployables can be on multiple 434 forms. This method pre-processes and infers yaml descriptors from the 435 various formats accepted. The rules are as following: 436 437 This function is a context manager, and should be used in conjunction with 438 the `with` keyword. 439 440 1. If `args` is an empty list, add the current directory to it. 441 2. For each arg: 442 - If arg refers to a config file, add it to the configs set. 443 - Else match the arg against the path matchers. The first match will win. 444 The match will be added to the services set. Matchers may run staging. 445 446 Args: 447 args: List[str], positional args as given on the command-line. 448 stager: staging.Stager, stager that will be invoked on sources that have 449 entries in the stager's registry. 450 path_matchers: List[Function], list of functions on the form 451 fn(path, stager) ordered by descending precedence, where fn returns 452 a Service or None if no match. 453 appyaml: str or None, the app.yaml location to used for deployment. 454 455 Raises: 456 FileNotFoundError: One or more argument does not point to an existing file 457 or directory. 458 UnknownSourceError: Could not infer a config or service from an arg. 459 DuplicateConfigError: Two or more config files have the same type. 460 DuplicateServiceError: Two or more services have the same service id. 461 462 Returns: 463 Tuple[List[Service], List[ConfigYamlInfo]], lists of deployable services 464 and configs. 465 """ 466 if not args: 467 args = ['.'] 468 paths = [os.path.abspath(arg) for arg in args] 469 configs = Configs() 470 services = Services() 471 if appyaml: 472 if len(paths) > 1: 473 raise exceptions.MultiDeployError() 474 if not os.path.exists(os.path.abspath(appyaml)): 475 raise exceptions.FileNotFoundError('File {0} referenced by --appyaml ' 476 'does not exist.'.format(appyaml)) 477 if not os.path.exists(paths[0]): 478 raise exceptions.FileNotFoundError(paths[0]) 479 480 for path in paths: 481 if not os.path.exists(path): 482 raise exceptions.FileNotFoundError(path) 483 config = yaml_parsing.ConfigYamlInfo.FromFile(path) 484 if config: 485 configs.Add(config) 486 continue 487 service = Service.FromPath(path, stager, path_matchers, appyaml) 488 if service: 489 services.Add(service) 490 continue 491 raise exceptions.UnknownSourceError(path) 492 return services.GetAll(), configs.GetAll() 493