Repository: interuss/dss
Pull request #1119 (2024-09-13 15:36:02)

[scd] Enable USSs to request an OVN for operational intents
Final step towards addressing #1078.
Still pending testing with upcoming uss_qualifier test scenario, but review welcomed in the meantime in any case.

mickmis: In interuss/monitoring#816 I could test that this works as expected. Merging this PR.

Patch:
diff --git a/pkg/scd/models/models.go b/pkg/scd/models/models.go
index e9d1889..25475d6 100644
--- a/pkg/scd/models/models.go
+++ b/pkg/scd/models/models.go
@@ -3,11 +3,14 @@ package models
 import (
 	"crypto/sha256"
 	"encoding/base64"
+	"fmt"
 	"net/url"
 	"strings"
 	"time"
 
+	"github.com/google/uuid"
 	restapi "github.com/interuss/dss/pkg/api/scdv1"
+	dssmodels "github.com/interuss/dss/pkg/models"
 	"github.com/interuss/stacktrace"
 )
 
@@ -20,6 +23,10 @@ const (
 	// Note that this UUID is not meant to be persisted to the database: it should only be used
 	// to populate required API fields for which a proper value does not exist.
 	NullV4UUID = restapi.SubscriptionID("00000000-0000-4000-8000-000000000000")
+
+	// maxClockSkew is the largest allowed interval between a client-provided
+	// time and the server's idea of the current time.
+	maxClockSkew = time.Minute * 5
 )
 
 type (
@@ -43,6 +50,28 @@ func NewOVNFromTime(t time.Time, salt string) OVN {
 	return OVN(ovn)
 }
 
+// NewOVNFromUUIDv7Suffix returns an OVN based on an UUIDv7 suffix: `{op_intent_id}_{uuidv7_suffix}`.
+// It validates that the suffix is indeed a UUIDv7 and that its timestamp is not too far from now.
+func NewOVNFromUUIDv7Suffix(now time.Time, oiID dssmodels.ID, suffix string) (OVN, error) {
+	uuidV7, err := uuid.Parse(suffix)
+	if err != nil {
+		return "", stacktrace.Propagate(err, "Suffix `%s` is not a valid UUID", suffix)
+	}
+	if uuidV7.Version() != 7 {
+		return "", stacktrace.NewError("Suffix `%s` is not version 7 but version %d", suffix, uuidV7.Version())
+	}
+
+	var (
+		ovnTime = time.Unix(uuidV7.Time().UnixTime())
+		skew    = now.Sub(ovnTime).Abs()
+	)
+	if skew > maxClockSkew {
+		return "", stacktrace.NewError("Suffix `%s` is too far away from now (got %s, max is %s)", suffix, skew.String(), maxClockSkew.String())
+	}
+
+	return OVN(fmt.Sprintf("%s_%s", oiID.String(), suffix)), nil
+}
+
 // Empty returns true if ovn indicates an empty opaque version number.
 func (ovn OVN) Empty() bool {
 	return len(ovn) == 0
diff --git a/pkg/scd/models/subscriptions.go b/pkg/scd/models/subscriptions.go
index 8e5ffcd..aa66ecb 100644
--- a/pkg/scd/models/subscriptions.go
+++ b/pkg/scd/models/subscriptions.go
@@ -15,10 +15,6 @@ const (
 	// maxSubscriptionDuration is the largest allowed interval between StartTime
 	// and EndTime.
 	maxSubscriptionDuration = time.Hour * 24
-
-	// maxClockSkew is the largest allowed interval between the StartTime of a new
-	// subscription and the server's idea of the current time.
-	maxClockSkew = time.Minute * 5
 )
 
 // Subscription represents an SCD subscription
diff --git a/pkg/scd/operational_intents_handler.go b/pkg/scd/operational_intents_handler.go
index b796229..3fdc7cc 100644
--- a/pkg/scd/operational_intents_handler.go
+++ b/pkg/scd/operational_intents_handler.go
@@ -322,7 +322,7 @@ func (a *Server) CreateOperationalIntentReference(ctx context.Context, req *rest
 			Message: dsserr.Handle(ctx, stacktrace.PropagateWithCode(req.BodyParseError, dsserr.BadRequest, "Malformed params"))}}
 	}
 
-	respOK, respConflict, err := a.upsertOperationalIntentReference(ctx, &req.Auth, req.Entityid, "", req.Body)
+	respOK, respConflict, err := a.upsertOperationalIntentReference(ctx, time.Now(), &req.Auth, req.Entityid, "", req.Body)
 	if err != nil {
 		err = stacktrace.Propagate(err, "Could not put Operational Intent Reference")
 		errResp := &restapi.ErrorResponse{Message: dsserr.Handle(ctx, err)}
@@ -358,7 +358,7 @@ func (a *Server) UpdateOperationalIntentReference(ctx context.Context, req *rest
 			Message: dsserr.Handle(ctx, stacktrace.PropagateWithCode(req.BodyParseError, dsserr.BadRequest, "Malformed params"))}}
 	}
 
-	respOK, respConflict, err := a.upsertOperationalIntentReference(ctx, &req.Auth, req.Entityid, req.Ovn, req.Body)
+	respOK, respConflict, err := a.upsertOperationalIntentReference(ctx, time.Now(), &req.Auth, req.Entityid, req.Ovn, req.Body)
 	if err != nil {
 		err = stacktrace.Propagate(err, "Could not put subscription")
 		errResp := &restapi.ErrorResponse{Message: dsserr.Handle(ctx, err)}
@@ -384,6 +384,7 @@ func (a *Server) UpdateOperationalIntentReference(ctx context.Context, req *rest
 type validOIRParams struct {
 	id                   dssmodels.ID
 	ovn                  scdmodels.OVN
+	newOVN               scdmodels.OVN
 	state                scdmodels.OperationalIntentState
 	extents              []*dssmodels.Volume4D
 	uExtent              *dssmodels.Volume4D
@@ -411,7 +412,7 @@ func (vp *validOIRParams) toOIR(manager dssmodels.Manager, attachedSub *scdmodel
 		ID:       vp.id,
 		Manager:  manager,
 		Version:  version,
-		OVN:      "", // TODO dss#1078: this field must be populated to support USSs setting OVNs in advance
+		OVN:      vp.newOVN, // non-empty only if the USS has requested an OVN
 		PastOVNs: pastOVNs,
 
 		StartTime:     vp.uExtent.StartTime,
@@ -430,6 +431,7 @@ func (vp *validOIRParams) toOIR(manager dssmodels.Manager, attachedSub *scdmodel
 // Note that this does NOT check for anything related to access controls: any error returned should be labeled
 // as a dsserr.BadRequest.
 func validateAndReturnUpsertParams(
+	now time.Time,
 	entityid restapi.EntityID,
 	ovn restapi.EntityOVN,
 	params *restapi.PutOperationalIntentReferenceParameters,
@@ -516,7 +518,7 @@ func validateAndReturnUpsertParams(
 		return nil, stacktrace.NewError("Missing time_end from extents")
 	}
 
-	if time.Now().After(*valid.uExtent.EndTime) {
+	if now.After(*valid.uExtent.EndTime) {
 		return nil, stacktrace.NewError("OperationalIntents may not end in the past")
 	}
 
@@ -538,6 +540,13 @@ func validateAndReturnUpsertParams(
 	}
 	valid.ovn = scdmodels.OVN(ovn)
 
+	if params.RequestedOvnSuffix != nil {
+		valid.newOVN, err = scdmodels.NewOVNFromUUIDv7Suffix(now, valid.id, string(*params.RequestedOvnSuffix))
+		if err != nil {
+			return nil, stacktrace.Propagate(err, "Invalid requested OVN suffix")
+		}
+	}
+
 	// Check if a subscription is required for this request:
 	// OIRs in an accepted state do not need a subscription.
 	if valid.state.RequiresSubscription() &&
@@ -805,11 +814,11 @@ func ensureSubscriptionCoversOIR(ctx context.Context, r repos.Repository, sub *s
 
 // 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,
+func (a *Server) upsertOperationalIntentReference(ctx context.Context, now time.Time, authorizedManager *api.AuthorizationResult, entityid restapi.EntityID, ovn restapi.EntityOVN, params *restapi.PutOperationalIntentReferenceParameters,
 ) (*restapi.ChangeOperationalIntentReferenceResponse, *restapi.AirspaceConflictResponse, error) {
 	// Note: validateAndReturnUpsertParams and checkUpsertPermissionsAndReturnManager could be moved out of this method and only the valid params passed,
 	// but this requires some changes in the caller that go beyond the immediate scope of #1088 and can be done later.
-	validParams, err := validateAndReturnUpsertParams(entityid, ovn, params, a.AllowHTTPBaseUrls)
+	validParams, err := validateAndReturnUpsertParams(now, entityid, ovn, params, a.AllowHTTPBaseUrls)
 	if err != nil {
 		return nil, nil, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Failed to validate Operational Intent Reference upsert parameters")
 	}


Test patch:
diff --git a/pkg/scd/models/models_test.go b/pkg/scd/models/models_test.go
index 4877a08..ee9d408 100644
--- a/pkg/scd/models/models_test.go
+++ b/pkg/scd/models/models_test.go
@@ -5,9 +5,105 @@ import (
 	"time"
 
 	"github.com/google/uuid"
+	dssmodels "github.com/interuss/dss/pkg/models"
 	"github.com/stretchr/testify/require"
 )
 
 func TestOVNFromTimeIsValid(t *testing.T) {
 	require.True(t, NewOVNFromTime(time.Now(), uuid.New().String()).Valid())
 }
+
+func TestNewOVNFromUUIDv7Suffix(t *testing.T) {
+	type cases []struct {
+		name string
+
+		now    time.Time
+		oiID   dssmodels.ID
+		suffix string
+
+		ovn string
+	}
+
+	t.Run("valid", func(t *testing.T) {
+		testCases := cases{{
+			name:   "exact",
+			now:    time.Date(2024, time.September, 10, 13, 02, 42, int(408*time.Millisecond), time.UTC),
+			oiID:   "bd65d3de-f52e-419d-acfb-ad85d557de99",
+			suffix: "0191dc07-76e8-7546-84f2-e739e9f44d77", // 2024-09-10T13:02:42.408Z
+			ovn:    "bd65d3de-f52e-419d-acfb-ad85d557de99_0191dc07-76e8-7546-84f2-e739e9f44d77",
+		}, {
+			name:   "before",
+			now:    time.Date(2024, time.September, 10, 13, 02, 24, 0, time.UTC),
+			oiID:   "e72589d4-8c14-4d6f-bd9c-1bfb8704e332",
+			suffix: "0191dc07-2f57-79fd-b021-80456ceb627f", // 2024-09-10T13:02:24.087Z
+			ovn:    "e72589d4-8c14-4d6f-bd9c-1bfb8704e332_0191dc07-2f57-79fd-b021-80456ceb627f",
+		}, {
+			name:   "after",
+			now:    time.Date(2024, time.September, 10, 13, 02, 48, 0, time.UTC),
+			oiID:   "f577437f-bc6b-4826-9c6b-7831b78eabcc",
+			suffix: "0191dc07-8a71-7a12-87ed-9baa6e889874", // 2024-09-10T13:02:47.409Z
+			ovn:    "f577437f-bc6b-4826-9c6b-7831b78eabcc_0191dc07-8a71-7a12-87ed-9baa6e889874",
+		}, {
+			name:   "before - max skew",
+			now:    time.Date(2024, time.September, 10, 12, 57, 25, 0, time.UTC),
+			oiID:   "e72589d4-8c14-4d6f-bd9c-1bfb8704e332",
+			suffix: "0191dc07-2f57-79fd-b021-80456ceb627f", // 2024-09-10T13:02:24.087Z
+			ovn:    "e72589d4-8c14-4d6f-bd9c-1bfb8704e332_0191dc07-2f57-79fd-b021-80456ceb627f",
+		}, {
+			name:   "after - max skew",
+			now:    time.Date(2024, time.September, 10, 13, 07, 47, 0, time.UTC),
+			oiID:   "f577437f-bc6b-4826-9c6b-7831b78eabcc",
+			suffix: "0191dc07-8a71-7a12-87ed-9baa6e889874", // 2024-09-10T13:02:47.409Z
+			ovn:    "f577437f-bc6b-4826-9c6b-7831b78eabcc_0191dc07-8a71-7a12-87ed-9baa6e889874",
+		}}
+
+		for _, testCase := range testCases {
+			t.Run(testCase.name, func(t *testing.T) {
+				ovn, err := NewOVNFromUUIDv7Suffix(testCase.now, testCase.oiID, testCase.suffix)
+				require.NoError(t, err)
+				require.EqualValues(t, testCase.ovn, ovn)
+			})
+		}
+	})
+
+	t.Run("invalid", func(t *testing.T) {
+		testCases := cases{{
+			name:   "before - past skew",
+			now:    time.Date(2024, time.September, 10, 12, 57, 24, 0, time.UTC),
+			oiID:   "e72589d4-8c14-4d6f-bd9c-1bfb8704e332",
+			suffix: "0191dc07-2f57-79fd-b021-80456ceb627f", // 2024-09-10T13:02:24.087Z
+		}, {
+			name:   "after - past skew",
+			now:    time.Date(2024, time.September, 10, 13, 07, 48, 0, time.UTC),
+			oiID:   "f577437f-bc6b-4826-9c6b-7831b78eabcc",
+			suffix: "0191dc07-8a71-7a12-87ed-9baa6e889874", // 2024-09-10T13:02:47.409Z
+		}, {
+			name:   "before - long past skew",
+			now:    time.Date(2024, time.September, 10, 11, 57, 24, 0, time.UTC),
+			oiID:   "e72589d4-8c14-4d6f-bd9c-1bfb8704e332",
+			suffix: "0191dc07-2f57-79fd-b021-80456ceb627f", // 2024-09-10T13:02:24.087Z
+		}, {
+			name:   "after - long past skew",
+			now:    time.Date(2024, time.September, 10, 14, 07, 48, 0, time.UTC),
+			oiID:   "f577437f-bc6b-4826-9c6b-7831b78eabcc",
+			suffix: "0191dc07-8a71-7a12-87ed-9baa6e889874", // 2024-09-10T13:02:47.409Z
+		}, {
+			name:   "uuidv4",
+			now:    time.Date(2024, time.September, 10, 13, 02, 24, 0, time.UTC),
+			oiID:   "e72589d4-8c14-4d6f-bd9c-1bfb8704e332",
+			suffix: "44299cb9-a722-4d9c-87bc-537a5aeb2b73",
+		}, {
+			name:   "not uuid",
+			now:    time.Date(2024, time.September, 10, 13, 02, 24, 0, time.UTC),
+			oiID:   "not_a_uuid",
+			suffix: "44299cb9-a722-4d9c-87bc-537a5aeb2b73",
+		}}
+
+		for _, testCase := range testCases {
+			t.Run(testCase.name, func(t *testing.T) {
+				_, err := NewOVNFromUUIDv7Suffix(testCase.now, testCase.oiID, testCase.suffix)
+				require.Error(t, err)
+			})
+		}
+	})
+}