315 lines
11 KiB
Python
315 lines
11 KiB
Python
# -*- coding: utf-8 -*- #
|
|
# Copyright 2023 Google LLC. All Rights Reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
"""Version-agnostic utilities for function source code."""
|
|
|
|
from __future__ import absolute_import
|
|
from __future__ import annotations
|
|
from __future__ import division
|
|
from __future__ import unicode_literals
|
|
|
|
import abc
|
|
import http
|
|
import os
|
|
import random
|
|
import re
|
|
import string
|
|
import time
|
|
from typing import Dict
|
|
|
|
from apitools.base.py import exceptions as http_exceptions
|
|
from apitools.base.py import http_wrapper
|
|
from apitools.base.py import transfer
|
|
from apitools.base.py import util as http_util
|
|
from googlecloudsdk.api_lib.storage import storage_api
|
|
from googlecloudsdk.api_lib.storage import storage_util
|
|
from googlecloudsdk.calliope import exceptions as calliope_exceptions
|
|
from googlecloudsdk.command_lib.functions import exceptions
|
|
from googlecloudsdk.command_lib.util import gcloudignore
|
|
from googlecloudsdk.core import log
|
|
from googlecloudsdk.core import resources
|
|
from googlecloudsdk.core import transports
|
|
from googlecloudsdk.core.util import archive
|
|
from googlecloudsdk.core.util import files as file_utils
|
|
|
|
# List of required files for each runtime per
|
|
# https://cloud.google.com/functions/docs/writing#directory-structure
|
|
# To keep things simple we don't check for file extensions, just required files.
|
|
# Every language except dotnet and java have a required file with an invariant
|
|
# name.
|
|
|
|
|
|
class RequiredFilesStrategy(object, metaclass=abc.ABCMeta):
|
|
"""Abstract base class for required files validation strategy."""
|
|
|
|
@abc.abstractmethod
|
|
def Validate(self, files_in_source_dir: list[str], runtime: str) -> None:
|
|
raise NotImplementedError()
|
|
|
|
|
|
class AllFilesPresentStrategy(RequiredFilesStrategy):
|
|
"""Strategy that requires all specified files to be present."""
|
|
|
|
def __init__(self, required_files: list[str]):
|
|
super(AllFilesPresentStrategy, self).__init__()
|
|
self._required_files = required_files
|
|
|
|
def Validate(self, files_in_source_dir: list[str], runtime: str) -> None:
|
|
for f in self._required_files:
|
|
if f not in files_in_source_dir:
|
|
raise exceptions.SourceArgumentError(
|
|
f'Provided source directory does not have file [{f}] which is '
|
|
f'required for [{runtime}]. Did you specify the right source?'
|
|
)
|
|
|
|
|
|
class PythonRequiredFilesStrategy(RequiredFilesStrategy):
|
|
"""Strategy for Python runtime required files validation."""
|
|
|
|
def Validate(self, files_in_source_dir: list[str], runtime: str) -> None:
|
|
if 'main.py' not in files_in_source_dir:
|
|
raise exceptions.SourceArgumentError(
|
|
'Provided source directory does not have file [main.py] which is '
|
|
f'required for [{runtime}]. Did you specify the right source?'
|
|
)
|
|
if (
|
|
'requirements.txt' not in files_in_source_dir
|
|
and 'pyproject.toml' not in files_in_source_dir
|
|
):
|
|
raise exceptions.SourceArgumentError(
|
|
'Provided source directory does not have file [requirements.txt or '
|
|
f'pyproject.toml] which is required for [{runtime}]. Did you '
|
|
'specify the right source?'
|
|
)
|
|
|
|
|
|
_REQUIRED_SOURCE_STRATEGIES = {
|
|
'dotnet': AllFilesPresentStrategy([]),
|
|
'go': AllFilesPresentStrategy(['go.mod']),
|
|
'java': AllFilesPresentStrategy([]),
|
|
'nodejs': AllFilesPresentStrategy(['package.json']),
|
|
'php': AllFilesPresentStrategy(['index.php', 'composer.json']),
|
|
'python': PythonRequiredFilesStrategy(),
|
|
'ruby': AllFilesPresentStrategy(['app.rb', 'Gemfile']),
|
|
}
|
|
|
|
|
|
def _GcloudIgnoreCreationPredicate(directory: str) -> bool:
|
|
return gcloudignore.AnyFileOrDirExists(
|
|
directory, gcloudignore.GIT_FILES + ['node_modules']
|
|
)
|
|
|
|
|
|
def _GetChooser(
|
|
path: str, ignore_file: str | None = None
|
|
) -> gcloudignore.FileChooser:
|
|
default_ignore_file = gcloudignore.DEFAULT_IGNORE_FILE + '\nnode_modules\n'
|
|
|
|
return gcloudignore.GetFileChooserForDir(
|
|
path,
|
|
default_ignore_file=default_ignore_file,
|
|
gcloud_ignore_creation_predicate=_GcloudIgnoreCreationPredicate,
|
|
ignore_file=ignore_file,
|
|
)
|
|
|
|
|
|
def _ValidateDirectoryExistsOrRaise(directory: str) -> None:
|
|
"""Validates that the given directory exists.
|
|
|
|
Args:
|
|
directory: a local path to the directory provided by user.
|
|
|
|
Returns:
|
|
The argument provided, if found valid.
|
|
Raises:
|
|
SourceArgumentError: If the user provided an invalid directory.
|
|
"""
|
|
if not os.path.exists(directory):
|
|
raise exceptions.SourceArgumentError('Provided directory does not exist')
|
|
if not os.path.isdir(directory):
|
|
raise exceptions.SourceArgumentError(
|
|
'Provided path does not point to a directory'
|
|
)
|
|
|
|
|
|
def _ValidateUnpackedSourceSize(
|
|
path: str, ignore_file: str | None = None
|
|
) -> None:
|
|
"""Validate size of unpacked source files."""
|
|
chooser = _GetChooser(path, ignore_file)
|
|
predicate = chooser.IsIncluded
|
|
try:
|
|
size_b = file_utils.GetTreeSizeBytes(path, predicate=predicate)
|
|
except OSError as e:
|
|
raise exceptions.FunctionsError(
|
|
'Error building source archive from path [{path}]. '
|
|
'Could not validate source files: [{error}]. '
|
|
'Please ensure that path [{path}] contains function code or '
|
|
'specify another directory with --source'.format(path=path, error=e)
|
|
)
|
|
size_limit_mb = 512
|
|
size_limit_b = size_limit_mb * 2**20
|
|
if size_b > size_limit_b:
|
|
raise exceptions.OversizedDeploymentError(
|
|
str(size_b) + 'B', str(size_limit_b) + 'B'
|
|
)
|
|
|
|
|
|
def ValidateDirectoryHasRequiredRuntimeFiles(source: str, runtime: str) -> None:
|
|
"""Validates the given source directory has the required runtime files."""
|
|
_ValidateDirectoryExistsOrRaise(source)
|
|
|
|
versionless_runtime = re.sub(r'[0-9]', '', runtime)
|
|
files_in_source_dir = os.listdir(source)
|
|
strategy = _REQUIRED_SOURCE_STRATEGIES.get(versionless_runtime)
|
|
if strategy:
|
|
strategy.Validate(files_in_source_dir, runtime)
|
|
|
|
|
|
def CreateSourcesZipFile(
|
|
zip_dir: str,
|
|
source_path: str,
|
|
ignore_file: str | None = None,
|
|
enforce_size_limit=False,
|
|
) -> str:
|
|
"""Prepare zip file with source of the function to upload.
|
|
|
|
Args:
|
|
zip_dir: str, directory in which zip file will be located. Name of the file
|
|
will be `fun.zip`.
|
|
source_path: str, directory containing the sources to be zipped.
|
|
ignore_file: custom ignore_file name. Override .gcloudignore file to
|
|
customize files to be skipped.
|
|
enforce_size_limit: if set, enforces that the unpacked source size is less
|
|
than or equal to 512 MB.
|
|
|
|
Returns:
|
|
Path to the zip file.
|
|
Raises:
|
|
FunctionsError
|
|
"""
|
|
_ValidateDirectoryExistsOrRaise(source_path)
|
|
if ignore_file and not os.path.exists(os.path.join(source_path, ignore_file)):
|
|
raise exceptions.IgnoreFileNotFoundError(
|
|
'File {0} referenced by --ignore-file does not exist.'.format(
|
|
ignore_file
|
|
)
|
|
)
|
|
if enforce_size_limit:
|
|
_ValidateUnpackedSourceSize(source_path, ignore_file)
|
|
zip_file_name = os.path.join(zip_dir, 'fun.zip')
|
|
try:
|
|
chooser = _GetChooser(source_path, ignore_file)
|
|
predicate = chooser.IsIncluded
|
|
archive.MakeZipFromDir(zip_file_name, source_path, predicate=predicate)
|
|
except ValueError as e:
|
|
raise exceptions.FunctionsError(
|
|
'Error creating a ZIP archive with the source code '
|
|
'for directory {0}: {1}'.format(source_path, str(e))
|
|
)
|
|
return zip_file_name
|
|
|
|
|
|
def _GenerateRemoteZipFileName(function_ref: resources.Resource) -> str:
|
|
region = function_ref.locationsId
|
|
name = function_ref.functionsId
|
|
suffix = ''.join(random.choice(string.ascii_lowercase) for _ in range(12))
|
|
return '{0}-{1}-{2}.zip'.format(region, name, suffix)
|
|
|
|
|
|
def UploadToStageBucket(
|
|
source_zip: str, function_ref: resources.Resource, stage_bucket: str
|
|
) -> storage_util.ObjectReference:
|
|
"""Uploads the given source ZIP file to the provided staging bucket.
|
|
|
|
Args:
|
|
source_zip: the source ZIP file to upload.
|
|
function_ref: the function resource reference.
|
|
stage_bucket: the name of GCS bucket to stage the files to.
|
|
|
|
Returns:
|
|
dest_object: a reference to the uploaded Cloud Storage object.
|
|
"""
|
|
zip_file = _GenerateRemoteZipFileName(function_ref)
|
|
bucket_ref = storage_util.BucketReference.FromArgument(stage_bucket)
|
|
dest_object = storage_util.ObjectReference.FromBucketRef(bucket_ref, zip_file)
|
|
try:
|
|
storage_api.StorageClient().CopyFileToGCS(source_zip, dest_object)
|
|
except calliope_exceptions.BadFileException:
|
|
raise exceptions.SourceUploadError(
|
|
'Failed to upload the function source code to the bucket {0}'.format(
|
|
stage_bucket
|
|
)
|
|
)
|
|
return dest_object
|
|
|
|
|
|
def _UploadFileToGeneratedUrlCheckResponse(
|
|
response: http_wrapper.Response,
|
|
) -> http_wrapper.CheckResponse:
|
|
if response.status_code == http.HTTPStatus.FORBIDDEN:
|
|
raise http_exceptions.HttpForbiddenError.FromResponse(response)
|
|
return http_wrapper.CheckResponse(response)
|
|
|
|
|
|
def UploadToGeneratedUrl(
|
|
source_zip: str, url: str, extra_headers: Dict[str, str] | None = None
|
|
) -> None:
|
|
"""Upload the given source ZIP file to provided generated URL.
|
|
|
|
Args:
|
|
source_zip: the source ZIP file to upload.
|
|
url: the signed Cloud Storage URL to upload to.
|
|
extra_headers: extra headers to attach to the request.
|
|
"""
|
|
extra_headers = extra_headers or {}
|
|
upload = transfer.Upload.FromFile(source_zip, mime_type='application/zip')
|
|
|
|
def _UploadRetryFunc(retry_args: http_wrapper.ExceptionRetryArgs) -> None:
|
|
if isinstance(retry_args.exc, http_exceptions.HttpForbiddenError):
|
|
log.debug('Caught delayed permission propagation error, retrying')
|
|
http_wrapper.RebuildHttpConnections(retry_args.http)
|
|
time.sleep(
|
|
http_util.CalculateWaitForRetry(
|
|
retry_args.num_retries, max_wait=retry_args.max_retry_wait
|
|
)
|
|
)
|
|
else:
|
|
upload.retry_func(retry_args)
|
|
|
|
try:
|
|
upload_request = http_wrapper.Request(
|
|
url,
|
|
http_method='PUT',
|
|
headers={'content-type': 'application/zip', **extra_headers},
|
|
)
|
|
upload_request.body = upload.stream.read()
|
|
response = http_wrapper.MakeRequest(
|
|
transports.GetApitoolsTransport(),
|
|
upload_request,
|
|
retry_func=_UploadRetryFunc,
|
|
check_response_func=_UploadFileToGeneratedUrlCheckResponse,
|
|
retries=upload.num_retries,
|
|
)
|
|
finally:
|
|
upload.stream.close()
|
|
|
|
if response.status_code // 100 != 2:
|
|
raise exceptions.SourceUploadError(
|
|
'Failed to upload the function source code to signed url: {url}. '
|
|
'Status: [{code}:{detail}]'.format(
|
|
url=url, code=response.status_code, detail=response.content
|
|
)
|
|
)
|