Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[scd] Enable USSs to request an OVN for operational intents #1119

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions pkg/scd/models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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 (
Expand All @@ -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
Expand Down
96 changes: 96 additions & 0 deletions pkg/scd/models/models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
})
}
4 changes: 0 additions & 4 deletions pkg/scd/models/subscriptions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 15 additions & 6 deletions pkg/scd/operational_intents_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down Expand Up @@ -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)}
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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")
}

Expand All @@ -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() &&
Expand Down Expand Up @@ -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")
}
Expand Down
Loading