| # Copyright 2014 The Chromium Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Cloud Endpoints API for Package Repository service.""" |
| |
| import functools |
| import logging |
| |
| import endpoints |
| import gae_ts_mon |
| |
| from protorpc import message_types |
| from protorpc import messages |
| from protorpc import remote |
| |
| from components import auth |
| from components import utils |
| |
| from . import acl |
| from . import client |
| from . import impl |
| |
| |
| # This is used by endpoints indirectly. |
| package = 'cipd' |
| |
| |
| ################################################################################ |
| ## Messages used by other messages. |
| |
| |
| class Status(messages.Enum): |
| """Response status code, shared by all responses.""" |
| # Operation finished successfully (generic "success" response). |
| SUCCESS = 1 |
| # The package instance was successfully registered. |
| REGISTERED = 2 |
| # The package instance was already registered (not a error). |
| ALREADY_REGISTERED = 3 |
| # Some uncategorized non-transient error happened. |
| ERROR = 4 |
| # No such package. |
| PACKAGE_NOT_FOUND = 5 |
| # Package itself is known, but requested instance_id isn't registered. |
| INSTANCE_NOT_FOUND = 6 |
| # Need to upload package data before registering the package. |
| UPLOAD_FIRST = 7 |
| # Client binary is not available, the call should be retried later. |
| NOT_EXTRACTED_YET = 8 |
| # Some asynchronous package processing failed. |
| PROCESSING_FAILED = 9 |
| # Asynchronous package processing is still running. |
| PROCESSING_NOT_FINISHED_YET = 10 |
| # More than one instance matches criteria in resolveVersion. |
| AMBIGUOUS_VERSION = 11 |
| |
| |
| class Package(messages.Message): |
| """Information about some registered package.""" |
| package_name = messages.StringField(1, required=True) |
| registered_by = messages.StringField(2, required=True) |
| registered_ts = messages.IntegerField(3, required=True) |
| hidden = messages.BooleanField(4, required=True) |
| |
| |
| def package_to_proto(entity): |
| """Package entity -> Package proto message.""" |
| return Package( |
| package_name=entity.package_name, |
| registered_by=entity.registered_by.to_bytes(), |
| registered_ts=utils.datetime_to_timestamp(entity.registered_ts), |
| hidden=bool(entity.hidden)) # None and False are not the same in protorpc |
| |
| |
| class PackageInstance(messages.Message): |
| """Information about some registered package instance.""" |
| package_name = messages.StringField(1, required=True) |
| instance_id = messages.StringField(2, required=True) |
| registered_by = messages.StringField(3, required=True) |
| registered_ts = messages.IntegerField(4, required=True) |
| |
| |
| def instance_to_proto(entity): |
| """PackageInstance entity -> PackageInstance proto message.""" |
| return PackageInstance( |
| package_name=entity.package_name, |
| instance_id=entity.instance_id, |
| registered_by=entity.registered_by.to_bytes(), |
| registered_ts=utils.datetime_to_timestamp(entity.registered_ts)) |
| |
| |
| class InstanceTag(messages.Message): |
| """Some single package instance tag.""" |
| tag = messages.StringField(1, required=True) |
| registered_by = messages.StringField(2, required=True) |
| registered_ts = messages.IntegerField(3, required=True) |
| |
| |
| def tag_to_proto(entity): |
| """InstanceTag entity -> InstanceTag proto message.""" |
| return InstanceTag( |
| tag=entity.tag, |
| registered_by=entity.registered_by.to_bytes(), |
| registered_ts=utils.datetime_to_timestamp(entity.registered_ts)) |
| |
| |
| class PackageRef(messages.Message): |
| """Information about some ref belonging to a package.""" |
| ref = messages.StringField(1, required=True) |
| instance_id = messages.StringField(2, required=True) |
| modified_by = messages.StringField(3, required=True) |
| modified_ts = messages.IntegerField(4, required=True) |
| |
| |
| def package_ref_to_proto(entity): |
| """PackageRef entity -> PackageRef proto message.""" |
| return PackageRef( |
| ref=entity.ref, |
| instance_id=entity.instance_id, |
| modified_by=entity.modified_by.to_bytes(), |
| modified_ts=utils.datetime_to_timestamp(entity.modified_ts)) |
| |
| |
| class PackageACL(messages.Message): |
| """Access control list for some package path and all parent paths.""" |
| |
| class ElementaryACL(messages.Message): |
| """Single per role, per package path ACL.""" |
| package_path = messages.StringField(1, required=True) |
| role = messages.StringField(2, required=True) |
| principals = messages.StringField(3, repeated=True) |
| modified_by = messages.StringField(4, required=True) |
| modified_ts = messages.IntegerField(5, required=True) |
| |
| # List of ACLs split by package path and role. No ordering. |
| acls = messages.MessageField(ElementaryACL, 1, repeated=True) |
| |
| |
| def package_acls_to_proto(per_role_acls): |
| """Dict {role -> list of PackageACL entities} -> PackageACL message.""" |
| acls = [] |
| for role, package_acl_entities in per_role_acls.iteritems(): |
| for e in package_acl_entities: |
| principals = [] |
| principals.extend(u.to_bytes() for u in e.users) |
| principals.extend('group:%s' % g for g in e.groups) |
| acls.append(PackageACL.ElementaryACL( |
| package_path=e.package_path, |
| role=role, |
| principals=principals, |
| modified_by=e.modified_by.to_bytes(), |
| modified_ts=utils.datetime_to_timestamp(e.modified_ts), |
| )) |
| return PackageACL(acls=acls) |
| |
| |
| class RoleChange(messages.Message): |
| """Describes a single modification to ACL.""" |
| class Action(messages.Enum): |
| GRANT = 1 |
| REVOKE = 2 |
| # Action to perform. |
| action = messages.EnumField(Action, 1, required=True) |
| # Role to modify ('OWNER', 'WRITER', 'READER', 'COUNTER_WRITER'...). |
| role = messages.StringField(2, required=True) |
| # Principal ('user:...' or 'group:...') to grant or revoke a role for. |
| principal = messages.StringField(3, required=True) |
| |
| |
| def role_change_from_proto(proto, package_path): |
| """RoleChange proto message -> acl.RoleChange object. |
| |
| Raises ValueError on format errors. |
| """ |
| if not acl.is_valid_role(proto.role): |
| raise ValueError('Invalid role %s' % proto.role) |
| |
| user = None |
| group = None |
| if proto.principal.startswith('group:'): |
| group = proto.principal[len('group:'):] |
| if not auth.is_valid_group_name(group): |
| raise ValueError('Invalid group name: "%s"' % group) |
| else: |
| # Raises ValueError if proto.user has invalid format, e.g. not 'user:...'. |
| user = auth.Identity.from_bytes(proto.principal) |
| |
| return acl.RoleChange( |
| package_path=package_path, |
| revoke=(proto.action != RoleChange.Action.GRANT), |
| role=proto.role, |
| user=user, |
| group=group) |
| |
| |
| class Processor(messages.Message): |
| """Status of some package instance processor.""" |
| class Status(messages.Enum): |
| PENDING = 1 |
| SUCCESS = 2 |
| FAILURE = 3 |
| # Name of the processor, defines what it does. |
| name = messages.StringField(1, required=True) |
| # Status of the processing. |
| status = messages.EnumField(Status, 2, required=True) |
| |
| |
| def processors_protos(instance): |
| """Given PackageInstance entity returns a list of Processor messages.""" |
| def procs_to_msg(procs, status): |
| return [Processor(name=name, status=status) for name in procs ] |
| processors = [] |
| processors += procs_to_msg( |
| instance.processors_pending, |
| Processor.Status.PENDING) |
| processors += procs_to_msg( |
| instance.processors_success, |
| Processor.Status.SUCCESS) |
| processors += procs_to_msg( |
| instance.processors_failure, |
| Processor.Status.FAILURE) |
| return processors |
| |
| |
| ################################################################################ |
| |
| |
| class PackageResponse(messages.Message): |
| """Results of fetchPackage, hidePackage and unhidePackage calls.""" |
| status = messages.EnumField(Status, 1, required=True) |
| error_message = messages.StringField(2, required=False) |
| |
| # For SUCCESS, information about the package. |
| package = messages.MessageField(Package, 3, required=False) |
| refs = messages.MessageField(PackageRef, 4, repeated=True) |
| |
| |
| ################################################################################ |
| |
| |
| class ListPackagesResponse(messages.Message): |
| """Results of listPackages call.""" |
| status = messages.EnumField(Status, 1, required=True) |
| error_message = messages.StringField(2, required=False) |
| |
| # For SUCCESS, names of the packages and names of directories. |
| packages = messages.StringField(3, repeated=True) |
| directories = messages.StringField(4, repeated=True) |
| |
| |
| ################################################################################ |
| |
| |
| class DeletePackageResponse(messages.Message): |
| """Results of deletePackage call.""" |
| status = messages.EnumField(Status, 1, required=True) |
| error_message = messages.StringField(2, required=False) |
| |
| |
| ################################################################################ |
| |
| |
| class FetchInstanceResponse(messages.Message): |
| """Results of fetchInstance call.""" |
| status = messages.EnumField(Status, 1, required=True) |
| error_message = messages.StringField(2, required=False) |
| |
| # For SUCCESS, information about the package instance. |
| instance = messages.MessageField(PackageInstance, 3, required=False) |
| # For SUCCESS, a signed url to fetch the package instance file from. |
| fetch_url = messages.StringField(4, required=False) |
| # For SUCCESS, list of processors applied to the instance. |
| processors = messages.MessageField(Processor, 5, repeated=True) |
| |
| |
| class ListInstancesResponse(messages.Message): |
| """Results of listInstances call.""" |
| status = messages.EnumField(Status, 1, required=True) |
| error_message = messages.StringField(2, required=False) |
| |
| # For SUCCESS, information about the instances. |
| instances = messages.MessageField(PackageInstance, 3, repeated=True) |
| # A query cursor (if there's more instances to fetch), or ''. |
| cursor = messages.StringField(4, required=False) |
| |
| |
| ################################################################################ |
| |
| |
| class RegisterInstanceResponse(messages.Message): |
| """Results of registerInstance call. |
| |
| upload_session_id and upload_url (if present) can be used with CAS service |
| (finishUpload call in particular). |
| |
| Callers are expected to execute following protocol: |
| 1. Attempt to register a package instance by calling registerInstance(...). |
| 2. On UPLOAD_FIRST response, upload package data and finalize the upload by |
| using upload_session_id and upload_url and calling cas.finishUpload. |
| 3. Once upload is finalized, call registerInstance(...) again. |
| """ |
| status = messages.EnumField(Status, 1, required=True) |
| error_message = messages.StringField(2, required=False) |
| |
| # For REGISTERED or ALREADY_REGISTERED, info about the package instance. |
| instance = messages.MessageField(PackageInstance, 3, required=False) |
| |
| # For UPLOAD_FIRST status, a unique identifier of the upload operation. |
| upload_session_id = messages.StringField(4, required=False) |
| # For UPLOAD_FIRST status, URL to PUT file to via resumable upload protocol. |
| upload_url = messages.StringField(5, required=False) |
| |
| |
| ################################################################################ |
| |
| |
| class SetRefRequest(messages.Message): |
| """Body of setRef call.""" |
| # ID of the package instance to point the ref too. |
| instance_id = messages.StringField(1, required=True) |
| |
| |
| class SetRefResponse(messages.Message): |
| """Results of setRef call.""" |
| status = messages.EnumField(Status, 1, required=True) |
| error_message = messages.StringField(2, required=False) |
| |
| # For SUCCESS status, details about the ref. |
| ref = messages.MessageField(PackageRef, 3, required=False) |
| |
| |
| class FetchRefsResponse(messages.Message): |
| """Results of fetchRefs call.""" |
| status = messages.EnumField(Status, 1, required=True) |
| error_message = messages.StringField(2, required=False) |
| |
| # For SUCCESS status, details about fetches refs. |
| refs = messages.MessageField(PackageRef, 3, repeated=True) |
| |
| |
| ################################################################################ |
| |
| |
| class FetchTagsResponse(messages.Message): |
| """Results of fetchTags call.""" |
| status = messages.EnumField(Status, 1, required=True) |
| error_message = messages.StringField(2, required=False) |
| |
| # For SUCCESS status, details about found tags. |
| tags = messages.MessageField(InstanceTag, 3, repeated=True) |
| |
| |
| class AttachTagsRequest(messages.Message): |
| """Body of attachTags call.""" |
| tags = messages.StringField(1, repeated=True) |
| |
| |
| class AttachTagsResponse(messages.Message): |
| """Results of attachTag call.""" |
| status = messages.EnumField(Status, 1, required=True) |
| error_message = messages.StringField(2, required=False) |
| |
| # For SUCCESS status, details about attached tags. |
| tags = messages.MessageField(InstanceTag, 3, repeated=True) |
| |
| |
| class DetachTagsResponse(messages.Message): |
| """Results of detachTags call.""" |
| status = messages.EnumField(Status, 1, required=True) |
| error_message = messages.StringField(2, required=False) |
| |
| |
| ################################################################################ |
| |
| |
| class SearchResponse(messages.Message): |
| """Results of searchInstances call.""" |
| status = messages.EnumField(Status, 1, required=True) |
| error_message = messages.StringField(2, required=False) |
| |
| # For SUCCESS, list of instances found. |
| instances = messages.MessageField(PackageInstance, 3, repeated=True) |
| |
| |
| class ResolveVersionResponse(messages.Message): |
| """Results of resolveVersion call.""" |
| status = messages.EnumField(Status, 1, required=True) |
| error_message = messages.StringField(2, required=False) |
| |
| # For SUCCESS, concrete existing instance ID. |
| instance_id = messages.StringField(3, required=False) |
| |
| |
| ################################################################################ |
| |
| |
| class FetchACLResponse(messages.Message): |
| """Results of fetchACL call.""" |
| status = messages.EnumField(Status, 1, required=True) |
| error_message = messages.StringField(2, required=False) |
| |
| # For SUCCESS status, list of ACLs split by package path and role. |
| acls = messages.MessageField(PackageACL, 3, required=False) |
| |
| |
| ################################################################################ |
| |
| |
| class ModifyACLRequest(messages.Message): |
| """Body of modifyACL call.""" |
| changes = messages.MessageField(RoleChange, 1, repeated=True) |
| |
| |
| class ModifyACLResponse(messages.Message): |
| """Results of modifyACL call.""" |
| status = messages.EnumField(Status, 1, required=True) |
| error_message = messages.StringField(2, required=False) |
| |
| |
| ################################################################################ |
| |
| |
| class FetchRolesResponse(messages.Message): |
| """Results of fetchRoles call.""" |
| status = messages.EnumField(Status, 1, required=True) |
| error_message = messages.StringField(2, required=False) |
| |
| # For SUCCESS status, roles the caller has in the package, including inherited |
| # ones. |
| roles = messages.StringField(3, repeated=True) |
| |
| |
| ################################################################################ |
| |
| |
| class FetchClientBinaryResponse(messages.Message): |
| """Results of fetchClientBinary call.""" |
| class ClientBinary(messages.Message): |
| # SHA1 hex digest of the extracted binary, for verification on the client. |
| sha1 = messages.StringField(1, required=True) |
| # Size of the binary file, just for information. |
| size = messages.IntegerField(2, required=True) |
| # A signed url to fetch the binary file from. |
| fetch_url = messages.StringField(3, required=True) |
| # The file name of the actual extracted binary |
| file_name = messages.StringField(4, required=True) |
| |
| status = messages.EnumField(Status, 1, required=True) |
| error_message = messages.StringField(2, required=False) |
| |
| # For SUCCESS or NOT_EXTRACTED_YET, information about the package instance. |
| instance = messages.MessageField(PackageInstance, 3, required=False) |
| # For SUCCESS, information about the client binary. |
| client_binary = messages.MessageField(ClientBinary, 4, required=False) |
| |
| |
| ################################################################################ |
| |
| |
| class IncrementCounterRequest(messages.Message): |
| """Body of incrementCounter call.""" |
| delta = messages.IntegerField(1, required=True) |
| |
| |
| class IncrementCounterResponse(messages.Message): |
| """Results of incrementCounter call.""" |
| status = messages.EnumField(Status, 1, required=True) |
| error_message = messages.StringField(2, required=False) |
| |
| |
| class ReadCounterResponse(messages.Message): |
| """Results of readCounter call.""" |
| status = messages.EnumField(Status, 1, required=True) |
| error_message = messages.StringField(2, required=False) |
| |
| value = messages.IntegerField(3, required=False) |
| created_ts = messages.IntegerField(4, required=False) |
| updated_ts = messages.IntegerField(5, required=False) |
| |
| |
| ################################################################################ |
| |
| |
| class Error(Exception): |
| status = Status.ERROR |
| |
| |
| class PackageNotFoundError(Error): |
| status = Status.PACKAGE_NOT_FOUND |
| |
| |
| class InstanceNotFoundError(Error): |
| status = Status.INSTANCE_NOT_FOUND |
| |
| |
| class ProcessingFailedError(Error): |
| status = Status.PROCESSING_FAILED |
| |
| |
| class ProcessingNotFinishedYetError(Error): |
| status = Status.PROCESSING_NOT_FINISHED_YET |
| |
| |
| class ValidationError(Error): |
| # TODO(vadimsh): Use VALIDATION_ERROR. It changes JSON protocol. |
| status = Status.ERROR |
| |
| |
| def validate_package_name(package_name): |
| if not impl.is_valid_package_path(package_name): |
| raise ValidationError('Invalid package name') |
| return package_name |
| |
| |
| def validate_package_path(package_path): |
| if not impl.is_valid_package_path(package_path): |
| raise ValidationError('Invalid package path') |
| return package_path |
| |
| |
| def validate_package_ref(ref): |
| if not impl.is_valid_package_ref(ref): |
| raise ValidationError('Invalid package ref name') |
| return ref |
| |
| |
| def validate_package_ref_list(refs): |
| if not refs: # pragma: no cover |
| raise ValidationError('Ref list is empty') |
| return [validate_package_ref(ref) for ref in refs] |
| |
| |
| def validate_instance_id(instance_id): |
| if not impl.is_valid_instance_id(instance_id): |
| raise ValidationError('Invalid package instance ID') |
| return instance_id |
| |
| |
| def validate_instance_tag(tag): |
| if not impl.is_valid_instance_tag(tag): |
| raise ValidationError('Invalid tag "%s"' % tag) |
| return tag |
| |
| |
| def validate_instance_tag_list(tags): |
| if not tags: |
| raise ValidationError('Tag list is empty') |
| return [validate_instance_tag(tag) for tag in tags] |
| |
| |
| def validate_instance_version(version): |
| if not impl.is_valid_instance_version(version): |
| raise ValidationError('Not a valid instance ID or tag: "%s"' % version) |
| return version |
| |
| |
| def validate_counter_name(counter_name): |
| if not impl.is_valid_counter_name(counter_name): |
| raise ValidationError('Invalid counter name') |
| return counter_name |
| |
| |
| def endpoints_method(request_message, response_message, **kwargs): |
| """Wrapper around Endpoint methods to simplify error handling. |
| |
| Catches Error exceptions and converts them to error responses. Assumes |
| response_message has fields 'status' and 'error_message'. |
| """ |
| assert hasattr(response_message, 'status') |
| assert hasattr(response_message, 'error_message') |
| def decorator(f): |
| @auth.endpoints_method(request_message, response_message, **kwargs) |
| @functools.wraps(f) |
| def wrapper(*args): |
| try: |
| response = f(*args) |
| if response.status is None: |
| response.status = Status.SUCCESS |
| return response |
| except Error as e: |
| return response_message( |
| status=e.status, |
| error_message=e.message if e.message else None) |
| except auth.Error as e: |
| caller = auth.get_current_identity().to_bytes() |
| logging.warning('%s (%s): %s', e.__class__.__name__, caller, e) |
| raise |
| return wrapper |
| return decorator |
| |
| |
| ################################################################################ |
| |
| |
| @auth.endpoints_api( |
| name='repo', |
| version='v1', |
| title='CIPD Package Repository API') |
| class PackageRepositoryApi(remote.Service): |
| """Package Repository API.""" |
| |
| # Cached value of 'service' property. |
| _service = None |
| |
| @property |
| def service(self): |
| """Returns configured impl.RepoService.""" |
| if self._service is None: |
| self._service = impl.get_repo_service() |
| if self._service is None: |
| raise endpoints.InternalServerErrorException( |
| 'Service is not configured') |
| return self._service |
| |
| def get_instance(self, package_name, instance_id): |
| """Grabs PackageInstance or raises appropriate *NotFoundError.""" |
| instance = self.service.get_instance(package_name, instance_id) |
| if instance is None: |
| pkg = self.service.get_package(package_name) |
| if pkg is None: |
| raise PackageNotFoundError() |
| raise InstanceNotFoundError() |
| return instance |
| |
| def verify_instance_exists(self, package_name, instance_id): |
| """Raises appropriate *NotFoundError if instance is missing.""" |
| self.get_instance(package_name, instance_id) |
| |
| def verify_instance_is_ready(self, package_name, instance_id): |
| """Raises appropriate error if instance doesn't exist or not ready yet. |
| |
| Instance is ready when all processors successfully finished. |
| """ |
| instance = self.get_instance(package_name, instance_id) |
| if instance.processors_failure: |
| raise ProcessingFailedError( |
| 'Failed processors: %s' % ', '.join(instance.processors_failure)) |
| if instance.processors_pending: |
| raise ProcessingNotFinishedYetError( |
| 'Pending processors: %s' % ', '.join(instance.processors_pending)) |
| |
| |
| ### Package methods. |
| |
| |
| @gae_ts_mon.instrument_endpoint() |
| @endpoints_method( |
| endpoints.ResourceContainer( |
| message_types.VoidMessage, |
| package_name=messages.StringField(1, required=True), |
| with_refs=messages.BooleanField(2, required=False)), |
| PackageResponse, |
| http_method='GET', |
| path='package', |
| name='fetchPackage') |
| @auth.public # ACL check is inside |
| def fetch_package(self, request): |
| """Returns information about a package.""" |
| package_name = validate_package_name(request.package_name) |
| |
| caller = auth.get_current_identity() |
| if not acl.can_fetch_package(package_name, caller): |
| raise auth.AuthorizationError() |
| |
| pkg = self.service.get_package(package_name) |
| if pkg is None: |
| raise PackageNotFoundError() |
| |
| refs = [] |
| if request.with_refs: |
| refs = self.service.query_package_refs(package_name) |
| |
| return PackageResponse( |
| package=package_to_proto(pkg), |
| refs=[package_ref_to_proto(r) for r in refs]) |
| |
| |
| @gae_ts_mon.instrument_endpoint() |
| @endpoints_method( |
| endpoints.ResourceContainer( |
| message_types.VoidMessage, |
| package_name=messages.StringField(1, required=True)), |
| PackageResponse, |
| http_method='POST', |
| path='package/hidden', |
| name='hidePackage') |
| @auth.public # ACL check is inside |
| def hide_package(self, request): |
| """Marks the package as hidden, it disappears from listPackages output.""" |
| return self.set_package_hidden(request.package_name, True) |
| |
| |
| @gae_ts_mon.instrument_endpoint() |
| @endpoints_method( |
| endpoints.ResourceContainer( |
| message_types.VoidMessage, |
| package_name=messages.StringField(1, required=True)), |
| PackageResponse, |
| http_method='DELETE', |
| path='package/hidden', |
| name='unhidePackage') |
| @auth.public # ACL check is inside |
| def unhide_package(self, request): |
| """Marks the package as visible, the reverse of hidePackage.""" |
| return self.set_package_hidden(request.package_name, False) |
| |
| |
| def set_package_hidden(self, package_name, hidden): |
| """Common implementation for hide_package and unhide_package.""" |
| package_name = validate_package_name(package_name) |
| |
| caller = auth.get_current_identity() |
| if not acl.can_modify_hidden(package_name, caller): |
| raise auth.AuthorizationError() |
| |
| def mutation(pkg): |
| if pkg.hidden == hidden: |
| return False |
| pkg.hidden = hidden |
| return True |
| |
| pkg = self.service.modify_package(package_name, mutation) |
| if pkg is None: |
| raise PackageNotFoundError() |
| |
| return PackageResponse(package=package_to_proto(pkg)) |
| |
| |
| @gae_ts_mon.instrument_endpoint() |
| @endpoints_method( |
| endpoints.ResourceContainer( |
| message_types.VoidMessage, |
| path=messages.StringField(1, required=False), |
| recursive=messages.BooleanField(2, required=False), |
| show_hidden=messages.BooleanField(3, required=False)), |
| ListPackagesResponse, |
| http_method='GET', |
| path='package/search', |
| name='listPackages') |
| @auth.public # ACL check is inside |
| def list_packages(self, request): |
| """Returns packages in the given directory and possibly subdirectories.""" |
| path = request.path or '' |
| recursive = request.recursive or False |
| show_hidden = request.show_hidden or False |
| |
| pkgs, dirs = self.service.list_packages(path, recursive, show_hidden) |
| caller = auth.get_current_identity() |
| visible_pkgs = [p for p in pkgs if acl.can_fetch_package(p, caller)] |
| visible_dirs = [d for d in dirs if acl.can_fetch_package(d, caller)] |
| visible_pkgs.sort() |
| visible_dirs.sort() |
| |
| return ListPackagesResponse(packages=visible_pkgs, directories=visible_dirs) |
| |
| |
| @gae_ts_mon.instrument_endpoint() |
| @endpoints_method( |
| endpoints.ResourceContainer( |
| message_types.VoidMessage, |
| package_name=messages.StringField(1, required=True)), |
| DeletePackageResponse, |
| http_method='DELETE', |
| path='package', |
| name='deletePackage') |
| @auth.public # ACL check is inside |
| def delete_package(self, request): |
| """Deletes a package along with all its instances.""" |
| package_name = validate_package_name(request.package_name) |
| |
| caller = auth.get_current_identity() |
| if not acl.can_delete_package(package_name, caller): |
| raise auth.AuthorizationError() |
| |
| deleted = self.service.delete_package(package_name) |
| if not deleted: |
| raise PackageNotFoundError() |
| return DeletePackageResponse() |
| |
| |
| ### PackageInstance methods. |
| |
| |
| @gae_ts_mon.instrument_endpoint() |
| @endpoints_method( |
| endpoints.ResourceContainer( |
| message_types.VoidMessage, |
| package_name=messages.StringField(1, required=True), |
| instance_id=messages.StringField(2, required=True)), |
| FetchInstanceResponse, |
| http_method='GET', |
| path='instance', |
| name='fetchInstance') |
| @auth.public # ACL check is inside |
| def fetch_instance(self, request): |
| """Returns signed URL that can be used to fetch a package instance.""" |
| package_name = validate_package_name(request.package_name) |
| instance_id = validate_instance_id(request.instance_id) |
| |
| caller = auth.get_current_identity() |
| if not acl.can_fetch_instance(package_name, caller): |
| raise auth.AuthorizationError() |
| |
| instance = self.get_instance(package_name, instance_id) |
| return FetchInstanceResponse( |
| instance=instance_to_proto(instance), |
| fetch_url=self.service.generate_fetch_url(instance), |
| processors=processors_protos(instance)) |
| |
| |
| @gae_ts_mon.instrument_endpoint() |
| @endpoints_method( |
| endpoints.ResourceContainer( |
| message_types.VoidMessage, |
| package_name=messages.StringField(1, required=True), |
| instance_id=messages.StringField(2, required=True)), |
| RegisterInstanceResponse, |
| path='instance', |
| http_method='POST', |
| name='registerInstance') |
| @auth.public # ACL check is inside |
| def register_instance(self, request): |
| """Registers a new package instance in the repository.""" |
| package_name = validate_package_name(request.package_name) |
| instance_id = validate_instance_id(request.instance_id) |
| |
| caller = auth.get_current_identity() |
| if not acl.can_register_instance(package_name, caller): |
| raise auth.AuthorizationError() |
| |
| instance = self.service.get_instance(package_name, instance_id) |
| if instance is not None: |
| return RegisterInstanceResponse( |
| status=Status.ALREADY_REGISTERED, |
| instance=instance_to_proto(instance)) |
| |
| # Need to upload to CAS first? Open an upload session. Caller must use |
| # CASServiceApi to finish the upload and then call registerInstance again. |
| if not self.service.is_instance_file_uploaded(package_name, instance_id): |
| upload_url, upload_session_id = self.service.create_upload_session( |
| package_name, instance_id, caller) |
| return RegisterInstanceResponse( |
| status=Status.UPLOAD_FIRST, |
| upload_session_id=upload_session_id, |
| upload_url=upload_url) |
| |
| # Package data is in the store. Make an entity. |
| instance, registered = self.service.register_instance( |
| package_name=package_name, |
| instance_id=instance_id, |
| caller=caller, |
| now=utils.utcnow()) |
| return RegisterInstanceResponse( |
| status=Status.REGISTERED if registered else Status.ALREADY_REGISTERED, |
| instance=instance_to_proto(instance)) |
| |
| |
| @gae_ts_mon.instrument_endpoint() |
| @endpoints_method( |
| endpoints.ResourceContainer( |
| message_types.VoidMessage, |
| package_name=messages.StringField(1, required=True), |
| limit=messages.IntegerField(2, default=100), |
| cursor=messages.StringField(3)), |
| ListInstancesResponse, |
| http_method='GET', |
| path='instances', |
| name='listInstances') |
| @auth.public # ACL check is inside |
| def list_instances(self, request): |
| """Lists all registered package instances, most recent first.""" |
| package_name = validate_package_name(request.package_name) |
| if request.limit <= 0: |
| raise ValidationError('The limit must be positive') |
| |
| caller = auth.get_current_identity() |
| if not acl.can_fetch_instance(package_name, caller): |
| raise auth.AuthorizationError() |
| |
| if not self.service.get_package(package_name): |
| raise PackageNotFoundError() |
| |
| try: |
| instances, cursor = self.service.list_instances( |
| package_name, limit=request.limit, cursor=request.cursor) |
| except ValueError as exc: |
| raise ValidationError(str(exc)) |
| |
| return ListInstancesResponse( |
| status=Status.SUCCESS, |
| instances=[instance_to_proto(i) for i in instances], |
| cursor=cursor) |
| |
| |
| ### Refs methods. |
| |
| |
| @gae_ts_mon.instrument_endpoint() |
| @endpoints_method( |
| endpoints.ResourceContainer( |
| SetRefRequest, |
| package_name=messages.StringField(1, required=True), |
| ref=messages.StringField(2, required=True)), |
| SetRefResponse, |
| path='ref', |
| http_method='POST', |
| name='setRef') |
| @auth.public # ACL check is inside |
| def set_ref(self, request): |
| """Creates a ref or moves an existing one.""" |
| package_name = validate_package_name(request.package_name) |
| ref = validate_package_ref(request.ref) |
| instance_id = validate_instance_id(request.instance_id) |
| |
| caller = auth.get_current_identity() |
| if not acl.can_move_ref(package_name, ref, caller): |
| raise auth.AuthorizationError('Not authorized to move "%s"' % ref) |
| self.verify_instance_is_ready(package_name, instance_id) |
| |
| ref_entity = self.service.set_package_ref( |
| package_name=package_name, |
| ref=ref, |
| instance_id=instance_id, |
| caller=caller, |
| now=utils.utcnow()) |
| return SetRefResponse(ref=package_ref_to_proto(ref_entity)) |
| |
| |
| @gae_ts_mon.instrument_endpoint() |
| @endpoints_method( |
| endpoints.ResourceContainer( |
| message_types.VoidMessage, |
| package_name=messages.StringField(1, required=True), |
| instance_id=messages.StringField(2, required=True), |
| ref=messages.StringField(3, repeated=True)), |
| FetchRefsResponse, |
| path='ref', |
| http_method='GET', |
| name='fetchRefs') |
| @auth.public # ACL check is inside |
| def fetch_refs(self, request): |
| """Lists package instance refs (newest first).""" |
| package_name = validate_package_name(request.package_name) |
| instance_id = validate_instance_id(request.instance_id) |
| refs = validate_package_ref_list(request.ref) if request.ref else None |
| |
| caller = auth.get_current_identity() |
| if not acl.can_fetch_instance(package_name, caller): |
| raise auth.AuthorizationError() |
| self.verify_instance_exists(package_name, instance_id) |
| |
| if not refs: |
| # Fetch all. |
| output = self.service.query_instance_refs(package_name, instance_id) |
| else: |
| # Fetch selected refs, pick ones pointing to the instance. |
| output = [ |
| r |
| for r in self.service.get_package_refs(package_name, refs).itervalues() |
| if r and r.instance_id == instance_id |
| ] |
| output.sort(key=lambda r: r.modified_ts, reverse=True) |
| |
| return FetchRefsResponse(refs=[package_ref_to_proto(ref) for ref in output]) |
| |
| |
| ### Tags methods. |
| |
| |
| @gae_ts_mon.instrument_endpoint() |
| @endpoints_method( |
| endpoints.ResourceContainer( |
| message_types.VoidMessage, |
| package_name=messages.StringField(1, required=True), |
| instance_id=messages.StringField(2, required=True), |
| tag=messages.StringField(3, repeated=True)), |
| FetchTagsResponse, |
| path='tags', |
| http_method='GET', |
| name='fetchTags') |
| @auth.public # ACL check is inside |
| def fetch_tags(self, request): |
| """Lists package instance tags (newest first).""" |
| package_name = validate_package_name(request.package_name) |
| instance_id = validate_instance_id(request.instance_id) |
| tags = validate_instance_tag_list(request.tag) if request.tag else None |
| |
| caller = auth.get_current_identity() |
| if not acl.can_fetch_instance(package_name, caller): |
| raise auth.AuthorizationError() |
| self.verify_instance_exists(package_name, instance_id) |
| |
| if not tags: |
| # Fetch all. |
| attached = self.service.query_tags(package_name, instance_id) |
| else: |
| # Fetch selected only. "Is tagged by?" check essentially. |
| found = self.service.get_tags(package_name, instance_id, tags) |
| attached = [found[tag] for tag in tags if found[tag]] |
| attached.sort(key=lambda t: t.registered_ts, reverse=True) |
| |
| return FetchTagsResponse(tags=[tag_to_proto(tag) for tag in attached]) |
| |
| |
| @gae_ts_mon.instrument_endpoint() |
| @endpoints_method( |
| endpoints.ResourceContainer( |
| AttachTagsRequest, |
| package_name=messages.StringField(1, required=True), |
| instance_id=messages.StringField(2, required=True)), |
| AttachTagsResponse, |
| path='tags', |
| http_method='POST', |
| name='attachTags') |
| @auth.public # ACL check is inside |
| def attach_tags(self, request): |
| """Attaches a set of tags to a package instance.""" |
| package_name = validate_package_name(request.package_name) |
| instance_id = validate_instance_id(request.instance_id) |
| tags = validate_instance_tag_list(request.tags) |
| |
| caller = auth.get_current_identity() |
| for tag in tags: |
| if not acl.can_attach_tag(package_name, tag, caller): |
| raise auth.AuthorizationError('Not authorized to attach "%s"' % tag) |
| self.verify_instance_is_ready(package_name, instance_id) |
| |
| attached = self.service.attach_tags( |
| package_name=package_name, |
| instance_id=instance_id, |
| tags=tags, |
| caller=caller, |
| now=utils.utcnow()) |
| return AttachTagsResponse(tags=[tag_to_proto(attached[t]) for t in tags]) |
| |
| |
| @gae_ts_mon.instrument_endpoint() |
| @endpoints_method( |
| endpoints.ResourceContainer( |
| message_types.VoidMessage, |
| package_name=messages.StringField(1, required=True), |
| instance_id=messages.StringField(2, required=True), |
| tag=messages.StringField(3, repeated=True)), |
| DetachTagsResponse, |
| path='tags', |
| http_method='DELETE', |
| name='detachTags') |
| @auth.public # ACL check is inside |
| def detach_tags(self, request): |
| """Removes given tags from a package instance.""" |
| package_name = validate_package_name(request.package_name) |
| instance_id = validate_instance_id(request.instance_id) |
| tags = validate_instance_tag_list(request.tag) |
| |
| caller = auth.get_current_identity() |
| for tag in tags: |
| if not acl.can_detach_tag(package_name, tag, caller): |
| raise auth.AuthorizationError('Not authorized to detach "%s"' % tag) |
| self.verify_instance_exists(package_name, instance_id) |
| |
| self.service.detach_tags( |
| package_name=package_name, |
| instance_id=instance_id, |
| tags=tags) |
| return DetachTagsResponse() |
| |
| |
| ### Search methods. |
| |
| |
| @gae_ts_mon.instrument_endpoint() |
| @endpoints_method( |
| endpoints.ResourceContainer( |
| message_types.VoidMessage, |
| tag=messages.StringField(1, required=True), |
| package_name=messages.StringField(2, required=False)), |
| SearchResponse, |
| path='instance/search', |
| http_method='GET', |
| name='searchInstances') |
| @auth.public # ACL check is inside |
| def search_instances(self, request): |
| """Returns package instances with given tag (in no particular order).""" |
| tag = validate_instance_tag(request.tag) |
| if request.package_name: |
| package_name = validate_package_name(request.package_name) |
| else: |
| package_name = None |
| |
| caller = auth.get_current_identity() |
| callback = None |
| if package_name: |
| # If search is limited to one package, check its ACL only once. |
| if not acl.can_fetch_instance(package_name, caller): |
| raise auth.AuthorizationError() |
| else: |
| # Filter out packages not allowed by ACL. |
| acl_cache = {} |
| def check_readable(package_name, _instance_id): |
| if package_name not in acl_cache: |
| acl_cache[package_name] = acl.can_fetch_instance(package_name, caller) |
| return acl_cache[package_name] |
| callback = check_readable |
| |
| found = self.service.search_by_tag(tag, package_name, callback) |
| return SearchResponse(instances=[instance_to_proto(i) for i in found]) |
| |
| |
| @gae_ts_mon.instrument_endpoint() |
| @endpoints_method( |
| endpoints.ResourceContainer( |
| message_types.VoidMessage, |
| package_name=messages.StringField(1, required=True), |
| version=messages.StringField(2, required=True)), |
| ResolveVersionResponse, |
| path='instance/resolve', |
| http_method='GET', |
| name='resolveVersion') |
| @auth.public # ACL check is inside |
| def resolve_version(self, request): |
| """Returns instance ID of an existing instance given a ref or a tag.""" |
| package_name = validate_package_name(request.package_name) |
| version = validate_instance_version(request.version) |
| |
| caller = auth.get_current_identity() |
| if not acl.can_fetch_instance(package_name, caller): |
| raise auth.AuthorizationError() |
| |
| pkg = self.service.get_package(package_name) |
| if pkg is None: |
| raise PackageNotFoundError() |
| |
| ids = self.service.resolve_version(package_name, version, limit=2) |
| if not ids: |
| raise InstanceNotFoundError() |
| if len(ids) > 1: |
| return ResolveVersionResponse( |
| status=Status.AMBIGUOUS_VERSION, |
| error_message='More than one instance has tag "%s" set' % version) |
| return ResolveVersionResponse(instance_id=ids[0]) |
| |
| |
| ### ACL methods. |
| |
| |
| @gae_ts_mon.instrument_endpoint() |
| @endpoints_method( |
| endpoints.ResourceContainer( |
| message_types.VoidMessage, |
| package_path=messages.StringField(1, required=True)), |
| FetchACLResponse, |
| http_method='GET', |
| path='acl', |
| name='fetchACL') |
| @auth.public # ACL check is inside |
| def fetch_acl(self, request): |
| """Returns access control list for a given package path.""" |
| package_path = validate_package_path(request.package_path) |
| |
| caller = auth.get_current_identity() |
| if not acl.can_fetch_acl(package_path, caller): |
| raise auth.AuthorizationError() |
| |
| return FetchACLResponse( |
| acls=package_acls_to_proto({ |
| role: acl.get_package_acls(package_path, role) |
| for role in acl.ROLES |
| })) |
| |
| |
| @gae_ts_mon.instrument_endpoint() |
| @endpoints_method( |
| endpoints.ResourceContainer( |
| ModifyACLRequest, |
| package_path=messages.StringField(1, required=True)), |
| ModifyACLResponse, |
| http_method='POST', |
| path='acl', |
| name='modifyACL') |
| @auth.public # ACL check is inside |
| def modify_acl(self, request): |
| """Changes access control list for a given package path.""" |
| package_path = validate_package_path(request.package_path) |
| |
| try: |
| changes = [ |
| role_change_from_proto(msg, package_path) |
| for msg in request.changes |
| ] |
| except ValueError as exc: |
| raise ValidationError('Invalid role change request: %s' % exc) |
| |
| caller = auth.get_current_identity() |
| if not acl.can_modify_acl(package_path, caller): |
| raise auth.AuthorizationError() |
| |
| # Apply changes. Do not catch ValueError. Validation above should be |
| # sufficient. If it is not, HTTP 500 and an uncaught exception in logs is |
| # exactly what is needed. |
| acl.modify_roles(changes, caller, utils.utcnow()) |
| return ModifyACLResponse() |
| |
| |
| @gae_ts_mon.instrument_endpoint() |
| @endpoints_method( |
| endpoints.ResourceContainer( |
| message_types.VoidMessage, |
| package_path=messages.StringField(1, required=True)), |
| FetchRolesResponse, |
| http_method='GET', |
| path='roles', |
| name='fetchRoles') |
| @auth.public |
| def fetch_roles(self, request): |
| """Queries what roles the caller has in the package prefix.""" |
| package_path = validate_package_path(request.package_path) |
| caller = auth.get_current_identity() |
| return FetchRolesResponse(roles=sorted(acl.get_roles(package_path, caller))) |
| |
| |
| ### ClientBinary methods. |
| |
| |
| @gae_ts_mon.instrument_endpoint() |
| @endpoints_method( |
| endpoints.ResourceContainer( |
| message_types.VoidMessage, |
| package_name=messages.StringField(1, required=True), |
| instance_id=messages.StringField(2, required=True)), |
| FetchClientBinaryResponse, |
| http_method='GET', |
| path='client', |
| name='fetchClientBinary') |
| @auth.public # ACL check is inside |
| def fetch_client_binary(self, request): |
| """Returns signed URL that can be used to fetch CIPD client binary.""" |
| package_name = validate_package_name(request.package_name) |
| if not client.is_cipd_client_package(package_name): |
| raise ValidationError('Not a CIPD client package') |
| instance_id = validate_instance_id(request.instance_id) |
| |
| caller = auth.get_current_identity() |
| if not acl.can_fetch_instance(package_name, caller): |
| raise auth.AuthorizationError() |
| |
| # Grab the location of the extracted binary. |
| instance = self.get_instance(package_name, instance_id) |
| client_info, error_message = self.service.get_client_binary_info(instance) |
| if error_message: |
| raise Error(error_message) |
| if client_info is None: |
| return FetchClientBinaryResponse( |
| status=Status.NOT_EXTRACTED_YET, |
| instance=instance_to_proto(instance)) |
| |
| return FetchClientBinaryResponse( |
| instance=instance_to_proto(instance), |
| client_binary=FetchClientBinaryResponse.ClientBinary( |
| sha1=client_info.sha1, |
| size=client_info.size, |
| fetch_url=client_info.fetch_url, |
| file_name=client.get_cipd_client_filename(package_name))) |
| |
| |
| ### Counter methods. |
| |
| |
| @gae_ts_mon.instrument_endpoint() |
| @endpoints_method( |
| endpoints.ResourceContainer( |
| IncrementCounterRequest, |
| package_name=messages.StringField(1, required=True), |
| instance_id=messages.StringField(2, required=True), |
| counter_name=messages.StringField(3, required=True)), |
| IncrementCounterResponse, |
| path='counter', |
| http_method='POST', |
| name='incrementCounter') |
| @auth.public # ACL check is inside |
| def increment_counter(self, request): |
| """Increments a counter on a package instance.""" |
| package_name = validate_package_name(request.package_name) |
| instance_id = validate_instance_id(request.instance_id) |
| counter_name = validate_counter_name(request.counter_name) |
| delta = request.delta |
| |
| if delta not in (0, 1): |
| raise ValidationError('Delta must be either 0 or 1') |
| |
| caller = auth.get_current_identity() |
| if not acl.can_modify_counter(package_name, caller): |
| raise auth.AuthorizationError() |
| self.verify_instance_exists(package_name, instance_id) |
| |
| self.service.increment_counter( |
| package_name=package_name, |
| instance_id=instance_id, |
| counter_name=counter_name, |
| delta=delta) |
| return IncrementCounterResponse() |
| |
| |
| @gae_ts_mon.instrument_endpoint() |
| @endpoints_method( |
| endpoints.ResourceContainer( |
| message_types.VoidMessage, |
| package_name=messages.StringField(1, required=True), |
| instance_id=messages.StringField(2, required=True), |
| counter_name=messages.StringField(3, required=True)), |
| ReadCounterResponse, |
| path='counter', |
| http_method='GET', |
| name='readCounter') |
| @auth.public # ACL check is inside |
| def read_counter(self, request): |
| """Increments a counter on a package instance.""" |
| package_name = validate_package_name(request.package_name) |
| instance_id = validate_instance_id(request.instance_id) |
| counter_name = validate_counter_name(request.counter_name) |
| |
| caller = auth.get_current_identity() |
| if not acl.can_read_counter(package_name, caller): |
| raise auth.AuthorizationError() |
| self.verify_instance_exists(package_name, instance_id) |
| |
| counter = self.service.read_counter( |
| package_name=package_name, |
| instance_id=instance_id, |
| counter_name=counter_name) |
| |
| response = ReadCounterResponse(value=counter.value) |
| if counter.created_ts is not None: |
| response.created_ts = utils.datetime_to_timestamp(counter.created_ts) |
| if counter.updated_ts is not None: |
| response.updated_ts = utils.datetime_to_timestamp(counter.updated_ts) |
| return response |