From 0df72ec6d40bd91f612521e18191e9af5b83642b Mon Sep 17 00:00:00 2001 From: John B Date: Fri, 17 Dec 2021 10:44:18 -0500 Subject: [PATCH 1/7] #1191 - Garden configuration validation --- src/app/beer_garden/api/http/client.py | 5 + .../api/http/handlers/v1/garden.py | 15 +- src/app/beer_garden/db/mongo/api.py | 22 +- src/app/beer_garden/db/mongo/models.py | 14 +- .../beer_garden/db/schemas/garden_schema.py | 110 +++++++ src/app/beer_garden/garden.py | 49 ++- src/app/test/auth_test.py | 6 +- .../test/db/mongo/models/garden_model_test.py | 310 ++++++++++++++++++ .../test/db/mongo/{ => models}/models_test.py | 137 -------- src/app/test/garden_test.py | 20 +- 10 files changed, 524 insertions(+), 164 deletions(-) create mode 100644 src/app/beer_garden/db/schemas/garden_schema.py create mode 100644 src/app/test/db/mongo/models/garden_model_test.py rename src/app/test/db/mongo/{ => models}/models_test.py (85%) diff --git a/src/app/beer_garden/api/http/client.py b/src/app/beer_garden/api/http/client.py index 95176edfa..26aa431b5 100644 --- a/src/app/beer_garden/api/http/client.py +++ b/src/app/beer_garden/api/http/client.py @@ -5,10 +5,12 @@ import six from brewtils.models import BaseModel +from brewtils.models import Garden as BrewtilsGarden from brewtils.schema_parser import SchemaParser import beer_garden.api import beer_garden.router +from beer_garden.db.schemas.garden_schema import GardenSchema class SerializeHelper(object): @@ -31,6 +33,9 @@ async def __call__(self, *args, serialize_kwargs=None, **kwargs): if self.json_dump(result): return json.dumps(result) if serialize_kwargs["to_string"] else result + if isinstance(result, BrewtilsGarden): + return GardenSchema(strict=True).dumps(result).data + return SchemaParser.serialize(result, **(serialize_kwargs or {})) @staticmethod diff --git a/src/app/beer_garden/api/http/handlers/v1/garden.py b/src/app/beer_garden/api/http/handlers/v1/garden.py index 0c94a7459..c08e0145f 100644 --- a/src/app/beer_garden/api/http/handlers/v1/garden.py +++ b/src/app/beer_garden/api/http/handlers/v1/garden.py @@ -2,11 +2,12 @@ from brewtils.errors import ModelValidationError from brewtils.models import Operation from brewtils.schema_parser import SchemaParser +from mongoengine.queryset.queryset import QuerySet from beer_garden.api.authorization import Permissions from beer_garden.api.http.handlers import AuthorizationHandler -from beer_garden.db.mongo.api import MongoParser from beer_garden.db.mongo.models import Garden +from beer_garden.db.schemas.garden_schema import GardenSchema from beer_garden.garden import local_garden GARDEN_CREATE = Permissions.GARDEN_CREATE.value @@ -40,7 +41,7 @@ async def get(self, garden_name): """ garden = self.get_or_raise(Garden, GARDEN_READ, name=garden_name) - response = MongoParser.serialize(garden) + response = GardenSchema(strict=True).dumps(garden).data self.set_header("Content-Type", "application/json; charset=UTF-8") self.write(response) @@ -138,10 +139,12 @@ async def patch(self, garden_name): ) ) elif operation == "config": + garden_to_update = GardenSchema(strict=True).load(op.value).data + garden_to_update.id = garden.id response = await self.client( Operation( operation_type="GARDEN_UPDATE_CONFIG", - args=[SchemaParser.parse_garden(op.value, from_string=False)], + args=[garden_to_update], ) ) elif operation == "sync": @@ -178,9 +181,9 @@ async def get(self): tags: - Garden """ - permitted_gardens = self.permissioned_queryset(Garden, GARDEN_READ) + permitted_gardens: QuerySet = self.permissioned_queryset(Garden, GARDEN_READ) - response = MongoParser.serialize(permitted_gardens, to_string=True) + response = GardenSchema(strict=True, many=True).dumps(permitted_gardens).data self.set_header("Content-Type", "application/json; charset=UTF-8") self.write(response) @@ -207,7 +210,7 @@ async def post(self): tags: - Garden """ - garden = SchemaParser.parse_garden(self.request.decoded_body, from_string=True) + garden = GardenSchema(strict=True).loads(self.request.decoded_body).data self.verify_user_permission_for_object(GARDEN_CREATE, garden) diff --git a/src/app/beer_garden/db/mongo/api.py b/src/app/beer_garden/db/mongo/api.py index 96eb778f0..97fb0aec0 100644 --- a/src/app/beer_garden/db/mongo/api.py +++ b/src/app/beer_garden/db/mongo/api.py @@ -75,7 +75,13 @@ def from_brewtils(obj: ModelItem) -> MongoModel: The Mongo model item """ - model_dict = SchemaParser.serialize(obj, to_string=False) + if isinstance(obj, brewtils.models.Garden): + # first step in decoupling from Brewtils + from beer_garden.db.schemas.garden_schema import GardenSchema + + model_dict = GardenSchema(strict=True).dump(obj).data + else: + model_dict = SchemaParser.serialize(obj, to_string=False) mongo_obj = MongoParser.parse(model_dict, type(obj), from_string=False) return mongo_obj @@ -108,8 +114,18 @@ def to_brewtils( if getattr(obj, "pre_serialize", None): obj.pre_serialize() - serialized = MongoParser.serialize(obj, to_string=True) - parsed = SchemaParser.parse(serialized, model_class, from_string=True, many=many) + if model_class == brewtils.models.Garden: + # first step in decoupling from Brewtils + from beer_garden.db.schemas.garden_schema import GardenSchema + + schema = GardenSchema(strict=True) + serialized = schema.dumps(obj, many=many).data + parsed = schema.loads(serialized, many=many).data + else: + serialized = MongoParser.serialize(obj, to_string=True) + parsed = SchemaParser.parse( + serialized, model_class, from_string=True, many=many + ) return parsed diff --git a/src/app/beer_garden/db/mongo/models.py b/src/app/beer_garden/db/mongo/models.py index 5346d2fa1..b6f6fc5a9 100644 --- a/src/app/beer_garden/db/mongo/models.py +++ b/src/app/beer_garden/db/mongo/models.py @@ -5,8 +5,11 @@ import pytz import six +from marshmallow import ValidationError as MarshmallowValidationError from passlib.apps import custom_app_context +from beer_garden.db.schemas.garden_schema import GardenConnectionsParamsSchema + try: from lark import ParseError from lark.exceptions import LarkError @@ -49,6 +52,7 @@ UUIDField, ValidationError, ) +from mongoengine.errors import ValidationError as MongoengineValidationError from beer_garden import config from beer_garden.db.mongo.querysets import FileFieldHandlingQuerySet @@ -781,6 +785,14 @@ def clean(self): ) +def validate_garden_connection_params(dict_field): + """Use the marshmallow schema to validate Garden connection parameters.""" + try: + GardenConnectionsParamsSchema(strict=True).validate(dict(dict_field)) + except MarshmallowValidationError as mmve: + raise MongoengineValidationError(mmve.messages) + + class Garden(MongoModel, Document): brewtils_model = brewtils.models.Garden @@ -789,7 +801,7 @@ class Garden(MongoModel, Document): status_info = EmbeddedDocumentField("StatusInfo", default=StatusInfo()) namespaces = ListField() connection_type = StringField() - connection_params = DictField() + connection_params = DictField(validation=validate_garden_connection_params) systems = ListField(ReferenceField(System, reverse_delete_rule=PULL)) meta = { diff --git a/src/app/beer_garden/db/schemas/garden_schema.py b/src/app/beer_garden/db/schemas/garden_schema.py new file mode 100644 index 000000000..4c3d509b3 --- /dev/null +++ b/src/app/beer_garden/db/schemas/garden_schema.py @@ -0,0 +1,110 @@ +import logging + +from brewtils.models import Garden as BrewtilsGarden +from brewtils.schemas import StatusInfoSchema # noqa # until we can fully decouple +from brewtils.schemas import SystemSchema # noqa # until we can fully decouple +from marshmallow import Schema, ValidationError, fields +from marshmallow.decorators import post_load, pre_load, validates_schema +from mongoengine.queryset.queryset import QuerySet + +logger = logging.getLogger(__name__) + + +class GardenBaseSchema(Schema): + """Class to give Marshmallow Schemas the desired behavior of throwing + exceptions on errors when marshalling/unmarshalling. Otherwise, each line of code + utilizing these would need to pull apart MarshalResult objects in order to return + a meaningful error.""" + + @validates_schema(skip_on_field_errors=False, pass_original=True) + def validate_all_keys(self, post_load_data, original_data, **kwargs): + # do not allow extraneous keys when operating on a dictionary + if isinstance(original_data, dict): + extra_args = original_data.keys() - post_load_data.keys() + + if len(extra_args) > 0: + formatted_good_keys = ", ".join( + map(lambda x: "'" + str(x) + "'", self.fields.keys()) + ) + formatted_bad_keys = ", ".join( + map(lambda x: "'" + str(x) + "'", extra_args) + ) + raise ValidationError( + f"Only {formatted_good_keys} allowed as keys; " + f"these are not allowed: {formatted_bad_keys}" + ) + + +def _port_validator(value): + return 0 < value < 65535 + + +class HttpConnectionParamsSchema(GardenBaseSchema): + host = fields.String(required=True) + port = fields.Integer( + required=True, + validate=_port_validator, + error_messages={ + **fields.Field.default_error_messages, + **{"validator_failed": "Value out of range for ports"}, + }, + ) + url_prefix = fields.String(required=True, dump_default="/", load_default="/") + ca_cert = fields.String(required=False, allow_none=True) + ca_verify = fields.Boolean(required=True) + client_cert = fields.String(required=False, allow_none=True) + client_key = fields.String(required=False, allow_none=True) + ssl = fields.Boolean(required=True) + + +class StompSSLParamsSchema(GardenBaseSchema): + use_ssl = fields.Boolean(required=True) + + +class StompHeaderSchema(GardenBaseSchema): + key = fields.String(required=True) + value = fields.String(required=True) + + +class StompConnectionParamsSchema(GardenBaseSchema): + ssl = fields.Nested("StompSSLParamsSchema", required=True) + headers = fields.List(fields.Nested("StompHeaderSchema"), required=False) + host = fields.String(required=True) + port = fields.Integer( + required=True, + validate=_port_validator, + error_messages={ + **fields.Field.default_error_messages, + **{"validator_failed": "Value out of range for ports"}, + }, + ) + send_destination = fields.String(required=False, allow_none=True) + subscribe_destination = fields.String(required=False, allow_none=True) + username = fields.String(required=False, allow_none=True) + password = fields.String(required=False, allow_none=True) + + +class GardenConnectionsParamsSchema(GardenBaseSchema): + http = fields.Nested("HttpConnectionParamsSchema", allow_none=True) + stomp = fields.Nested("StompConnectionParamsSchema", allow_none=True) + + +class GardenSchema(GardenBaseSchema): + id = fields.Str(allow_none=True) + # TODO the name field must be allowed to be blank for child garden registration + name = fields.Str(allow_none=False) + status = fields.Str(allow_none=True) + status_info = fields.Nested(StatusInfoSchema, allow_none=True) + connection_type = fields.Str(allow_none=False) + connection_params = fields.Nested( + "GardenConnectionsParamsSchema", + allow_none=True, + dump_default={}, + load_default={}, + ) + namespaces = fields.List(fields.Str(), allow_none=True) + systems = fields.Nested(SystemSchema, many=True, allow_none=True) + + @post_load + def make_object(self, data): + return BrewtilsGarden(**data) diff --git a/src/app/beer_garden/garden.py b/src/app/beer_garden/garden.py index 2dd64f098..3bdf2a3af 100644 --- a/src/app/beer_garden/garden.py +++ b/src/app/beer_garden/garden.py @@ -168,6 +168,49 @@ def remove_garden(garden_name: str) -> None: return garden +def get_connection_defaults(): + # Explicitly load default config options into garden params + spec = YapconfSpec(_CONNECTION_SPEC) + # bg_host is required to load brewtils garden spec + defaults = spec.load_config({"bg_host": ""}) + + config_map = { + "bg_host": "host", + "bg_port": "port", + "ssl_enabled": "ssl", + "bg_url_prefix": "url_prefix", + "ca_cert": "ca_cert", + "ca_verify": "ca_verify", + "client_cert": "client_cert", + } + + # TODO: this is a temporary work-around until Brewtils is configured to provide + # sensible defaults + sensible_defaults = { + "bg_host": "somehostname", + "bg_port": 1025, + "ssl_enabled": False, + "bg_url_prefix": "/", + "ca_cert": "none", + "ca_verify": False, + "client_cert": "none", + } + # substitute the sensible default only if we're provided `None` or an empty string + defaults = { + key: ( + defaults[key] + if ( + (defaults[key] is not None and (isinstance(defaults[key], bool))) + or defaults[key] + ) + else sensible_defaults[key] + ) + for key in config_map + } + + return defaults + + @publish_event(Events.GARDEN_CREATED) def create_garden(garden: Garden) -> Garden: """Create a new Garden @@ -179,11 +222,6 @@ def create_garden(garden: Garden) -> Garden: The created Garden """ - # Explicitly load default config options into garden params - spec = YapconfSpec(_CONNECTION_SPEC) - # bg_host is required to load brewtils garden spec - defaults = spec.load_config({"bg_host": ""}) - config_map = { "bg_host": "host", "bg_port": "port", @@ -193,6 +231,7 @@ def create_garden(garden: Garden) -> Garden: "ca_verify": "ca_verify", "client_cert": "client_cert", } + defaults = get_connection_defaults() if garden.connection_params is None: garden.connection_params = {} diff --git a/src/app/test/auth_test.py b/src/app/test/auth_test.py index f97ca904d..6f7fa9a00 100644 --- a/src/app/test/auth_test.py +++ b/src/app/test/auth_test.py @@ -113,7 +113,11 @@ def user_with_role_assignments( @pytest.fixture def test_garden(role_assignment_for_garden_scope): - garden = Garden(**role_assignment_for_garden_scope.domain.identifiers).save() + args = { + **{"connection_type": "LOCAL"}, + **role_assignment_for_garden_scope.domain.identifiers, + } + garden = Garden(**args).save() yield garden garden.delete() diff --git a/src/app/test/db/mongo/models/garden_model_test.py b/src/app/test/db/mongo/models/garden_model_test.py new file mode 100644 index 000000000..3afcbd7a7 --- /dev/null +++ b/src/app/test/db/mongo/models/garden_model_test.py @@ -0,0 +1,310 @@ +# -*- coding: utf-8 -*- +import copy +from contextlib import nullcontext as does_not_raise + +import pytest +from mongoengine import NotUniqueError, connect +from mongoengine.errors import ValidationError + +from beer_garden.db.mongo.models import Garden, System + +v1_str = "v1" +v2_str = "v2" +garden_name = "test_garden" + +garbage_headers_extra_key = [ + { + "key": "key_2", + "value": "value_2", + "extra_key": "value_doesnt_matter", + }, +] +garbage_headers_wrong_key = [ + {"notakey": "key_1", "value": "value_1"}, +] + + +class TestGarden: + @classmethod + def setup_class(cls): + connect("beer_garden", host="mongomock://localhost") + Garden.drop_collection() + Garden.ensure_indexes() + + @pytest.fixture() + def local_garden(self): + garden = Garden(name=garden_name, connection_type="LOCAL").save() + yield garden + garden.delete() + + @pytest.fixture + def child_system(self): + return System(name="echoer", namespace="child_garden") + + @pytest.fixture + def child_system_v1(self, child_system): + system: System = copy.deepcopy(child_system) + system.version = v1_str + system.save() + yield system + system.delete() + + @pytest.fixture + def child_system_v2(self, child_system): + system: System = copy.deepcopy(child_system) + system.version = v2_str + system.save() + yield system + system.delete() + + @pytest.fixture + def child_system_v1_diff_id(self, child_system): + system: System = copy.deepcopy(child_system) + system.version = v1_str + system.save() + yield system + system.delete() + + @pytest.fixture + def child_garden(self, child_system_v1): + garden = Garden( + name="child_garden", connection_type="HTTP", systems=[child_system_v1] + ).save() + yield garden + garden.delete() + + def test_garden_names_are_required_to_be_unique(self, local_garden): + """Attempting to create a garden that shares a name with an existing garden + should raise an exception""" + with pytest.raises(NotUniqueError): + Garden(name=local_garden.name, connection_type="HTTP").save() + + def test_only_one_local_garden_may_exist(self, local_garden): + """Attempting to create more than one garden with connection_type of LOCAL + should raise an exception""" + with pytest.raises(NotUniqueError): + Garden(name=f"not{local_garden.name}", connection_type="LOCAL").save() + + def test_child_garden_system_attrib_update(self, child_garden, child_system_v2): + """If the systems of a child garden are updated such that their names, + namespaces, or versions are changed, the original systems are removed and + replaced with the new systems when the garden is saved.""" + orig_system_ids = set( + map(lambda x: str(getattr(x, "id")), child_garden.systems) # noqa: B009 + ) + orig_system_versions = set( + map( + lambda x: str(getattr(x, "version")), child_garden.systems # noqa: B009 + ) + ) + + assert v1_str in orig_system_versions and v2_str not in orig_system_versions + + child_garden.systems = [child_system_v2] + child_garden.deep_save() + + # we check that the garden written to the DB has the correct systems + db_garden = Garden.objects().first() + + new_system_ids = set( + map(lambda x: str(getattr(x, "id")), db_garden.systems) # noqa: B009 + ) + new_system_versions = set( + map(lambda x: str(getattr(x, "version")), db_garden.systems) # noqa: B009 + ) + + assert v1_str not in new_system_versions and v2_str in new_system_versions + assert new_system_ids.intersection(orig_system_ids) == set() + + def test_child_garden_system_id_update(self, child_garden, child_system_v1_diff_id): + """If the systems of a child garden are updated such that the names, namespaces + and versions remain constant, but the IDs are different, the original systms + are removed and replaced with the new systems when the garden is saved.""" + orig_system_ids = set( + map(lambda x: str(getattr(x, "id")), child_garden.systems) # noqa: B009 + ) + new_system_id = str(child_system_v1_diff_id.id) + + assert new_system_id not in orig_system_ids + + child_garden.systems = [child_system_v1_diff_id] + child_garden.deep_save() + db_garden = Garden.objects().first() + + new_system_ids = set( + map(lambda x: str(getattr(x, "id")), db_garden.systems) # noqa: B009 + ) + + assert new_system_id in new_system_ids + assert orig_system_ids.intersection(new_system_ids) == set() + + +class TestGardenConnectionParameters: + @classmethod + def setup_class(cls): + connect("beer_garden", host="mongomock://localhost") + Garden.drop_collection() + Garden.ensure_indexes() + + @pytest.fixture(autouse=True) + def drop(self): + Garden.drop_collection() + + @pytest.fixture + def bad_conn_params(self): + return dict([("nonempty", "dictionaries"), ("should", "fail")]) + + @pytest.fixture + def http_conn_params(self): + return { + "http": { + "port": 2337, + "ssl": True, + "url_prefix": "/", + "ca_verify": True, + "host": "bg-child1", + } + } + + @pytest.fixture + def stomp_conn_params_basic(self): + return { + "stomp": { + "ssl": {"use_ssl": False}, + "headers": [], + "host": "activemq", + "port": 61613, + "send_destination": "send_destination", + "subscribe_destination": "subscribe_destination", + "username": "beer_garden", + "password": "password", + } + } + + @pytest.fixture + def stomp_conn_params_with_headers(self, stomp_conn_params_basic): + stomp_conn_params = copy.deepcopy(stomp_conn_params_basic) + headers = [{"key": f"key_{i+1}", "value": f"value_{i+1}"} for i in range(3)] + stomp_conn_params["stomp"]["headers"] = headers + return stomp_conn_params + + @pytest.fixture + def bad_conn_params_with_partial_good(self, http_conn_params, bad_conn_params): + return {**http_conn_params, **bad_conn_params} + + @pytest.fixture + def bad_conn_params_with_full_good( + self, bad_conn_params_with_partial_good, stomp_conn_params_basic + ): + return {**stomp_conn_params_basic, **bad_conn_params_with_partial_good} + + @pytest.mark.parametrize( + "conn_parm", + ( + pytest.lazy_fixture("bad_conn_params"), + pytest.lazy_fixture("bad_conn_params_with_partial_good"), + pytest.lazy_fixture("bad_conn_params_with_full_good"), + ), + ) + def test_local_garden_save_fails_with_nonempty_conn_params(self, conn_parm): + with pytest.raises(ValidationError) as excinfo: + Garden( + name=garden_name, + connection_type="LOCAL", + connection_params=conn_parm, + ).save() + assert "not allowed" in str(excinfo.value) + + def test_local_garden_save_succeeds_with_empty_conn_params(self): + with does_not_raise(): + Garden( + name=garden_name, connection_type="LOCAL", connection_params={} + ).save().delete() + + @pytest.mark.parametrize( + "conn_parm", + ( + pytest.lazy_fixture("bad_conn_params"), + # pytest.lazy_fixture("bad_conn_params_with_partial_good"), + # pytest.lazy_fixture("bad_conn_params_with_full_good"), + ), + ) + def test_remote_garden_save_fails_with_bad_conn_params(self, conn_parm): + with pytest.raises(ValidationError) as excinfo: + Garden( + name=garden_name, + connection_type="HTTP", + connection_params=conn_parm, + ).save() + assert "not allowed" in str(excinfo.value) + + @pytest.mark.parametrize("required", ("port", "ssl", "ca_verify", "host")) + def test_required_http_params_missing_fails(self, http_conn_params, required): + conn_param_values = http_conn_params["http"] + _ = conn_param_values.pop(required) + conn_params = {"http": conn_param_values} + + with pytest.raises(ValidationError) as excinfo: + Garden( + name=garden_name, connection_type="HTTP", connection_params=conn_params + ).save() + assert "Missing data" in str(excinfo.value) + + @pytest.mark.parametrize("required", ("ssl", "host", "port")) + def test_required_stomp_params_missing_fails( + self, stomp_conn_params_basic, required + ): + conn_param_values = stomp_conn_params_basic["stomp"] + _ = conn_param_values.pop(required) + conn_params = {"stomp": conn_param_values} + + with pytest.raises(ValidationError) as excinfo: + Garden( + name=garden_name, connection_type="HTTP", connection_params=conn_params + ).save() + assert "Missing data" in str(excinfo.value) + + def test_remote_garden_save_succeeds_with_only_good_http_headers( + self, http_conn_params + ): + garden = Garden( + name=garden_name, + connection_type="HTTP", + connection_params=http_conn_params, + ) + with does_not_raise(): + garden.save() + garden.delete() + + def test_remote_garden_save_succeeds_with_only_good_stomp_headers( + self, stomp_conn_params_with_headers + ): + garden = Garden( + name=garden_name, + connection_type="STOMP", + connection_params=stomp_conn_params_with_headers, + ) + with does_not_raise(): + garden.save() + garden.delete() + + @pytest.mark.parametrize( + "bad_headers", + ( + garbage_headers_extra_key, + # garbage_headers_wrong_key, + ), + ) + def test_remote_garden_save_fails_with_garbage_stomp_headers( + self, stomp_conn_params_basic, bad_headers + ): + test_params = stomp_conn_params_basic["stomp"] + test_params["headers"] = bad_headers + connection_params = {"stomp": test_params} + + with pytest.raises(ValidationError) as exc: + Garden( + name=garden_name, + connection_type="STOMP", + connection_params=connection_params, + ).save() diff --git a/src/app/test/db/mongo/models_test.py b/src/app/test/db/mongo/models/models_test.py similarity index 85% rename from src/app/test/db/mongo/models_test.py rename to src/app/test/db/mongo/models/models_test.py index 52e04df8e..0e3f04a76 100644 --- a/src/app/test/db/mongo/models_test.py +++ b/src/app/test/db/mongo/models/models_test.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -import copy from datetime import datetime, timedelta from uuid import uuid4 @@ -18,7 +17,6 @@ Command, CommandPublishingBlockList, DateTrigger, - Garden, Instance, Job, Parameter, @@ -722,138 +720,3 @@ def test_blocklist_entries_are_required_to_be_unique(self, command_blocklist): CommandPublishingBlockList( namespace=self.namespace, system=self.system, command=self.command ).save() - - -class TestGarden: - v1_str = "v1" - v2_str = "v2" - garden_name = "test_garden" - - @classmethod - def setup_class(cls): - connect("beer_garden", host="mongomock://localhost") - Garden.drop_collection() - Garden.ensure_indexes() - - @pytest.fixture() - def local_garden(self, mongo_conn): - garden = Garden(name=self.garden_name, connection_type="LOCAL").save() - - yield garden - - garden.delete() - - @pytest.fixture - def child_system(self): - return System(name="echoer", namespace="child_garden") - - @pytest.fixture - def child_system_v1(self, child_system): - system: System = copy.deepcopy(child_system) - system.version = self.v1_str - system.save() - - yield system - - system.delete() - - @pytest.fixture - def child_system_v2(self, child_system): - system: System = copy.deepcopy(child_system) - system.version = self.v2_str - system.save() - - yield system - - system.delete() - - @pytest.fixture - def child_system_v1_diff_id(self, child_system): - system: System = copy.deepcopy(child_system) - system.version = self.v1_str - system.save() - - yield system - - system.delete() - - @pytest.fixture - def child_garden(self, child_system_v1): - garden = Garden( - name="child_garden", connection_type="http", systems=[child_system_v1] - ).save() - - yield garden - - garden.delete() - - def test_garden_names_are_required_to_be_unique(self, local_garden): - """Attempting to create a garden that shares a name with an existing garden - should raise an exception""" - with pytest.raises(NotUniqueError): - Garden(name=local_garden.name, connection_type="HTTP").save() - - def test_only_one_local_garden_may_exist(self, local_garden): - """Attempting to create more than one garden with connection_type of LOCAL - should raise an exception""" - with pytest.raises(NotUniqueError): - Garden(name=f"not{local_garden.name}", connection_type="LOCAL").save() - - def test_child_garden_system_attrib_update(self, child_garden, child_system_v2): - """If the systems of a child garden are updated such that their names, - namespaces, or versions are changed, the original systems are removed and - replaced with the new systems when the garden is saved.""" - orig_system_ids = set( - map(lambda x: str(getattr(x, "id")), child_garden.systems) # noqa: B009 - ) - orig_system_versions = set( - map( - lambda x: str(getattr(x, "version")), child_garden.systems # noqa: B009 - ) - ) - - assert ( - self.v1_str in orig_system_versions - and self.v2_str not in orig_system_versions - ) - - child_garden.systems = [child_system_v2] - child_garden.deep_save() - - # we check that the garden written to the DB has the correct systems - db_garden = Garden.objects().first() - - new_system_ids = set( - map(lambda x: str(getattr(x, "id")), db_garden.systems) # noqa: B009 - ) - new_system_versions = set( - map(lambda x: str(getattr(x, "version")), db_garden.systems) # noqa: B009 - ) - - assert ( - self.v1_str not in new_system_versions - and self.v2_str in new_system_versions - ) - assert new_system_ids.intersection(orig_system_ids) == set() - - def test_child_garden_system_id_update(self, child_garden, child_system_v1_diff_id): - """If the systems of a child garden are updated such that the names, namespaces - and versions remain constant, but the IDs are different, the original systms - are removed and replaced with the new systems when the garden is saved.""" - orig_system_ids = set( - map(lambda x: str(getattr(x, "id")), child_garden.systems) # noqa: B009 - ) - new_system_id = str(child_system_v1_diff_id.id) - - assert new_system_id not in orig_system_ids - - child_garden.systems = [child_system_v1_diff_id] - child_garden.deep_save() - db_garden = Garden.objects().first() - - new_system_ids = set( - map(lambda x: str(getattr(x, "id")), db_garden.systems) # noqa: B009 - ) - - assert new_system_id in new_system_ids - assert orig_system_ids.intersection(new_system_ids) == set() diff --git a/src/app/test/garden_test.py b/src/app/test/garden_test.py index a4403b5ee..611f34853 100644 --- a/src/app/test/garden_test.py +++ b/src/app/test/garden_test.py @@ -10,6 +10,7 @@ from beer_garden.db.mongo.models import Garden, System from beer_garden.garden import ( create_garden, + get_connection_defaults, get_garden, get_gardens, local_garden, @@ -124,9 +125,8 @@ def test_remove_garden_removes_related_systems(self, localgarden, remotegarden): # confirm that systems of other gardens remain intact assert len(System.objects.filter(namespace=localgarden.name)) == 1 - def test_create_garden_loads_default_config(self, bg_garden): + def test_create_garden_loads_default_config(self, remotegarden): """create_garden should explicitly load default HTTP configs from brewtils""" - http_params = { "host": "localhost", "port": 1337, @@ -137,15 +137,15 @@ def test_create_garden_loads_default_config(self, bg_garden): "client_cert": "/def", } - bg_garden.connection_params = {"http": http_params} + remotegarden.connection_params = {"http": http_params} - garden = create_garden(bg_garden) + garden = create_garden(remotegarden) for key in http_params: assert garden.connection_params["http"][key] == http_params[key] - def test_create_garden_with_empty_connection_params(self, bg_garden): - """create_garden should explicitly load default HTTP configs from brewtils when empty""" - + def test_create_garden_with_empty_connection_params(self, remotegarden): + """create_garden should explicitly load default HTTP configs from brewtils when + empty""" config_map = { "bg_host": "host", "bg_port": "port", @@ -156,10 +156,8 @@ def test_create_garden_with_empty_connection_params(self, bg_garden): "client_cert": "client_cert", } - spec = YapconfSpec(_CONNECTION_SPEC) - # bg_host is required by brewtils garden spec - defaults = spec.load_config({"bg_host": ""}) + defaults = get_connection_defaults() + garden = create_garden(remotegarden) - garden = create_garden(bg_garden) for key in config_map: assert garden.connection_params["http"][config_map[key]] == defaults[key] From 97c5701395ce6dba68eab59b9bc9ceab01c1c069 Mon Sep 17 00:00:00 2001 From: John B Date: Fri, 17 Dec 2021 11:15:51 -0500 Subject: [PATCH 2/7] #1141 - Garden connection import/export --- src/app/beer_garden/api/http/base_handler.py | 67 +++++++++- src/app/beer_garden/api/http/client.py | 11 +- .../api/http/handlers/v1/garden.py | 36 ++++-- .../beer_garden/db/schemas/garden_schema.py | 5 +- src/app/beer_garden/events/handlers.py | 27 ++-- src/app/beer_garden/garden.py | 35 ++--- .../handlers/v1/garden_connection_test.py | 120 ++++++++++++++++++ .../test/db/mongo/models/garden_model_test.py | 8 +- src/app/test/garden_test.py | 2 - 9 files changed, 255 insertions(+), 56 deletions(-) create mode 100644 src/app/test/api/http/unit/handlers/v1/garden_connection_test.py diff --git a/src/app/beer_garden/api/http/base_handler.py b/src/app/beer_garden/api/http/base_handler.py index 37afcd982..632b3717c 100644 --- a/src/app/beer_garden/api/http/base_handler.py +++ b/src/app/beer_garden/api/http/base_handler.py @@ -4,7 +4,7 @@ import json import re import socket -from typing import Type, Union +from typing import Any, Dict, Text, Type, Union from brewtils.errors import ( AuthorizationRequired, @@ -198,7 +198,7 @@ def write_error(self, status_code, **kwargs): message = error_dict.get("message", getattr(e, "message", str(e))) code = error_dict.get("status_code", 500) elif issubclass(typ3, BaseHTTPError): - message = typ3.reason + message = self._reason code = typ3.status_code elif config.get("ui.debug_mode"): message = str(e) @@ -210,7 +210,7 @@ def write_error(self, status_code, **kwargs): ) self.set_header("Content-Type", "application/json; charset=UTF-8") - self.set_status(code) + self.set_status(code, reason=message) self.finish({"message": message}) @property @@ -250,3 +250,64 @@ def schema_validated_body(self, schema: Type[Schema]) -> dict: return schema(strict=True).load(self.request_body).data except MarshmallowValidationError: raise BadRequest + + def load_or_raise( + self, + schema: Type[Schema], + arg: Any, + from_string: bool = True, + many: bool = False, + ): + """Apply a schema to an argument or raise a validation exception. + + This is used to validate user-provided data. + + Args: + schema: A schema derived from a marshmallow Schema that the argument + will be validated against + arg: The data to validate + from_string: Process `arg` as string if `True` + many: Process `arg` as a list of objects if `True` + + Returns: + The result of deserializing the data + + Raises: + BadRequest: The supplied data failed to validate against the schema + """ + schema = schema(strict=True, many=many) + + try: + return schema.loads(arg).data if from_string else schema.load(arg).data + except MarshmallowValidationError as mmve: + raise BadRequest(reason=str(mmve)) + + def format_response( + self, schema: Type[Schema], arg: Any, to_string: bool = True, many: bool = False + ) -> Union[Text, Dict]: + """Apply a schema to an argument or raise an internal error. + + This is used to format internal models into serialized format, which could + potentially fail validation in cases where bad data was allowed into the + database. + + Args: + schema: A schema derived from a marshmallow Schema that the argment + will be validated against + arg: The data to validate + to_string: Convert `arg` to string if `True` + many: The `arg` will be treated as a list of multiple objects if `True` + + Returns: + The result of serializing the data + + Raises: + MarshmallowValidationError: The supplied data failed to validate against the + schema + + """ + return ( + schema(strict=True, many=many).dumps(arg).data + if to_string + else schema(strict=True, many=many).dump(arg).data + ) diff --git a/src/app/beer_garden/api/http/client.py b/src/app/beer_garden/api/http/client.py index 26aa431b5..32b159b0c 100644 --- a/src/app/beer_garden/api/http/client.py +++ b/src/app/beer_garden/api/http/client.py @@ -30,11 +30,14 @@ async def __call__(self, *args, serialize_kwargs=None, **kwargs): if serialize_kwargs.get("return_raw") or isinstance(result, six.string_types): return result - if self.json_dump(result): - return json.dumps(result) if serialize_kwargs["to_string"] else result - if isinstance(result, BrewtilsGarden): - return GardenSchema(strict=True).dumps(result).data + return ( + GardenSchema(strict=True).dumps(result).data + if serialize_kwargs["to_string"] + else GardenSchema(strict=True).dump(result).data + ) + elif self.json_dump(result): + return json.dumps(result) if serialize_kwargs["to_string"] else result return SchemaParser.serialize(result, **(serialize_kwargs or {})) diff --git a/src/app/beer_garden/api/http/handlers/v1/garden.py b/src/app/beer_garden/api/http/handlers/v1/garden.py index c08e0145f..9ce4c7676 100644 --- a/src/app/beer_garden/api/http/handlers/v1/garden.py +++ b/src/app/beer_garden/api/http/handlers/v1/garden.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- +import logging + from brewtils.errors import ModelValidationError -from brewtils.models import Operation +from brewtils.models import Operation as BrewtilsOperation from brewtils.schema_parser import SchemaParser from mongoengine.queryset.queryset import QuerySet @@ -10,6 +12,8 @@ from beer_garden.db.schemas.garden_schema import GardenSchema from beer_garden.garden import local_garden +logger = logging.getLogger(__name__) + GARDEN_CREATE = Permissions.GARDEN_CREATE.value GARDEN_READ = Permissions.GARDEN_READ.value GARDEN_UPDATE = Permissions.GARDEN_UPDATE.value @@ -41,7 +45,7 @@ async def get(self, garden_name): """ garden = self.get_or_raise(Garden, GARDEN_READ, name=garden_name) - response = GardenSchema(strict=True).dumps(garden).data + response = self.format_response(GardenSchema, garden) self.set_header("Content-Type", "application/json; charset=UTF-8") self.write(response) @@ -68,7 +72,9 @@ async def delete(self, garden_name): """ garden = self.get_or_raise(Garden, GARDEN_DELETE, name=garden_name) - await self.client(Operation(operation_type="GARDEN_DELETE", args=[garden.name])) + await self.client( + BrewtilsOperation(operation_type="GARDEN_DELETE", args=[garden.name]) + ) self.set_status(204) @@ -126,30 +132,35 @@ async def patch(self, garden_name): if operation in ["initializing", "running", "stopped", "block"]: response = await self.client( - Operation( + BrewtilsOperation( operation_type="GARDEN_UPDATE_STATUS", args=[garden.name, operation.upper()], ) ) elif operation == "heartbeat": response = await self.client( - Operation( + BrewtilsOperation( operation_type="GARDEN_UPDATE_STATUS", args=[garden.name, "RUNNING"], ) ) elif operation == "config": - garden_to_update = GardenSchema(strict=True).load(op.value).data + garden_to_update = self.load_or_raise( + GardenSchema, op.value, from_string=False + ) + # don't let a name change sneak through here + garden_to_update.name = garden_name garden_to_update.id = garden.id + response = await self.client( - Operation( + BrewtilsOperation( operation_type="GARDEN_UPDATE_CONFIG", args=[garden_to_update], ) ) elif operation == "sync": response = await self.client( - Operation( + BrewtilsOperation( operation_type="GARDEN_SYNC", kwargs={"sync_target": garden.name}, ) @@ -182,8 +193,7 @@ async def get(self): - Garden """ permitted_gardens: QuerySet = self.permissioned_queryset(Garden, GARDEN_READ) - - response = GardenSchema(strict=True, many=True).dumps(permitted_gardens).data + response = self.format_response(GardenSchema, permitted_gardens, many=True) self.set_header("Content-Type", "application/json; charset=UTF-8") self.write(response) @@ -210,12 +220,12 @@ async def post(self): tags: - Garden """ - garden = GardenSchema(strict=True).loads(self.request.decoded_body).data + garden = self.load_or_raise(GardenSchema, self.request.decoded_body) self.verify_user_permission_for_object(GARDEN_CREATE, garden) response = await self.client( - Operation( + BrewtilsOperation( operation_type="GARDEN_CREATE", args=[garden], ) @@ -275,7 +285,7 @@ async def patch(self): if operation == "sync": response = await self.client( - Operation( + BrewtilsOperation( operation_type="GARDEN_SYNC", ) ) diff --git a/src/app/beer_garden/db/schemas/garden_schema.py b/src/app/beer_garden/db/schemas/garden_schema.py index 4c3d509b3..5975c07f4 100644 --- a/src/app/beer_garden/db/schemas/garden_schema.py +++ b/src/app/beer_garden/db/schemas/garden_schema.py @@ -4,8 +4,7 @@ from brewtils.schemas import StatusInfoSchema # noqa # until we can fully decouple from brewtils.schemas import SystemSchema # noqa # until we can fully decouple from marshmallow import Schema, ValidationError, fields -from marshmallow.decorators import post_load, pre_load, validates_schema -from mongoengine.queryset.queryset import QuerySet +from marshmallow.decorators import post_load, validates_schema logger = logging.getLogger(__name__) @@ -95,7 +94,7 @@ class GardenSchema(GardenBaseSchema): name = fields.Str(allow_none=False) status = fields.Str(allow_none=True) status_info = fields.Nested(StatusInfoSchema, allow_none=True) - connection_type = fields.Str(allow_none=False) + connection_type = fields.Str(allow_none=True) connection_params = fields.Nested( "GardenConnectionsParamsSchema", allow_none=True, diff --git a/src/app/beer_garden/events/handlers.py b/src/app/beer_garden/events/handlers.py index 772608a21..4d6b5b267 100644 --- a/src/app/beer_garden/events/handlers.py +++ b/src/app/beer_garden/events/handlers.py @@ -34,19 +34,22 @@ def garden_callbacks(event: Event) -> None: logger.debug(f"{event!r}") # These are all the MAIN PROCESS subsystems that care about events - for handler in [ - beer_garden.application.handle_event, - beer_garden.garden.handle_event, - beer_garden.plugin.handle_event, - beer_garden.requests.handle_event, - beer_garden.router.handle_event, - beer_garden.systems.handle_event, - beer_garden.scheduler.handle_event, - beer_garden.log.handle_event, - beer_garden.files.handle_event, - beer_garden.local_plugins.manager.handle_event, + for handler, handler_tag in [ + (beer_garden.application.handle_event, "Application"), + (beer_garden.garden.handle_event, "Garden"), + (beer_garden.plugin.handle_event, "Plugin"), + (beer_garden.requests.handle_event, "Request"), + (beer_garden.router.handle_event, "Rounter"), + (beer_garden.systems.handle_event, "System"), + (beer_garden.scheduler.handle_event, "Scheduler"), + (beer_garden.log.handle_event, "Log"), + (beer_garden.files.handle_event, "File"), + (beer_garden.local_plugins.manager.handle_event, "Local plugins manager"), ]: try: handler(deepcopy(event)) except Exception as ex: - logger.exception(f"Error executing callback for {event!r}: {ex}") + logger.exception( + f"'{handler_tag}' event handler received an error executing callback" + f" for {event!r}: {ex}" + ) diff --git a/src/app/beer_garden/garden.py b/src/app/beer_garden/garden.py index 3bdf2a3af..555be2bde 100644 --- a/src/app/beer_garden/garden.py +++ b/src/app/beer_garden/garden.py @@ -186,29 +186,34 @@ def get_connection_defaults(): # TODO: this is a temporary work-around until Brewtils is configured to provide # sensible defaults + bad_defaults = {"ssl_enabled", "ca_verify"} sensible_defaults = { - "bg_host": "somehostname", + "bg_host": "child_hostname", "bg_port": 1025, "ssl_enabled": False, "bg_url_prefix": "/", - "ca_cert": "none", "ca_verify": False, - "client_cert": "none", } + # substitute the sensible default only if we're provided `None` or an empty string - defaults = { - key: ( - defaults[key] - if ( - (defaults[key] is not None and (isinstance(defaults[key], bool))) - or defaults[key] - ) - else sensible_defaults[key] - ) - for key in config_map - } + new_defaults = {} + for key in defaults: + # setting ssl and ca_verify to `True` by default makes no sense + if key in bad_defaults: + new_defaults[key] = sensible_defaults[key] + else: + provided = defaults[key] + if key in config_map: + if provided is not None and provided: + # always use a string that is not empty + new_defaults[key] = provided + else: + # but use the empty string if we don't provide an alternative + new_defaults[key] = ( + sensible_defaults[key] if key in sensible_defaults else "" + ) - return defaults + return new_defaults @publish_event(Events.GARDEN_CREATED) diff --git a/src/app/test/api/http/unit/handlers/v1/garden_connection_test.py b/src/app/test/api/http/unit/handlers/v1/garden_connection_test.py new file mode 100644 index 000000000..9447418d3 --- /dev/null +++ b/src/app/test/api/http/unit/handlers/v1/garden_connection_test.py @@ -0,0 +1,120 @@ +import json + +import pytest +from tornado.httpclient import HTTPRequest, HTTPResponse + +from beer_garden.db.mongo.models import Garden +from beer_garden.db.schemas.garden_schema import GardenSchema + + +@pytest.fixture +def http_connection_params(): + return { + "http": { + "host": "somehost", + "port": 10001, + "url_prefix": "/", + "ca_verify": False, + "ssl": False, + } + } + + +@pytest.fixture +def stomp_connection_params(): + return { + "stomp": { + "host": "somehost", + "port": 10001, + "ssl": {"use_ssl": False}, + "headers": [], + "send_destination": "sendtohere", + "subscribe_destination": "listenhere", + "username": "stompuser", + "password": "stomppassword", + } + } + + +@pytest.fixture +def garden_with_http_connection_params(http_connection_params): + garden = Garden( + name="somehttpgardenname", + connection_type="HTTP", + connection_params=http_connection_params, + ).save() + + yield garden + + garden.delete() + + +@pytest.fixture +def garden_with_stomp_connection_params(stomp_connection_params): + garden = Garden( + name="somestompgardenname", + connection_type="STOMP", + connection_params=stomp_connection_params, + ).save() + + yield garden + + garden.delete() + + +class TestGardenConnections: + @pytest.mark.parametrize( + "garden, required_fields, endpoint", + ( + ( + pytest.lazy_fixture("garden_with_http_connection_params"), + {"host", "port", "ssl"}, + "http", + ), + ( + pytest.lazy_fixture("garden_with_stomp_connection_params"), + {"host", "port"}, + "stomp", + ), + ), + ) + @pytest.mark.gen_test + def test_import_with_missing_required_params_returns_useful_message( + self, + garden, + required_fields, + endpoint, + http_client, + base_url, + ): + missing_data_message = "Missing data for required field." # default marshmallow + + endpoint_connection_params = garden.connection_params.pop(endpoint) + for required_field in required_fields: + _ = endpoint_connection_params.pop(required_field) + + garden.connection_params = {endpoint: endpoint_connection_params} + patch_request_body = f"""{{"operations": [ + {{ + "operation": "config", + "value": {GardenSchema(strict=True).dumps(garden).data} + }} + ]}} + """ + + url = f"{base_url}/api/v1/gardens/" + garden.name + headers = {"Content-Type": "application/json", "Accept": "application/json"} + + request = HTTPRequest( + url, method="PATCH", headers=headers, body=patch_request_body + ) + + response: HTTPResponse + response = yield http_client.fetch(request, raise_error=False) + error_dict = json.loads(response.error.message.replace("'", '"'))[ + "connection_params" + ][endpoint] + + for field in required_fields: + assert field in error_dict + assert error_dict[field].pop() == missing_data_message diff --git a/src/app/test/db/mongo/models/garden_model_test.py b/src/app/test/db/mongo/models/garden_model_test.py index 3afcbd7a7..c44bbbb82 100644 --- a/src/app/test/db/mongo/models/garden_model_test.py +++ b/src/app/test/db/mongo/models/garden_model_test.py @@ -225,8 +225,8 @@ def test_local_garden_save_succeeds_with_empty_conn_params(self): "conn_parm", ( pytest.lazy_fixture("bad_conn_params"), - # pytest.lazy_fixture("bad_conn_params_with_partial_good"), - # pytest.lazy_fixture("bad_conn_params_with_full_good"), + pytest.lazy_fixture("bad_conn_params_with_partial_good"), + pytest.lazy_fixture("bad_conn_params_with_full_good"), ), ) def test_remote_garden_save_fails_with_bad_conn_params(self, conn_parm): @@ -292,7 +292,7 @@ def test_remote_garden_save_succeeds_with_only_good_stomp_headers( "bad_headers", ( garbage_headers_extra_key, - # garbage_headers_wrong_key, + garbage_headers_wrong_key, ), ) def test_remote_garden_save_fails_with_garbage_stomp_headers( @@ -302,7 +302,7 @@ def test_remote_garden_save_fails_with_garbage_stomp_headers( test_params["headers"] = bad_headers connection_params = {"stomp": test_params} - with pytest.raises(ValidationError) as exc: + with pytest.raises(ValidationError): Garden( name=garden_name, connection_type="STOMP", diff --git a/src/app/test/garden_test.py b/src/app/test/garden_test.py index 611f34853..89651d9f2 100644 --- a/src/app/test/garden_test.py +++ b/src/app/test/garden_test.py @@ -2,9 +2,7 @@ import pytest from brewtils.models import Garden as BrewtilsGarden from brewtils.models import System as BrewtilsSystem -from brewtils.specification import _CONNECTION_SPEC from mongoengine import DoesNotExist, connect -from yapconf import YapconfSpec from beer_garden import config from beer_garden.db.mongo.models import Garden, System From 5c2b7b0eb2658e8c6788def2b297f3c207b2b1ae Mon Sep 17 00:00:00 2001 From: John B Date: Wed, 5 Jan 2022 08:59:46 -0500 Subject: [PATCH 3/7] Typo --- src/app/beer_garden/events/handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/beer_garden/events/handlers.py b/src/app/beer_garden/events/handlers.py index 4d6b5b267..945d57cc6 100644 --- a/src/app/beer_garden/events/handlers.py +++ b/src/app/beer_garden/events/handlers.py @@ -39,7 +39,7 @@ def garden_callbacks(event: Event) -> None: (beer_garden.garden.handle_event, "Garden"), (beer_garden.plugin.handle_event, "Plugin"), (beer_garden.requests.handle_event, "Request"), - (beer_garden.router.handle_event, "Rounter"), + (beer_garden.router.handle_event, "Router"), (beer_garden.systems.handle_event, "System"), (beer_garden.scheduler.handle_event, "Scheduler"), (beer_garden.log.handle_event, "Log"), From defe44e1ed2f7ff80b1800ffa9eb790becea3549 Mon Sep 17 00:00:00 2001 From: John B Date: Wed, 5 Jan 2022 12:19:37 -0500 Subject: [PATCH 4/7] Massive eslint refactor --- src/ui/src/index.js | 396 +++++----- src/ui/src/js/configs/compiler_config.js | 4 +- src/ui/src/js/configs/dt_renderer.js | 206 ++--- src/ui/src/js/configs/http_interceptor.js | 62 +- src/ui/src/js/configs/routes.js | 470 ++++++------ src/ui/src/js/controllers/about.js | 18 +- src/ui/src/js/controllers/admin_garden.js | 71 +- .../src/js/controllers/admin_garden_view.js | 80 +- src/ui/src/js/controllers/admin_queue.js | 69 +- src/ui/src/js/controllers/admin_role.js | 328 ++++---- src/ui/src/js/controllers/admin_system.js | 151 ++-- .../controllers/admin_system_force_delete.js | 42 +- .../src/js/controllers/admin_system_logs.js | 95 +-- src/ui/src/js/controllers/admin_user.js | 224 +++--- src/ui/src/js/controllers/command_index.js | 132 ++-- src/ui/src/js/controllers/command_view.js | 232 +++--- .../src/js/controllers/job/create_command.js | 4 +- .../src/js/controllers/job/create_request.js | 106 +-- .../src/js/controllers/job/create_system.js | 4 +- .../src/js/controllers/job/create_trigger.js | 132 ++-- src/ui/src/js/controllers/job/export_jobs.js | 54 +- src/ui/src/js/controllers/job/import_jobs.js | 73 +- src/ui/src/js/controllers/job_index.js | 12 +- src/ui/src/js/controllers/job_view.js | 120 +-- src/ui/src/js/controllers/login.js | 48 +- src/ui/src/js/controllers/request_index.js | 364 ++++----- src/ui/src/js/controllers/request_view.js | 267 +++---- src/ui/src/js/controllers/system_index.js | 123 +-- src/ui/src/js/directives/custom_on_change.js | 10 +- src/ui/src/js/directives/fetch_data.js | 60 +- src/ui/src/js/directives/system_status.js | 36 +- src/ui/src/js/run.js | 174 ++--- src/ui/src/js/services/admin_service.js | 4 +- src/ui/src/js/services/command_service.js | 5 +- src/ui/src/js/services/error_service.js | 62 +- src/ui/src/js/services/event_service.js | 14 +- src/ui/src/js/services/garden_service.js | 321 ++++---- src/ui/src/js/services/instance_service.js | 20 +- src/ui/src/js/services/job_service.js | 714 +++++++++--------- src/ui/src/js/services/namespace_service.js | 2 +- src/ui/src/js/services/permission_service.js | 4 +- src/ui/src/js/services/queue_service.js | 12 +- src/ui/src/js/services/request_service.js | 70 +- src/ui/src/js/services/role_service.js | 56 +- src/ui/src/js/services/runner_service.js | 20 +- src/ui/src/js/services/system_service.js | 85 +-- src/ui/src/js/services/token_service.js | 62 +- src/ui/src/js/services/user_service.js | 38 +- src/ui/src/js/services/utility_service.js | 70 +- 49 files changed, 2866 insertions(+), 2860 deletions(-) diff --git a/src/ui/src/index.js b/src/ui/src/index.js index 9a15f9869..a3aa3bbc7 100644 --- a/src/ui/src/index.js +++ b/src/ui/src/index.js @@ -1,238 +1,238 @@ -"use strict"; +'use strict'; // Javascript // Utilities -import "babel-polyfill"; -import "objectpath"; -import "tv4"; -import "jquery"; +import 'babel-polyfill'; +import 'objectpath'; +import 'tv4'; +import 'jquery'; -import moment from "moment"; -import "moment-timezone"; -moment.tz.setDefault("UTC"); +import moment from 'moment'; +import 'moment-timezone'; +moment.tz.setDefault('UTC'); // Angular -import angular from "angular"; -import "angular-animate"; -import "angular-confirm"; -import "angular-filter"; -import "angular-sanitize"; -import "angular-ui-ace"; -import "angular-ui-bootstrap"; -import "@uirouter/angularjs"; -import "angular-bootstrap-switch"; -import "angular-strap"; -import "angular-local-storage"; - -import "bootstrap"; -import "bootstrap-switch"; -import "eonasdan-bootstrap-datetimepicker"; -import "ui-select"; -import "metismenu"; -import "startbootstrap-sb-admin-2/js/sb-admin-2.js"; -import "datatables.net"; -import "datatables.net-bs"; -import "datatables-columnfilter"; -import "datatables-columnfilter/dist/dataTables.lcf.eonasdan.js"; -import "jwt-decode"; -import "angular-datatables/dist/angular-datatables.js"; -import "angular-datatables/dist/plugins/light-columnfilter/angular-datatables.light-columnfilter.js"; // eslint-disable-line max-len -import "angular-datatables/dist/plugins/bootstrap/angular-datatables.bootstrap.js"; -import "angular-schema-form-bootstrap/dist/angular-schema-form-bootstrap-bundled.js"; -import "ace-builds/src-noconflict/ace.js"; -import "ace-builds/src-noconflict/mode-json.js"; -import "ace-builds/src-noconflict/theme-dawn.js"; +import angular from 'angular'; +import 'angular-animate'; +import 'angular-confirm'; +import 'angular-filter'; +import 'angular-sanitize'; +import 'angular-ui-ace'; +import 'angular-ui-bootstrap'; +import '@uirouter/angularjs'; +import 'angular-bootstrap-switch'; +import 'angular-strap'; +import 'angular-local-storage'; + +import 'bootstrap'; +import 'bootstrap-switch'; +import 'eonasdan-bootstrap-datetimepicker'; +import 'ui-select'; +import 'metismenu'; +import 'startbootstrap-sb-admin-2/js/sb-admin-2.js'; +import 'datatables.net'; +import 'datatables.net-bs'; +import 'datatables-columnfilter'; +import 'datatables-columnfilter/dist/dataTables.lcf.eonasdan.js'; +import 'jwt-decode'; +import 'angular-datatables/dist/angular-datatables.js'; +import 'angular-datatables/dist/plugins/light-columnfilter/angular-datatables.light-columnfilter.js'; // eslint-disable-line max-len +import 'angular-datatables/dist/plugins/bootstrap/angular-datatables.bootstrap.js'; +import 'angular-schema-form-bootstrap/dist/angular-schema-form-bootstrap-bundled.js'; +import 'ace-builds/src-noconflict/ace.js'; +import 'ace-builds/src-noconflict/mode-json.js'; +import 'ace-builds/src-noconflict/theme-dawn.js'; // Our ASF addons and builder -import "@beer-garden/builder"; -import "@beer-garden/addons"; +import '@beer-garden/builder'; +import '@beer-garden/addons'; // TODO - This needs to be served separately right now, something about WebWorkers? // require('ace-builds/src-noconflict/worker-json.js'); // CSS -import "bootstrap/dist/css/bootstrap.css"; -import "bootstrap/dist/css/bootstrap-theme.css"; -import "bootstrap-switch/dist/css/bootstrap3/bootstrap-switch.css"; -import "metismenu/dist/metisMenu.css"; -import "startbootstrap-sb-admin-2/dist/css/sb-admin-2.css"; -import "datatables.net-bs/css/dataTables.bootstrap.css"; -import "ui-select/dist/select.css"; -import "font-awesome/css/font-awesome.css"; -import "./styles/custom.css"; +import 'bootstrap/dist/css/bootstrap.css'; +import 'bootstrap/dist/css/bootstrap-theme.css'; +import 'bootstrap-switch/dist/css/bootstrap3/bootstrap-switch.css'; +import 'metismenu/dist/metisMenu.css'; +import 'startbootstrap-sb-admin-2/dist/css/sb-admin-2.css'; +import 'datatables.net-bs/css/dataTables.bootstrap.css'; +import 'ui-select/dist/select.css'; +import 'font-awesome/css/font-awesome.css'; +import './styles/custom.css'; // Now load our actual application components -import appRun from "./js/run.js"; -import runDTRenderer from "./js/configs/dt_renderer.js"; -import routeConfig from "./js/configs/routes.js"; +import appRun from './js/run.js'; +import runDTRenderer from './js/configs/dt_renderer.js'; +import routeConfig from './js/configs/routes.js'; import { interceptorService, authInterceptorService, interceptorConfig, -} from "./js/configs/http_interceptor.js"; - -import { compilerConfig } from "./js/configs/compiler_config.js"; - -import fetchDataDirective from "./js/directives/fetch_data.js"; -import bgStatusDirective from "./js/directives/system_status.js"; -import customOnChangeDirective from "./js/directives/custom_on_change.js"; - -import adminService from "./js/services/admin_service.js"; -import commandService from "./js/services/command_service.js"; -import instanceService from "./js/services/instance_service.js"; -import queueService from "./js/services/queue_service.js"; -import requestService from "./js/services/request_service.js"; -import systemService from "./js/services/system_service.js"; -import userService from "./js/services/user_service.js"; -import roleService from "./js/services/role_service.js"; -import permissionService from "./js/services/permission_service.js"; -import tokenService from "./js/services/token_service.js"; -import utilityService from "./js/services/utility_service.js"; -import jobService from "./js/services/job_service.js"; -import errorService from "./js/services/error_service.js"; -import eventService from "./js/services/event_service.js"; -import namespaceService from "./js/services/namespace_service.js"; -import gardenService from "./js/services/garden_service.js"; -import runnerService from "./js/services//runner_service.js"; - -import aboutController from "./js/controllers/about.js"; -import adminQueueController from "./js/controllers/admin_queue.js"; -import adminSystemController from "./js/controllers/admin_system.js"; -import adminSystemLogsController from "./js/controllers/admin_system_logs.js"; -import adminSystemForceDeleteController from "./js/controllers/admin_system_force_delete.js"; +} from './js/configs/http_interceptor.js'; + +import {compilerConfig} from './js/configs/compiler_config.js'; + +import fetchDataDirective from './js/directives/fetch_data.js'; +import bgStatusDirective from './js/directives/system_status.js'; +import customOnChangeDirective from './js/directives/custom_on_change.js'; + +import adminService from './js/services/admin_service.js'; +import commandService from './js/services/command_service.js'; +import instanceService from './js/services/instance_service.js'; +import queueService from './js/services/queue_service.js'; +import requestService from './js/services/request_service.js'; +import systemService from './js/services/system_service.js'; +import userService from './js/services/user_service.js'; +import roleService from './js/services/role_service.js'; +import permissionService from './js/services/permission_service.js'; +import tokenService from './js/services/token_service.js'; +import utilityService from './js/services/utility_service.js'; +import jobService from './js/services/job_service.js'; +import errorService from './js/services/error_service.js'; +import eventService from './js/services/event_service.js'; +import namespaceService from './js/services/namespace_service.js'; +import gardenService from './js/services/garden_service.js'; +import runnerService from './js/services//runner_service.js'; + +import aboutController from './js/controllers/about.js'; +import adminQueueController from './js/controllers/admin_queue.js'; +import adminSystemController from './js/controllers/admin_system.js'; +import adminSystemLogsController from './js/controllers/admin_system_logs.js'; +import adminSystemForceDeleteController from './js/controllers/admin_system_force_delete.js'; import { adminUserController, newUserController, -} from "./js/controllers/admin_user.js"; +} from './js/controllers/admin_user.js'; import { adminRoleController, newRoleController, -} from "./js/controllers/admin_role.js"; -import adminGardenController from "./js/controllers/admin_garden.js"; -import adminGardenViewController from "./js/controllers/admin_garden_view.js"; -import commandIndexController from "./js/controllers/command_index.js"; -import commandViewController from "./js/controllers/command_view.js"; -import requestIndexController from "./js/controllers/request_index.js"; +} from './js/controllers/admin_role.js'; +import adminGardenController from './js/controllers/admin_garden.js'; +import adminGardenViewController from './js/controllers/admin_garden_view.js'; +import commandIndexController from './js/controllers/command_index.js'; +import commandViewController from './js/controllers/command_view.js'; +import requestIndexController from './js/controllers/request_index.js'; import requestViewController, { slideAnimation, -} from "./js/controllers/request_view.js"; -import systemIndexController from "./js/controllers/system_index.js"; -import jobIndexController from "./js/controllers/job_index.js"; +} from './js/controllers/request_view.js'; +import systemIndexController from './js/controllers/system_index.js'; +import jobIndexController from './js/controllers/job_index.js'; import { jobViewController, jobRunNowModalController, -} from "./js/controllers/job_view.js"; -import jobCreateSystemController from "./js/controllers/job/create_system.js"; -import jobExportController from "./js/controllers/job/export_jobs.js"; +} from './js/controllers/job_view.js'; +import jobCreateSystemController from './js/controllers/job/create_system.js'; +import jobExportController from './js/controllers/job/export_jobs.js'; import { jobImportController, jobImportModalController, -} from "./js/controllers/job/import_jobs.js"; -import jobCreateCommandController from "./js/controllers/job/create_command.js"; -import jobCreateRequestController from "./js/controllers/job/create_request.js"; -import jobCreateTriggerController from "./js/controllers/job/create_trigger.js"; -import loginController from "./js/controllers/login.js"; +} from './js/controllers/job/import_jobs.js'; +import jobCreateCommandController from './js/controllers/job/create_command.js'; +import jobCreateRequestController from './js/controllers/job/create_request.js'; +import jobCreateTriggerController from './js/controllers/job/create_trigger.js'; +import loginController from './js/controllers/login.js'; // Partials -import "./partials/about.html"; -import "./partials/admin_system.html"; -import "./partials/admin_user.html"; -import "./partials/admin_role.html"; -import "./partials/admin_garden_index.html"; -import "./partials/admin_garden_view.html"; -import "./partials/command_index.html"; -import "./partials/command_view.html"; -import "./partials/request_index.html"; -import "./partials/request_view.html"; -import "./partials/system_index.html"; -import "./partials/job_index.html"; -import "./partials/job_view.html"; -import "./partials/job/create_system.html"; -import "./partials/job/create_command.html"; -import "./partials/job/create_request.html"; -import "./partials/job/create_trigger.html"; +import './partials/about.html'; +import './partials/admin_system.html'; +import './partials/admin_user.html'; +import './partials/admin_role.html'; +import './partials/admin_garden_index.html'; +import './partials/admin_garden_view.html'; +import './partials/command_index.html'; +import './partials/command_view.html'; +import './partials/request_index.html'; +import './partials/request_view.html'; +import './partials/system_index.html'; +import './partials/job_index.html'; +import './partials/job_view.html'; +import './partials/job/create_system.html'; +import './partials/job/create_command.html'; +import './partials/job/create_request.html'; +import './partials/job/create_trigger.html'; // Images -import "./image/fa-beer.png"; -import "./image/fa-coffee.png"; +import './image/fa-beer.png'; +import './image/fa-coffee.png'; // Finally, FINALLY, we have all our dependencies imported. Create the Angularness! angular - .module("bgApp", [ - "ui.router", - "ui.bootstrap", - "ui.ace", - "datatables", - "datatables.bootstrap", - "datatables.light-columnfilter", - "schemaForm", - "angular-confirm", - "angular.filter", - "ngAnimate", - "frapontillo.bootstrap-switch", - "mgcrea.ngStrap", - "LocalStorageModule", - "beer-garden.addons", - "beer-garden.builder", - ]) - .run(appRun) - .run(runDTRenderer) - .config(routeConfig) - .config(interceptorConfig) - .config(compilerConfig) - .service("APIInterceptor", interceptorService) - .service("authInterceptorService", authInterceptorService) - .animation(".slide", slideAnimation) - - .directive("fetchData", fetchDataDirective) - .directive("bgStatus", bgStatusDirective) - .directive("customOnChange", customOnChangeDirective) - - .factory("AdminService", adminService) - .factory("CommandService", commandService) - .factory("InstanceService", instanceService) - .factory("QueueService", queueService) - .factory("RequestService", requestService) - .factory("SystemService", systemService) - .factory("UserService", userService) - .factory("RoleService", roleService) - .factory("PermissionService", permissionService) - .factory("TokenService", tokenService) - .factory("UtilityService", utilityService) - .factory("JobService", jobService) - .factory("ErrorService", errorService) - .factory("EventService", eventService) - .factory("NamespaceService", namespaceService) - .factory("GardenService", gardenService) - .factory("RunnerService", runnerService) - - .controller("AboutController", aboutController) - .controller("AdminQueueController", adminQueueController) - .controller( - "AdminSystemForceDeleteController", - adminSystemForceDeleteController - ) - .controller("AdminSystemController", adminSystemController) - .controller("AdminSystemLogsController", adminSystemLogsController) - .controller("AdminUserController", adminUserController) - .controller("NewUserController", newUserController) - .controller("AdminRoleController", adminRoleController) - .controller("NewRoleController", newRoleController) - .controller("AdminGardenController", adminGardenController) - .controller("AdminGardenViewController", adminGardenViewController) - .controller("CommandIndexController", commandIndexController) - .controller("CommandViewController", commandViewController) - .controller("RequestIndexController", requestIndexController) - .controller("RequestViewController", requestViewController) - .controller("SystemIndexController", systemIndexController) - .controller("JobIndexController", jobIndexController) - .controller("JobViewController", jobViewController) - .controller("JobRunNowModalController", jobRunNowModalController) - .controller("JobCreateSystemController", jobCreateSystemController) - .controller("JobCreateCommandController", jobCreateCommandController) - .controller("JobCreateRequestController", jobCreateRequestController) - .controller("JobCreateTriggerController", jobCreateTriggerController) - .controller("JobExportController", jobExportController) - .controller("JobImportController", jobImportController) - .controller("JobImportModalController", jobImportModalController) - .controller("LoginController", loginController); + .module('bgApp', [ + 'ui.router', + 'ui.bootstrap', + 'ui.ace', + 'datatables', + 'datatables.bootstrap', + 'datatables.light-columnfilter', + 'schemaForm', + 'angular-confirm', + 'angular.filter', + 'ngAnimate', + 'frapontillo.bootstrap-switch', + 'mgcrea.ngStrap', + 'LocalStorageModule', + 'beer-garden.addons', + 'beer-garden.builder', + ]) + .run(appRun) + .run(runDTRenderer) + .config(routeConfig) + .config(interceptorConfig) + .config(compilerConfig) + .service('APIInterceptor', interceptorService) + .service('authInterceptorService', authInterceptorService) + .animation('.slide', slideAnimation) + + .directive('fetchData', fetchDataDirective) + .directive('bgStatus', bgStatusDirective) + .directive('customOnChange', customOnChangeDirective) + + .factory('AdminService', adminService) + .factory('CommandService', commandService) + .factory('InstanceService', instanceService) + .factory('QueueService', queueService) + .factory('RequestService', requestService) + .factory('SystemService', systemService) + .factory('UserService', userService) + .factory('RoleService', roleService) + .factory('PermissionService', permissionService) + .factory('TokenService', tokenService) + .factory('UtilityService', utilityService) + .factory('JobService', jobService) + .factory('ErrorService', errorService) + .factory('EventService', eventService) + .factory('NamespaceService', namespaceService) + .factory('GardenService', gardenService) + .factory('RunnerService', runnerService) + + .controller('AboutController', aboutController) + .controller('AdminQueueController', adminQueueController) + .controller( + 'AdminSystemForceDeleteController', + adminSystemForceDeleteController + ) + .controller('AdminSystemController', adminSystemController) + .controller('AdminSystemLogsController', adminSystemLogsController) + .controller('AdminUserController', adminUserController) + .controller('NewUserController', newUserController) + .controller('AdminRoleController', adminRoleController) + .controller('NewRoleController', newRoleController) + .controller('AdminGardenController', adminGardenController) + .controller('AdminGardenViewController', adminGardenViewController) + .controller('CommandIndexController', commandIndexController) + .controller('CommandViewController', commandViewController) + .controller('RequestIndexController', requestIndexController) + .controller('RequestViewController', requestViewController) + .controller('SystemIndexController', systemIndexController) + .controller('JobIndexController', jobIndexController) + .controller('JobViewController', jobViewController) + .controller('JobRunNowModalController', jobRunNowModalController) + .controller('JobCreateSystemController', jobCreateSystemController) + .controller('JobCreateCommandController', jobCreateCommandController) + .controller('JobCreateRequestController', jobCreateRequestController) + .controller('JobCreateTriggerController', jobCreateTriggerController) + .controller('JobExportController', jobExportController) + .controller('JobImportController', jobImportController) + .controller('JobImportModalController', jobImportModalController) + .controller('LoginController', loginController); diff --git a/src/ui/src/js/configs/compiler_config.js b/src/ui/src/js/configs/compiler_config.js index 9f1b73ae7..456c8cc91 100644 --- a/src/ui/src/js/configs/compiler_config.js +++ b/src/ui/src/js/configs/compiler_config.js @@ -1,10 +1,10 @@ -compilerConfig.$inject = ["$compileProvider"]; +compilerConfig.$inject = ['$compileProvider']; /** * compilerConfig - Angular configuration object for the Angular compiler. * @param {$compileProvider} $compileProvider Angular's $compileProvider object. */ export function compilerConfig($compileProvider) { $compileProvider.aHrefSanitizationTrustedUrlList( - /^\s*(https?|ftp|mailto|tel|file|blob):/ + /^\s*(https?|ftp|mailto|tel|file|blob):/ ); } diff --git a/src/ui/src/js/configs/dt_renderer.js b/src/ui/src/js/configs/dt_renderer.js index fd4c84252..86847a222 100644 --- a/src/ui/src/js/configs/dt_renderer.js +++ b/src/ui/src/js/configs/dt_renderer.js @@ -1,4 +1,4 @@ -runDTRenderer.$inject = ["DTRendererService"]; +runDTRenderer.$inject = ['DTRendererService']; /** * runDTRenderer - Tweak datatables rendering @@ -6,122 +6,122 @@ runDTRenderer.$inject = ["DTRendererService"]; */ export default function runDTRenderer(DTRendererService) { DTRendererService.registerPlugin({ - postRender: function (options, result) { + postRender: function(options, result) { if (options && options.childContainer) { - let childContainer = $("") - .attr("id", "childContainer") - .css("margin-right", "20px") - .append( - $("") - .attr("id", "childCheck") - .attr("type", "checkbox") - .css("margin-top", "-4px") - .change(() => { - $(".dataTable").dataTable().fnUpdate(); - }) - ) - .append( - $("