diff --git a/monitoring/monitorlib/schema_validation.py b/monitoring/monitorlib/schema_validation.py index 399d89ae8f..a8a754152b 100644 --- a/monitoring/monitorlib/schema_validation.py +++ b/monitoring/monitorlib/schema_validation.py @@ -70,6 +70,14 @@ class F3548_21(str, Enum): ) AirspaceConflictResponse = "components.schemas.AirspaceConflictResponse" + ChangeConstraintReferenceResponse = ( + "components.schemas.ChangeConstraintReferenceResponse" + ) + GetConstraintReferenceResponse = "components.schemas.GetConstraintReferenceResponse" + QueryConstraintReferenceResponse = ( + "components.schemas.QueryConstraintReferenceResponse" + ) + _openapi_content_cache: Dict[str, dict] = {} diff --git a/monitoring/prober/infrastructure.py b/monitoring/prober/infrastructure.py index eebf526b9c..399236f5f4 100644 --- a/monitoring/prober/infrastructure.py +++ b/monitoring/prober/infrastructure.py @@ -100,7 +100,7 @@ def wrapper_default_scope(*args, **kwargs): resource_type_code_descriptions: Dict[ResourceType, str] = {} -# Next code: 390 +# Next code: 391 def register_resource_type(code: int, description: str) -> ResourceType: """Register that the specified code refers to the described resource. diff --git a/monitoring/uss_qualifier/configurations/dev/f3548_self_contained.yaml b/monitoring/uss_qualifier/configurations/dev/f3548_self_contained.yaml index a414d999f0..2c51bcd9b9 100644 --- a/monitoring/uss_qualifier/configurations/dev/f3548_self_contained.yaml +++ b/monitoring/uss_qualifier/configurations/dev/f3548_self_contained.yaml @@ -66,6 +66,7 @@ v1: # ASTM F3548-21 USS emulation roles - utm.strategic_coordination - utm.availability_arbitration + - utm.constraint_management # For authentication test purposes. # Remove if the authentication provider pointed to by AUTH_SPEC does not support it. - "" diff --git a/monitoring/uss_qualifier/configurations/dev/library/environment_containers.yaml b/monitoring/uss_qualifier/configurations/dev/library/environment_containers.yaml index a01fd59c9c..bd078ad054 100644 --- a/monitoring/uss_qualifier/configurations/dev/library/environment_containers.yaml +++ b/monitoring/uss_qualifier/configurations/dev/library/environment_containers.yaml @@ -27,6 +27,7 @@ utm_auth: - utm.strategic_coordination - utm.conformance_monitoring_sa - utm.availability_arbitration + - utm.constraint_management # InterUSS versioning automated testing - interuss.versioning.read_system_versions - interuss.geospatial_map.query diff --git a/monitoring/uss_qualifier/configurations/dev/library/environment_localhost.yaml b/monitoring/uss_qualifier/configurations/dev/library/environment_localhost.yaml index 045a945317..46e6e4bfac 100644 --- a/monitoring/uss_qualifier/configurations/dev/library/environment_localhost.yaml +++ b/monitoring/uss_qualifier/configurations/dev/library/environment_localhost.yaml @@ -27,6 +27,7 @@ utm_auth: - utm.strategic_coordination - utm.conformance_monitoring_sa - utm.availability_arbitration + - utm.constraint_management # InterUSS versioning automated testing - interuss.versioning.read_system_versions - interuss.geospatial_map.query diff --git a/monitoring/uss_qualifier/resources/astm/f3548/v21/dss.py b/monitoring/uss_qualifier/resources/astm/f3548/v21/dss.py index 99cae33c37..f7a0501700 100644 --- a/monitoring/uss_qualifier/resources/astm/f3548/v21/dss.py +++ b/monitoring/uss_qualifier/resources/astm/f3548/v21/dss.py @@ -33,6 +33,11 @@ ExchangeRecord, ErrorReport, AirspaceConflictResponse, + PutConstraintReferenceParameters, + ChangeConstraintReferenceResponse, + ConstraintReference, + QueryConstraintReferenceParameters, + QueryConstraintReferencesResponse, ) from uas_standards.astm.f3548.v21.constants import Scope @@ -457,6 +462,139 @@ def set_uss_availability( result = query.parse_json_result(UssAvailabilityStatusResponse) return result.version, query + def put_constraint_ref( + self, + cr_id: str, + extents: List[Volume4D], + uss_base_url: UssBaseURL, + ovn: Optional[str] = None, + ) -> Tuple[ConstraintReference, List[SubscriberToNotify], Query]: + """ + Create or update a constraint reference. + Returns: + the constraint reference created or updated, the subscribers to notify, the query + Raises: + * QueryError if request failed, if HTTP status code is different than 200 or 201, or if the parsing of the response failed. + """ + self._uses_scope(Scope.ConstraintManagement) + create = ovn is None + if create: + op = OPERATIONS[OperationID.CreateConstraintReference] + url = op.path.format(entityid=cr_id) + query_type = QueryType.F3548v21DSSCreateConstraintReference + else: + op = OPERATIONS[OperationID.UpdateConstraintReference] + url = op.path.format(entityid=cr_id, ovn=ovn) + query_type = QueryType.F3548v21DSSUpdateConstraintReference + + req = PutConstraintReferenceParameters( + extents=extents, + uss_base_url=uss_base_url, + ) + query = query_and_describe( + self.client, + op.verb, + url, + query_type, + self.participant_id, + scope=Scope.ConstraintManagement, + json=req, + ) + if (create and query.status_code == 201) or ( + not create and query.status_code == 200 + ): + result = query.parse_json_result(ChangeConstraintReferenceResponse) + return result.constraint_reference, result.subscribers, query + else: + err_msg = query.error_message if query.error_message is not None else "" + raise QueryError( + f"Received code {query.status_code} when attempting to {'create' if create else 'update'} constraint reference with ID {cr_id}; error message: `{err_msg}`", + query, + ) + + def get_constraint_ref(self, id: str) -> Tuple[ConstraintReference, Query]: + """ + Retrieve a constraint reference from the DSS, using only its ID + Raises: + * QueryError: if request failed, if HTTP status code is different than 200, or if the parsing of the response failed. + """ + self._uses_scope(Scope.ConstraintManagement) + op = OPERATIONS[OperationID.GetConstraintReference] + query = query_and_describe( + self.client, + op.verb, + op.path.format(entityid=id), + QueryType.F3548v21DSSGetConstraintReference, + self.participant_id, + scope=Scope.ConstraintManagement, + ) + if query.status_code != 200: + raise QueryError( + f"Received code {query.status_code} when attempting to retrieve constraint reference {id}{f'; error message: `{query.error_message}`' if query.error_message is not None else ''}", + query, + ) + else: + result = query.parse_json_result(ChangeConstraintReferenceResponse) + return result.constraint_reference, query + + def find_constraint_ref( + self, extent: Volume4D + ) -> Tuple[List[ConstraintReference], Query]: + """ + Find constraint references overlapping with a given volume 4D. + Raises: + * QueryError: if request failed, if HTTP status code is different than 200, or if the parsing of the response failed. + """ + self._uses_scope(Scope.ConstraintManagement) + op = OPERATIONS[OperationID.QueryConstraintReferences] + req = QueryConstraintReferenceParameters(area_of_interest=extent) + query = query_and_describe( + self.client, + op.verb, + op.path, + QueryType.F3548v21DSSQueryConstraintReferences, + self.participant_id, + scope=Scope.ConstraintManagement, + json=req, + ) + if query.status_code != 200: + raise QueryError( + f"Received code {query.status_code} when attempting to find operational intents in {extent}{f'; error message: `{query.error_message}`' if query.error_message is not None else ''}", + query, + ) + else: + result = query.parse_json_result(QueryConstraintReferencesResponse) + return result.constraint_references, query + + def delete_constraint_ref( + self, + id: str, + ovn: str, + ) -> Tuple[ConstraintReference, List[SubscriberToNotify], Query]: + """ + Delete a constraint reference. + Raises: + * QueryError: if request failed, if HTTP status code is different than 200, or if the parsing of the response failed. + """ + self._uses_scope(Scope.ConstraintManagement) + op = OPERATIONS[OperationID.DeleteConstraintReference] + query = query_and_describe( + self.client, + op.verb, + op.path.format(entityid=id, ovn=ovn), + QueryType.F3548v21DSSDeleteConstraintReference, + self.participant_id, + scope=Scope.ConstraintManagement, + ) + if query.status_code != 200: + raise QueryError( + f"Received code {query.status_code} when attempting to delete constraint reference {id}{f'; error message: `{query.error_message}`' if query.error_message is not None else ''}", + query, + ) + else: + result = query.parse_json_result(ChangeConstraintReferenceResponse) + return result.constraint_reference, result.subscribers, query + def make_report( self, exchange: ExchangeRecord, diff --git a/monitoring/uss_qualifier/resources/astm/f3548/v21/planning_area.py b/monitoring/uss_qualifier/resources/astm/f3548/v21/planning_area.py index 3aab48a5f9..118cc82ac7 100644 --- a/monitoring/uss_qualifier/resources/astm/f3548/v21/planning_area.py +++ b/monitoring/uss_qualifier/resources/astm/f3548/v21/planning_area.py @@ -9,6 +9,7 @@ EntityID, PutOperationalIntentReferenceParameters, ImplicitSubscriptionParameters, + PutConstraintReferenceParameters, ) from monitoring.monitorlib.geo import make_latlng_rect, Volume3D @@ -101,6 +102,29 @@ def get_new_operational_intent_ref_params( else None, ) + def get_new_constraint_ref_params( + self, + time_start: datetime.datetime, + time_end: datetime.datetime, + ) -> PutConstraintReferenceParameters: + """ + Builds a PutConstraintReferenceParameters object that can be used against the DSS OCR API. + + The extents contained in these parameters contain a single 4DVolume, which may not be entirely realistic, + but is sufficient in situations where the content of the CR is irrelevant as long as it is valid, such + as for testing authentication or parameter validation. + """ + return PutConstraintReferenceParameters( + extents=[ + Volume4D( + volume=self.volume, + time_start=Time(time_start), + time_end=Time(time_end), + ).to_f3548v21() + ], + uss_base_url=self.base_url, + ) + class PlanningAreaResource(Resource[PlanningAreaSpecification]): specification: PlanningAreaSpecification diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/dss/clean_workspace.md b/monitoring/uss_qualifier/scenarios/astm/utm/dss/clean_workspace.md index bab9225e40..d594ae6d6e 100644 --- a/monitoring/uss_qualifier/scenarios/astm/utm/dss/clean_workspace.md +++ b/monitoring/uss_qualifier/scenarios/astm/utm/dss/clean_workspace.md @@ -28,3 +28,18 @@ If the DSS cannot be queried for the existing test ID, the DSS is likely not imp ## 🛑 Subscription can be deleted check **[astm.f3548.v21.DSS0005,5](../../../../requirements/astm/f3548/v21.md)** requires the implementation of the DSS endpoint to allow callers to delete subscriptions they created. + +## 🛑 Constraint references can be queried by ID check + +If an existing constraint reference cannot directly be queried by its ID, or if for a non-existing one the DSS replies with a status code different than 404, +the DSS implementation is in violation of **[astm.f3548.v21.DSS0005,3](../../../../requirements/astm/f3548/v21.md)**. + +## 🛑 Constraint references can be searched for check + +A client with valid credentials should be allowed to search for constraint references in a given area. +Otherwise, the DSS is not in compliance with **[astm.f3548.v21.DSS0005,4](../../../../requirements/astm/f3548/v21.md)**. + +## 🛑 Constraint reference removed check + +If an existing constraint cannot be deleted by its manager when providing the proper ID and OVN, the DSS implementation is in violation of +**[astm.f3548.v21.DSS0005,3](../../../../requirements/astm/f3548/v21.md)**. diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/dss/fragments/cr/crud/create_correct.md b/monitoring/uss_qualifier/scenarios/astm/utm/dss/fragments/cr/crud/create_correct.md new file mode 100644 index 0000000000..a754fc6bdf --- /dev/null +++ b/monitoring/uss_qualifier/scenarios/astm/utm/dss/fragments/cr/crud/create_correct.md @@ -0,0 +1,21 @@ +# Create constraint reference test step fragment + +This test step fragment validates that: + - a query to create a constraint reference with valid parameters succeeds + - the response to the query conforms to the OpenAPI specification + - the content of the response reflects the created constraint reference + +## [Query Success](./create_query.md) + +Check query succeeds + +## [Response Format](./create_format.md) + +Check response format + +## 🛑 Create constraint reference response content is correct check + +A successful constraint reference creation query is expected to return a body, the content of which reflects the created constraint reference. +If the content of the response does not correspond to what was requested, the DSS is failing to implement **[astm.f3548.v21.DSS0005,3](../../../../../../../requirements/astm/f3548/v21.md)**. + +This check will usually be performing a series of sub-checks from the [validate](../validate) fragments. diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/dss/fragments/cr/crud/create_format.md b/monitoring/uss_qualifier/scenarios/astm/utm/dss/fragments/cr/crud/create_format.md new file mode 100644 index 0000000000..cd582251c1 --- /dev/null +++ b/monitoring/uss_qualifier/scenarios/astm/utm/dss/fragments/cr/crud/create_format.md @@ -0,0 +1,9 @@ +# Create constraint reference response format test step fragment + +This test step fragment validates that a constraint references creation returns a body in the correct format. + +## 🛑 Create constraint reference response format conforms to spec check + +The response to a successful constraint reference creation query is expected to conform to the format defined by the OpenAPI specification under the `A3.1` Annex of ASTM F3548−21. + +If it does not, the DSS is failing to implement **[astm.f3548.v21.DSS0005,3](../../../../../../../requirements/astm/f3548/v21.md)**. diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/dss/fragments/cr/crud/create_query.md b/monitoring/uss_qualifier/scenarios/astm/utm/dss/fragments/cr/crud/create_query.md new file mode 100644 index 0000000000..915b1ac38b --- /dev/null +++ b/monitoring/uss_qualifier/scenarios/astm/utm/dss/fragments/cr/crud/create_query.md @@ -0,0 +1,8 @@ +# Create constraint reference test step fragment + +This test step fragment validates that a query to create a constraint reference with valid parameters succeeds + +## 🛑 Create constraint reference query succeeds check + +As per **[astm.f3548.v21.DSS0005,3](../../../../../../../requirements/astm/f3548/v21.md)**, the DSS API must allow callers to create a constraint reference with either one or both of the +start and end time missing, provided all the required parameters are valid. diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/dss/fragments/cr/validate/correctness.md b/monitoring/uss_qualifier/scenarios/astm/utm/dss/fragments/cr/validate/correctness.md new file mode 100644 index 0000000000..1df306088a --- /dev/null +++ b/monitoring/uss_qualifier/scenarios/astm/utm/dss/fragments/cr/validate/correctness.md @@ -0,0 +1,51 @@ +# Validate the content of a constraint reference test step fragment + +This test step fragment attempts to validate the content of a single constraint reference returned by the DSS. + +The code for these checks lives in the [cr_validator.py](../../../validators/cr_validator.py) class. + +## ⚠️ Returned constraint reference ID is correct check + +If the returned constraint reference ID does not correspond to the one specified in the creation parameters, +**[astm.f3548.v21.DSS0005,3](../../../../../../../requirements/astm/f3548/v21.md)** is not respected. + +## ⚠️ Returned constraint reference has a manager check + +If the returned constraint reference has no manager defined, **[astm.f3548.v21.DSS0005,3](../../../../../../../requirements/astm/f3548/v21.md)** is not respected. + +## ⚠️ Returned constraint reference manager is correct check + +The returned manager must correspond to the identity of the client that created the constraint at the DSS, +otherwise the DSS is in violation of **[astm.f3548.v21.DSS0005,3](../../../../../../../requirements/astm/f3548/v21.md)**. + +## ⚠️ Returned constraint reference has an USS base URL check + +If the returned constraint reference has no USS base URL defined, **[astm.f3548.v21.DSS0005,3](../../../../../../../requirements/astm/f3548/v21.md)** is not respected. + +## ⚠️ Returned constraint reference base URL is correct check + +The returned USS base URL must be prefixed with the USS base URL that was provided at constraint reference creation, otherwise the DSS is in violation of **[astm.f3548.v21.DSS0005,3](../../../../../../../requirements/astm/f3548/v21.md)**. + +## ⚠️ Returned constraint reference has a start time check + +If the returned constraint reference has no start time defined, **[astm.f3548.v21.DSS0005,3](../../../../../../../requirements/astm/f3548/v21.md)** is not respected. + +## ⚠️ Returned start time is correct check + +The returned start time must be the same as the provided one, otherwise the DSS is in violation of **[astm.f3548.v21.DSS0005,3](../../../../../../../requirements/astm/f3548/v21.md)**. + +## ⚠️ Returned constraint reference has an end time check + +Constraint references need a defined end time in order to limit their duration: if the DSS omits to set the end time, it will be in violation of **[astm.f3548.v21.DSS0005,3](../../../../../../../requirements/astm/f3548/v21.md)**. + +## ⚠️ Returned end time is correct check + +The returned end time must be the same as the provided one, otherwise the DSS is in violation of **[astm.f3548.v21.DSS0005,3](../../../../../../../requirements/astm/f3548/v21.md)**. + +## ⚠️ Returned constraint reference has an OVN check + +If the returned constraint reference has no OVN defined, **[astm.f3548.v21.DSS0005,3](../../../../../../../requirements/astm/f3548/v21.md)** is not respected. + +## ⚠️ Returned constraint reference has a version check + +If the returned constraint reference has no version defined, **[astm.f3548.v21.DSS0005,3](../../../../../../../requirements/astm/f3548/v21.md)** is not respected. diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/dss/synchronization/__init__.py b/monitoring/uss_qualifier/scenarios/astm/utm/dss/synchronization/__init__.py index ecdb51c52a..6dcc7440c0 100644 --- a/monitoring/uss_qualifier/scenarios/astm/utm/dss/synchronization/__init__.py +++ b/monitoring/uss_qualifier/scenarios/astm/utm/dss/synchronization/__init__.py @@ -1,3 +1,4 @@ from .subscription_synchronization import SubscriptionSynchronization from .op_intent_ref_synchronization import OIRSynchronization from .uss_availability_synchronization import USSAvailabilitySynchronization +from .constraint_ref_synchronization import CRSynchronization diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/dss/synchronization/constraint_ref_synchronization.md b/monitoring/uss_qualifier/scenarios/astm/utm/dss/synchronization/constraint_ref_synchronization.md new file mode 100644 index 0000000000..af20abd469 --- /dev/null +++ b/monitoring/uss_qualifier/scenarios/astm/utm/dss/synchronization/constraint_ref_synchronization.md @@ -0,0 +1,52 @@ +# ASTM SCD DSS: Constraint Reference Synchronization test scenario + +## Overview + +Verifies that all CRUD operations on constraint references performed on a given DSS instance +are properly propagated to every other DSS instance participating in the deployment. + +## Resources + +### dss + +[`DSSInstanceResource`](../../../../../resources/astm/f3548/v21/dss.py) the DSS instance through which entities are created, modified and deleted. + +### other_instances + +[`DSSInstancesResource`](../../../../../resources/astm/f3548/v21/dss.py) pointing to the DSS instances used to confirm that entities are properly propagated. + +### id_generator + +[`IDGeneratorResource`](../../../../../resources/interuss/id_generator.py) providing the constraint reference ID for this scenario. + +### planning_area + +[`PlanningAreaResource`](../../../../../resources/astm/f3548/v21/planning_area.py) describes the 3D volume in which constraint reference will be created. + +### client_identity + +[`ClientIdentityResource`](../../../../../resources/communications/client_identity.py) to be used for this scenario. + +## Setup test case + +### [Ensure clean workspace test step](../clean_workspace.md) + +This step ensures that no constraint reference with the known test ID exists in the DSS. + +## CR synchronization test case + +This test case creates an constraint reference on the main DSS, and verifies that it is properly synchronized to the other DSS instances. + +It then goes on to mutate and delete it, each time confirming that all other DSSes return the expected results. + +### Create CR validation test step + +#### [Create CR](../fragments/cr/crud/create_correct.md) + +Verify that an constraint reference can be created on the primary DSS. + +#### [CR Content is correct](../fragments/cr/validate/correctness.md) + +Verify that the constraint reference returned by the DSS under test is properly formatted and contains the expected content. + +## [Cleanup](../clean_workspace.md) diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/dss/synchronization/constraint_ref_synchronization.py b/monitoring/uss_qualifier/scenarios/astm/utm/dss/synchronization/constraint_ref_synchronization.py new file mode 100644 index 0000000000..636c3e7106 --- /dev/null +++ b/monitoring/uss_qualifier/scenarios/astm/utm/dss/synchronization/constraint_ref_synchronization.py @@ -0,0 +1,202 @@ +from datetime import datetime, timedelta +from typing import List, Optional + +from uas_standards.astm.f3548.v21.api import ( + EntityID, + PutConstraintReferenceParameters, + ConstraintReference, +) +from uas_standards.astm.f3548.v21.constants import Scope + +from monitoring.monitorlib.fetch import QueryError +from monitoring.monitorlib.geotemporal import Volume4D +from monitoring.prober.infrastructure import register_resource_type +from monitoring.uss_qualifier.resources.astm.f3548.v21 import PlanningAreaResource +from monitoring.uss_qualifier.resources.astm.f3548.v21.dss import ( + DSSInstanceResource, + DSSInstancesResource, + DSSInstance, +) +from monitoring.uss_qualifier.resources.communications import ClientIdentityResource +from monitoring.uss_qualifier.resources.interuss.id_generator import IDGeneratorResource +from monitoring.uss_qualifier.scenarios.astm.utm.dss import test_step_fragments +from monitoring.uss_qualifier.scenarios.astm.utm.dss.validators.cr_validator import ( + ConstraintReferenceValidator, +) +from monitoring.uss_qualifier.scenarios.scenario import ( + TestScenario, + ScenarioCannotContinueError, +) +from monitoring.uss_qualifier.suites.suite import ExecutionContext + + +class CRSynchronization(TestScenario): + """ + A scenario that checks if multiple DSS instances properly synchronize + constraint references. + + Not in the scope of the first version of this: + - access rights (making sure only the manager of the OIR can mutate it) + - control of the area synchronization (by doing area searches against the secondaries) + - mutation of an entity on a secondary DSS when it was created on the primary + - deletion of an entity on a secondary DSS when it was created on the primary + """ + + SUB_TYPE = register_resource_type( + 390, "Operational Intent Reference for synchronization checks" + ) + + _dss: DSSInstance + + _secondary_dss_instances: List[DSSInstance] + + # Base identifier for the OIR that will be created + _cr_id: EntityID + + # Base parameters used for OIR creation + _cr_params: PutConstraintReferenceParameters + + # Keep track of the current OIR state + _current_cr: Optional[ConstraintReference] + + _expected_manager: str + + def __init__( + self, + dss: DSSInstanceResource, + other_instances: DSSInstancesResource, + id_generator: IDGeneratorResource, + client_identity: ClientIdentityResource, + planning_area: PlanningAreaResource, + ): + """ + Args: + dss: dss to test + other_instances: dss instances to be checked for proper synchronization + id_generator: will let us generate specific identifiers + client_identity: tells us the identity we should expect as an entity's manager + planning_area: An Area to use for the tests. It should be an area for which the DSS is responsible, + but has no other requirements. + + """ + super().__init__() + scopes_primary = { + Scope.StrategicCoordination: "cleanup leftover subscriptions and operational intent references", + Scope.ConstraintManagement: "create and delete constraint references", + } + scopes_secondaries = { + Scope.ConstraintManagement: "read, mutate and delete constraint references" + } + + self._dss = dss.get_instance(scopes_primary) + self._primary_pid = self._dss.participant_id + + self._secondary_dss_instances = [ + sec_dss.get_instance(scopes_secondaries) + for sec_dss in other_instances.dss_instances + ] + + self._cr_id = id_generator.id_factory.make_id(self.SUB_TYPE) + self._expected_manager = client_identity.subject() + self._planning_area = planning_area.specification + + # Build a ready-to-use 4D volume with no specified time for searching + # the currently active CRs + self._planning_area_volume4d = Volume4D( + volume=self._planning_area.volume, + ) + + def run(self, context: ExecutionContext): + + # Check that we actually have at least one other DSS to test against: + if not self._secondary_dss_instances: + raise ScenarioCannotContinueError( + "Cannot run CRSynchronization scenario: no other DSS instances to test against" + ) + + self.begin_test_scenario(context) + self._setup_case() + self.begin_test_case("CR synchronization") + + self.begin_test_step("Create CR validation") + self._create_cr_with_params(self._cr_params) + self.end_test_step() + + # Other steps to follow in subsequent PRs + + self.end_test_case() + self.end_test_scenario() + + def _setup_case(self): + self.begin_test_case("Setup") + # Multiple runs of the scenario seem to rely on the same instance of it: + # thus we need to reset the state of the scenario before running it. + self._current_cr = None + # We need times that are close to 'now': the params are set + # at the beginning of each scenario run. + self._cr_params = self._planning_area.get_new_constraint_ref_params( + time_start=datetime.now() - timedelta(seconds=10), + time_end=datetime.now() + timedelta(minutes=20), + ) + self.begin_test_step("Ensure clean workspace") + self._ensure_clean_workspace_step() + self.end_test_step() + self.end_test_case() + + def _ensure_clean_workspace_step(self): + + # Delete any active CRs we might own + test_step_fragments.cleanup_active_constraint_refs( + self, + self._dss, + self._planning_area_volume4d.to_f3548v21(), + self._expected_manager, + ) + + # Make sure the OIR ID we are going to use is available + test_step_fragments.cleanup_constraint_ref(self, self._dss, self._cr_id) + # Drop any active subs we might own and that could interfere + test_step_fragments.cleanup_active_subs( + self, self._dss, self._planning_area_volume4d.to_f3548v21() + ) + + def _create_cr_with_params(self, creation_params: PutConstraintReferenceParameters): + + with self.check( + "Create constraint reference query succeeds", [self._primary_pid] + ) as check: + try: + cr, subs, q = self._dss.put_constraint_ref( + cr_id=self._cr_id, + extents=creation_params.extents, + uss_base_url=creation_params.uss_base_url, + ovn=None, + ) + self.record_query(q) + except QueryError as qe: + self.record_queries(qe.queries) + check.record_failed( + summary="Create constraint reference failed", + details=qe.msg, + query_timestamps=qe.query_timestamps, + ) + return + + with self.check( + "Create constraint reference response content is correct", + [self._primary_pid], + ) as check: + ConstraintReferenceValidator( + main_check=check, + scenario=self, + expected_manager=self._expected_manager, + participant_id=[self._primary_pid], + cr_params=creation_params, + ).validate_created_cr(self._cr_id, new_cr=q) + + self._current_cr = cr + + def cleanup(self): + self.begin_cleanup() + self._ensure_clean_workspace_step() + self.end_cleanup() diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/dss/test_step_fragments.py b/monitoring/uss_qualifier/scenarios/astm/utm/dss/test_step_fragments.py index 08e609da68..b7fa8f7134 100644 --- a/monitoring/uss_qualifier/scenarios/astm/utm/dss/test_step_fragments.py +++ b/monitoring/uss_qualifier/scenarios/astm/utm/dss/test_step_fragments.py @@ -27,7 +27,7 @@ def remove_op_intent( scenario.record_query(query) except fetch.QueryError as e: scenario.record_queries(e.queries) - query = e.queries[0] + query = e.cause check.record_failed( summary=f"Could not remove op intent reference {oi_id}", details=f"When attempting to remove op intent reference {oi_id} from the DSS, received {query.status_code}; {e}", @@ -37,6 +37,33 @@ def remove_op_intent( # TODO: Attempt to notify subscribers +def remove_constraint_ref( + scenario: TestScenarioType, dss: DSSInstance, cr_id: EntityID, ovn: str +) -> None: + """Remove the specified constraint reference from the DSS. + + The specified constraint reference must be managed by `dss`'s auth adapter subscriber. + + This function implements parts of the test step fragment described in astm/utm/dss/clean_workspace.md. + """ + with scenario.check("Constraint reference removed", dss.participant_id) as check: + try: + removed_ref, subscribers_to_notify, query = dss.delete_constraint_ref( + cr_id, ovn + ) + scenario.record_query(query) + except fetch.QueryError as e: + scenario.record_queries(e.queries) + query = e.cause + check.record_failed( + summary=f"Could not remove constraint reference {cr_id}", + details=f"When attempting to remove constraint reference {cr_id} from the DSS, received {query.status_code}; {e}", + query_timestamps=[query.request.timestamp], + ) + + # TODO: Attempt to notify subscribers + + def cleanup_sub( scenario: TestScenarioType, dss: DSSInstance, sub_id: EntityID ) -> Optional[MutatedSubscription]: @@ -77,7 +104,7 @@ def cleanup_active_subs( ) -> None: """Search for and delete all active subscriptions at the DSS. - This function implements the test step fragment described in search_and_delete_active_subs.md. + This function implements the test step fragment described in clean_workspace.md. """ query = dss.query_subscriptions(volume) scenario.record_query(query) @@ -95,6 +122,39 @@ def cleanup_active_subs( cleanup_sub(scenario, dss, sub_id) +def cleanup_active_constraint_refs( + scenario: TestScenarioType, + dss: DSSInstance, + volume: Volume4D, + manager_identity: str, +) -> None: + """ + Search for and delete all active constraint references at the DSS. + + This function implements some of the test step fragment described in clean_workspace.md: + - Constraint references can be searched for + - Constraint reference removed + """ + with scenario.check( + "Constraint references can be searched for", [dss.participant_id] + ) as check: + try: + crs, query = dss.find_constraint_ref(volume) + scenario.record_query(query) + except QueryError as qe: + scenario.record_queries(qe.queries) + check.record_failed( + summary="Failed to query constraint references", + details=f"Failed to query constraint references: got response code {qe.cause.status_code}", + query_timestamps=[qe.cause.request.timestamp], + ) + return + + for cr in crs: + if cr.manager == manager_identity: + remove_constraint_ref(scenario, dss, cr.id, cr.ovn) + + def cleanup_active_oirs( scenario: TestScenarioType, dss: DSSInstance, @@ -109,9 +169,9 @@ def cleanup_active_oirs( except QueryError as qe: scenario.record_queries(qe.queries) check.record_failed( - summary="Failed to query operational intent references", - details=f"Failed to query operational intent references: got response code {qe.queries[0].status_code}", - query_timestamps=[qe.queries[0].request.timestamp], + summary=f"Failed OIR search with HTTP code {qe.cause.status_code}", + details=f"Failed to query operational intent references: {qe}", + query_timestamps=[qe.cause.request.timestamp], ) return @@ -142,3 +202,34 @@ def cleanup_op_intent( return remove_op_intent(scenario, dss, oi_id, oir.ovn) + + +def cleanup_constraint_ref( + scenario: TestScenarioType, dss: DSSInstance, cr_id: EntityID +) -> None: + """ + Remove the specified constraint reference from the DSS, if it exists. + + This function implements some of the test step fragment described in clean_workspace.md: + - Constraint references can be queried by ID + - Constraint reference removed + """ + + with scenario.check( + "Constraint references can be queried by ID", [dss.participant_id] + ) as check: + try: + cr, q = dss.get_constraint_ref(cr_id) + scenario.record_query(q) + except fetch.QueryError as e: + scenario.record_queries(e.queries) + if e.cause_status_code != 404: + check.record_failed( + summary="CR Get query returned code different from 200 or 404", + details=e.msg, + query_timestamps=e.query_timestamps, + ) + else: + return + + remove_constraint_ref(scenario, dss, cr_id, cr.ovn) diff --git a/monitoring/uss_qualifier/scenarios/astm/utm/dss/validators/cr_validator.py b/monitoring/uss_qualifier/scenarios/astm/utm/dss/validators/cr_validator.py new file mode 100644 index 0000000000..7cf1300ff4 --- /dev/null +++ b/monitoring/uss_qualifier/scenarios/astm/utm/dss/validators/cr_validator.py @@ -0,0 +1,281 @@ +from datetime import datetime +from typing import Optional, List + +from implicitdict import ImplicitDict +from uas_standards.astm.f3548.v21.api import ( + EntityID, + ConstraintReference, + ChangeConstraintReferenceResponse, + EntityOVN, + GetConstraintReferenceResponse, + QueryConstraintReferencesResponse, + PutConstraintReferenceParameters, + ConstraintReference, + ChangeConstraintReferenceResponse, +) + +from monitoring.monitorlib import schema_validation, fetch +from monitoring.monitorlib.geotemporal import Volume4DCollection +from monitoring.monitorlib.schema_validation import F3548_21 +from monitoring.uss_qualifier.scenarios.astm.utm.dss.validators import ( + fail_with_schema_errors, +) +from monitoring.uss_qualifier.scenarios.scenario import PendingCheck, TestScenario + +TIME_TOLERANCE_SEC = 1 +"""tolerance when comparing created vs returned timestamps""" + + +class ConstraintReferenceValidator: + """ + Wraps the validation logic for an constraint reference that was returned by a DSS + + It will compare the provided CR with the parameters specified at its creation. + """ + + _main_check: PendingCheck + """ + The overarching check corresponding to the general validation of a CR. + This check will be failed if any of the sub-checks carried out by this validator fail. + """ + + _scenario: TestScenario + """ + Scenario in which this validator is being used. Will be used to register checks. + """ + + _cr_params: Optional[PutConstraintReferenceParameters] + _pid: List[str] + """Participant ID(s) to use for the checks""" + + def __init__( + self, + main_check: PendingCheck, + scenario: TestScenario, + expected_manager: str, + participant_id: List[str], + cr_params: Optional[PutConstraintReferenceParameters], + ): + self._main_check = main_check + self._scenario = scenario + self._pid = participant_id + self._cr_params = cr_params + self._expected_manager = expected_manager + vol_collection = Volume4DCollection.from_f3548v21(cr_params.extents) + self._expected_start = vol_collection.time_start.datetime + self._expected_end = vol_collection.time_end.datetime + + def _fail_sub_check( + self, sub_check: PendingCheck, summary: str, details: str, t_dss: datetime + ) -> None: + """ + Fail the passed sub check with the passed summary and details, and fail + the main check with the passed details. + + Note that this method should only be used to fail sub-checks related to the CONTENT of the CR, + but not its FORMAT, as the main-check should only be pertaining to the content. + + The provided timestamp is forwarded into the query_timestamps of the check failure. + """ + sub_check.record_failed( + summary=summary, + details=details, + query_timestamps=[t_dss], + ) + + self._main_check.record_failed( + summary=f"Invalid CR returned by the DSS: {summary}", + details=details, + query_timestamps=[t_dss], + ) + + def _validate_cr( + self, + expected_entity_id: EntityID, + dss_cr: ConstraintReference, + t_dss: datetime, + previous_version: Optional[int], + expected_version: Optional[int], + previous_ovn: Optional[str], + expected_ovn: Optional[str], + ) -> None: + """ + Args: + expected_entity_id: the ID we expect to find in the entity + dss_cr: the CR returned by the DSS + t_dss: timestamp of the query to the DSS for failure reporting + previous_ovn: previous OVN of the entity, if we are verifying a mutation + expected_ovn: expected OVN of the entity, if we are verifying a read query + previous_version: previous version of the entity, if we are verifying a mutation + expected_version: expected version of the entity, if we are verifying a read query + """ + + with self._scenario.check( + "Returned constraint reference ID is correct", self._pid + ) as check: + if dss_cr.id != expected_entity_id: + self._fail_sub_check( + check, + summary=f"Returned CR ID is incorrect", + details=f"Expected CR ID {expected_entity_id}, got {dss_cr.id}", + t_dss=t_dss, + ) + + with self._scenario.check( + "Returned constraint reference has a manager", self._pid + ) as check: + # Check for empty string. None should have failed the schema check earlier + if not dss_cr.manager: + self._fail_sub_check( + check, + summary="No CR manager was specified", + details=f"Expected: {self._expected_manager}, got an empty or undefined string", + t_dss=t_dss, + ) + + with self._scenario.check( + "Returned constraint reference manager is correct", self._pid + ) as check: + if dss_cr.manager != self._expected_manager: + self._fail_sub_check( + check, + summary="Returned manager is incorrect", + details=f"Expected {self._expected_manager}, got {dss_cr.manager}", + t_dss=t_dss, + ) + + with self._scenario.check( + "Returned constraint reference has an USS base URL", self._pid + ) as check: + # If uss_base_url is not present, or it is None or Empty, we should fail: + if "uss_base_url" not in dss_cr or not dss_cr.uss_base_url: + self._fail_sub_check( + check, + summary="Returned CR has no USS base URL", + details="The CR returned by the DSS has no USS base URL when it should have one", + t_dss=t_dss, + ) + + with self._scenario.check( + "Returned constraint reference base URL is correct", self._pid + ) as check: + if dss_cr.uss_base_url != self._cr_params.uss_base_url: + self._fail_sub_check( + check, + summary="Returned USS Base URL does not match provided one", + details=f"Provided: {self._cr_params.uss_base_url}, Returned: {dss_cr.uss_base_url}", + t_dss=t_dss, + ) + + with self._scenario.check( + "Returned constraint reference has a start time", self._pid + ) as check: + if "time_start" not in dss_cr or dss_cr.time_start is None: + self._fail_sub_check( + check, + summary="Returned CR has no start time", + details="The constraint reference returned by the DSS has no start time when it should have one", + t_dss=t_dss, + ) + + with self._scenario.check( + "Returned constraint reference has an end time", self._pid + ) as check: + if "time_end" not in dss_cr or dss_cr.time_end is None: + self._fail_sub_check( + check, + summary="Returned CR has no end time", + details="The constraint reference returned by the DSS has no end time when it should have one", + t_dss=t_dss, + ) + + with self._scenario.check("Returned start time is correct", self._pid) as check: + if ( + abs( + dss_cr.time_start.value.datetime - self._expected_start + ).total_seconds() + > TIME_TOLERANCE_SEC + ): + self._fail_sub_check( + check, + summary="Returned start time does not match provided one", + details=f"Provided: {self._cr_params.start_time}, Returned: {dss_cr.time_start}", + t_dss=t_dss, + ) + + with self._scenario.check("Returned end time is correct", self._pid) as check: + if ( + abs(dss_cr.time_end.value.datetime - self._expected_end).total_seconds() + > TIME_TOLERANCE_SEC + ): + self._fail_sub_check( + check, + summary="Returned end time does not match provided one", + details=f"Provided: {self._cr_params.end_time}, Returned: {dss_cr.time_end}", + t_dss=t_dss, + ) + + with self._scenario.check( + "Returned constraint reference has an OVN", self._pid + ) as check: + if dss_cr.ovn is None: + self._fail_sub_check( + check, + summary="Returned CR has no OVN", + details="The constraint reference returned by the DSS has no OVN when it should have one", + t_dss=t_dss, + ) + + # TODO add check for: + # - subscription ID of the CR (based on passed parameters, if these were set) + + def _validate_put_cr_response_schema( + self, cr_query: fetch.Query, t_dss: datetime, action: str + ) -> bool: + """Validate response bodies for creation and mutation of CRs. + Returns 'False' if the schema validation failed, 'True' otherwise. + """ + + check_name = ( + "Create constraint reference response format conforms to spec" + if action == "create" + else "Mutate constraint reference response format conforms to spec" + ) + + with self._scenario.check(check_name, self._pid) as check: + errors = schema_validation.validate( + F3548_21.OpenAPIPath, + F3548_21.ChangeConstraintReferenceResponse, + cr_query.response.json, + ) + if errors: + fail_with_schema_errors(check, errors, t_dss) + return False + + return True + + def validate_created_cr( + self, expected_cr_id: EntityID, new_cr: fetch.Query + ) -> None: + """Validate a CR that was just explicitly created, meaning + we don't have a previous version to compare to, and we expect it to not be an implicit one.""" + + t_dss = new_cr.request.timestamp + + # Validate the response schema + if not self._validate_put_cr_response_schema(new_cr, t_dss, "create"): + return + + # Expected to pass given that we validated the JSON against the schema + parsed_resp = new_cr.parse_json_result(ChangeConstraintReferenceResponse) + + # Validate the CR itself + self._validate_cr( + expected_entity_id=expected_cr_id, + dss_cr=parsed_resp.constraint_reference, + t_dss=t_dss, + previous_version=None, + expected_version=None, + previous_ovn=None, + expected_ovn=None, + ) diff --git a/monitoring/uss_qualifier/suites/astm/utm/dss_probing.md b/monitoring/uss_qualifier/suites/astm/utm/dss_probing.md index 3d815ac1dd..6c3caadc59 100644 --- a/monitoring/uss_qualifier/suites/astm/utm/dss_probing.md +++ b/monitoring/uss_qualifier/suites/astm/utm/dss_probing.md @@ -4,20 +4,21 @@ ## [Actions](../../README.md#actions) -1. Scenario: [ASTM SCD DSS: USS Availability Synchronization](../../../scenarios/astm/utm/dss/synchronization/uss_availability_synchronization.md) ([`scenarios.astm.utm.dss.synchronization.USSAvailabilitySynchronization`](../../../scenarios/astm/utm/dss/synchronization/uss_availability_synchronization.py)) -2. Scenario: [ASTM F3548-21 UTM DSS Operational Intent Reference State Transitions](../../../scenarios/astm/utm/dss/op_intent_ref_state_transitions.md) ([`scenarios.astm.utm.dss.OpIntentReferenceStateTransitions`](../../../scenarios/astm/utm/dss/op_intent_ref_state_transitions.py)) -3. Scenario: [ASTM SCD DSS: Subscription and entity deletion interaction](../../../scenarios/astm/utm/dss/subscription_interactions_deletion.md) ([`scenarios.astm.utm.dss.SubscriptionInteractionsDeletion`](../../../scenarios/astm/utm/dss/subscription_interactions_deletion.py)) -4. Scenario: [ASTM SCD DSS: Subscription and entity interaction](../../../scenarios/astm/utm/dss/subscription_interactions.md) ([`scenarios.astm.utm.dss.SubscriptionInteractions`](../../../scenarios/astm/utm/dss/subscription_interactions.py)) -5. Scenario: [ASTM SCD DSS: Operational Intent Reference Key Validation](../../../scenarios/astm/utm/dss/op_intent_ref_key_validation.md) ([`scenarios.astm.utm.dss.OIRKeyValidation`](../../../scenarios/astm/utm/dss/op_intent_ref_key_validation.py)) -6. Scenario: [ASTM SCD DSS: Operational Intent Reference Synchronization](../../../scenarios/astm/utm/dss/synchronization/op_intent_ref_synchronization.md) ([`scenarios.astm.utm.dss.synchronization.OIRSynchronization`](../../../scenarios/astm/utm/dss/synchronization/op_intent_ref_synchronization.py)) -7. Scenario: [ASTM SCD DSS: Interfaces authentication](../../../scenarios/astm/utm/dss/authentication/authentication_validation.md) ([`scenarios.astm.utm.dss.authentication.AuthenticationValidation`](../../../scenarios/astm/utm/dss/authentication/authentication_validation.py)) -8. Scenario: [ASTM SCD DSS: Subscription Simple](../../../scenarios/astm/utm/dss/subscription_simple.md) ([`scenarios.astm.utm.dss.SubscriptionSimple`](../../../scenarios/astm/utm/dss/subscription_simple.py)) -9. Scenario: [ASTM SCD DSS: Subscription Validation](../../../scenarios/astm/utm/dss/subscription_validation.md) ([`scenarios.astm.utm.dss.SubscriptionValidation`](../../../scenarios/astm/utm/dss/subscription_validation.py)) -10. Scenario: [ASTM F3548-21 UTM DSS Operational Intent Reference Access Control](../../../scenarios/astm/utm/dss/op_intent_ref_access_control.md) ([`scenarios.astm.utm.dss.OpIntentReferenceAccessControl`](../../../scenarios/astm/utm/dss/op_intent_ref_access_control.py)) -11. Scenario: [ASTM F3548-21 UTM DSS interoperability](../../../scenarios/astm/utm/dss/dss_interoperability.md) ([`scenarios.astm.utm.dss.DSSInteroperability`](../../../scenarios/astm/utm/dss/dss_interoperability.py)) -12. Scenario: [ASTM SCD DSS: Subscription Synchronization](../../../scenarios/astm/utm/dss/synchronization/subscription_synchronization.md) ([`scenarios.astm.utm.dss.synchronization.SubscriptionSynchronization`](../../../scenarios/astm/utm/dss/synchronization/subscription_synchronization.py)) -13. Scenario: [ASTM UTM DSS: Direct CRDB access](../../../scenarios/astm/utm/dss/crdb_access.md) ([`scenarios.astm.utm.dss.CRDBAccess`](../../../scenarios/astm/utm/dss/crdb_access.py)) -14. Scenario: [ASTM SCD DSS: Report](../../../scenarios/astm/utm/dss/report.md) ([`scenarios.astm.utm.dss.Report`](../../../scenarios/astm/utm/dss/report.py)) +1. Scenario: [ASTM SCD DSS: Constraint Reference Synchronization](../../../scenarios/astm/utm/dss/synchronization/constraint_ref_synchronization.md) ([`scenarios.astm.utm.dss.synchronization.CRSynchronization`](../../../scenarios/astm/utm/dss/synchronization/constraint_ref_synchronization.py)) +2. Scenario: [ASTM SCD DSS: USS Availability Synchronization](../../../scenarios/astm/utm/dss/synchronization/uss_availability_synchronization.md) ([`scenarios.astm.utm.dss.synchronization.USSAvailabilitySynchronization`](../../../scenarios/astm/utm/dss/synchronization/uss_availability_synchronization.py)) +3. Scenario: [ASTM F3548-21 UTM DSS Operational Intent Reference State Transitions](../../../scenarios/astm/utm/dss/op_intent_ref_state_transitions.md) ([`scenarios.astm.utm.dss.OpIntentReferenceStateTransitions`](../../../scenarios/astm/utm/dss/op_intent_ref_state_transitions.py)) +4. Scenario: [ASTM SCD DSS: Subscription and entity deletion interaction](../../../scenarios/astm/utm/dss/subscription_interactions_deletion.md) ([`scenarios.astm.utm.dss.SubscriptionInteractionsDeletion`](../../../scenarios/astm/utm/dss/subscription_interactions_deletion.py)) +5. Scenario: [ASTM SCD DSS: Subscription and entity interaction](../../../scenarios/astm/utm/dss/subscription_interactions.md) ([`scenarios.astm.utm.dss.SubscriptionInteractions`](../../../scenarios/astm/utm/dss/subscription_interactions.py)) +6. Scenario: [ASTM SCD DSS: Operational Intent Reference Key Validation](../../../scenarios/astm/utm/dss/op_intent_ref_key_validation.md) ([`scenarios.astm.utm.dss.OIRKeyValidation`](../../../scenarios/astm/utm/dss/op_intent_ref_key_validation.py)) +7. Scenario: [ASTM SCD DSS: Operational Intent Reference Synchronization](../../../scenarios/astm/utm/dss/synchronization/op_intent_ref_synchronization.md) ([`scenarios.astm.utm.dss.synchronization.OIRSynchronization`](../../../scenarios/astm/utm/dss/synchronization/op_intent_ref_synchronization.py)) +8. Scenario: [ASTM SCD DSS: Interfaces authentication](../../../scenarios/astm/utm/dss/authentication/authentication_validation.md) ([`scenarios.astm.utm.dss.authentication.AuthenticationValidation`](../../../scenarios/astm/utm/dss/authentication/authentication_validation.py)) +9. Scenario: [ASTM SCD DSS: Subscription Simple](../../../scenarios/astm/utm/dss/subscription_simple.md) ([`scenarios.astm.utm.dss.SubscriptionSimple`](../../../scenarios/astm/utm/dss/subscription_simple.py)) +10. Scenario: [ASTM SCD DSS: Subscription Validation](../../../scenarios/astm/utm/dss/subscription_validation.md) ([`scenarios.astm.utm.dss.SubscriptionValidation`](../../../scenarios/astm/utm/dss/subscription_validation.py)) +11. Scenario: [ASTM F3548-21 UTM DSS Operational Intent Reference Access Control](../../../scenarios/astm/utm/dss/op_intent_ref_access_control.md) ([`scenarios.astm.utm.dss.OpIntentReferenceAccessControl`](../../../scenarios/astm/utm/dss/op_intent_ref_access_control.py)) +12. Scenario: [ASTM F3548-21 UTM DSS interoperability](../../../scenarios/astm/utm/dss/dss_interoperability.md) ([`scenarios.astm.utm.dss.DSSInteroperability`](../../../scenarios/astm/utm/dss/dss_interoperability.py)) +13. Scenario: [ASTM SCD DSS: Subscription Synchronization](../../../scenarios/astm/utm/dss/synchronization/subscription_synchronization.md) ([`scenarios.astm.utm.dss.synchronization.SubscriptionSynchronization`](../../../scenarios/astm/utm/dss/synchronization/subscription_synchronization.py)) +14. Scenario: [ASTM UTM DSS: Direct CRDB access](../../../scenarios/astm/utm/dss/crdb_access.md) ([`scenarios.astm.utm.dss.CRDBAccess`](../../../scenarios/astm/utm/dss/crdb_access.py)) +15. Scenario: [ASTM SCD DSS: Report](../../../scenarios/astm/utm/dss/report.md) ([`scenarios.astm.utm.dss.Report`](../../../scenarios/astm/utm/dss/report.py)) ## [Checked requirements](../../README.md#checked-requirements) @@ -29,20 +30,30 @@