Merge pull request #3024 from coreos-inc/manageable-robots
Manageable robots epic
This commit is contained in:
		
						commit
						6c43b7ff0d
					
				
					 24 changed files with 411 additions and 131 deletions
				
			
		|  | @ -321,7 +321,7 @@ def require_scope(scope_object): | |||
|   return wrapper | ||||
| 
 | ||||
| 
 | ||||
| def validate_json_request(schema_name): | ||||
| def validate_json_request(schema_name, optional=False): | ||||
|   def wrapper(func): | ||||
|     @add_method_metadata('request_schema', schema_name) | ||||
|     @wraps(func) | ||||
|  | @ -330,9 +330,10 @@ def validate_json_request(schema_name): | |||
|       try: | ||||
|         json_data = request.get_json() | ||||
|         if json_data is None: | ||||
|           raise InvalidRequest('Missing JSON body') | ||||
| 
 | ||||
|         validate(json_data, schema) | ||||
|           if not optional: | ||||
|             raise InvalidRequest('Missing JSON body') | ||||
|         else: | ||||
|           validate(json_data, schema) | ||||
|         return func(self, *args, **kwargs) | ||||
|       except ValidationError as ex: | ||||
|         raise InvalidRequest(ex.message) | ||||
|  |  | |||
|  | @ -2,14 +2,31 @@ | |||
| 
 | ||||
| from endpoints.api import (resource, nickname, ApiResource, log_action, related_user_resource, | ||||
|                            require_user_admin, require_scope, path_param, parse_args, | ||||
|                            truthy_bool, query_param) | ||||
|                            truthy_bool, query_param, validate_json_request) | ||||
| from endpoints.api.robot_models_pre_oci import pre_oci_model as model | ||||
| from endpoints.exception import Unauthorized | ||||
| from auth.permissions import AdministerOrganizationPermission, OrganizationMemberPermission | ||||
| from auth.auth_context import get_authenticated_user | ||||
| from auth import scopes | ||||
| from util.names import format_robot_username | ||||
| from flask import abort | ||||
| from flask import abort, request | ||||
| 
 | ||||
| 
 | ||||
| CREATE_ROBOT_SCHEMA = { | ||||
|   'type': 'object', | ||||
|   'description': 'Optional data for creating a robot', | ||||
|   'properties': { | ||||
|     'description': { | ||||
|       'type': 'string', | ||||
|       'description': 'Optional text description for the robot', | ||||
|       'maxLength': 255, | ||||
|     }, | ||||
|     'unstructured_metadata': { | ||||
|       'type': 'object', | ||||
|       'description': 'Optional unstructured metadata for the robot', | ||||
|     }, | ||||
|   }, | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| def robots_list(prefix, include_permissions=False): | ||||
|  | @ -38,6 +55,9 @@ class UserRobotList(ApiResource): | |||
|             'The short name for the robot, without any user or organization prefix') | ||||
| class UserRobot(ApiResource): | ||||
|   """ Resource for managing a user's robots. """ | ||||
|   schemas = { | ||||
|     'CreateRobot': CREATE_ROBOT_SCHEMA, | ||||
|   } | ||||
| 
 | ||||
|   @require_user_admin | ||||
|   @nickname('getUserRobot') | ||||
|  | @ -45,16 +65,23 @@ class UserRobot(ApiResource): | |||
|     """ Returns the user's robot with the specified name. """ | ||||
|     parent = get_authenticated_user() | ||||
|     robot = model.get_user_robot(robot_shortname, parent) | ||||
|     return robot.to_dict() | ||||
|     return robot.to_dict(include_metadata=True) | ||||
| 
 | ||||
|   @require_user_admin | ||||
|   @nickname('createUserRobot') | ||||
|   @validate_json_request('CreateRobot', optional=True) | ||||
|   def put(self, robot_shortname): | ||||
|     """ Create a new user robot with the specified name. """ | ||||
|     parent = get_authenticated_user() | ||||
|     robot = model.create_user_robot(robot_shortname, parent) | ||||
|     log_action('create_robot', parent.username, {'robot': robot_shortname}) | ||||
|     return robot.to_dict(), 201 | ||||
|     create_data = request.get_json() or {} | ||||
|     robot = model.create_user_robot(robot_shortname, parent, create_data.get('description'), | ||||
|                                     create_data.get('unstructured_metadata')) | ||||
|     log_action('create_robot', parent.username, { | ||||
|       'robot': robot_shortname, | ||||
|       'description': create_data.get('description'), | ||||
|       'unstructured_metadata': create_data.get('unstructured_metadata'), | ||||
|     }) | ||||
|     return robot.to_dict(include_metadata=True), 201 | ||||
| 
 | ||||
|   @require_user_admin | ||||
|   @nickname('deleteUserRobot') | ||||
|  | @ -94,6 +121,9 @@ class OrgRobotList(ApiResource): | |||
| @related_user_resource(UserRobot) | ||||
| class OrgRobot(ApiResource): | ||||
|   """ Resource for managing an organization's robots. """ | ||||
|   schemas = { | ||||
|     'CreateRobot': CREATE_ROBOT_SCHEMA, | ||||
|   } | ||||
| 
 | ||||
|   @require_scope(scopes.ORG_ADMIN) | ||||
|   @nickname('getOrgRobot') | ||||
|  | @ -102,19 +132,26 @@ class OrgRobot(ApiResource): | |||
|     permission = AdministerOrganizationPermission(orgname) | ||||
|     if permission.can(): | ||||
|       robot = model.get_org_robot(robot_shortname, orgname) | ||||
|       return robot.to_dict() | ||||
|       return robot.to_dict(include_metadata=True) | ||||
| 
 | ||||
|     raise Unauthorized() | ||||
| 
 | ||||
|   @require_scope(scopes.ORG_ADMIN) | ||||
|   @nickname('createOrgRobot') | ||||
|   @validate_json_request('CreateRobot', optional=True) | ||||
|   def put(self, orgname, robot_shortname): | ||||
|     """ Create a new robot in the organization. """ | ||||
|     permission = AdministerOrganizationPermission(orgname) | ||||
|     if permission.can(): | ||||
|       robot = model.create_org_robot(robot_shortname, orgname) | ||||
|       log_action('create_robot', orgname, {'robot': robot_shortname}) | ||||
|       return robot.to_dict(), 201 | ||||
|       create_data = request.get_json() or {} | ||||
|       robot = model.create_org_robot(robot_shortname, orgname, create_data.get('description'), | ||||
|                                      create_data.get('unstructured_metadata')) | ||||
|       log_action('create_robot', orgname, { | ||||
|         'robot': robot_shortname, | ||||
|         'description': create_data.get('description'), | ||||
|         'unstructured_metadata': create_data.get('unstructured_metadata'), | ||||
|       }) | ||||
|       return robot.to_dict(include_metadata=True), 201 | ||||
| 
 | ||||
|     raise Unauthorized() | ||||
| 
 | ||||
|  |  | |||
|  | @ -3,6 +3,8 @@ from collections import namedtuple | |||
| 
 | ||||
| from six import add_metaclass | ||||
| 
 | ||||
| from endpoints.api import format_date | ||||
| 
 | ||||
| 
 | ||||
| class Permission(namedtuple('Permission', ['repository_name', 'repository_visibility_name', 'role_name'])): | ||||
|   """ | ||||
|  | @ -36,24 +38,32 @@ class RobotWithPermissions( | |||
|   namedtuple('RobotWithPermissions', [ | ||||
|     'name', | ||||
|     'password', | ||||
|     'created', | ||||
|     'last_accessed', | ||||
|     'teams', | ||||
|     'repository_names', | ||||
|     'description', | ||||
|   ])): | ||||
|   """ | ||||
|   RobotWithPermissions is a list of robot entries. | ||||
|   :type name: string | ||||
|   :type password: string | ||||
|   :type created: datetime|None | ||||
|   :type last_accessed: datetime|None | ||||
|   :type teams: [Team] | ||||
|   :type repository_names: [string] | ||||
| 
 | ||||
|   :type description: string | ||||
|   """ | ||||
| 
 | ||||
|   def to_dict(self): | ||||
|     return { | ||||
|       'name': self.name, | ||||
|       'token': self.password, | ||||
|       'created': format_date(self.created) if self.created is not None else None, | ||||
|       'last_accessed': format_date(self.last_accessed) if self.last_accessed is not None else None, | ||||
|       'teams': [team.to_dict() for team in self.teams], | ||||
|       'repositories': self.repository_names | ||||
|       'repositories': self.repository_names, | ||||
|       'description': self.description, | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|  | @ -61,20 +71,35 @@ class Robot( | |||
|   namedtuple('Robot', [ | ||||
|     'name', | ||||
|     'password', | ||||
|     'created', | ||||
|     'last_accessed', | ||||
|     'description', | ||||
|     'unstructured_metadata', | ||||
|   ])): | ||||
|   """ | ||||
|   Robot represents a robot entity. | ||||
|   :type name: string | ||||
|   :type password: string | ||||
| 
 | ||||
|   :type created: datetime|None | ||||
|   :type last_accessed: datetime|None | ||||
|   :type description: string | ||||
|   :type unstructured_metadata: dict | ||||
|   """ | ||||
| 
 | ||||
|   def to_dict(self): | ||||
|     return { | ||||
|   def to_dict(self, include_metadata=False): | ||||
|     data = { | ||||
|       'name': self.name, | ||||
|       'token': self.password | ||||
|       'token': self.password, | ||||
|       'created': format_date(self.created) if self.created is not None else None, | ||||
|       'last_accessed': format_date(self.last_accessed) if self.last_accessed is not None else None, | ||||
|       'description': self.description, | ||||
|     } | ||||
| 
 | ||||
|     if include_metadata: | ||||
|       data['unstructured_metadata'] = self.unstructured_metadata | ||||
|      | ||||
|     return data | ||||
| 
 | ||||
| 
 | ||||
| @add_metaclass(ABCMeta) | ||||
| class RobotInterface(object): | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| from app import avatar | ||||
| from data import model | ||||
| from data.database import User, FederatedLogin, Team as TeamTable, Repository | ||||
| from data.database import User, FederatedLogin, Team as TeamTable, Repository, RobotAccountMetadata | ||||
| from endpoints.api.robot_models_interface import (RobotInterface, Robot, RobotWithPermissions, Team, | ||||
|                                                   Permission) | ||||
| 
 | ||||
|  | @ -23,14 +23,21 @@ class RobotPreOCIModel(RobotInterface): | |||
|         robot_dict = { | ||||
|           'name': robot_name, | ||||
|           'token': robot_tuple.get(FederatedLogin.service_ident), | ||||
|           'created': robot_tuple.get(User.creation_date), | ||||
|           'last_accessed': robot_tuple.get(User.last_accessed), | ||||
|           'description': robot_tuple.get(RobotAccountMetadata.description), | ||||
|           'unstructured_metadata': robot_tuple.get(RobotAccountMetadata.unstructured_json), | ||||
|         } | ||||
| 
 | ||||
|         if include_permissions: | ||||
|           robot_dict.update({ | ||||
|             'teams': [], | ||||
|             'repositories': [] | ||||
|             'repositories': [], | ||||
|           }) | ||||
|       robots[robot_name] = Robot(robot_dict['name'], robot_dict['token']) | ||||
| 
 | ||||
|       robots[robot_name] = Robot(robot_dict['name'], robot_dict['token'], robot_dict['created'], | ||||
|                                  robot_dict['last_accessed'], robot_dict['description'], | ||||
|                                  robot_dict['unstructured_metadata']) | ||||
|       if include_permissions: | ||||
|         team_name = robot_tuple.get(TeamTable.name) | ||||
|         repository_name = robot_tuple.get(Repository.name) | ||||
|  | @ -48,40 +55,52 @@ class RobotPreOCIModel(RobotInterface): | |||
|         if repository_name is not None: | ||||
|           if repository_name not in robot_dict['repositories']: | ||||
|             robot_dict['repositories'].append(repository_name) | ||||
|         robots[robot_name] = RobotWithPermissions(robot_dict['name'], robot_dict['token'], robot_dict['teams'], | ||||
|                                                   robot_dict['repositories']) | ||||
|         robots[robot_name] = RobotWithPermissions(robot_dict['name'], robot_dict['token'], | ||||
|                                                   robot_dict['created'],  | ||||
|                                                   robot_dict['last_accessed'], | ||||
|                                                   robot_dict['teams'], | ||||
|                                                   robot_dict['repositories'], | ||||
|                                                   robot_dict['description']) | ||||
| 
 | ||||
|     return robots.values() | ||||
| 
 | ||||
|   def regenerate_user_robot_token(self, robot_shortname, owning_user): | ||||
|     robot, password = model.user.regenerate_robot_token(robot_shortname, owning_user) | ||||
|     return Robot(robot.username, password) | ||||
|     robot, password, metadata = model.user.regenerate_robot_token(robot_shortname, owning_user) | ||||
|     return Robot(robot.username, password, robot.creation_date, robot.last_accessed, | ||||
|                  metadata.description, metadata.unstructured_json) | ||||
| 
 | ||||
|   def regenerate_org_robot_token(self, robot_shortname, orgname): | ||||
|     parent = model.organization.get_organization(orgname) | ||||
|     robot, password = model.user.regenerate_robot_token(robot_shortname, parent) | ||||
|     return Robot(robot.username, password) | ||||
|     robot, password, metadata = model.user.regenerate_robot_token(robot_shortname, parent) | ||||
|     return Robot(robot.username, password, robot.creation_date, robot.last_accessed, | ||||
|                  metadata.description, metadata.unstructured_json) | ||||
| 
 | ||||
|   def delete_robot(self, robot_username): | ||||
|     model.user.delete_robot(robot_username) | ||||
| 
 | ||||
|   def create_user_robot(self, robot_shortname, owning_user): | ||||
|     robot, password = model.user.create_robot(robot_shortname, owning_user) | ||||
|     return Robot(robot.username, password) | ||||
|   def create_user_robot(self, robot_shortname, owning_user, description, unstructured_metadata): | ||||
|     robot, password = model.user.create_robot(robot_shortname, owning_user, description or '', | ||||
|                                               unstructured_metadata) | ||||
|     return Robot(robot.username, password, robot.creation_date, robot.last_accessed, | ||||
|                  description or '', unstructured_metadata) | ||||
| 
 | ||||
|   def create_org_robot(self, robot_shortname, orgname): | ||||
|   def create_org_robot(self, robot_shortname, orgname, description, unstructured_metadata): | ||||
|     parent = model.organization.get_organization(orgname) | ||||
|     robot, password = model.user.create_robot(robot_shortname, parent) | ||||
|     return Robot(robot.username, password) | ||||
|     robot, password = model.user.create_robot(robot_shortname, parent, description or '', | ||||
|                                               unstructured_metadata) | ||||
|     return Robot(robot.username, password, robot.creation_date, robot.last_accessed, | ||||
|                  description or '', unstructured_metadata) | ||||
| 
 | ||||
|   def get_org_robot(self, robot_shortname, orgname): | ||||
|     parent = model.organization.get_organization(orgname) | ||||
|     robot, password = model.user.get_robot(robot_shortname, parent) | ||||
|     return Robot(robot.username, password) | ||||
|     robot, password, metadata = model.user.get_robot_and_metadata(robot_shortname, parent) | ||||
|     return Robot(robot.username, password, robot.creation_date, robot.last_accessed, | ||||
|                  metadata.description, metadata.unstructured_json) | ||||
| 
 | ||||
|   def get_user_robot(self, robot_shortname, owning_user): | ||||
|     robot, password = model.user.get_robot(robot_shortname, owning_user) | ||||
|     return Robot(robot.username, password) | ||||
|     robot, password, metadata = model.user.get_robot_and_metadata(robot_shortname, owning_user) | ||||
|     return Robot(robot.username, password, robot.creation_date, robot.last_accessed, | ||||
|                  metadata.description, metadata.unstructured_json) | ||||
| 
 | ||||
| 
 | ||||
| pre_oci_model = RobotPreOCIModel() | ||||
|  |  | |||
							
								
								
									
										38
									
								
								endpoints/api/test/test_robot.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								endpoints/api/test/test_robot.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,38 @@ | |||
| import pytest | ||||
| import json | ||||
| 
 | ||||
| from data import model | ||||
| from endpoints.api import api | ||||
| from endpoints.api.test.shared import conduct_api_call | ||||
| from endpoints.api.robot import UserRobot, OrgRobot | ||||
| from endpoints.test.shared import client_with_identity | ||||
| 
 | ||||
| from test.test_ldap import mock_ldap | ||||
| 
 | ||||
| from test.fixtures import * | ||||
| 
 | ||||
| @pytest.mark.parametrize('endpoint', [ | ||||
|   UserRobot, | ||||
|   OrgRobot, | ||||
| ]) | ||||
| @pytest.mark.parametrize('body', [ | ||||
|   {}, | ||||
|   {'description': 'this is a description'}, | ||||
|   {'unstructured_metadata': {'foo': 'bar'}}, | ||||
|   {'description': 'this is a description', 'unstructured_metadata': {'foo': 'bar'}}, | ||||
| ]) | ||||
| def test_create_robot_with_metadata(endpoint, body, client): | ||||
|   with client_with_identity('devtable', client) as cl: | ||||
|     # Create the robot with the specified body. | ||||
|     conduct_api_call(cl, endpoint, 'PUT', {'orgname': 'buynlarge', 'robot_shortname': 'somebot'}, | ||||
|                      body, expected_code=201) | ||||
| 
 | ||||
|     # Ensure the create succeeded. | ||||
|     resp = conduct_api_call(cl, endpoint, 'GET', { | ||||
|       'orgname': 'buynlarge', | ||||
|       'robot_shortname': 'somebot', | ||||
|     }) | ||||
| 
 | ||||
|     body = body or {} | ||||
|     assert resp.json['description'] == (body.get('description') or '') | ||||
|     assert resp.json['unstructured_metadata'] == (body.get('unstructured_metadata') or {}) | ||||
		Reference in a new issue