From d4a864c1fb4a5f2232d0645b0e799aa353a71ff4 Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Tue, 7 Nov 2023 09:13:18 -0800 Subject: [PATCH] [mock_uss] Remove API objects from flight planning routines (#320) * Use flight planning business objects * Remove API objects from clear_area * Fix residual errors * Address comments * Only delete op intents that we manage --- .../mock_uss/f3548v21/flight_planning.py | 69 ++- monitoring/mock_uss/flight_planning/routes.py | 121 ++---- monitoring/mock_uss/flights/planning.py | 23 +- .../scd_injection/routes_injection.py | 393 +++++++++--------- monitoring/mock_uss/uspace/flight_auth.py | 10 +- .../clients/flight_planning/planning.py | 85 +++- .../mock_uss/mock_uss_scd_injection_api.py | 10 + .../reports/tested_requirements.py | 4 +- 8 files changed, 405 insertions(+), 310 deletions(-) diff --git a/monitoring/mock_uss/f3548v21/flight_planning.py b/monitoring/mock_uss/f3548v21/flight_planning.py index 1c03d68bb5..6869a4547a 100644 --- a/monitoring/mock_uss/f3548v21/flight_planning.py +++ b/monitoring/mock_uss/f3548v21/flight_planning.py @@ -25,18 +25,15 @@ class PlanningError(Exception): pass -def validate_request(req_body: scd_api.InjectFlightRequest) -> None: +def validate_request(op_intent: f3548_v21.OperationalIntent) -> None: """Raise a PlannerError if the request is not valid. Args: - req_body: Information about the requested flight. + op_intent: Information about the requested flight. """ # Validate max number of vertices nb_vertices = 0 - for volume in ( - req_body.operational_intent.volumes - + req_body.operational_intent.off_nominal_volumes - ): + for volume in op_intent.details.volumes + op_intent.details.off_nominal_volumes: if volume.volume.has_field_with_value("outline_polygon"): nb_vertices += len(volume.volume.outline_polygon.vertices) if volume.volume.has_field_with_value("outline_circle"): @@ -49,34 +46,32 @@ def validate_request(req_body: scd_api.InjectFlightRequest) -> None: # Validate max planning horizon for creation start_time = Volume4DCollection.from_interuss_scd_api( - req_body.operational_intent.volumes - + req_body.operational_intent.off_nominal_volumes + op_intent.details.volumes + op_intent.details.off_nominal_volumes ).time_start.datetime time_delta = start_time - datetime.now(tz=start_time.tzinfo) if ( time_delta.days > OiMaxPlanHorizonDays - and req_body.operational_intent.state == scd_api.OperationalIntentState.Accepted + and op_intent.reference.state == scd_api.OperationalIntentState.Accepted ): raise PlanningError( f"Operational intent to plan is too far away in time (max OiMaxPlanHorizonDays={OiMaxPlanHorizonDays})" ) # Validate no off_nominal_volumes if in Accepted or Activated state - if len(req_body.operational_intent.off_nominal_volumes) > 0 and ( - req_body.operational_intent.state == scd_api.OperationalIntentState.Accepted - or req_body.operational_intent.state == scd_api.OperationalIntentState.Activated + if len(op_intent.details.off_nominal_volumes) > 0 and ( + op_intent.reference.state == scd_api.OperationalIntentState.Accepted + or op_intent.reference.state == scd_api.OperationalIntentState.Activated ): raise PlanningError( - f"Operational intent specifies an off-nominal volume while being in {req_body.operational_intent.state} state" + f"Operational intent specifies an off-nominal volume while being in {op_intent.reference.state} state" ) # Validate intent is currently active if in Activated state # I.e. at least one volume has start time in the past and end time in the future - if req_body.operational_intent.state == scd_api.OperationalIntentState.Activated: + if op_intent.reference.state == scd_api.OperationalIntentState.Activated: now = arrow.utcnow().datetime active_volume = Volume4DCollection.from_interuss_scd_api( - req_body.operational_intent.volumes - + req_body.operational_intent.off_nominal_volumes + op_intent.details.volumes + op_intent.details.off_nominal_volumes ).has_active_volume(now) if not active_volume: raise PlanningError( @@ -247,10 +242,15 @@ def op_intent_from_flightinfo( uss_base_url="{}/mock/scd".format(webapp.config[KEY_BASE_URL]), subscription_id="UNKNOWN", ) + if "astm_f3548_21" in flight_info and flight_info.astm_f3548_21: + priority = flight_info.astm_f3548_21.priority + else: + # TODO: Ensure this function is only called when sufficient information is available, or raise ValueError + priority = 0 details = f3548_v21.OperationalIntentDetails( volumes=volumes, off_nominal_volumes=off_nominal_volumes, - priority=flight_info.astm_f3548_21.priority, + priority=priority, ) return f3548_v21.OperationalIntent( reference=reference, @@ -436,3 +436,38 @@ def share_op_intent( utm_client, subscriber.uss_base_url, update ) return record + + +def delete_op_intent( + op_intent_ref: f3548_v21.OperationalIntentReference, log: Callable[[str], None] +): + """Remove the operational intent reference from the DSS in compliance with ASTM F3548-21. + + Args: + op_intent_ref: Operational intent reference to remove. + log: Means of indicating debugging information. + + Raises: + * QueryError + * ConnectionError + * requests.exceptions.ConnectionError + """ + result = scd_client.delete_operational_intent_reference( + utm_client, + op_intent_ref.id, + op_intent_ref.ovn, + ) + + base_url = "{}/mock/scd".format(webapp.config[KEY_BASE_URL]) + for subscriber in result.subscribers: + if subscriber.uss_base_url == base_url: + # Do not notify ourselves + continue + update = f3548_v21.PutOperationalIntentDetailsParameters( + operational_intent_id=result.operational_intent_reference.id, + subscriptions=subscriber.subscriptions, + ) + log(f"Notifying {subscriber.uss_base_url}") + scd_client.notify_operational_intent_details_changed( + utm_client, subscriber.uss_base_url, update + ) diff --git a/monitoring/mock_uss/flight_planning/routes.py b/monitoring/mock_uss/flight_planning/routes.py index 0c7d90cf97..79067624df 100644 --- a/monitoring/mock_uss/flight_planning/routes.py +++ b/monitoring/mock_uss/flight_planning/routes.py @@ -1,4 +1,5 @@ import os +import uuid from datetime import timedelta from typing import Tuple @@ -6,6 +7,8 @@ from implicitdict import ImplicitDict from loguru import logger +from monitoring.mock_uss.f3548v21.flight_planning import op_intent_from_flightinfo +from monitoring.mock_uss.flights.database import FlightRecord from monitoring.mock_uss.scd_injection.routes_injection import ( inject_flight, lock_flight, @@ -15,12 +18,11 @@ ) from monitoring.monitorlib.clients.flight_planning.flight_info import ( FlightInfo, - AirspaceUsageState, ) from monitoring.monitorlib.clients.mock_uss.mock_uss_scd_injection_api import ( - MockUSSInjectFlightRequest, - MockUssFlightBehavior, + MockUSSUpsertFlightPlanRequest, ) +from monitoring.monitorlib.geotemporal import Volume4D from uas_standards.interuss.automated_testing.flight_planning.v1 import api from uas_standards.interuss.automated_testing.flight_planning.v1.constants import Scope from uas_standards.interuss.automated_testing.scd.v1 import api as scd_api @@ -66,95 +68,53 @@ def log(msg: str) -> None: json = flask.request.json if json is None: raise ValueError("Request did not contain a JSON payload") - req_body: api.UpsertFlightPlanRequest = ImplicitDict.parse( - json, api.UpsertFlightPlanRequest + req_body: MockUSSUpsertFlightPlanRequest = ImplicitDict.parse( + json, MockUSSUpsertFlightPlanRequest ) except ValueError as e: msg = "Create flight {} unable to parse JSON: {}".format(flight_plan_id, e) return msg, 400 - info = FlightInfo.from_flight_plan(req_body.flight_plan) - req = MockUSSInjectFlightRequest(info.to_scd_inject_flight_request()) - if "behavior" in json: - try: - req.behavior = ImplicitDict.parse(json["behavior"], MockUssFlightBehavior) - except ValueError as e: - msg = f"Create flight {flight_plan_id} unable to parse `behavior` field: {str(e)}" - return msg, 400 existing_flight = lock_flight(flight_plan_id, log) try: - if existing_flight: - usage_state = existing_flight.flight_info.basic_information.usage_state - if usage_state == AirspaceUsageState.Planned: - old_status = api.FlightPlanStatus.Planned - elif usage_state == AirspaceUsageState.InUse: - old_status = api.FlightPlanStatus.OkToFly - else: - raise ValueError(f"Unrecognized usage_state '{usage_state}'") - else: - old_status = api.FlightPlanStatus.NotPlanned - - scd_resp, code = inject_flight(flight_plan_id, req, existing_flight) + info = FlightInfo.from_flight_plan(req_body.flight_plan) + op_intent = op_intent_from_flightinfo(info, str(uuid.uuid4())) + new_flight = FlightRecord( + flight_info=info, + op_intent=op_intent, + mod_op_sharing_behavior=req_body.behavior + if "behavior" in req_body and req_body.behavior + else None, + ) + + inject_resp = inject_flight(flight_plan_id, new_flight, existing_flight) finally: release_flight_lock(flight_plan_id, log) - if scd_resp.result == scd_api.InjectFlightResponseResult.Planned: - result = api.PlanningActivityResult.Completed - plan_status = api.FlightPlanStatus.Planned - notes = None - elif scd_resp.result == scd_api.InjectFlightResponseResult.ReadyToFly: - result = api.PlanningActivityResult.Completed - plan_status = api.FlightPlanStatus.OkToFly - notes = None - elif ( - scd_resp.result == scd_api.InjectFlightResponseResult.ConflictWithFlight - or scd_resp.result == scd_api.InjectFlightResponseResult.Rejected - ): - result = api.PlanningActivityResult.Rejected - plan_status = old_status - notes = scd_resp.notes if "notes" in scd_resp else None - elif scd_resp.result == scd_api.InjectFlightResponseResult.Failed: - result = api.PlanningActivityResult.Failed - plan_status = old_status - notes = scd_resp.notes if "notes" in scd_resp else None - else: - raise ValueError(f"Unexpected scd inject_flight result '{scd_resp.result}'") - resp = api.UpsertFlightPlanResponse( - planning_result=result, - notes=notes, - flight_plan_status=plan_status, + planning_result=api.PlanningActivityResult(inject_resp.activity_result), + flight_plan_status=api.FlightPlanStatus(inject_resp.flight_plan_status), ) - for k, v in scd_resp.items(): - if k not in {"result", "notes", "operational_intent_id"}: + for k, v in inject_resp.items(): + if k not in {"planning_result", "flight_plan_status"}: resp[k] = v - return flask.jsonify(resp), code + return flask.jsonify(resp), 200 @webapp.route("/flight_planning/v1/flight_plans/", methods=["DELETE"]) @requires_scope(Scope.Plan) def flight_planning_v1_delete_flight(flight_plan_id: str) -> Tuple[str, int]: """Implements flight deletion in SCD automated testing injection API.""" - scd_resp, code = delete_flight(flight_plan_id) - if code != 200: - raise RuntimeError( - f"DELETE flight plan endpoint expected code 200 from scd handler but received {code} instead" - ) - - if scd_resp.result == scd_api.DeleteFlightResponseResult.Closed: - result = api.PlanningActivityResult.Completed - status = api.FlightPlanStatus.Closed - else: - result = api.PlanningActivityResult.Failed - status = ( - api.FlightPlanStatus.NotPlanned - ) # delete_flight only fails like this when the flight doesn't exist - kwargs = {"planning_result": result, "flight_plan_status": status} - if "notes" in scd_resp: - kwargs["notes"] = scd_resp.notes - resp = api.DeleteFlightPlanResponse(**kwargs) + del_resp = delete_flight(flight_plan_id) - return flask.jsonify(resp), code + resp = api.DeleteFlightPlanResponse( + planning_result=api.PlanningActivityResult(del_resp.activity_result), + flight_plan_status=api.FlightPlanStatus(del_resp.flight_plan_status), + ) + for k, v in del_resp.items(): + if k not in {"planning_result", "flight_plan_status"}: + resp[k] = v + return flask.jsonify(resp), 200 @webapp.route("/flight_planning/v1/clear_area_requests", methods=["POST"]) @@ -170,13 +130,14 @@ def flight_planning_v1_clear_area() -> Tuple[str, int]: msg = "Unable to parse ClearAreaRequest JSON request: {}".format(e) return msg, 400 - scd_req = scd_api.ClearAreaRequest( - request_id=req.request_id, - extent=ImplicitDict.parse(req.extent, scd_api.Volume4D), - ) - - scd_resp, code = clear_area(scd_req) + clear_resp = clear_area(Volume4D.from_flight_planning_api(req.extent)) - resp = ImplicitDict.parse(scd_resp, api.ClearAreaResponse) + resp = api.ClearAreaResponse( + outcome=api.ClearAreaOutcome( + success=clear_resp.success, + message="See `details`", + details=clear_resp, + ) + ) - return flask.jsonify(resp), code + return flask.jsonify(resp), 200 diff --git a/monitoring/mock_uss/flights/planning.py b/monitoring/mock_uss/flights/planning.py index cc07cfd4bf..7acb6baa61 100644 --- a/monitoring/mock_uss/flights/planning.py +++ b/monitoring/mock_uss/flights/planning.py @@ -1,6 +1,6 @@ import time from datetime import datetime -from typing import Callable +from typing import Callable, Optional from monitoring.mock_uss.flights.database import FlightRecord, db, DEADLOCK_TIMEOUT @@ -45,3 +45,24 @@ def release_flight_lock(flight_id: str, log: Callable[[str], None]) -> None: # FlightRecord was just a placeholder for a new flight log(f"Releasing placeholder for existing flight_id {flight_id}") del tx.flights[flight_id] + + +def delete_flight_record(flight_id: str) -> Optional[FlightRecord]: + deadline = datetime.utcnow() + DEADLOCK_TIMEOUT + while True: + with db as tx: + if flight_id in tx.flights: + flight = tx.flights[flight_id] + if flight and not flight.locked: + # FlightRecord was a true existing flight not being mutated anywhere else + del tx.flights[flight_id] + return flight + else: + # No FlightRecord found + return None + # There is a race condition with another handler to create or modify the requested flight; wait for that to resolve + time.sleep(0.5) + if datetime.utcnow() > deadline: + raise RuntimeError( + f"Deadlock in delete_flight while attempting to gain access to flight {flight_id} (now: {datetime.utcnow()}, deadline: {deadline})" + ) diff --git a/monitoring/mock_uss/scd_injection/routes_injection.py b/monitoring/mock_uss/scd_injection/routes_injection.py index 4b14e6bdf9..58e8fb6b86 100644 --- a/monitoring/mock_uss/scd_injection/routes_injection.py +++ b/monitoring/mock_uss/scd_injection/routes_injection.py @@ -1,31 +1,35 @@ import os import traceback from datetime import datetime, timedelta -import time -from typing import Tuple, Optional +from typing import Tuple, Optional, List, Dict import flask from implicitdict import ImplicitDict, StringBasedDateTime from loguru import logger import requests.exceptions -from monitoring.mock_uss.flights.planning import lock_flight, release_flight_lock +from monitoring.mock_uss.flights.planning import ( + lock_flight, + release_flight_lock, + delete_flight_record, +) from monitoring.mock_uss.f3548v21 import utm_client from monitoring.monitorlib.clients.flight_planning.flight_info import ( FlightInfo, - AirspaceUsageState, + FlightID, ) -from uas_standards.astm.f3548.v21.api import ( - PutOperationalIntentDetailsParameters, +from monitoring.monitorlib.clients.flight_planning.planning import ( + PlanningActivityResponse, + PlanningActivityResult, + FlightPlanStatus, ) +from uas_standards.astm.f3548.v21 import api as f3548v21 +from uas_standards.interuss.automated_testing.scd.v1 import api as scd_api from uas_standards.interuss.automated_testing.scd.v1.api import ( - InjectFlightResponse, - InjectFlightResponseResult, DeleteFlightResponse, DeleteFlightResponseResult, ClearAreaRequest, ClearAreaOutcome, - ClearAreaResponse, Capability, CapabilitiesResponse, ) @@ -41,10 +45,12 @@ check_op_intent, share_op_intent, op_intent_from_flightinfo, + delete_op_intent, ) import monitoring.mock_uss.uspace.flight_auth from monitoring.monitorlib import versioning from monitoring.monitorlib.clients import scd as scd_client +from monitoring.monitorlib.clients.flight_planning.planning import ClearAreaResponse from monitoring.monitorlib.fetch import QueryError from monitoring.monitorlib.geo import Polygon from monitoring.monitorlib.geotemporal import Volume4D @@ -122,45 +128,56 @@ def log(msg): msg = "Create flight {} unable to parse JSON: {}".format(flight_id, e) return msg, 400 existing_flight = lock_flight(flight_id, log) + + # Construct potential new flight + flight_info = FlightInfo.from_scd_inject_flight_request(req_body) + op_intent = op_intent_from_flightinfo(flight_info, flight_id) + new_flight = FlightRecord( + flight_info=flight_info, + op_intent=op_intent, + mod_op_sharing_behavior=req_body.behavior if "behavior" in req_body else None, + ) + try: - json, code = inject_flight(flight_id, req_body, existing_flight) + resp = inject_flight(flight_id, new_flight, existing_flight) finally: release_flight_lock(flight_id, log) - return flask.jsonify(json), code + return flask.jsonify(resp.to_inject_flight_response()), 200 def inject_flight( flight_id: str, - req_body: MockUSSInjectFlightRequest, + new_flight: FlightRecord, existing_flight: Optional[FlightRecord], -) -> Tuple[InjectFlightResponse, int]: +) -> PlanningActivityResponse: pid = os.getpid() locality = get_locality() def log(msg: str): logger.debug(f"[inject_flight/{pid}:{flight_id}] {msg}") - # Construct potential new flight - flight_info = FlightInfo.from_scd_inject_flight_request(req_body) - op_intent = op_intent_from_flightinfo(flight_info, flight_id) - new_flight = FlightRecord( - flight_info=flight_info, - op_intent=op_intent, - mod_op_sharing_behavior=req_body.behavior if "behavior" in req_body else None, + old_status = FlightPlanStatus.from_flightinfo( + existing_flight.flight_info if existing_flight else None ) + def unsuccessful( + result: PlanningActivityResult, msg: str + ) -> PlanningActivityResponse: + return PlanningActivityResponse( + flight_id=flight_id, + queries=[], + activity_result=result, + flight_plan_status=old_status, + notes=msg, + ) + # Validate request try: if locality.is_uspace_applicable(): - uspace.flight_auth.validate_request(req_body) - validate_request(req_body) + uspace.flight_auth.validate_request(new_flight.flight_info) + validate_request(new_flight.op_intent) except PlanningError as e: - return ( - InjectFlightResponse( - result=InjectFlightResponseResult.Rejected, notes=str(e) - ), - 200, - ) + return unsuccessful(PlanningActivityResult.Rejected, str(e)) step_name = "performing unknown operation" try: @@ -168,13 +185,7 @@ def log(msg: str): try: key = check_op_intent(new_flight, existing_flight, locality, log) except PlanningError as e: - return ( - InjectFlightResponse( - result=InjectFlightResponseResult.Rejected, - notes=str(e), - ), - 200, - ) + return unsuccessful(PlanningActivityResult.Rejected, str(e)) step_name = "sharing operational intent in DSS" record = share_op_intent(new_flight, existing_flight, key, log) @@ -188,144 +199,115 @@ def log(msg: str): step_name = "returning final successful result" log("Complete.") - if ( - new_flight.flight_info.basic_information.usage_state - == AirspaceUsageState.InUse - ): - injection_result = InjectFlightResponseResult.ReadyToFly - else: - injection_result = InjectFlightResponseResult.Planned - return ( - InjectFlightResponse( - result=injection_result, - operational_intent_id=new_flight.op_intent.reference.id, - ), - 200, + return PlanningActivityResponse( + flight_id=flight_id, + queries=[], # TODO: Add queries used + activity_result=PlanningActivityResult.Completed, + flight_plan_status=FlightPlanStatus.from_flightinfo(record.flight_info), ) except (ValueError, ConnectionError) as e: notes = ( f"{e.__class__.__name__} while {step_name} for flight {flight_id}: {str(e)}" ) - return ( - InjectFlightResponse(result=InjectFlightResponseResult.Failed, notes=notes), - 200, - ) + return unsuccessful(PlanningActivityResult.Failed, notes) except requests.exceptions.ConnectionError as e: notes = f"Connection error to {e.request.method} {e.request.url} while {step_name} for flight {flight_id}: {str(e)}" - response = InjectFlightResponse( - result=InjectFlightResponseResult.Failed, notes=notes - ) + response = unsuccessful(PlanningActivityResult.Failed, notes) response["stacktrace"] = _make_stacktrace(e) - return response, 200 + return response except QueryError as e: notes = f"Unexpected response from remote server while {step_name} for flight {flight_id}: {str(e)}" - response = InjectFlightResponse( - result=InjectFlightResponseResult.Failed, notes=notes - ) + response = unsuccessful(PlanningActivityResult.Failed, notes) response["queries"] = e.queries response["stacktrace"] = e.stacktrace - return response, 200 + return response @webapp.route("/scdsc/v1/flights/", methods=["DELETE"]) @requires_scope(SCOPE_SCD_QUALIFIER_INJECT) def scdsc_delete_flight(flight_id: str) -> Tuple[str, int]: """Implements flight deletion in SCD automated testing injection API.""" - json, code = delete_flight(flight_id) - return flask.jsonify(json), code - + del_resp = delete_flight(flight_id) -def delete_flight(flight_id) -> Tuple[DeleteFlightResponse, int]: - pid = os.getpid() - logger.debug(f"[delete_flight/{pid}:{flight_id}] Acquiring flight") - deadline = datetime.utcnow() + DEADLOCK_TIMEOUT - while True: - with db as tx: - if flight_id in tx.flights: - flight = tx.flights[flight_id] - if flight and not flight.locked: - # FlightRecord was a true existing flight not being mutated anywhere else - del tx.flights[flight_id] - break - else: - # No FlightRecord found - flight = None - break - # There is a race condition with another handler to create or modify the requested flight; wait for that to resolve - time.sleep(0.5) - if datetime.utcnow() > deadline: - logger.error( - f"[delete_flight/{pid}:{flight_id}] Deadlock (now: {datetime.utcnow()}, deadline: {deadline})" - ) + if del_resp.activity_result == PlanningActivityResult.Completed: + if del_resp.flight_plan_status != FlightPlanStatus.Closed: raise RuntimeError( - f"Deadlock in delete_flight while attempting to gain access to flight {flight_id}" + f"delete_flight indicated {del_resp.activity_result}, but flight_plan_status was '{del_resp.flight_plan_status}' rather than Closed" ) + result = DeleteFlightResponseResult.Closed + if "notes" in del_resp and del_resp.notes: + notes = del_resp.notes + else: + notes = None + else: + result = DeleteFlightResponseResult.Failed + notes = f"delete_flight indicated `activity_result`={del_resp.activity_result}, `flight_plan_status`={del_resp.flight_plan_status}" + if "notes" in del_resp and del_resp.notes: + notes += ": " + del_resp.notes + resp = DeleteFlightResponse(result=result) + if notes is not None: + resp.notes = notes + return flask.jsonify(resp), 200 + + +def delete_flight(flight_id) -> PlanningActivityResponse: + pid = os.getpid() - if flight is None: - return ( - DeleteFlightResponse( - result=DeleteFlightResponseResult.Failed, - notes="Flight {} does not exist".format(flight_id), - ), - 200, + def log(msg: str): + logger.debug(f"[delete_flight/{pid}:{flight_id}] {msg}") + + log("Acquiring and deleting flight") + flight = delete_flight_record(flight_id) + + old_status = FlightPlanStatus.from_flightinfo( + flight.flight_info if flight else None + ) + + def unsuccessful(msg: str) -> PlanningActivityResponse: + return PlanningActivityResponse( + flight_id=flight_id, + queries=[], + activity_result=PlanningActivityResult.Failed, + flight_plan_status=old_status, + notes=msg, ) + if flight is None: + return unsuccessful("Flight {} does not exist".format(flight_id)) + # Delete operational intent from DSS step_name = "performing unknown operation" try: step_name = f"deleting operational intent {flight.op_intent.reference.id} with OVN {flight.op_intent.reference.ovn} from DSS" - logger.debug(f"[delete_flight/{pid}:{flight_id}] {step_name}") - result = scd_client.delete_operational_intent_reference( - utm_client, - flight.op_intent.reference.id, - flight.op_intent.reference.ovn, - ) - - step_name = "notifying subscribers" - base_url = "{}/mock/scd".format(webapp.config[KEY_BASE_URL]) - for subscriber in result.subscribers: - if subscriber.uss_base_url == base_url: - # Do not notify ourselves - continue - update = PutOperationalIntentDetailsParameters( - operational_intent_id=result.operational_intent_reference.id, - subscriptions=subscriber.subscriptions, - ) - logger.debug( - f"[delete_flight/{pid}:{flight_id}] Notifying {subscriber.uss_base_url}" - ) - scd_client.notify_operational_intent_details_changed( - utm_client, subscriber.uss_base_url, update - ) + log(step_name) + delete_op_intent(flight.op_intent.reference, log) except (ValueError, ConnectionError) as e: notes = ( f"{e.__class__.__name__} while {step_name} for flight {flight_id}: {str(e)}" ) - logger.debug(f"[delete_flight/{pid}:{flight_id}] {notes}") - return ( - DeleteFlightResponse(result=DeleteFlightResponseResult.Failed, notes=notes), - 200, - ) + log(notes) + return unsuccessful(notes) except requests.exceptions.ConnectionError as e: notes = f"Connection error to {e.request.method} {e.request.url} while {step_name} for flight {flight_id}: {str(e)}" - logger.debug(f"[delete_flight/{pid}:{flight_id}] {notes}") - response = DeleteFlightResponse( - result=DeleteFlightResponseResult.Failed, notes=notes - ) + log(notes) + response = unsuccessful(notes) response["stacktrace"] = _make_stacktrace(e) - return response, 200 + return response except QueryError as e: notes = f"Unexpected response from remote server while {step_name} for flight {flight_id}: {str(e)}" - logger.debug(f"[delete_flight/{pid}:{flight_id}] {notes}") - response = DeleteFlightResponse( - result=DeleteFlightResponseResult.Failed, notes=notes - ) + log(notes) + response = unsuccessful(notes) response["queries"] = e.queries response["stacktrace"] = e.stacktrace - return response, 200 - - logger.debug(f"[delete_flight/{pid}:{flight_id}] Complete.") - return DeleteFlightResponse(result=DeleteFlightResponseResult.Closed), 200 + return response + + log("Complete.") + return PlanningActivityResponse( + flight_id=flight_id, + queries=[], + activity_result=PlanningActivityResult.Completed, + flight_plan_status=FlightPlanStatus.Closed, + ) @webapp.route("/scdsc/v1/clear_area_requests", methods=["POST"]) @@ -340,27 +322,42 @@ def scdsc_clear_area() -> Tuple[str, int]: except ValueError as e: msg = "Unable to parse ClearAreaRequest JSON request: {}".format(e) return msg, 400 - json, code = clear_area(req) - return flask.jsonify(json), code + clear_resp = clear_area(Volume4D.from_interuss_scd_api(req.extent)) + resp = scd_api.ClearAreaResponse( + outcome=ClearAreaOutcome( + success=clear_resp.success, + message="See `details` field for more information", + timestamp=StringBasedDateTime(datetime.utcnow()), + ), + ) + resp["request"] = req + resp["details"] = clear_resp -def clear_area(req: ClearAreaRequest) -> Tuple[ClearAreaResponse, int]: - def make_result(success: bool, msg: str) -> ClearAreaResponse: - return ClearAreaResponse( - outcome=ClearAreaOutcome( - success=success, - message=msg, - timestamp=StringBasedDateTime(datetime.utcnow()), - ), - request=req, + return flask.jsonify(resp), 200 + + +def clear_area(extent: Volume4D) -> ClearAreaResponse: + flights_deleted: List[FlightID] = [] + flight_deletion_errors: Dict[FlightID, dict] = {} + op_intents_removed: List[f3548v21.EntityOVN] = [] + op_intent_removal_errors: Dict[f3548v21.EntityOVN, dict] = {} + + def make_result(error: Optional[dict] = None) -> ClearAreaResponse: + resp = ClearAreaResponse( + flights_deleted=flights_deleted, + flight_deletion_errors=flight_deletion_errors, + op_intents_removed=op_intents_removed, + op_intent_removal_errors=op_intent_removal_errors, ) + if error is not None: + resp.error = error + return resp step_name = "performing unknown operation" try: - # Find operational intents in the DSS + # Find every operational intent in the DSS relevant to the extent step_name = "constructing DSS operational intent query" - # TODO: Simply use the req.extent 4D volume more directly - extent = Volume4D.from_interuss_scd_api(req.extent) start_time = extent.time_start.datetime end_time = extent.time_end.datetime area = extent.rect_bounds @@ -377,77 +374,67 @@ def make_result(success: bool, msg: str) -> ClearAreaResponse: op_intent_refs = scd_client.query_operational_intent_references( utm_client, vol4 ) + op_intent_ids = {oi.id for oi in op_intent_refs} - # Try to delete every operational intent found - op_intent_ids = ", ".join(op_intent_ref.id for op_intent_ref in op_intent_refs) - step_name = f"deleting operational intents {{{op_intent_ids}}}" - dss_deletion_results = {} - deleted = set() + # Try to remove all relevant flights normally + for flight_id, flight in db.value.flights.items(): + # TODO: Check for intersection with flight's area rather than just relying on DSS query + if flight.op_intent.reference.id not in op_intent_ids: + continue + + del_resp = delete_flight(flight_id) + if ( + del_resp.activity_result == PlanningActivityResult.Completed + and del_resp.flight_plan_status == FlightPlanStatus.Closed + ): + flights_deleted.append(flight_id) + op_intents_removed.append(flight.op_intent.reference.id) + else: + notes = f"Deleting known flight {flight_id} {del_resp.activity_result} with `flight_plan_status`={del_resp.flight_plan_status}" + if "notes" in del_resp and del_resp.notes: + notes += ": " + del_resp.notes + flight_deletion_errors[flight_id] = {"notes": notes} + + # Try to delete every remaining operational intent that we manage + self_sub = utm_client.auth_adapter.get_sub() + op_intent_refs = [ + oi + for oi in op_intent_refs + if oi.id not in op_intents_removed and oi.manager == self_sub + ] + op_intent_ids_str = ", ".join( + op_intent_ref.id for op_intent_ref in op_intent_refs + ) + step_name = f"deleting operational intents {{{op_intent_ids_str}}}" for op_intent_ref in op_intent_refs: try: scd_client.delete_operational_intent_reference( utm_client, op_intent_ref.id, op_intent_ref.ovn ) - dss_deletion_results[op_intent_ref.id] = "Deleted from DSS" - deleted.add(op_intent_ref.id) + op_intents_removed.append(op_intent_ref.id) except QueryError as e: - dss_deletion_results[op_intent_ref.id] = { - "deletion_success": False, + op_intent_removal_errors[op_intent_ref.id] = { "message": str(e), "queries": e.queries, "stacktrace": e.stacktrace, } - # Delete corresponding flight injections and cached operational intents - step_name = "deleting flight injections and cached operational intents" - deadline = datetime.utcnow() + DEADLOCK_TIMEOUT - while True: - pending_flights = set() - with db as tx: - flights_to_delete = [] - for flight_id, record in tx.flights.items(): - if record is None or record.locked: - pending_flights.add(flight_id) - continue - if record.op_intent.reference.id in deleted: - flights_to_delete.append(flight_id) - for flight_id in flights_to_delete: - del tx.flights[flight_id] - - cache_deletions = [] - for op_intent_id in deleted: - if op_intent_id in tx.cached_operations: - del tx.cached_operations[op_intent_id] - cache_deletions.append(op_intent_id) - - if not pending_flights: - break - time.sleep(0.5) - if datetime.utcnow() > deadline: - logger.error( - f"[clear_area] Deadlock (now: {datetime.utcnow()}, deadline: {deadline})" - ) - raise RuntimeError( - f"Deadlock in clear_area while attempting to gain access to flight(s) {', '.join(pending_flights)}" - ) + # Clear the op intent cache for every op intent removed + with db as tx: + for op_intent_id in op_intents_removed: + if op_intent_id in tx.cached_operations: + del tx.cached_operations[op_intent_id] except (ValueError, ConnectionError) as e: msg = f"{e.__class__.__name__} while {step_name}: {str(e)}" - return make_result(False, msg), 200 + return make_result({"message": msg}) except requests.exceptions.ConnectionError as e: msg = f"Connection error to {e.request.method} {e.request.url} while {step_name}: {str(e)}" - result = make_result(False, msg) - result["stacktrace"] = _make_stacktrace(e) - return result, 200 + return make_result({"message": msg, "stacktrace": _make_stacktrace(e)}) except QueryError as e: msg = f"Unexpected response from remote server while {step_name}: {str(e)}" - result = make_result(False, msg) - result["queries"] = e.queries - result["stacktrace"] = e.stacktrace - return result, 200 - - result = make_result(True, "Area clearing attempt complete") - result["dss_deletions"] = dss_deletion_results - result["flight_deletions"] = (flights_to_delete,) - result["cache_deletions"] = cache_deletions - return result, 200 + return make_result( + {"message": msg, "queries": e.queries, "stacktrace": e.stacktrace} + ) + + return make_result() diff --git a/monitoring/mock_uss/uspace/flight_auth.py b/monitoring/mock_uss/uspace/flight_auth.py index 5d6b3c989c..9929b9306f 100644 --- a/monitoring/mock_uss/uspace/flight_auth.py +++ b/monitoring/mock_uss/uspace/flight_auth.py @@ -1,14 +1,16 @@ from monitoring.mock_uss.f3548v21.flight_planning import PlanningError +from monitoring.monitorlib.clients.flight_planning.flight_info import FlightInfo from monitoring.monitorlib.uspace import problems_with_flight_authorisation -from uas_standards.interuss.automated_testing.scd.v1 import api as scd_api -def validate_request(req_body: scd_api.InjectFlightRequest) -> None: +def validate_request(flight_info: FlightInfo) -> None: """Raise a PlannerError if the request is not valid. Args: - req_body: Information about the requested flight. + flight_info: Information about the requested flight. """ - problems = problems_with_flight_authorisation(req_body.flight_authorisation) + problems = problems_with_flight_authorisation( + flight_info.uspace_flight_authorisation + ) if problems: raise PlanningError(", ".join(problems)) diff --git a/monitoring/monitorlib/clients/flight_planning/planning.py b/monitoring/monitorlib/clients/flight_planning/planning.py index 8f473f8575..d60375d08a 100644 --- a/monitoring/monitorlib/clients/flight_planning/planning.py +++ b/monitoring/monitorlib/clients/flight_planning/planning.py @@ -1,9 +1,17 @@ +from __future__ import annotations from enum import Enum -from typing import Optional, List +from typing import Optional, List, Dict from implicitdict import ImplicitDict - -from monitoring.monitorlib.clients.flight_planning.flight_info import FlightID +from uas_standards.astm.f3548.v21 import api as f3548v21 +from uas_standards.interuss.automated_testing.scd.v1 import api as scd_api + +from monitoring.monitorlib.clients.flight_planning.flight_info import ( + FlightID, + FlightInfo, + UasState, + AirspaceUsageState, +) from monitoring.monitorlib.fetch import Query @@ -41,6 +49,16 @@ class FlightPlanStatus(str, Enum): Closed = "Closed" """The flight plan was closed successfully by the USS and is now out of the UTM system.""" + @staticmethod + def from_flightinfo(info: Optional[FlightInfo]) -> FlightPlanStatus: + if info is None: + return FlightPlanStatus.NotPlanned + if info.basic_information.uas_state != UasState.Nominal: + return FlightPlanStatus.OffNominal + if info.basic_information.usage_state == AirspaceUsageState.InUse: + return FlightPlanStatus.OkToFly + return FlightPlanStatus.Planned + class AdvisoryInclusion(str, Enum): """Indication of whether any advisories or conditions were provided to the user along with the result of a flight planning attempt.""" @@ -68,4 +86,65 @@ class PlanningActivityResponse(ImplicitDict): flight_plan_status: FlightPlanStatus """Status of the flight plan following the flight planning activity.""" + notes: Optional[str] + """Any human-readable notes regarding the activity.""" + includes_advisories: Optional[AdvisoryInclusion] = AdvisoryInclusion.Unknown + + def to_inject_flight_response(self) -> scd_api.InjectFlightResponse: + if self.activity_result == PlanningActivityResult.Completed: + if self.flight_plan_status == FlightPlanStatus.Planned: + result = scd_api.InjectFlightResponseResult.Planned + elif self.flight_plan_status == FlightPlanStatus.OkToFly: + result = scd_api.InjectFlightResponseResult.ReadyToFly + elif self.flight_plan_status == FlightPlanStatus.OffNominal: + result = scd_api.InjectFlightResponseResult.ReadyToFly + elif self.flight_plan_status == FlightPlanStatus.NotPlanned: + raise ValueError( + "Cannot represent PlanningActivityResponse of {Completed, NotPlanned} as an scd injection API InjectFlightResponseResult" + ) + elif self.flight_plan_status == FlightPlanStatus.Closed: + raise ValueError( + "Cannot represent PlanningActivityResponse of {Completed, Closed} as an scd injection API InjectFlightResponseResult" + ) + else: + raise ValueError( + f"Invalid `flight_plan_status` '{self.flight_plan_status}' in PlanningActivityResponse" + ) + elif self.activity_result == PlanningActivityResult.Rejected: + result = scd_api.InjectFlightResponseResult.Rejected + elif self.activity_result == PlanningActivityResult.Failed: + result = scd_api.InjectFlightResponseResult.Failed + elif self.activity_result == PlanningActivityResult.NotSupported: + result = scd_api.InjectFlightResponseResult.NotSupported + else: + raise ValueError( + f"Invalid `activity_result` '{self.activity_result}' in PlanningActivityResponse" + ) + notes = {"notes": self.notes} if "notes" in self else {} + return scd_api.InjectFlightResponse(result=result, **notes) + + +class ClearAreaResponse(ImplicitDict): + flights_deleted: List[FlightID] + """List of IDs of flights that were deleted during this area clearing operation.""" + + flight_deletion_errors: Dict[FlightID, dict] + """When an error was encountered deleting a particular flight, information about that error.""" + + op_intents_removed: List[f3548v21.EntityOVN] + """List of IDs of ASTM F3548-21 operational intent references that were removed during this area clearing operation.""" + + op_intent_removal_errors: Dict[f3548v21.EntityOVN, dict] + """When an error was encountered removing a particular operational intent reference, information about that error.""" + + error: Optional[dict] = None + """If an error was encountered that could not be linked to a specific flight or operational intent, information about it will be populated here.""" + + @property + def success(self) -> bool: + return ( + not self.flight_deletion_errors + and not self.op_intent_removal_errors + and self.error is None + ) diff --git a/monitoring/monitorlib/clients/mock_uss/mock_uss_scd_injection_api.py b/monitoring/monitorlib/clients/mock_uss/mock_uss_scd_injection_api.py index 30679ab3b9..6c53d91f2a 100644 --- a/monitoring/monitorlib/clients/mock_uss/mock_uss_scd_injection_api.py +++ b/monitoring/monitorlib/clients/mock_uss/mock_uss_scd_injection_api.py @@ -1,5 +1,9 @@ from implicitdict import ImplicitDict from typing import List, Optional + +from uas_standards.interuss.automated_testing.flight_planning.v1.api import ( + UpsertFlightPlanRequest, +) from uas_standards.interuss.automated_testing.scd.v1.api import InjectFlightRequest @@ -28,3 +32,9 @@ class MockUSSInjectFlightRequest(InjectFlightRequest): """InjectFlightRequest sent to mock_uss, which looks for the optional additional fields below.""" behavior: Optional[MockUssFlightBehavior] + + +class MockUSSUpsertFlightPlanRequest(UpsertFlightPlanRequest): + """UpsertFlightPlanRequest sent to mock_uss, which looks for the optional additional fields below.""" + + behavior: Optional[MockUssFlightBehavior] diff --git a/monitoring/uss_qualifier/reports/tested_requirements.py b/monitoring/uss_qualifier/reports/tested_requirements.py index 3c61d1c75f..acb0abdbb4 100644 --- a/monitoring/uss_qualifier/reports/tested_requirements.py +++ b/monitoring/uss_qualifier/reports/tested_requirements.py @@ -185,8 +185,8 @@ class TestedBreakdown(ImplicitDict): class TestRunInformation(ImplicitDict): test_run_id: str - start_time: Optional[str] - end_time: Optional[str] + start_time: Optional[str] = None + end_time: Optional[str] = None baseline: str environment: str