diff --git a/pkg/scd/models/operational_intents.go b/pkg/scd/models/operational_intents.go index 6e5d607a3..182aa8a53 100644 --- a/pkg/scd/models/operational_intents.go +++ b/pkg/scd/models/operational_intents.go @@ -5,9 +5,7 @@ import ( "github.com/golang/geo/s2" restapi "github.com/interuss/dss/pkg/api/scdv1" - dsserr "github.com/interuss/dss/pkg/errors" dssmodels "github.com/interuss/dss/pkg/models" - "github.com/interuss/stacktrace" ) // Aggregates constants for operational intents. @@ -128,25 +126,6 @@ func (o *OperationalIntent) ToRest() *restapi.OperationalIntentReference { return result } -// ValidateTimeRange validates the time range of o. -func (o *OperationalIntent) ValidateTimeRange() error { - if o.StartTime == nil { - return stacktrace.NewErrorWithCode(dsserr.BadRequest, "Operation must have an time_start") - } - - // EndTime cannot be omitted for new Operational Intents. - if o.EndTime == nil { - return stacktrace.NewErrorWithCode(dsserr.BadRequest, "Operation must have an time_end") - } - - // EndTime cannot be before StartTime. - if o.EndTime.Sub(*o.StartTime) < 0 { - return stacktrace.NewErrorWithCode(dsserr.BadRequest, "Operation time_end must be after time_start") - } - - return nil -} - // SetCells is a convenience function that accepts an int64 array and converts // to s2.CellUnion. // TODO: wrap s2.CellUnion in a custom type that embeds the struct such that diff --git a/pkg/scd/operational_intents_handler.go b/pkg/scd/operational_intents_handler.go index 8a7039f13..edde460c2 100644 --- a/pkg/scd/operational_intents_handler.go +++ b/pkg/scd/operational_intents_handler.go @@ -94,8 +94,8 @@ func (a *Server) DeleteOperationalIntentReference(ctx context.Context, req *rest } } - // Find Subscriptions that may overlap the OperationalIntent's Volume4D - allsubs, err := r.SearchSubscriptions(ctx, &dssmodels.Volume4D{ + // Gather the subscriptions that need to be notified + notifyVolume := &dssmodels.Volume4D{ StartTime: old.StartTime, EndTime: old.EndTime, SpatialVolume: &dssmodels.Volume3D{ @@ -104,22 +104,11 @@ func (a *Server) DeleteOperationalIntentReference(ctx context.Context, req *rest Footprint: dssmodels.GeometryFunc(func() (s2.CellUnion, error) { return old.Cells, nil }), - }}) - if err != nil { - return stacktrace.Propagate(err, "Unable to search Subscriptions in repo") - } - - // Limit Subscription notifications to only those interested in OperationalIntents - subs := repos.Subscriptions{} - for _, s := range allsubs { - if s.NotifyForOperationalIntents { - subs = append(subs, s) - } - } + }} - // Increment notification indices for Subscriptions to be notified - if err := subs.IncrementNotificationIndices(ctx, r); err != nil { - return stacktrace.Propagate(err, "Unable to increment notification indices") + subsToNotify, err := getRelevantSubscriptionsAndIncrementIndices(ctx, r, notifyVolume) + if err != nil { + return stacktrace.Propagate(err, "could not obtain relevant subscriptions") } // Delete OperationalIntent from repo @@ -139,7 +128,7 @@ func (a *Server) DeleteOperationalIntentReference(ctx context.Context, req *rest // Return response to client response = &restapi.ChangeOperationalIntentReferenceResponse{ OperationalIntentReference: *old.ToRest(), - Subscribers: makeSubscribersToNotify(subs), + Subscribers: makeSubscribersToNotify(subsToNotify), } return nil @@ -364,13 +353,46 @@ func (a *Server) UpdateOperationalIntentReference(ctx context.Context, req *rest } type validOIRParams struct { - id dssmodels.ID - ovn restapi.EntityOVN - state scdmodels.OperationalIntentState - extents []*dssmodels.Volume4D - uExtent *dssmodels.Volume4D - cells s2.CellUnion - subscriptionID dssmodels.ID + id dssmodels.ID + ovn restapi.EntityOVN + state scdmodels.OperationalIntentState + extents []*dssmodels.Volume4D + uExtent *dssmodels.Volume4D + cells s2.CellUnion + subscriptionID dssmodels.ID + ussBaseURL string + implicitSubscription struct { + requested bool + baseURL string + forConstraints bool + } + key map[scdmodels.OVN]bool +} + +func (vp *validOIRParams) toOIR(manager dssmodels.Manager, attachedSub *scdmodels.Subscription, version int32) *scdmodels.OperationalIntent { + // For OIR's in the accepted state, we may not have a attachedSub available, + // in such cases the attachedSub ID on scdmodels.OperationalIntent will be nil + // and will be replaced with the 'NullV4UUID' when sent over to a client. + var subID *dssmodels.ID + if attachedSub != nil { + // Note: do _not_ use vp.subscriptionID here, as it may be empty + subID = &attachedSub.ID + } + return &scdmodels.OperationalIntent{ + ID: vp.id, + Manager: manager, + Version: scdmodels.VersionNumber(version), + + StartTime: vp.uExtent.StartTime, + EndTime: vp.uExtent.EndTime, + AltitudeLower: vp.uExtent.SpatialVolume.AltitudeLo, + AltitudeUpper: vp.uExtent.SpatialVolume.AltitudeHi, + Cells: vp.cells, + + USSBaseURL: vp.ussBaseURL, + SubscriptionID: subID, + State: vp.state, + } } // validateAndReturnUpsertParams checks that the parameters for an Operational Intent Reference upsert are valid. @@ -395,11 +417,45 @@ func validateAndReturnUpsertParams( return nil, stacktrace.NewError("Missing required UssBaseUrl") } + valid.ussBaseURL = string(params.UssBaseUrl) + + if params.SubscriptionId != nil { + valid.subscriptionID, err = dssmodels.IDFromOptionalString(string(*params.SubscriptionId)) + if err != nil { + return nil, stacktrace.NewError("Invalid ID format for Subscription ID: `%s`", *params.SubscriptionId) + } + } + + if params.NewSubscription != nil { + // The spec states that NewSubscription.UssBaseUrl is required and an empty value + // makes no sense, so we will fail if an implicit subscription is requested but the base url is empty + if params.NewSubscription.UssBaseUrl == "" { + return nil, stacktrace.NewError("Missing required USS base url for new subscription (in parameters for implicit subscription)") + } + // If an implicit subscription is requested, the Subscription ID cannot be present. + if params.SubscriptionId != nil { + return nil, stacktrace.NewError("Cannot provide both a Subscription ID and request an implicit subscription") + } + valid.implicitSubscription.requested = true + valid.implicitSubscription.baseURL = string(params.NewSubscription.UssBaseUrl) + // notify for constraints defaults to false if not specified + if params.NewSubscription.NotifyForConstraints != nil { + valid.implicitSubscription.forConstraints = *params.NewSubscription.NotifyForConstraints + } + } + if !allowHTTPBaseUrls { err = scdmodels.ValidateUSSBaseURL(string(params.UssBaseUrl)) if err != nil { return nil, stacktrace.Propagate(err, "Failed to validate base URL") } + + if params.NewSubscription != nil { + err := scdmodels.ValidateUSSBaseURL(valid.implicitSubscription.baseURL) + if err != nil { + return nil, stacktrace.Propagate(err, "Failed to validate USS base URL for subscription (in parameters for implicit subscription)") + } + } } valid.state = scdmodels.OperationalIntentState(params.State) @@ -433,6 +489,10 @@ func validateAndReturnUpsertParams( return nil, stacktrace.NewError("OperationalIntents may not end in the past") } + if valid.uExtent.StartTime.After(*valid.uExtent.EndTime) { + return nil, stacktrace.NewError("Operation time_end must be after time_start") + } + valid.cells, err = valid.uExtent.CalculateSpatialCovering() if err != nil { return nil, stacktrace.Propagate(err, "Invalid area") @@ -447,13 +507,6 @@ func validateAndReturnUpsertParams( } valid.ovn = ovn - if params.SubscriptionId != nil { - valid.subscriptionID, err = dssmodels.IDFromOptionalString(string(*params.SubscriptionId)) - if err != nil { - return nil, stacktrace.NewError("Invalid ID format for Subscription ID: `%s`", *params.SubscriptionId) - } - } - // Check if a subscription is required for this request: // OIRs in an accepted state do not need a subscription. if valid.state.RequiresSubscription() && @@ -463,6 +516,14 @@ func validateAndReturnUpsertParams( return nil, stacktrace.NewError("Provided Operational Intent Reference state `%s` requires either a subscription ID or information to create an implicit subscription", valid.state) } + // Construct a hash set of OVNs as the key + valid.key = map[scdmodels.OVN]bool{} + if params.Key != nil { + for _, ovn := range *params.Key { + valid.key[scdmodels.OVN(ovn)] = true + } + } + return valid, nil } @@ -508,6 +569,168 @@ func validateUpsertRequestAgainstPreviousOIR( return nil } +// createAndStoreNewImplicitSubscription will create a brand new implicit subscription based on the provided parameters, +// store it and return it. +func createAndStoreNewImplicitSubscription(ctx context.Context, r repos.Repository, manager dssmodels.Manager, validParams *validOIRParams) (*scdmodels.Subscription, error) { + subToUpsert := scdmodels.Subscription{ + ID: dssmodels.ID(uuid.New().String()), + Manager: manager, + StartTime: validParams.uExtent.StartTime, + EndTime: validParams.uExtent.EndTime, + AltitudeLo: validParams.uExtent.SpatialVolume.AltitudeLo, + AltitudeHi: validParams.uExtent.SpatialVolume.AltitudeHi, + Cells: validParams.cells, + USSBaseURL: validParams.implicitSubscription.baseURL, + NotifyForOperationalIntents: true, + NotifyForConstraints: validParams.implicitSubscription.forConstraints, + ImplicitSubscription: true, + } + + return r.UpsertSubscription(ctx, &subToUpsert) +} + +// computeNotificationVolume computes the volume that needs to be queried for subscriptions +// given the requested extent and the (possibly nil) previous operational intent. +// The returned volume is either the union of the requested extent and the previous OIR's extent, or just the requested extent +// if the previous OIR is nil. +func computeNotificationVolume( + previousOIR *scdmodels.OperationalIntent, + requestedExtent *dssmodels.Volume4D) (*dssmodels.Volume4D, error) { + + if previousOIR == nil { + return requestedExtent, nil + } + + // Compute total affected Volume4D for notification purposes + oldVolume := &dssmodels.Volume4D{ + StartTime: previousOIR.StartTime, + EndTime: previousOIR.EndTime, + SpatialVolume: &dssmodels.Volume3D{ + AltitudeHi: previousOIR.AltitudeUpper, + AltitudeLo: previousOIR.AltitudeLower, + Footprint: dssmodels.GeometryFunc(func() (s2.CellUnion, error) { + return previousOIR.Cells, nil + }), + }, + } + notifyVolume, err := dssmodels.UnionVolumes4D(requestedExtent, oldVolume) + if err != nil { + return nil, stacktrace.Propagate(err, "Error constructing 4D volumes union") + } + + return notifyVolume, nil +} + +// getRelevantSubscriptionsAndIncrementIndices retrieves the subscriptions relevant to the passed volume and increments their notification indices +// before returning them. +func getRelevantSubscriptionsAndIncrementIndices( + ctx context.Context, + r repos.Repository, + notifyVolume *dssmodels.Volume4D, +) (repos.Subscriptions, error) { + + // Find Subscriptions that may need to be notified + allsubs, err := r.SearchSubscriptions(ctx, notifyVolume) + if err != nil { + return nil, stacktrace.Propagate(err, "Failed to search for impacted subscriptions.") + } + + // Limit Subscription notifications to only those interested in OperationalIntents + subs := repos.Subscriptions{} + for _, sub := range allsubs { + if sub.NotifyForOperationalIntents { + subs = append(subs, sub) + } + } + + // Increment notification indices for relevant Subscriptions + if err := subs.IncrementNotificationIndices(ctx, r); err != nil { + return nil, stacktrace.Propagate(err, "Failed to increment notification indices of relevant subscriptions") + } + + return subs, nil +} + +// validateKeyAndProvideConflictResponse ensures that the provided key contains all the necessary OVNs relevant for the area covered by the OperationalIntent. +// - If all required keys are provided, (nil, nil) will be returned. +// - If keys are missing, the conflict response to be sent back as well as an error with the dsserr.MissingOVNs code will be returned. +// - In case of any other error, (nil, error) will be returned. +func validateKeyAndProvideConflictResponse( + ctx context.Context, + r repos.Repository, + requestingManager dssmodels.Manager, + params *validOIRParams, + attachedSubscription *scdmodels.Subscription, +) (*restapi.AirspaceConflictResponse, error) { + + // Identify OperationalIntents missing from the key + var missingOps []*scdmodels.OperationalIntent + relevantOps, err := r.SearchOperationalIntents(ctx, params.uExtent) + if err != nil { + return nil, stacktrace.Propagate(err, "Unable to SearchOperations") + } + for _, relevantOp := range relevantOps { + _, ok := params.key[relevantOp.OVN] + // Note: The OIR being mutated does not need to be specified in the key: + if !ok && relevantOp.RequiresKey() && relevantOp.ID != params.id { + missingOps = append(missingOps, relevantOp) + } + } + + // Identify Constraints missing from the key + var missingConstraints []*scdmodels.Constraint + if attachedSubscription != nil && attachedSubscription.NotifyForConstraints { + constraints, err := r.SearchConstraints(ctx, params.uExtent) + if err != nil { + return nil, stacktrace.Propagate(err, "Unable to SearchConstraints") + } + for _, relevantConstraint := range constraints { + if _, ok := params.key[relevantConstraint.OVN]; !ok { + missingConstraints = append(missingConstraints, relevantConstraint) + } + } + } + + // If the client is missing some OVNs, provide the pointers to the + // information they need + if len(missingOps) > 0 || len(missingConstraints) > 0 { + msg := "Current OVNs not provided for one or more OperationalIntents or Constraints" + responseConflict := &restapi.AirspaceConflictResponse{Message: &msg} + + if len(missingOps) > 0 { + responseConflict.MissingOperationalIntents = new([]restapi.OperationalIntentReference) + for _, missingOp := range missingOps { + p := missingOp.ToRest() + // We scrub the OVNs of entities not owned by the requesting manager to make sure + // they have really contacted the managing USS + if missingOp.Manager != requestingManager { + noOvnPhrase := restapi.EntityOVN(scdmodels.NoOvnPhrase) + p.Ovn = &noOvnPhrase + } + *responseConflict.MissingOperationalIntents = append(*responseConflict.MissingOperationalIntents, *p) + } + } + + if len(missingConstraints) > 0 { + responseConflict.MissingConstraints = new([]restapi.ConstraintReference) + for _, missingConstraint := range missingConstraints { + c := missingConstraint.ToRest() + // We scrub the OVNs of entities not owned by the requesting manager to make sure + // they have really contacted the managing USS + if missingConstraint.Manager != requestingManager { + noOvnPhrase := restapi.EntityOVN(scdmodels.NoOvnPhrase) + c.Ovn = &noOvnPhrase + } + *responseConflict.MissingConstraints = append(*responseConflict.MissingConstraints, *c) + } + } + + return responseConflict, stacktrace.NewErrorWithCode(dsserr.MissingOVNs, "Missing OVNs: %v", msg) + } + + return nil, nil +} + // upsertOperationalIntentReference inserts or updates an Operational Intent. // If the ovn argument is empty (""), it will attempt to create a new Operational Intent. func (a *Server) upsertOperationalIntentReference(ctx context.Context, authorizedManager *api.AuthorizationResult, entityid restapi.EntityID, ovn restapi.EntityOVN, params *restapi.PutOperationalIntentReferenceParameters, @@ -552,40 +775,15 @@ func (a *Server) upsertOperationalIntentReference(ctx context.Context, authorize var sub *scdmodels.Subscription if validParams.subscriptionID.Empty() { - // Create an implicit subscription if the implicit subscription params are set: - // for situations where these params are required but have not been set, - // an error will have been returned earlier. - // If they are not set at this point, continue without creating an implicit subscription. - if params.NewSubscription != nil && params.NewSubscription.UssBaseUrl != "" { - if !a.AllowHTTPBaseUrls { - err := scdmodels.ValidateUSSBaseURL(string(params.NewSubscription.UssBaseUrl)) - if err != nil { - return stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Failed to validate USS base URL") - } - } - - subToUpsert := scdmodels.Subscription{ - ID: dssmodels.ID(uuid.New().String()), - Manager: manager, - StartTime: validParams.uExtent.StartTime, - EndTime: validParams.uExtent.EndTime, - AltitudeLo: validParams.uExtent.SpatialVolume.AltitudeLo, - AltitudeHi: validParams.uExtent.SpatialVolume.AltitudeHi, - Cells: validParams.cells, - USSBaseURL: string(params.NewSubscription.UssBaseUrl), - NotifyForOperationalIntents: true, - ImplicitSubscription: true, - } - if params.NewSubscription.NotifyForConstraints != nil { - subToUpsert.NotifyForConstraints = *params.NewSubscription.NotifyForConstraints - } - - sub, err = r.UpsertSubscription(ctx, &subToUpsert) - if err != nil { + // Create an implicit subscription if one has been requested. + // Requesting neither an explicit nor an implicit subscription is allowed for ACCEPTED states: + // for other states, an error will have been returned earlier. + // if no implicit subscription is requested and we reached this point, we will proceed without subscription + if validParams.implicitSubscription.requested { + if sub, err = createAndStoreNewImplicitSubscription(ctx, r, manager, validParams); err != nil { return stacktrace.Propagate(err, "Failed to create implicit subscription") } } - } else { // Use existing Subscription sub, err = r.GetSubscription(ctx, validParams.subscriptionID) @@ -634,131 +832,14 @@ func (a *Server) upsertOperationalIntentReference(ctx context.Context, authorize } if validParams.state.RequiresKey() { - // Construct a hash set of OVNs as the key - key := map[scdmodels.OVN]bool{} - if params.Key != nil { - for _, ovn := range *params.Key { - key[scdmodels.OVN(ovn)] = true - } - } - - // Identify OperationalIntents missing from the key - var missingOps []*scdmodels.OperationalIntent - relevantOps, err := r.SearchOperationalIntents(ctx, validParams.uExtent) + responseConflict, err = validateKeyAndProvideConflictResponse(ctx, r, manager, validParams, sub) if err != nil { - return stacktrace.Propagate(err, "Unable to SearchOperations") - } - for _, relevantOp := range relevantOps { - _, ok := key[relevantOp.OVN] - // Note: The OIR being mutated does not need to be specified in the key: - if !ok && relevantOp.RequiresKey() && relevantOp.ID != validParams.id { - if relevantOp.Manager != manager { - relevantOp.OVN = scdmodels.NoOvnPhrase - } - missingOps = append(missingOps, relevantOp) - } - } - - // Identify Constraints missing from the key - var missingConstraints []*scdmodels.Constraint - if sub != nil && sub.NotifyForConstraints { - constraints, err := r.SearchConstraints(ctx, validParams.uExtent) - if err != nil { - return stacktrace.Propagate(err, "Unable to SearchConstraints") - } - for _, relevantConstraint := range constraints { - if _, ok := key[relevantConstraint.OVN]; !ok { - if relevantConstraint.Manager != manager { - relevantConstraint.OVN = scdmodels.NoOvnPhrase - } - missingConstraints = append(missingConstraints, relevantConstraint) - } - } - } - - // If the client is missing some OVNs, provide the pointers to the - // information they need - if len(missingOps) > 0 || len(missingConstraints) > 0 { - msg := "Current OVNs not provided for one or more OperationalIntents or Constraints" - responseConflict = &restapi.AirspaceConflictResponse{Message: &msg} - - if len(missingOps) > 0 { - responseConflict.MissingOperationalIntents = new([]restapi.OperationalIntentReference) - for _, missingOp := range missingOps { - p := missingOp.ToRest() - if missingOp.Manager != manager { - noOvnPhrase := restapi.EntityOVN(scdmodels.NoOvnPhrase) - p.Ovn = &noOvnPhrase - } - *responseConflict.MissingOperationalIntents = append(*responseConflict.MissingOperationalIntents, *p) - } - } - - if len(missingConstraints) > 0 { - responseConflict.MissingConstraints = new([]restapi.ConstraintReference) - for _, missingConstraint := range missingConstraints { - c := missingConstraint.ToRest() - if missingConstraint.Manager != manager { - noOvnPhrase := restapi.EntityOVN(scdmodels.NoOvnPhrase) - c.Ovn = &noOvnPhrase - } - *responseConflict.MissingConstraints = append(*responseConflict.MissingConstraints, *c) - } - } - - return stacktrace.NewErrorWithCode(dsserr.MissingOVNs, "Missing OVNs: %v", msg) + return stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), "Failed to validate key") } } - // For OIR's in the accepted state, we may not have a subscription available, - // in such cases the subscription ID on scdmodels.OperationalIntent will be nil - // and will be replaced with the 'NullV4UUID' when sent over to a client. - var subID *dssmodels.ID = nil - if sub != nil { - subID = &sub.ID - } - // Construct the new OperationalIntent - op := &scdmodels.OperationalIntent{ - ID: validParams.id, - Manager: manager, - Version: scdmodels.VersionNumber(version + 1), - - StartTime: validParams.uExtent.StartTime, - EndTime: validParams.uExtent.EndTime, - AltitudeLower: validParams.uExtent.SpatialVolume.AltitudeLo, - AltitudeUpper: validParams.uExtent.SpatialVolume.AltitudeHi, - Cells: validParams.cells, - - USSBaseURL: string(params.UssBaseUrl), - SubscriptionID: subID, - State: validParams.state, - } - err = op.ValidateTimeRange() - if err != nil { - return stacktrace.Propagate(err, "Error validating time range") - } - - // Compute total affected Volume4D for notification purposes - var notifyVol4 *dssmodels.Volume4D - if old == nil { - notifyVol4 = validParams.uExtent - } else { - oldVol4 := &dssmodels.Volume4D{ - StartTime: old.StartTime, - EndTime: old.EndTime, - SpatialVolume: &dssmodels.Volume3D{ - AltitudeHi: old.AltitudeUpper, - AltitudeLo: old.AltitudeLower, - Footprint: dssmodels.GeometryFunc(func() (s2.CellUnion, error) { - return old.Cells, nil - }), - }} - notifyVol4, err = dssmodels.UnionVolumes4D(validParams.uExtent, oldVol4) - if err != nil { - return stacktrace.Propagate(err, "Error constructing 4D volumes union") - } - } + op := validParams.toOIR(manager, sub, version+1) // Upsert the OperationalIntent op, err = r.UpsertOperationalIntent(ctx, op) @@ -766,30 +847,21 @@ func (a *Server) upsertOperationalIntentReference(ctx context.Context, authorize return stacktrace.Propagate(err, "Failed to upsert OperationalIntent in repo") } - // Find Subscriptions that may need to be notified - allsubs, err := r.SearchSubscriptions(ctx, notifyVol4) + notifyVolume, err := computeNotificationVolume(old, validParams.uExtent) if err != nil { - return err - } - - // Limit Subscription notifications to only those interested in OperationalIntents - subs := repos.Subscriptions{} - for _, sub := range allsubs { - if sub.NotifyForOperationalIntents { - subs = append(subs, sub) - } + return stacktrace.Propagate(err, "Failed to compute notification volume") } - // Increment notification indices for relevant Subscriptions - err = subs.IncrementNotificationIndices(ctx, r) + // Notify relevant Subscriptions + subsToNotify, err := getRelevantSubscriptionsAndIncrementIndices(ctx, r, notifyVolume) if err != nil { - return err + return stacktrace.Propagate(err, "Failed to notify relevant Subscriptions") } // Return response to client responseOK = &restapi.ChangeOperationalIntentReferenceResponse{ OperationalIntentReference: *op.ToRest(), - Subscribers: makeSubscribersToNotify(subs), + Subscribers: makeSubscribersToNotify(subsToNotify), } return nil