Skip to content

Commit

Permalink
[mock_uss] Add flight_planning API implementation to mock_uss (#287)
Browse files Browse the repository at this point in the history
* Add flight_planning API implementation to mock_uss

* Address comments
  • Loading branch information
BenjaminPelletier authored Oct 26, 2023
1 parent cb5c1d4 commit 1199553
Show file tree
Hide file tree
Showing 13 changed files with 442 additions and 89 deletions.
5 changes: 5 additions & 0 deletions monitoring/mock_uss/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
SERVICE_TRACER = "tracer"
SERVICE_INTERACTION_LOGGING = "interaction_logging"
SERVICE_VERSIONING = "versioning"
SERVICE_FLIGHT_PLANNING = "flight_planning"

webapp = MockUSS(__name__)
enabled_services = set()
Expand Down Expand Up @@ -102,6 +103,10 @@ def require_config_value(config_key: str) -> None:
enabled_services.add(SERVICE_VERSIONING)
from monitoring.mock_uss.versioning import routes as versioning_routes

if SERVICE_FLIGHT_PLANNING in webapp.config[config.KEY_SERVICES]:
enabled_services.add(SERVICE_FLIGHT_PLANNING)
from monitoring.mock_uss.flight_planning import routes as flight_planning_routes

msg = (
"################################################################################\n"
+ "################################ Configuration ################################\n"
Expand Down
4 changes: 2 additions & 2 deletions monitoring/mock_uss/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ services:
- MOCK_USS_TOKEN_AUDIENCE=scdsc.uss1.localutm,localhost,host.docker.internal
- MOCK_USS_BASE_URL=http://scdsc.uss1.localutm
# TODO: remove interaction_logging once dedicated mock_uss is involved in tests
- MOCK_USS_SERVICES=scdsc,versioning,interaction_logging
- MOCK_USS_SERVICES=scdsc,versioning,interaction_logging,flight_planning
- MOCK_USS_INTERACTIONS_LOG_DIR=output/scdsc_a_interaction_logs
- MOCK_USS_PORT=80
expose:
Expand Down Expand Up @@ -47,7 +47,7 @@ services:
- MOCK_USS_PUBLIC_KEY=/var/test-certs/auth2.pem
- MOCK_USS_TOKEN_AUDIENCE=scdsc.uss2.localutm,localhost,host.docker.internal
- MOCK_USS_BASE_URL=http://scdsc.uss2.localutm
- MOCK_USS_SERVICES=scdsc,versioning
- MOCK_USS_SERVICES=scdsc,versioning,flight_planning
- MOCK_USS_PORT=80
expose:
- 80
Expand Down
Empty file.
182 changes: 182 additions & 0 deletions monitoring/mock_uss/flight_planning/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import os
from datetime import timedelta
from typing import Tuple

import flask
from implicitdict import ImplicitDict
from loguru import logger

from monitoring.mock_uss.scdsc.routes_injection import (
inject_flight,
lock_flight,
release_flight_lock,
delete_flight,
clear_area,
)
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,
)
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

from monitoring.mock_uss import webapp, require_config_value
from monitoring.mock_uss.auth import requires_scope
from monitoring.mock_uss.config import KEY_BASE_URL
from monitoring.monitorlib.idempotency import idempotent_request


require_config_value(KEY_BASE_URL)

DEADLOCK_TIMEOUT = timedelta(seconds=5)


@webapp.route("/flight_planning/v1/status", methods=["GET"])
@requires_scope(Scope.DirectAutomatedTest)
def flight_planning_v1_status() -> Tuple[str, int]:
json, code = injection_status()
return flask.jsonify(json), code


def injection_status() -> Tuple[dict, int]:
return (
api.StatusResponse(
status=api.StatusResponseStatus.Ready,
api_name="Flight Planning Automated Testing Interface",
api_version="v0.3.0",
),
200,
)


@webapp.route("/flight_planning/v1/flight_plans/<flight_plan_id>", methods=["PUT"])
@requires_scope(Scope.Plan)
@idempotent_request()
def flight_planning_v1_upsert_flight_plan(flight_plan_id: str) -> Tuple[str, int]:
def log(msg: str) -> None:
logger.debug(f"[upsert_plan/{os.getpid()}:{flight_plan_id}] {msg}")

log("Starting handler")
try:
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
)
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)
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,
)
for k, v in scd_resp.items():
if k not in {"result", "notes", "operational_intent_id"}:
resp[k] = v
return flask.jsonify(resp), code


@webapp.route("/flight_planning/v1/flight_plans/<flight_plan_id>", 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)

return flask.jsonify(resp), code


@webapp.route("/flight_planning/v1/clear_area_requests", methods=["POST"])
@requires_scope(Scope.DirectAutomatedTest)
@idempotent_request()
def flight_planning_v1_clear_area() -> Tuple[str, int]:
try:
json = flask.request.json
if json is None:
raise ValueError("Request did not contain a JSON payload")
req: api.ClearAreaRequest = ImplicitDict.parse(json, api.ClearAreaRequest)
except ValueError as e:
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)

resp = ImplicitDict.parse(scd_resp, api.ClearAreaResponse)

return flask.jsonify(resp), code
97 changes: 55 additions & 42 deletions monitoring/mock_uss/scdsc/routes_injection.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import traceback
from datetime import datetime, timedelta
import time
from typing import List, Tuple, Optional
from typing import List, Tuple, Callable, Optional
import uuid

import flask
Expand Down Expand Up @@ -37,7 +37,7 @@
from monitoring.mock_uss.config import KEY_BASE_URL
from monitoring.mock_uss.dynamic_configuration.configuration import get_locality
from monitoring.mock_uss.scdsc import database, utm_client
from monitoring.mock_uss.scdsc.database import db
from monitoring.mock_uss.scdsc.database import db, FlightRecord
from monitoring.mock_uss.scdsc.flight_planning import (
validate_request,
check_for_disallowed_conflicts,
Expand Down Expand Up @@ -158,7 +158,11 @@ def scd_capabilities() -> Tuple[dict, int]:
@idempotent_request()
def scdsc_inject_flight(flight_id: str) -> Tuple[str, int]:
"""Implements flight injection in SCD automated testing injection API."""
logger.debug(f"[inject_flight/{os.getpid()}:{flight_id}] Starting handler")

def log(msg):
logger.debug(f"[inject_flight/{os.getpid()}:{flight_id}] {msg}")

log("Starting handler")
try:
json = flask.request.json
if json is None:
Expand All @@ -167,7 +171,11 @@ def scdsc_inject_flight(flight_id: str) -> Tuple[str, int]:
except ValueError as e:
msg = "Create flight {} unable to parse JSON: {}".format(flight_id, e)
return msg, 400
json, code = inject_flight(flight_id, req_body)
existing_flight = lock_flight(flight_id, log)
try:
json, code = inject_flight(flight_id, req_body, existing_flight)
finally:
release_flight_lock(flight_id, log)
return flask.jsonify(json), code


Expand All @@ -180,29 +188,9 @@ def _mock_uss_flight_behavior_in_req(
return None


def inject_flight(
flight_id: str, req_body: MockUSSInjectFlightRequest
) -> Tuple[dict, int]:
pid = os.getpid()
locality = get_locality()

def log(msg: str):
logger.debug(f"[inject_flight/{pid}:{flight_id}] {msg}")

# Validate request
log("Validating request")
try:
validate_request(req_body, locality)
except PlanningError as e:
return (
InjectFlightResponse(
result=InjectFlightResponseResult.Rejected, notes=str(e)
),
200,
)

def lock_flight(flight_id: str, log: Callable[[str], None]) -> FlightRecord:
# If this is a change to an existing flight, acquire lock to that flight
log("Acquiring lock for flight")
log(f"Acquiring lock for flight {flight_id}")
deadline = datetime.utcnow() + DEADLOCK_TIMEOUT
while True:
with db as tx:
Expand All @@ -221,14 +209,49 @@ def log(msg: str):
# We found an existing flight but it was locked; wait for it to become
# available
time.sleep(0.5)
log(
f"Waiting for flight lock resolution; now: {datetime.utcnow()} deadline: {deadline}"
)

if datetime.utcnow() > deadline:
log(f"Deadlock (now: {datetime.utcnow()}, deadline: {deadline})")
raise RuntimeError(
f"Deadlock in inject_flight while attempting to gain access to flight {flight_id}"
)
return existing_flight


def release_flight_lock(flight_id: str, log: Callable[[str], None]) -> None:
with db as tx:
if flight_id in tx.flights:
if tx.flights[flight_id]:
# FlightRecord was a true existing flight
log(f"Releasing lock on existing flight_id {flight_id}")
tx.flights[flight_id].locked = False
else:
# 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 inject_flight(
flight_id: str,
req_body: MockUSSInjectFlightRequest,
existing_flight: Optional[FlightRecord],
) -> Tuple[InjectFlightResponse, int]:
pid = os.getpid()
locality = get_locality()

def log(msg: str):
logger.debug(f"[inject_flight/{pid}:{flight_id}] {msg}")

# Validate request
log("Validating request")
try:
validate_request(req_body, locality)
except PlanningError as e:
return (
InjectFlightResponse(
result=InjectFlightResponseResult.Rejected, notes=str(e)
),
200,
)

step_name = "performing unknown operation"
try:
Expand Down Expand Up @@ -390,16 +413,6 @@ def log(msg: str):
response["queries"] = e.queries
response["stacktrace"] = e.stacktrace
return response, 200
finally:
with db as tx:
if tx.flights[flight_id]:
# FlightRecord was a true existing flight
log(f"Releasing placeholder for flight_id {flight_id}")
tx.flights[flight_id].locked = False
else:
# FlightRecord was just a placeholder for a new flight
log("Releasing lock on existing flight_id {flight_id}")
del tx.flights[flight_id]


@webapp.route("/scdsc/v1/flights/<flight_id>", methods=["DELETE"])
Expand All @@ -410,7 +423,7 @@ def scdsc_delete_flight(flight_id: str) -> Tuple[str, int]:
return flask.jsonify(json), code


def delete_flight(flight_id) -> Tuple[dict, int]:
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
Expand Down Expand Up @@ -519,7 +532,7 @@ def scdsc_clear_area() -> Tuple[str, int]:
return flask.jsonify(json), code


def clear_area(req: ClearAreaRequest) -> Tuple[dict, int]:
def clear_area(req: ClearAreaRequest) -> Tuple[ClearAreaResponse, int]:
def make_result(success: bool, msg: str) -> ClearAreaResponse:
return ClearAreaResponse(
outcome=ClearAreaOutcome(
Expand Down
2 changes: 1 addition & 1 deletion monitoring/mock_uss/scdsc/routes_scdsc.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def scdsc_get_operational_intent_details(entityid: str):
tx = db.value
flight = None
for f in tx.flights.values():
if f.op_intent.reference.id == entityid:
if f and f.op_intent.reference.id == entityid:
flight = f
break

Expand Down
Loading

0 comments on commit 1199553

Please sign in to comment.