Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dispatch Routing from yaml #3136

Merged
merged 23 commits into from
Aug 22, 2019
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f2c292e
add patch to project to be able to deploy dispatch rules
whoarethebritons Jul 16, 2019
4d46941
use a template to generate nginx config for applications and get disp…
whoarethebritons Jul 16, 2019
09826e2
syntax error
whoarethebritons Jul 16, 2019
3a8feff
syntax error
whoarethebritons Jul 16, 2019
f80e377
Merge branch 'master' of https://github.com/AppScale/appscale into di…
whoarethebritons Jul 16, 2019
afc1b97
make sure dispatch variable is not nil
whoarethebritons Jul 16, 2019
30b4dc2
accessing wrong type of variable
whoarethebritons Jul 16, 2019
fd94c5a
syntax error
whoarethebritons Jul 16, 2019
abd21dd
typo: server_name rather than load_balancer_ip
whoarethebritons Jul 16, 2019
30806a8
add headers for port redirect for http/https and rearrange template t…
whoarethebritons Jul 17, 2019
5084fc2
modifications to be more similar to the Admin REST API endpoint
whoarethebritons Jul 23, 2019
3d781a4
fix request processing, operation usage, and domain validation
whoarethebritons Jul 24, 2019
249b31e
Merge branch 'master' of https://github.com/AppScale/appscale into di…
whoarethebritons Aug 6, 2019
6d03de0
Merge branch 'master' of https://github.com/AppScale/appscale into di…
whoarethebritons Aug 7, 2019
1130752
name => project_id to match convention
whoarethebritons Aug 12, 2019
ee156de
store dispatchRules unmodified, modify them for nginx in our nginx li…
whoarethebritons Aug 14, 2019
2f772f8
add in comments for regexes/methods from GAE's 1.9.69 SDK
whoarethebritons Aug 14, 2019
0551e55
change string formatting
whoarethebritons Aug 14, 2019
24196a7
Merge branch 'master' of https://github.com/AppScale/appscale into di…
whoarethebritons Aug 15, 2019
9b667a7
Merge branch 'master' of https://github.com/AppScale/appscale into di…
whoarethebritons Aug 15, 2019
ae1ba4a
Merge branch 'master' of https://github.com/AppScale/appscale into di…
whoarethebritons Aug 16, 2019
810e1e4
Merge branch 'master' of https://github.com/AppScale/appscale into di…
whoarethebritons Aug 19, 2019
fe7a049
fix exception handling for dispatch updates
whoarethebritons Aug 21, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 81 additions & 1 deletion AdminServer/appscale/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@
DeleteServiceOperation,
CreateVersionOperation,
DeleteVersionOperation,
UpdateVersionOperation
UpdateVersionOperation,
UpdateApplicationOperation
)
from .operations_cache import OperationsCache
from .push_worker_manager import GlobalPushWorkerManager
Expand Down Expand Up @@ -221,6 +222,83 @@ class LifecycleState(object):
DELETE_IN_PROGRESS = 'DELETE_IN_PROGRESS'


class AppsHandler(BaseHandler):
""" Manages applications. """
def initialize(self, ua_client, zk_client):
""" Defines required resources to handle requests.

Args:
ua_client: A UAClient.
zk_client: A KazooClient.
"""
self.ua_client = ua_client
self.zk_client = zk_client


@gen.coroutine
def patch(self, name):
cdonati marked this conversation as resolved.
Show resolved Hide resolved
""" Updates an Application. Currently this is only supported for the
dispatch rules.

Args:
name: A string specifying a project ID.
"""
self.authenticate(name, self.ua_client)

if name in constants.IMMUTABLE_PROJECTS:
raise CustomHTTPError(HTTPCodes.BAD_REQUEST,
message='{} cannot be updated'.format(name))

update_mask = self.get_argument('updateMask', None)
if not update_mask:
message = 'At least one field must be specified for this operation.'
raise CustomHTTPError(HTTPCodes.BAD_REQUEST, message=message)

desired_fields = update_mask.split(',')
supported_fields = {'dispatchRules'}
for field in desired_fields:
if field not in supported_fields:
message = ('This operation is only supported on the following '
'field(s): [{}]'.format(', '.join(supported_fields)))
raise CustomHTTPError(HTTPCodes.BAD_REQUEST, message=message)

project_node = '/'.join(['/appscale', 'projects', name])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
project_node = '/'.join(['/appscale', 'projects', name])
project_node = '/appscale/projects/{}'.format(name)

services_node = '/'.join([project_node, 'services'])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
services_node = '/'.join([project_node, 'services'])
services_node = '{}/services'.format(project_node)

if not self.zk_client.exists(project_node):
raise CustomHTTPError(HTTPCodes.NOT_FOUND,
message='Project does not exist')

try:
service_ids = self.zk_client.get_children(services_node)
except NoNodeError:
raise CustomHTTPError(HTTPCodes.INTERNAL_ERROR,
message='Services node not found for project')
payload = json.loads(self.request.body)
dispatch_rules = utils.routing_rules_from_dict(payload=payload,
project_id=name,
services=service_ids)

dispatch_node = '/'.join(['/appscale', 'projects', name, 'dispatch'])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
dispatch_node = '/'.join(['/appscale', 'projects', name, 'dispatch'])
dispatch_node = '/appscale/projects/{}/dispatch'.format(name)


try:
self.zk_client.set(dispatch_node, json.dumps(dispatch_rules))
except NoNodeError:
try:
self.zk_client.create(dispatch_node, json.dumps(dispatch_rules))
except NoNodeError:
raise CustomHTTPError(HTTPCodes.NOT_FOUND,
message='{} not found'.format(name))

logger.info('Updated dispatch for {}'.format(name))
# TODO: add verification for dispatchRules being applied. For now,
# assume the controller picks it up instantly.
patch_operation = UpdateApplicationOperation(name)
patch_operation.finish(dispatch_rules)
operations[patch_operation.id] = patch_operation
self.write(json_encode(patch_operation.rest_repr()))



class BaseVersionHandler(BaseHandler):

def get_version(self, project_id, service_id, version_id):
Expand Down Expand Up @@ -1392,6 +1470,8 @@ def main():
{'acc': acc, 'ua_client': ua_client, 'zk_client': zk_client,
'version_update_lock': version_update_lock, 'thread_pool': thread_pool,
'controller_state': controller_state}),
('/v1/apps/([^/]*)', AppsHandler,
{'ua_client': ua_client, 'zk_client': zk_client}),
('/v1/apps/([^/]*)/operations/([a-z0-9-]+)', OperationsHandler,
{'ua_client': ua_client}),
('/api/cron/update', UpdateCronHandler,
Expand Down
46 changes: 46 additions & 0 deletions AdminServer/appscale/admin/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class Methods(object):
CREATE_VERSION = 'google.appengine.v1.Versions.CreateVersion'
DELETE_VERSION = 'google.appengine.v1.Versions.DeleteVersion'
UPDATE_VERSION = 'google.appengine.v1.Versions.UpdateVersion'
UPDATE_APPLICATION = 'google.appengine.v1.Applications.UpdateApplication'


class OperationTimeout(Exception):
Expand Down Expand Up @@ -74,6 +75,11 @@ class InvalidQueueConfiguration(Exception):
pass


class InvalidDispatchConfiguration(Exception):
""" Indicates that the dispatch rule is not valid. """
pass


class ServingStatus(object):
""" The possible serving states for a project or version. """
SERVING = 'SERVING'
Expand All @@ -86,6 +92,7 @@ class Types(object):
EMPTY = 'type.googleapis.com/google.protobuf.Empty'
OPERATION_METADATA = 'type.googleapis.com/google.appengine.v1.OperationMetadataV1'
VERSION = 'type.googleapis.com/google.appengine.v1.Version'
APPLICATION = 'type.googleapis.com/google.appengine.v1.Application'


# The parent directory for source code extraction.
Expand Down Expand Up @@ -185,6 +192,45 @@ class Types(object):
# A regex rule for validating targets, will not match instance.version.module.
TQ_TARGET_REGEX = re.compile(r'^([a-zA-Z0-9\-]+[\.]?[a-zA-Z0-9\-]*)$')

# A set of regex rules to validate dispatch domains.
DISPATCH_DOMAIN_REGEX_SINGLE_ASTERISK = re.compile(r'^\*$')
DISPATCH_DOMAIN_REGEX_ASTERISKS = re.compile(r'\*')
DISPATCH_DOMAIN_REGEX_ASTERISK_DOT = re.compile(r'\*\.')
_URL_SPLITTER_RE = re.compile(r'^([^/]+)(/.*)$')

# Regular expression for a hostname based on
# http://tools.ietf.org/html/rfc1123.
#
# This pattern is more restrictive than the RFC because it only accepts
# lower case letters.
_URL_HOST_EXACT_PATTERN_RE = re.compile(r"""
# 0 or more . terminated hostname segments (may not start or end in -).
^([a-z0-9]([a-z0-9\-]*[a-z0-9])*\.)*
# followed by a host name segment.
([a-z0-9]([a-z0-9\-]*[a-z0-9])*)$""", re.VERBOSE)

_URL_IP_V4_ADDR_RE = re.compile(r"""
#4 1-3 digit numbers separated by .
^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$""", re.VERBOSE)
scragraham marked this conversation as resolved.
Show resolved Hide resolved

# Regular expression for a prefix of a hostname based on
# http://tools.ietf.org/html/rfc1123. Restricted to lower case letters.
_URL_HOST_SUFFIX_PATTERN_RE = re.compile(r"""
# Single star or
^([*]|
# Host prefix with no ., Ex '*-a' or
[*][a-z0-9\-]*[a-z0-9]|
# Host prefix with ., Ex '*-a.b-c.d'
[*](\.|[a-z0-9\-]*[a-z0-9]\.)([a-z0-9]([a-z0-9\-]*[a-z0-9])*\.)*
([a-z0-9]([a-z0-9\-]*[a-z0-9])*))$
""", re.VERBOSE)

# A set of regex rules to validate dispatch paths.
DISPATCH_PATH_REGEX = re.compile(r'/[0-9a-z/]*[*]?$')

# A regex rule to help make dispatch urls nginx friendly.
NGINX_DISPATCH_REGEX = re.compile(r'\w*(?<!\.)\*')

REQUIRED_PULL_QUEUE_FIELDS = ['name', 'mode']

REQUIRED_PUSH_QUEUE_FIELDS = ['name', 'rate']
Expand Down
32 changes: 32 additions & 0 deletions AdminServer/appscale/admin/operation.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,35 @@ def __init__(self, project_id, service_id, version):
super(UpdateVersionOperation, self).__init__(
project_id, service_id, version)
self.method = Methods.UPDATE_VERSION


class UpdateApplicationOperation(Operation):
""" A container that keeps track of CreateVersion operations. """
def __init__(self, project_id):
""" Creates a new CreateVersionOperation.

Args:
project_id: A string specifying a project ID.
"""
super(UpdateApplicationOperation, self).__init__(project_id)
self.method = Methods.UPDATE_APPLICATION

def finish(self, dispatchRules):
""" Marks the operation as completed.

Args:
dispatchRules: A list of the dispatch rules that were applied to the
application object.
"""
self.response = {
'@type': Types.VERSION,
'name': 'apps/{}'.format(self.project_id),
'id': self.project_id,
'dispatchRules': dispatchRules,
'servingStatus': ServingStatus.SERVING,
# TODO: add other fields to response for UpdateApplication
# "authDomain", "locationId", "codeBucket", "defaultHostname",
# "defaultBucket", "gcrDomain"
}

self.done = True
79 changes: 79 additions & 0 deletions AdminServer/appscale/admin/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,26 @@
from kazoo.exceptions import NoNodeError

from .constants import (
_URL_HOST_EXACT_PATTERN_RE,
_URL_HOST_SUFFIX_PATTERN_RE,
_URL_IP_V4_ADDR_RE,
AUTO_HTTP_PORTS,
AUTO_HTTPS_PORTS,
CustomHTTPError,
DEFAULT_VERSION,
DISPATCH_PATH_REGEX,
DISPATCH_DOMAIN_REGEX_SINGLE_ASTERISK,
DISPATCH_DOMAIN_REGEX_ASTERISKS,
DISPATCH_DOMAIN_REGEX_ASTERISK_DOT,
HAPROXY_PORTS,
GO,
InvalidCronConfiguration,
InvalidDispatchConfiguration,
InvalidQueueConfiguration,
InvalidSource,
JAVA,
JAVA8,
NGINX_DISPATCH_REGEX,
REQUIRED_PULL_QUEUE_FIELDS,
REQUIRED_PUSH_QUEUE_FIELDS,
SOURCES_DIRECTORY,
Expand Down Expand Up @@ -581,3 +591,72 @@ def _constant_time_compare(val_a, val_b):
constant_time_compare = hmac.compare_digest
else:
constant_time_compare = _constant_time_compare


def validate_routing_rule(rule, services):
"""
domain: string. Domain name to match against. The wildcard "*" is supported
if specified before a period: "*.". Defaults to matching all domains: "*".
path: string. Pathname within the host. Must start with a "/". A single "*"
can be included at the end of the path. The sum of the lengths of the
domain and path may not exceed 100 characters.
service: string. Resource ID of a service in this application that should
serve the matched request. The service must already exist. Example: default.
Tip: You can include glob patterns like the `*` wildcard character in the
`url` element; however, those patterns can be used only before the host name
and at the end of the URL path.

"""
service = rule['service']
domain = rule['domain']
path = rule['path']
if service not in services:
raise InvalidDispatchConfiguration('Service does not exist.')
if domain.startswith('*'):
if not _URL_HOST_SUFFIX_PATTERN_RE.match(domain):
raise InvalidDispatchConfiguration(
'Invalid host pattern {}'.format(domain))
else:
if not _URL_HOST_EXACT_PATTERN_RE.match(domain):
raise InvalidDispatchConfiguration(
'Invalid host pattern {}'.format(domain))
matcher = _URL_IP_V4_ADDR_RE.match(domain)
if matcher and sum(1 for x in matcher.groups() if int(x) <= 255) == 4:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we decide to keep this regex match:

Suggested change
if matcher and sum(1 for x in matcher.groups() if int(x) <= 255) == 4:
if matcher and all(int(x) <= 255 for x in matcher.groups()):

If we use a more accurate regex, this becomes:

    if matcher is not None:

raise InvalidDispatchConfiguration(
'Host may not match an ipv4 address {}'.format(domain))

if not DISPATCH_PATH_REGEX.match(path):
raise InvalidDispatchConfiguration('Invalid path pattern {}.'.format(path))

if not DISPATCH_DOMAIN_REGEX_SINGLE_ASTERISK.match(domain):
asterisks = DISPATCH_DOMAIN_REGEX_ASTERISKS.findall(domain)
asterisk_dot = DISPATCH_DOMAIN_REGEX_ASTERISK_DOT.findall(domain)
if len(asterisks) != len(asterisk_dot):
raise InvalidDispatchConfiguration(
'Invalid host pattern {}'.format(domain))

if len(domain) + len(path) > 100:
raise InvalidDispatchConfiguration('URL over 100 characters.')


def routing_rules_from_dict(payload, project_id, services):
try:
given_routing_rules = payload['dispatchRules']
except KeyError:
raise InvalidQueueConfiguration('Payload must contain dispatchRules')

rules = []
for rule in given_routing_rules:
validate_routing_rule(rule, services)
rules.append(convert_to_nginx_friendly_rule(rule, project_id))
cdonati marked this conversation as resolved.
Show resolved Hide resolved

return rules

def convert_to_nginx_friendly_rule(rule, project_id):
# Domain defaults to '*'
domain = rule['domain'] or '*'
url = '{}{}'.format(domain, rule['path'])
nginx_url = NGINX_DISPATCH_REGEX.sub('.*', url)
nginx_service_backend = 'gae_{}_{}_{}'.format(project_id, rule['service'],
DEFAULT_VERSION)
return {'url': nginx_url, 'service': nginx_service_backend}
Loading