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

Add Duration Extension API #576

Merged
merged 16 commits into from
Oct 10, 2024
Merged
Show file tree
Hide file tree
Changes from 15 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
12 changes: 12 additions & 0 deletions docs/users/api-spec-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,18 @@ Returns 202 (Accepted) on success, and and returns the converted commitment as a

In this example, a commitment for 1 unit of the original resource can be converted into a commitment for 2 units of the target resource.

### POST "/v1/domains/:domain_id/projects/:project_id/commitments/:commitment_id/update-duration"

Change the duration of a commitment to a supported alternative.
Requires a request body like:
```json
{
"duration": "3 years"
}
```

Returns 200 (OK) on Success, and returns the updated commitment as a JSON document.

### DELETE /v1/domains/:domain\_id/projects/:project\_id/commitments/:id

Deletes a commitment within the given project. Requires a cloud-admin token. On success, returns 204 (No Content).
Expand Down
92 changes: 92 additions & 0 deletions internal/api/commitment.go
Original file line number Diff line number Diff line change
Expand Up @@ -1045,3 +1045,95 @@ func (p *v1Provider) getCommitmentConversionRate(source, target core.ResourceBeh
toAmount = source.CommitmentConversion.Weight / divisor
return fromAmount, toAmount
}

// ExtendCommitmentDuration handles POST /v1/domains/{domain_id}/projects/{project_id}/commitments/{commitment_id}/update-duration
func (p *v1Provider) UpdateCommitmentDuration(w http.ResponseWriter, r *http.Request) {
httpapi.IdentifyEndpoint(r, "/v1/domains/:domain_id/projects/:project_id/commitments/:commitment_id/update-duration")
token := p.CheckToken(r)
if !token.Require(w, "project:edit") {
return
}
commitmentID := mux.Vars(r)["commitment_id"]
if commitmentID == "" {
http.Error(w, "no transfer token provided", http.StatusBadRequest)
return
}
dbDomain := p.FindDomainFromRequest(w, r)
if dbDomain == nil {
return
}
dbProject := p.FindProjectFromRequest(w, r, dbDomain)
if dbProject == nil {
return
}
var Request struct {
Duration limesresources.CommitmentDuration `json:"duration"`
}
req := Request
if !RequireJSON(w, r, &req) {
return
}

var dbCommitment db.ProjectCommitment
err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "no such commitment", http.StatusNotFound)
return
} else if respondwith.ErrorText(w, err) {
return
}

now := p.timeNow()
if dbCommitment.ExpiresAt.Before(now) || dbCommitment.ExpiresAt.Equal(now) {
http.Error(w, "unable to process expired commitment", http.StatusForbidden)
return
}

if dbCommitment.State == db.CommitmentStateSuperseded {
msg := fmt.Sprintf("unable to operate on commitment with a state of %s", dbCommitment.State)
http.Error(w, msg, http.StatusForbidden)
return
}

var loc datamodel.AZResourceLocation
err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
if errors.Is(err, sql.ErrNoRows) {
// defense in depth: this should not happen because all the relevant tables are connected by FK constraints
http.Error(w, "no route to this commitment", http.StatusNotFound)
return
} else if respondwith.ErrorText(w, err) {
return
}
behavior := p.Cluster.BehaviorForResource(loc.ServiceType, loc.ResourceName)
if !slices.Contains(behavior.CommitmentDurations, req.Duration) {
msg := fmt.Sprintf("provided duration: %s does not match the config %v", req.Duration, behavior.CommitmentDurations)
http.Error(w, msg, http.StatusUnprocessableEntity)
return
}

newExpiresAt := req.Duration.AddTo(unwrapOrDefault(dbCommitment.ConfirmBy, dbCommitment.CreatedAt))
if newExpiresAt.Before(dbCommitment.ExpiresAt) {
msg := fmt.Sprintf("duration change from %s to %s forbidden", dbCommitment.Duration, req.Duration)
http.Error(w, msg, http.StatusForbidden)
return
}

dbCommitment.Duration = req.Duration
dbCommitment.ExpiresAt = newExpiresAt
_, err = p.DB.Update(&dbCommitment)
if respondwith.ErrorText(w, err) {
return
}

c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token)
logAndPublishEvent(p.timeNow(), r, token, http.StatusAccepted, commitmentEventTarget{
DomainID: dbDomain.UUID,
DomainName: dbDomain.Name,
ProjectID: dbProject.UUID,
ProjectName: dbProject.Name,
Commitments: []limesresources.Commitment{c},
})

respondwith.JSON(w, http.StatusOK, map[string]any{"commitment": c})
}
148 changes: 147 additions & 1 deletion internal/api/commitment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const testCommitmentsYAMLWithoutMinConfirmDate = `
resource_behavior:
# the resources in "first" have commitments, the ones in "second" do not
- resource: second/.*
commitment_durations: ["1 hour", "2 hours"]
commitment_durations: ["1 hour", "2 hours", "3 hours"]
- resource: second/things
commitment_is_az_aware: false
- resource: second/capacity
Expand Down Expand Up @@ -1325,3 +1325,149 @@ func Test_ConvertCommitments(t *testing.T) {
ExpectStatus: http.StatusAccepted,
}.Check(t, s.Handler)
}

func Test_UpdateCommitmentDuration(t *testing.T) {
s := test.NewSetup(t,
test.WithDBFixtureFile("fixtures/start-data-commitments.sql"),
test.WithConfig(testCommitmentsYAMLWithoutMinConfirmDate),
test.WithAPIHandler(NewV1API),
)

// Positive: confirmed commitment
assert.HTTPRequest{
Method: http.MethodPost,
Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/new",
Body: assert.JSONObject{
"commitment": assert.JSONObject{
"service_type": "second",
"resource_name": "capacity",
"availability_zone": "az-one",
"amount": 10,
"duration": "2 hours",
},
},
ExpectStatus: http.StatusCreated,
}.Check(t, s.Handler)

// Fast forward by 1 hour. Creation_time = 0; Now = 1; (Expire = Creation_time + 2 hours)
s.Clock.StepBy(1 * time.Hour)
VoigtS marked this conversation as resolved.
Show resolved Hide resolved

assert.HTTPRequest{
Method: http.MethodPost,
Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/1/update-duration",
Body: assert.JSONObject{"duration": "3 hours"},
ExpectBody: assert.JSONObject{"commitment": assert.JSONObject{
"id": 1,
"service_type": "second",
"resource_name": "capacity",
"availability_zone": "az-one",
"amount": 10,
"unit": "B",
"duration": "3 hours",
"created_at": s.Clock.Now().Add(-1 * time.Hour).Unix(),
"creator_uuid": "uuid-for-alice",
"creator_name": "alice@Default",
"can_be_deleted": true,
"confirmed_at": s.Clock.Now().Add(-1 * time.Hour).Unix(),
"expires_at": s.Clock.Now().Add(2 * time.Hour).Unix(),
}},
ExpectStatus: http.StatusOK,
}.Check(t, s.Handler)

// Positive: Pending commitment
assert.HTTPRequest{
Method: http.MethodPost,
Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/new",
Body: assert.JSONObject{
"commitment": assert.JSONObject{
"service_type": "second",
"resource_name": "capacity",
"availability_zone": "az-one",
"amount": 10,
"confirm_by": s.Clock.Now().Add(1 * day).Unix(),
"duration": "1 hours",
},
},
ExpectStatus: http.StatusCreated,
}.Check(t, s.Handler)

assert.HTTPRequest{
Method: http.MethodPost,
Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/2/update-duration",
Body: assert.JSONObject{"duration": "3 hours"},
ExpectBody: assert.JSONObject{"commitment": assert.JSONObject{
"id": 2,
"service_type": "second",
"resource_name": "capacity",
"availability_zone": "az-one",
"amount": 10,
"unit": "B",
"duration": "3 hours",
"created_at": s.Clock.Now().Unix(),
"creator_uuid": "uuid-for-alice",
"creator_name": "alice@Default",
"can_be_deleted": true,
"confirm_by": s.Clock.Now().Add(1 * day).Unix(),
"expires_at": s.Clock.Now().Add(3*time.Hour + 1*day).Unix(),
}},
ExpectStatus: http.StatusOK,
}.Check(t, s.Handler)

// Negative: Provided date is invalid
assert.HTTPRequest{
Method: http.MethodPost,
Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/1/update-duration",
Body: assert.JSONObject{"duration": "99 hours"},
ExpectBody: assert.StringData("provided duration: 99 hours does not match the config [1 hour 2 hours 3 hours]\n"),
ExpectStatus: http.StatusUnprocessableEntity,
}.Check(t, s.Handler)

// Negative: Provided Date < Commitment Duration
assert.HTTPRequest{
Method: http.MethodPost,
Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/1/update-duration",
Body: assert.JSONObject{"duration": "1 hour"},
ExpectBody: assert.StringData("duration change from 3 hours to 1 hour forbidden\n"),
ExpectStatus: http.StatusForbidden,
}.Check(t, s.Handler)

// Negative: Expired commitment.
s.Clock.StepBy(-1 * time.Hour)
assert.HTTPRequest{
Method: http.MethodPost,
Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/new",
Body: assert.JSONObject{
"commitment": assert.JSONObject{
"service_type": "second",
"resource_name": "capacity",
"availability_zone": "az-one",
"amount": 10,
"duration": "1 hours",
},
},
ExpectStatus: http.StatusCreated,
}.Check(t, s.Handler)

s.Clock.StepBy(1 * time.Hour)
assert.HTTPRequest{
Method: http.MethodPost,
Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/3/update-duration",
Body: assert.JSONObject{"duration": "2 hours"},
ExpectBody: assert.StringData("unable to process expired commitment\n"),
ExpectStatus: http.StatusForbidden,
}.Check(t, s.Handler)

// Negative: Superseded commitment
s.Clock.StepBy(-1 * time.Hour)
_, err := s.DB.Exec("UPDATE project_commitments SET state='superseded' where ID = 3")
if err != nil {
t.Fatal(err)
}
assert.HTTPRequest{
Method: http.MethodPost,
Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/3/update-duration",
Body: assert.JSONObject{"duration": "2 hours"},
ExpectBody: assert.StringData("unable to operate on commitment with a state of superseded\n"),
ExpectStatus: http.StatusForbidden,
}.Check(t, s.Handler)
}
1 change: 1 addition & 0 deletions internal/api/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ func (p *v1Provider) AddTo(r *mux.Router) {
r.Methods("POST").Path("/v1/domains/{domain_id}/projects/{project_id}/transfer-commitment/{id}").HandlerFunc(p.TransferCommitment)
r.Methods("GET").Path("/v1/commitment-conversion/{service_type}/{resource_name}").HandlerFunc(p.GetCommitmentConversions)
r.Methods("POST").Path("/v1/domains/{domain_id}/projects/{project_id}/commitments/{commitment_id}/convert").HandlerFunc(p.ConvertCommitment)
r.Methods("POST").Path("/v1/domains/{domain_id}/projects/{project_id}/commitments/{commitment_id}/update-duration").HandlerFunc(p.UpdateCommitmentDuration)
}

// RequireJSON will parse the request body into the given data structure, or
Expand Down