diff --git a/drydock_provisioner/control/api.py b/drydock_provisioner/control/api.py index 4ffbc407..14598358 100644 --- a/drydock_provisioner/control/api.py +++ b/drydock_provisioner/control/api.py @@ -25,6 +25,7 @@ from .health import HealthResource from .bootaction import BootactionUnitsResource from .bootaction import BootactionFilesResource from .bootaction import BootactionResource +from .validation import ValidationResource from .base import DrydockRequest, BaseResource from .middleware import AuthMiddleware, ContextMiddleware, LoggingMiddleware @@ -78,6 +79,10 @@ def start_api(state_manager=None, ingester=None, orchestrator=None): state_manager=state_manager, orchestrator=orchestrator)), ('/bootactions/{action_id}', BootactionResource( state_manager=state_manager, orchestrator=orchestrator)), + + # API to validate schemas + ('/validatedesign', ValidationResource( + state_manager=state_manager, orchestrator=orchestrator)), ] for path, res in v1_0_routes: diff --git a/drydock_provisioner/control/validation.py b/drydock_provisioner/control/validation.py new file mode 100644 index 00000000..4124ca87 --- /dev/null +++ b/drydock_provisioner/control/validation.py @@ -0,0 +1,94 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import falcon +import json + +from drydock_provisioner import policy +from drydock_provisioner.control.base import StatefulResource +import drydock_provisioner.error as errors + + +class ValidationResource(StatefulResource): + """ + Drydock validation endpoint + """ + + def __init__(self, orchestrator=None, **kwargs): + """Object initializer. + + :param orchestrator: instance of orchestrator.Orchestrator + """ + super().__init__(**kwargs) + self.orchestrator = orchestrator + + @policy.ApiEnforcer('physical_provisioner:validate_site_design') + def on_post(self, req, resp): + + # create resp message + resp_message = { + 'kind': 'Status', + 'apiVersion': 'v1', + 'metaData': {}, + 'status': '', + 'message': '', + 'reason': 'Validation', + 'details': { + 'errorCount': 0, + 'messageList': [] + }, + 'code': '', + } + + try: + json_data = self.req_json(req) + + if json_data is None: + resp.status = falcon.HTTP_400 + err_message = 'Request body must not be empty for validation.' + self.error(req.context, err_message) + return self.return_error(resp, falcon.HTTP_400, err_message) + + design_ref = json_data.get('href', None) + + if not design_ref: + resp.status = falcon.HTTP_400 + err_message = 'The "href" key must be provided in the request body.' + self.error(req.context, err_message) + return self.return_error(resp, falcon.HTTP_400, err_message) + + message, design_data = self.orchestrator.get_effective_site( + design_ref) + + resp_message['details']['errorCount'] = message.error_count + resp_message['details']['messageList'] = [m.to_dict() for m in message.message_list] + + if message.error_count == 0: + resp_message['status'] = 'Valid' + resp_message['message'] = 'Drydock Validations succeeded' + resp_message['code'] = 200 + resp.status = falcon.HTTP_200 + resp.body = json.dumps(resp_message) + else: + resp_message['status'] = 'Invalid' + resp_message['message'] = 'Drydock Validations failed' + resp_message['code'] = 400 + resp.status = falcon.HTTP_400 + resp.body = json.dumps(resp_message) + + except errors.InvalidFormat as e: + err_message = str(e) + resp.status = falcon.HTTP_400 + self.error(req.context, err_message) + self.return_error(resp, falcon.HTTP_400, err_message) diff --git a/drydock_provisioner/objects/fields.py b/drydock_provisioner/objects/fields.py index 1fd55a46..d4fe1f27 100644 --- a/drydock_provisioner/objects/fields.py +++ b/drydock_provisioner/objects/fields.py @@ -187,3 +187,8 @@ class NetworkLinkTrunkingMode(BaseDrydockEnum): class NetworkLinkTrunkingModeField(fields.BaseEnumField): AUTO_TYPE = NetworkLinkTrunkingMode() + + +class ValidationResult(BaseDrydockEnum): + Success = 'success' + Failure = 'failure' diff --git a/drydock_provisioner/orchestrator/orchestrator.py b/drydock_provisioner/orchestrator/orchestrator.py index 9cabe3ca..0d10b536 100644 --- a/drydock_provisioner/orchestrator/orchestrator.py +++ b/drydock_provisioner/orchestrator/orchestrator.py @@ -20,7 +20,6 @@ import uuid import ulid2 import concurrent.futures import os -import yaml import drydock_provisioner.config as config import drydock_provisioner.objects as objects @@ -35,6 +34,7 @@ from .actions.orchestrator import VerifyNodes from .actions.orchestrator import PrepareNodes from .actions.orchestrator import DeployNodes from .actions.orchestrator import DestroyNodes +from .validations.validator import Validator class Orchestrator(object): @@ -265,24 +265,6 @@ class Orchestrator(object): return status, site_design - def _validate_design(self, site_design, result_status=None): - """Validate the design in site_design passes all validation rules. - - Apply all validation rules to the design in site_design. If result_status is - defined, update it with validation messages. Otherwise a new status instance - will be created and returned. - - :param site_design: instance of objects.SiteDesign - :param result_status: instance of objects.TaskStatus - """ - # TODO(sh8121att) actually implement the validation rules defined in the readme - - if result_status is not None: - result_status = objects.TaskStatus() - result_status.set_status(hd_fields.ActionResult.Success) - - return result_status - def get_effective_site(self, design_ref): """Ingest design data and compile the effective model of the design. @@ -293,13 +275,13 @@ class Orchestrator(object): """ status = None site_design = None + val = Validator() try: status, site_design = self.get_described_site(design_ref) if status.status == hd_fields.ActionResult.Success: self.compute_model_inheritance(site_design) self.compute_bootaction_targets(site_design) - status = self._validate_design(site_design, result_status=status) - self.logger.debug("Status of effective design:\n%s" % yaml.dump(status.to_dict())) + status = val.validate_design(site_design, result_status=status) except Exception as ex: if status is not None: status.add_status_msg( diff --git a/drydock_provisioner/orchestrator/validations/__init__.py b/drydock_provisioner/orchestrator/validations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/drydock_provisioner/orchestrator/validations/validator.py b/drydock_provisioner/orchestrator/validations/validator.py new file mode 100644 index 00000000..6ef1c487 --- /dev/null +++ b/drydock_provisioner/orchestrator/validations/validator.py @@ -0,0 +1,71 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Business Logic Validation""" + +import drydock_provisioner.objects.fields as hd_fields + +from drydock_provisioner.objects.task import TaskStatus +from drydock_provisioner.objects.task import TaskStatusMessage + + +class Validator(): + def validate_design(self, site_design, result_status=None): + """Validate the design in site_design passes all validation rules. + + Apply all validation rules to the design in site_design. If result_status is + defined, update it with validation messages. Otherwise a new status instance + will be created and returned. + + :param site_design: instance of objects.SiteDesign + :param result_status: instance of objects.TaskStatus + """ + + if result_status is None: + result_status = TaskStatus() + + validation_error = False + for rule in rule_set: + output = rule(site_design) + result_status.message_list.extend(output) + error_msg = [m for m in output if m.error] + result_status.error_count = result_status.error_count + len( + error_msg) + if len(error_msg) > 0: + validation_error = True + + if validation_error: + result_status.set_status(hd_fields.ValidationResult.Failure) + else: + result_status.set_status(hd_fields.ValidationResult.Success) + + return result_status + + # TODO: (sh8121att) actually implement validation logic + @classmethod + def no_duplicate_IPs_check(cls, site_design): + message_list = [] + message_list.append(TaskStatusMessage(msg='Unique Ip', error=False, ctx_type='NA', ctx='NA')) + + return message_list + + # TODO: (sh8121att) actually implement validation logic + @classmethod + def no_outside_IPs_check(cls, site_design): + message_list = [] + message_list.append(TaskStatusMessage(msg='No outside Ip', error=False, ctx_type='NA', ctx='NA')) + + return message_list + + +rule_set = [Validator.no_duplicate_IPs_check, Validator.no_outside_IPs_check] diff --git a/drydock_provisioner/policy.py b/drydock_provisioner/policy.py index f5a84d95..311632f9 100644 --- a/drydock_provisioner/policy.py +++ b/drydock_provisioner/policy.py @@ -121,6 +121,17 @@ class DrydockPolicy(object): }]) ] + # Validate Design Policy + validation_rules = [ + policy.DocumentedRuleDefault( + 'physical_provisioner:validate_site_design', 'role:admin', + 'Validate site design', + [{ + 'path': '/api/v1.0/validatedesign', + 'method': 'POST' + }]), + ] + def __init__(self): self.enforcer = policy.Enforcer(cfg.CONF) @@ -128,6 +139,7 @@ class DrydockPolicy(object): self.enforcer.register_defaults(DrydockPolicy.base_rules) self.enforcer.register_defaults(DrydockPolicy.task_rules) self.enforcer.register_defaults(DrydockPolicy.data_rules) + self.enforcer.register_defaults(DrydockPolicy.validation_rules) self.enforcer.load_rules() def authorize(self, action, ctx): @@ -183,5 +195,6 @@ def list_policies(): default_policy.extend(DrydockPolicy.base_rules) default_policy.extend(DrydockPolicy.task_rules) default_policy.extend(DrydockPolicy.data_rules) + default_policy.extend(DrydockPolicy.validation_rules) return default_policy diff --git a/etc/drydock/policy.yaml.sample b/etc/drydock/policy.yaml.sample new file mode 100644 index 00000000..0812ca43 --- /dev/null +++ b/etc/drydock/policy.yaml.sample @@ -0,0 +1,54 @@ +# Actions requiring admin authority +#"admin_required": "role:admin or is_admin:1" + +# Get task status +# GET /api/v1.0/tasks +# GET /api/v1.0/tasks/{task_id} +#"physical_provisioner:read_task": "role:admin" + +# Create a task +# POST /api/v1.0/tasks +#"physical_provisioner:create_task": "role:admin" + +# Create validate_design task +# POST /api/v1.0/tasks +#"physical_provisioner:validate_design": "role:admin" + +# Create verify_site task +# POST /api/v1.0/tasks +#"physical_provisioner:verify_site": "role:admin" + +# Create prepare_site task +# POST /api/v1.0/tasks +#"physical_provisioner:prepare_site": "role:admin" + +# Create verify_nodes task +# POST /api/v1.0/tasks +#"physical_provisioner:verify_nodes": "role:admin" + +# Create prepare_nodes task +# POST /api/v1.0/tasks +#"physical_provisioner:prepare_nodes": "role:admin" + +# Create deploy_nodes task +# POST /api/v1.0/tasks +#"physical_provisioner:deploy_nodes": "role:admin" + +# Create destroy_nodes task +# POST /api/v1.0/tasks +#"physical_provisioner:destroy_nodes": "role:admin" + +# Read loaded design data +# GET /api/v1.0/designs +# GET /api/v1.0/designs/{design_id} +#"physical_provisioner:read_data": "role:admin" + +# Load design data +# POST /api/v1.0/designs +# POST /api/v1.0/designs/{design_id}/parts +#"physical_provisioner:ingest_data": "role:admin" + +# Validate site design +# POST /api/v1.0/validatedesign +#"physical_provisioner:validate_site_design": "role:admin" + diff --git a/tests/unit/test_api_validation.py b/tests/unit/test_api_validation.py new file mode 100644 index 00000000..30a22d22 --- /dev/null +++ b/tests/unit/test_api_validation.py @@ -0,0 +1,94 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Test Validation API""" +from falcon import testing + +import pytest +import json + +from drydock_provisioner import policy +from drydock_provisioner.control.api import start_api + +import falcon + + +class TestValidationApi(object): + def test_post_validation_resp(self, input_files, falcontest): + + input_file = input_files.join("deckhand_fullsite.yaml") + design_ref = "file://%s" % str(input_file) + + url = '/api/v1.0/validatedesign' + hdr = { + 'Content-Type': 'application/json', + 'X-IDENTITY-STATUS': 'Confirmed', + 'X-USER-NAME': 'Test', + 'X-ROLES': 'admin' + } + body = { + 'rel': "design", + 'href': design_ref, + 'type': "application/x-yaml", + } + + result = falcontest.simulate_post( + url, headers=hdr, body=json.dumps(body)) + + assert result.status == falcon.HTTP_200 + + def test_href_error(self, input_files, falcontest): + url = '/api/v1.0/validatedesign' + hdr = { + 'Content-Type': 'application/json', + 'X-IDENTITY-STATUS': 'Confirmed', + 'X-USER-NAME': 'Test', + 'X-ROLES': 'admin' + } + body = { + 'rel': "design", + 'href': '', + 'type': "application/x-yaml", + } + + result = falcontest.simulate_post( + url, headers=hdr, body=json.dumps(body)) + + assert result.status == falcon.HTTP_400 + + def test_json_data_error(self, input_files, falcontest): + url = '/api/v1.0/validatedesign' + hdr = { + 'Content-Type': 'application/json', + 'X-IDENTITY-STATUS': 'Confirmed', + 'X-USER-NAME': 'Test', + 'X-ROLES': 'admin' + } + body = {} + + result = falcontest.simulate_post( + url, headers=hdr, body=json.dumps(body)) + + assert result.status == falcon.HTTP_400 + + @pytest.fixture() + def falcontest(self, drydock_state, deckhand_ingester, deckhand_orchestrator): + """Create a test harness for the the Falcon API framework.""" + policy.policy_engine = policy.DrydockPolicy() + policy.policy_engine.register_policy() + + return testing.TestClient( + start_api( + state_manager=drydock_state, + ingester=deckhand_ingester, + orchestrator=deckhand_orchestrator)) diff --git a/tests/unit/test_policy_engine.py b/tests/unit/test_policy_engine.py index ec89e5f9..746174b5 100644 --- a/tests/unit/test_policy_engine.py +++ b/tests/unit/test_policy_engine.py @@ -29,7 +29,8 @@ class TestDefaultRules(): expected_calls = [ mocker.call.register_defaults(DrydockPolicy.base_rules), mocker.call.register_defaults(DrydockPolicy.task_rules), - mocker.call.register_defaults(DrydockPolicy.data_rules) + mocker.call.register_defaults(DrydockPolicy.data_rules), + mocker.call.register_defaults(DrydockPolicy.validation_rules) ] # Validate the oslo_policy Enforcer was loaded with expected default policy rules diff --git a/tests/unit/test_validate_design.py b/tests/unit/test_validate_design.py new file mode 100644 index 00000000..f199152b --- /dev/null +++ b/tests/unit/test_validate_design.py @@ -0,0 +1,42 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import drydock_provisioner.objects.fields as hd_fields +from drydock_provisioner.statemgmt.state import DrydockState +from drydock_provisioner.ingester.ingester import Ingester +from drydock_provisioner.orchestrator.validations.validator import Validator + + +class TestDesignValidator(object): + def test_validate_design(self, input_files, setup): + """Test the basic validation engine.""" + + input_file = input_files.join("fullsite.yaml") + + design_state = DrydockState() + design_ref = "file://%s" % str(input_file) + + ingester = Ingester() + ingester.enable_plugin( + 'drydock_provisioner.ingester.plugins.yaml.YamlIngester') + design_status, design_data = ingester.ingest_data( + design_state=design_state, design_ref=design_ref) + + val = Validator() + response = val.validate_design(design_data) + + for msg in response.message_list: + assert msg.error is False + assert response.error_count == 0 + assert response.status == hd_fields.ValidationResult.Success