diff --git a/api-ref/source/containers.inc b/api-ref/source/containers.inc index 8f0c26558..54495d82d 100644 --- a/api-ref/source/containers.inc +++ b/api-ref/source/containers.inc @@ -9,7 +9,7 @@ stops, pauses, unpauses, restarts, renames, commits, kills, attaches to containe gets archive from container, puts archive to container, and adds security group for specified container, executes command in a running container, gets logs of a container, displays the running processes in a container, resizes -the tty session used by the exec. +the tty session used by the exec, list actions and action detail for a container. Create new container ==================== @@ -1227,3 +1227,101 @@ Response Example .. literalinclude:: samples/container-network-list-resp.json :language: javascript + +List Actions For Container +========================== + +.. rest_method:: GET /v1/containers/{container_ident}/container_actions + +List actions for a container + +Response Codes +-------------- + +.. rest_status_code:: success status.yaml + + - 200 + +.. rest_status_code:: error status.yaml + + - 401 + - 403 + - 404 + +Request +------- + +.. rest_parameters:: parameters.yaml + + - container_ident: container_ident + +Response +-------- + +.. rest_parameters:: parameters.yaml + + - action: action + - container_uuid: uuid + - message: message + - request_id: request_id_body + - start_time: start_time + - project_id: project_id_container_action + - user_id: user_id + +**Example List Actions For Container: JSON response** + +.. literalinclude:: samples/container-actions-list-resp.json + :language: javascript + + +Show Container Action Details +============================= + +.. rest_method:: GET /v1/containers/{container_ident}/container_actions/{request_ident} + +Show details for a container action. + +Response Codes +-------------- + +.. rest_status_code:: success status.yaml + + - 200 + +.. rest_status_code:: error status.yaml + + - 401 + - 403 + - 404 + +Request +------- + +.. rest_parameters:: parameters.yaml + + - container_ident: container_ident + - request_ident: request_ident + +Responce +-------- + +.. rest_parameters:: parameters.yaml + + - action: action + - container_uuid: uuid + - message: message + - project_id: project_id_container_action + - request_id: request_id_body + - start_time: start_time + - user_id: user_id + - events: container_action_events + - events.event: event + - events.start_time: event_start_time + - events.finish_time: event_finish_time + - events.result: event_result + - events.traceback: event_traceback + +**Example Show Container Action Details: JSON response** + +.. literalinclude:: samples/container-action-get-resp.json + :language: javascript diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml index 8d45a78bc..dc226f03e 100644 --- a/api-ref/source/parameters.yaml +++ b/api-ref/source/parameters.yaml @@ -17,6 +17,12 @@ host_ident: in: path required: true type: string +request_ident: + description: | + The ID of the request. + in: path + required: true + type: string destination_path: description: | The destination path in a container when putting archive to a container. @@ -164,6 +170,12 @@ width: in: query required: true type: string +action: + description: | + The name of the action. + in: body + required: true + type: string addresses: type: string description: | @@ -187,6 +199,24 @@ command: Send command to the container. in: body type: string +container_action: + description: | + The container action object. + in: body + required: true + type: object +container_action_events: + description: | + The events occurred in this action. + in: body + required: true + type: array +container_actions: + description: | + List of the actions for the given container. + in: body + required: true + type: array container_list: type: array in: body @@ -240,6 +270,54 @@ environment: The environment variables. in: body type: array +event: + description: | + The name of the event. + in: body + required: true + type: string +event_finish_time: + description: | + The date and time when the event was finished. The date and time + stamp format is `ISO 8601 `_ + + :: + + CCYY-MM-DDThh:mm:ss±hh:mm + + For example, ``2015-08-27T09:49:58-05:00``. The ``±hh:mm`` + value, if included, is the time zone as an offset from UTC. In + the previous example, the offset value is ``-05:00``. + in: body + required: true + type: string +event_result: + description: | + The result of the event. + in: body + required: true + type: string +event_start_time: + description: | + The date and time when the event was started. The date and time + stamp format is `ISO 8601 `_ + + :: + + CCYY-MM-DDThh:mm:ss±hh:mm + + For example, ``2015-08-27T09:49:58-05:00``. The ``±hh:mm`` + value, if included, is the time zone as an offset from UTC. In + the previous example, the offset value is ``-05:00``. + in: body + required: true + type: string +event_traceback: + description: | + The traceback stack if error occurred in this event. + in: body + required: true + type: string exec_exit_code: description: | The exit code of the command executed in a container. @@ -346,6 +424,12 @@ memory: The container memory size in MiB. in: body type: integer +message: + description: | + The error message message about this action when error occurred. + in: body + required: true + type: string name: description: | The name of the container. @@ -394,6 +478,12 @@ ports: in: body required: true type: string +project_id_container_action: + description: | + The UUID of the project that this container belongs to. + in: body + required: ture + type: string ps_output: description: | The output of zun top. @@ -406,6 +496,12 @@ report_count: in: body required: true type: integer +request_id_body: + description: | + The request id generated when execute the API of this action. + in: body + required: true + type: string restart_policy: description: | Restart policy to apply when a container exits. Allowed values are @@ -436,6 +532,21 @@ services: in: body required: true type: array +start_time: + description: | + The date and time when the action was started. The date and time + stamp format is `ISO 8601 `_ + + :: + + CCYY-MM-DDThh:mm:ss±hh:mm + + For example, ``2015-08-27T09:49:58-05:00``. The ``±hh:mm`` + value, if included, is the time zone as an offset from UTC. In + the previous example, the offset value is ``-05:00``. + in: body + required: true + type: string stat: description: | The stat information when doing get_archive. @@ -506,6 +617,12 @@ updated_at: in: body required: true type: string +user_id: + description: | + The user ID of the user who owns the container. + in: body + required: true + type: string uuid: description: | UUID of the resource. diff --git a/api-ref/source/samples/container-action-get-resp.json b/api-ref/source/samples/container-action-get-resp.json new file mode 100644 index 000000000..b1555f8e5 --- /dev/null +++ b/api-ref/source/samples/container-action-get-resp.json @@ -0,0 +1,18 @@ +{ + "action": "create", + "events": [ + { + "event": "container__do_container_start", + "finish_time": "2018-03-04 17:03:07+00:00", + "result": "Success", + "start_time": "2018-03-04 17:02:57+00:00", + "traceback": null + } + ], + "container_uuid": "b48316c5-71e8-45e4-9884-6c78055b9b13", + "message": null, + "project_id": "853719b303ef4858a195535eb520e58d", + "request_id": "req-3293a3f1-b44c-4609-b8d2-d81b105636b8", + "start_time": "2018-03-04 17:02:54+00:00", + "user_id": "22e81669093742b7a74b1d715a9a5813" +} diff --git a/api-ref/source/samples/container-actions-list-resp.json b/api-ref/source/samples/container-actions-list-resp.json new file mode 100644 index 000000000..945da5969 --- /dev/null +++ b/api-ref/source/samples/container-actions-list-resp.json @@ -0,0 +1,21 @@ +[ + { + "action": "create", + "container_uuid": "b48316c5-71e8-45e4-9884-6c78055b9b13", + "message": null, + "project_id": "853719b303ef4858a195535eb520e58d", + "request_id": "req-25517360-b757-47d3-be45-0e8d2a01b36a", + "start_time": "2018-03-04 19:48:49+00:00", + "user_id": "22e81669093742b7a74b1d715a9a5813" + }, + { + "action": "stop", + "container_uuid": "b48316c5-71e8-45e4-9884-6c78055b9b13", + "message": null, + "project_id": "853719b303ef4858a195535eb520e58d", + "request_id": "req-3293a3f1-b44c-4609-b8d2-d81b105636b8", + "start_time": "2018-03-04 17:02:54+00:00", + "user_id": "22e81669093742b7a74b1d715a9a5813" + } +] + diff --git a/zun/api/controllers/v1/containers.py b/zun/api/controllers/v1/containers.py index 9c5180a04..ed3c35b3a 100644 --- a/zun/api/controllers/v1/containers.py +++ b/zun/api/controllers/v1/containers.py @@ -74,6 +74,82 @@ class ContainerCollection(collection.Collection): return collection +class ContainersActionsController(base.Controller): + """Controller for Container Actions.""" + + def __init__(self): + super(ContainersActionsController, self).__init__() + self._action_keys = ['action', 'container_uuid', 'request_id', + 'user_id', 'project_id', 'start_time', + 'message'] + self._event_keys = ['event', 'start_time', 'finish_time', 'result', + 'traceback'] + + def _format_action(self, action_raw): + action = {} + action_dict = action_raw.as_dict() + for key in self._action_keys: + action[key] = action_dict.get(key) + return action + + def _format_event(self, event_raw, show_traceback=False): + event = {} + event_dict = event_raw.as_dict() + for key in self._event_keys: + # By default, non-admins are not allowed to see traceback details. + if key == 'traceback' and not show_traceback: + event['traceback'] = None + continue + event[key] = event_dict.get(key) + return event + + @pecan.expose('json') + @exception.wrap_pecan_controller_exception + def get_all(self, container_ident, **kwargs): + """Retrieve a list of container actions.""" + context = pecan.request.context + policy.enforce(context, "container:actions", + action="container:actions") + container = utils.get_container(container_ident) + actions_raw = objects.ContainerAction.get_by_container_uuid( + context, container.uuid) + actions = [self._format_action(action) for action in actions_raw] + + return actions + + @pecan.expose('json') + @exception.wrap_pecan_controller_exception + def get_one(self, container_ident, request_ident, **kwargs): + """Retrieve information about the action.""" + + context = pecan.request.context + policy.enforce(context, "container:actions", + action="container:actions") + container = utils.get_container(container_ident) + action = objects.ContainerAction.get_by_request_id( + context, container.uuid, request_ident) + + if action is None: + raise exception.ResourceNotFound(name="Action", id=request_ident) + + action_id = action.id + if CONF.database.backend == 'etcd': + # etcd using action.uuid get the unique action instead of action.id + action_id = action.uuid + + action = self._format_action(action) + show_traceback = False + if policy.enforce(context, "container:action:events", + do_raise=False, action="container:action:events"): + show_traceback = True + + events_raw = objects.ContainerActionEvent.get_by_action(context, + action_id) + action['events'] = [self._format_event(evt, show_traceback) + for evt in events_raw] + return action + + class ContainersController(base.Controller): """Controller for Containers.""" @@ -102,6 +178,8 @@ class ContainersController(base.Controller): 'remove_security_group': ['POST'] } + container_actions = ContainersActionsController() + @pecan.expose('json') @exception.wrap_pecan_controller_exception def get_all(self, **kwargs): diff --git a/zun/common/policies/__init__.py b/zun/common/policies/__init__.py index 29bdd7b1f..80ed2da7b 100644 --- a/zun/common/policies/__init__.py +++ b/zun/common/policies/__init__.py @@ -15,6 +15,7 @@ import itertools from zun.common.policies import base from zun.common.policies import capsule from zun.common.policies import container +from zun.common.policies import container_action from zun.common.policies import host from zun.common.policies import image from zun.common.policies import network @@ -29,5 +30,6 @@ def list_rules(): zun_service.list_rules(), host.list_rules(), capsule.list_rules(), - network.list_rules() + network.list_rules(), + container_action.list_rules() ) diff --git a/zun/common/policies/container_action.py b/zun/common/policies/container_action.py new file mode 100644 index 000000000..c98c9ea9a --- /dev/null +++ b/zun/common/policies/container_action.py @@ -0,0 +1,53 @@ +# 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. + +from oslo_policy import policy + +from zun.common.policies import base + +ACTION = 'container:actions' +EVENT = 'container:action:events' + +rules = [ + policy.DocumentedRuleDefault( + name=ACTION, + check_str=base.RULE_ADMIN_OR_OWNER, + description='List actions and show action details for a container', + operations=[ + { + 'path': '/v1/containers/{container_ident}/container_actions/', + 'method': 'GET' + }, + { + 'path': '/v1/containers/{container_ident}/container_actions/' + '{request_id}', + 'method': 'GET' + } + ] + ), + policy.DocumentedRuleDefault( + name=EVENT, + check_str=base.RULE_ADMIN_API, + description="Add events details in action details for a container.", + operations=[ + { + 'path': '/v1/containers/{container_ident}/container_actions/' + '{request_id}', + 'method': 'GET' + } + ] + ) +] + + +def list_rules(): + return rules diff --git a/zun/tests/unit/api/controllers/v1/test_containers.py b/zun/tests/unit/api/controllers/v1/test_containers.py index e8549d1a1..5938cb1bb 100644 --- a/zun/tests/unit/api/controllers/v1/test_containers.py +++ b/zun/tests/unit/api/controllers/v1/test_containers.py @@ -1891,3 +1891,180 @@ class TestContainerEnforcement(api_base.FunctionalTest): self._owner_check('container:%s' % action, self.post_json, '/containers/%s/%s/' % (container.uuid, action), {}, expect_errors=True) + + +class TestContainerActionController(api_base.FunctionalTest): + + def _format_action(self, action, expect_traceback=True): + '''Remove keys that aren't serialized.''' + to_delete = ('id', 'finish_time', 'created_at', 'updated_at', + 'deleted_at', 'deleted') + for key in to_delete: + if key in action: + del (action[key]) + for event in action.get('events', []): + self._format_event(event, expect_traceback) + return action + + def _format_event(self, event, expect_traceback=True): + '''Remove keys that aren't serialized.''' + to_delete = ['id', 'created_at', 'updated_at', 'deleted_at', 'deleted', + 'action_id'] + if not expect_traceback: + event['traceback'] = None + for key in to_delete: + if key in event: + del (event[key]) + return event + + @mock.patch('zun.objects.Container.get_by_uuid') + @mock.patch('zun.objects.ContainerAction.get_by_container_uuid') + def test_list_actions(self, mock_get_by_container_uuid, + mock_container_get_by_uuid): + test_container = utils.get_test_container() + test_action = utils.get_test_action_value( + container_uuid=test_container['uuid']) + + container_object = objects.Container(self.context, **test_container) + action_object = objects.ContainerAction(self.context, **test_action) + + mock_container_get_by_uuid.return_value = container_object + mock_get_by_container_uuid.return_value = [action_object] + response = self.get('/v1/containers/%s/container_actions' % + test_container['uuid']) + + mock_get_by_container_uuid.assert_called_once_with( + mock.ANY, + test_container['uuid']) + + self.assertEqual(200, response.status_int) + self.assertEqual(self._format_action(test_action), + self._format_action(response.json[0])) + + @mock.patch('zun.objects.Container.get_by_uuid') + @mock.patch('zun.common.policy.enforce') + @mock.patch('zun.objects.ContainerActionEvent.get_by_action') + @mock.patch('zun.objects.ContainerAction.get_by_request_id') + def test_get_action_with_events_allowed(self, mock_get_by_request_id, + mock_get_by_action, mock_policy, + mock_container_get_by_uuid): + mock_policy.return_value = True + test_container = utils.get_test_container() + test_action = utils.get_test_action_value( + container_uuid=test_container['uuid']) + test_event = utils.get_test_action_event_value( + action_id=test_action['id']) + test_action['events'] = [test_event] + + container_object = objects.Container(self.context, **test_container) + action_object = objects.ContainerAction(self.context, **test_action) + event_object = objects.ContainerActionEvent(self.context, **test_event) + + mock_container_get_by_uuid.return_value = container_object + mock_get_by_request_id.return_value = action_object + mock_get_by_action.return_value = [event_object] + + response = self.get('/v1/containers/%s/container_actions/%s' % ( + test_container['uuid'], test_action['request_id'])) + + mock_get_by_request_id.assert_called_once_with( + mock.ANY, test_container['uuid'], test_action['request_id']) + mock_get_by_action.assert_called_once_with(mock.ANY, test_action['id']) + + self.assertEqual(200, response.status_int) + self.assertEqual(self._format_action(test_action), + self._format_action(response.json)) + + @mock.patch('zun.objects.Container.get_by_uuid') + @mock.patch('zun.common.policy.enforce') + @mock.patch('zun.objects.ContainerActionEvent.get_by_action') + @mock.patch('zun.objects.ContainerAction.get_by_request_id') + def test_get_action_with_events_not_allowed(self, mock_get_by_request_id, + mock_get_by_action, + mock_policy, + mock_container_get_by_uuid): + mock_policy.return_value = False + test_container = utils.get_test_container() + container_obj = objects.Container(self.context, **test_container) + test_action = utils.get_test_action_value( + container_uuid=test_container['uuid']) + test_event = utils.get_test_action_event_value( + action_id=test_action['id']) + test_action['events'] = [test_event] + action_object = objects.ContainerAction(self.context, **test_action) + event_object = objects.ContainerActionEvent(self.context, **test_event) + + mock_container_get_by_uuid.return_value = container_obj + mock_get_by_request_id.return_value = action_object + mock_get_by_action.return_value = [event_object] + + response = self.get('/v1/containers/%s/container_actions/%s' % ( + test_container['uuid'], test_action['request_id'])) + + mock_get_by_request_id.assert_called_once_with( + mock.ANY, test_container['uuid'], test_action['request_id']) + mock_get_by_action.assert_called_once_with(mock.ANY, test_action['id']) + + self.assertEqual(200, response.status_int) + self.assertEqual(self._format_action(test_action, + expect_traceback=False), + self._format_action(response.json)) + + @mock.patch('zun.objects.Container.get_by_uuid') + @mock.patch('zun.objects.ContainerAction.get_by_request_id') + def test_action_not_found(self, mock_get_by_request_id, + mock_container_get_by_uuid): + + test_container = utils.get_test_container() + container_obj = objects.Container(self.context, **test_container) + + mock_container_get_by_uuid.return_value = container_obj + mock_get_by_request_id.return_value = None + + fake_request_id = 'request' + + self.assertRaises(AppError, self.get, + ('/v1/containers/%s/container_actions/%s' % + (test_container['uuid'], fake_request_id))) + mock_get_by_request_id.assert_called_once_with( + mock.ANY, test_container['uuid'], fake_request_id) + + @mock.patch('zun.objects.Container.get_by_uuid') + def test_container_not_found(self, mock_container_get_by_uuid): + test_container = utils.get_test_container() + + self.assertRaises(AppError, self.get, + ('/v1/containers/%s/container_actions' + % test_container['uuid'])) + mock_container_get_by_uuid.assert_called_once_with( + mock.ANY, test_container['uuid']) + + +class TestContainerActionEnforcement(api_base.FunctionalTest): + + def _common_policy_check(self, rule, func, *arg, **kwarg): + rules = dict({rule: 'project_id:non_fake'}, + **kwarg.pop('bypass_rules', {})) + self.policy.set_rules(rules) + response = func(*arg, **kwarg) + self.assertEqual(403, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertEqual( + "Policy doesn't allow %s to be performed." % rule, + response.json['errors'][0]['detail']) + + def test_list_actions_disallow_by_project(self): + container = obj_utils.create_test_container(self.context) + + self._common_policy_check( + 'container:actions', self.get, + '/v1/containers/%s/container_actions/' % container.uuid, + expect_errors=True) + + def test_get_action_disallow_by_project(self): + container = obj_utils.create_test_container(self.context) + + self._common_policy_check( + 'container:actions', self.get, + '/v1/containers/%s/container_actions/fake_request' % + container.uuid, expect_errors=True) diff --git a/zun/tests/unit/db/utils.py b/zun/tests/unit/db/utils.py index 8218c31fd..fc44f173e 100644 --- a/zun/tests/unit/db/utils.py +++ b/zun/tests/unit/db/utils.py @@ -439,7 +439,7 @@ class FakeObject(object): return getattr(self, key) -def get_test_action(**kwargs): +def get_test_action_value(**kwargs): action_values = { 'created_at': kwargs.get('created_at'), 'updated_at': kwargs.get('updated_at'), @@ -455,13 +455,19 @@ def get_test_action(**kwargs): 'message': kwargs.get('message', 'fake-message'), } + return action_values + + +def get_test_action(**kwargs): + + action_values = get_test_action_value(**kwargs) fake_action = FakeObject() for k, v in action_values.items(): setattr(fake_action, k, v) return fake_action -def get_test_action_event(**kwargs): +def get_test_action_event_value(**kwargs): event_values = { 'created_at': kwargs.get('created_at'), @@ -475,6 +481,12 @@ def get_test_action_event(**kwargs): 'traceback': kwargs.get('traceback', 'fake-tb'), } + return event_values + + +def get_test_action_event(**kwargs): + + event_values = get_test_action_event_value(**kwargs) fake_event = FakeObject() for k, v in event_values.items(): setattr(fake_event, k, v)