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 commitment renewal endpoint #674

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
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
13 changes: 12 additions & 1 deletion docs/users/api-spec-resources.md
Original file line number Diff line number Diff line change
@@ -556,6 +556,17 @@ Merges active commitments on the same resource within the given project. The new
```
Returns 202 (Accepted) on success, and returns the merged commitment as a JSON document.

### POST /v1/domains/:domain\_id/projects/:project\_id/commitments/renew

Renews active commitments within the given project. The newly created commitments will be pending commitments. Their activation date `confirm_by` will be set to the `expiration_date` of the old commitments. The renewal of a commitment can take place 90 days before its expiration. Requires a project-admin token, and a request body that is a JSON document like:

```json
{
"commitment_ids": [1,2,5]
}
```
Returns 202 (Accepted) on success, and returns the renewed commitments as a JSON document.

### POST /v1/domains/:domain\_id/projects/:project\_id/commitments/can-confirm

Checks if a new commitment within the given project could be confirmed immediately.
@@ -818,4 +829,4 @@ Requires the `?service_type` query parameter.
### GET /admin/liquid/service-usage-request

Generates the request body payload for querying the LIQUID API endpoint /v1/projects/:uuid/report-usage of a specific service and project.
Requires the `?service_type` and `?project_id` query parameters.
Requires the `?service_type` and `?project_id` query parameters.
154 changes: 154 additions & 0 deletions internal/api/commitment.go
Original file line number Diff line number Diff line change
@@ -23,6 +23,7 @@ import (
"encoding/json"
"errors"
"fmt"
"maps"
"net/http"
"slices"
"strings"
@@ -213,6 +214,7 @@ func (p *v1Provider) convertCommitmentToDisplayForm(c db.ProjectCommitment, loc
ExpiresAt: limes.UnixEncodedTime{Time: c.ExpiresAt},
TransferStatus: c.TransferStatus,
TransferToken: c.TransferToken,
WasExtended: c.WasExtended,
}
}

@@ -612,6 +614,158 @@ func (p *v1Provider) MergeProjectCommitments(w http.ResponseWriter, r *http.Requ
respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
}

// RenewProjectCommitments handles POST /v1/domains/:domain_id/projects/:project_id/commitments/renew.
func (p *v1Provider) RenewProjectCommitments(w http.ResponseWriter, r *http.Request) {
httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/renew")
token := p.CheckToken(r)
if !token.Require(w, "project:edit") {
return
}
dbDomain := p.FindDomainFromRequest(w, r)
if dbDomain == nil {
return
}
dbProject := p.FindProjectFromRequest(w, r, dbDomain)
if dbProject == nil {
return
}
var parseTarget struct {
CommitmentIDs []db.ProjectCommitmentID `json:"commitment_ids"`
}
if !RequireJSON(w, r, &parseTarget) {
return
}

// Load commitments
commitmentIDs := parseTarget.CommitmentIDs
dbCommitments := make([]db.ProjectCommitment, len(commitmentIDs))
for i, commitmentID := range commitmentIDs {
err := p.DB.SelectOne(&dbCommitments[i], 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()

// Check if commitments are renewable
for _, dbCommitment := range dbCommitments {
msg := []string{fmt.Sprintf("CommitmentID: %v", dbCommitment.ID)}
if dbCommitment.State != db.CommitmentStateActive {
msg = append(msg, fmt.Sprintf("invalid commitment state: %s", dbCommitment.State))
}
if now.Before(dbCommitment.ExpiresAt.Add(-(3 * 30 * 24 * time.Hour))) {
msg = append(msg, "renewal attempt too early")
}
if now.After(dbCommitment.ExpiresAt) {
msg = append(msg, "commitment expired")
}
if dbCommitment.TransferStatus != limesresources.CommitmentTransferStatusNone {
msg = append(msg, "commitment in transfer")
}
if dbCommitment.WasExtended {
msg = append(msg, "commitment already renewed")
}

if len(msg) > 1 {
http.Error(w, strings.Join(msg, " - "), http.StatusConflict)
return
}
}

// Create renewed commitments
tx, err := p.DB.Begin()
if respondwith.ErrorText(w, err) {
return
}
defer sqlext.RollbackUnlessCommitted(tx)

type renewContext struct {
commitment db.ProjectCommitment
location core.AZResourceLocation
context db.CommitmentWorkflowContext
}
dbRenewedCommitments := make(map[db.ProjectCommitmentID]renewContext)
for _, commitment := range dbCommitments {
var loc core.AZResourceLocation
err := p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, commitment.AZResourceID).
Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "no route to this commitment", http.StatusNotFound)
return
} else if respondwith.ErrorText(w, err) {
return
}

creationContext := db.CommitmentWorkflowContext{
Reason: db.CommitmentReasonRenew,
RelatedCommitmentIDs: []db.ProjectCommitmentID{commitment.ID},
}
buf, err := json.Marshal(creationContext)
if respondwith.ErrorText(w, err) {
return
}
dbRenewedCommitment := db.ProjectCommitment{
AZResourceID: commitment.AZResourceID,
Amount: commitment.Amount,
Duration: commitment.Duration,
CreatedAt: now,
CreatorUUID: token.UserUUID(),
CreatorName: fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
ConfirmBy: &commitment.ExpiresAt,
ExpiresAt: commitment.Duration.AddTo(unwrapOrDefault(&commitment.ExpiresAt, now)),
State: db.CommitmentStatePlanned,
CreationContextJSON: json.RawMessage(buf),
}

err = tx.Insert(&dbRenewedCommitment)
if respondwith.ErrorText(w, err) {
return
}
dbRenewedCommitments[dbRenewedCommitment.ID] = renewContext{commitment: dbRenewedCommitment, location: loc, context: creationContext}

commitment.WasExtended = true
_, err = tx.Update(&commitment)
if respondwith.ErrorText(w, err) {
return
}
}

err = tx.Commit()
if respondwith.ErrorText(w, err) {
return
}

// Create resultset and auditlogs
var commitments []limesresources.Commitment
for _, key := range slices.Sorted(maps.Keys(dbRenewedCommitments)) {
ctx := dbRenewedCommitments[key]
c := p.convertCommitmentToDisplayForm(ctx.commitment, ctx.location, token)
commitments = append(commitments, c)
auditEvent := commitmentEventTarget{
DomainID: dbDomain.UUID,
DomainName: dbDomain.Name,
ProjectID: dbProject.UUID,
ProjectName: dbProject.Name,
Commitments: []limesresources.Commitment{c},
WorkflowContext: &ctx.context,
}

p.auditor.Record(audittools.Event{
Time: p.timeNow(),
Request: r,
User: token,
ReasonCode: http.StatusAccepted,
Action: cadf.UpdateAction,
Target: auditEvent,
})
}

respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitments": commitments})
}

// DeleteProjectCommitment handles DELETE /v1/domains/:domain_id/projects/:project_id/commitments/:id.
func (p *v1Provider) DeleteProjectCommitment(w http.ResponseWriter, r *http.Request) {
httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id")
150 changes: 150 additions & 0 deletions internal/api/commitment_test.go
Original file line number Diff line number Diff line change
@@ -1697,3 +1697,153 @@ func Test_MergeCommitments(t *testing.T) {
}
assert.DeepEqual(t, "commitment supersede context", supersedeContext, expectedContext)
}

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

req1 := assert.JSONObject{
"id": 1,
"service_type": "second",
"resource_name": "capacity",
"availability_zone": "az-one",
"amount": 2,
"duration": "1 hour",
}
req2 := assert.JSONObject{
"id": 2,
"service_type": "second",
"resource_name": "capacity_portion",
"availability_zone": "az-two",
"amount": 1,
"duration": "2 hours",
}

assert.HTTPRequest{
Method: http.MethodPost,
Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/new",
Body: assert.JSONObject{"commitment": req1},
ExpectStatus: http.StatusCreated,
}.Check(t, s.Handler)
assert.HTTPRequest{
Method: http.MethodPost,
Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/new",
Body: assert.JSONObject{"commitment": req2},
ExpectStatus: http.StatusCreated,
}.Check(t, s.Handler)

resp1 := assert.JSONObject{
"id": 3,
"service_type": "second",
"resource_name": "capacity",
"availability_zone": "az-one",
"amount": 2,
"unit": "B",
"duration": "1 hour",
"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 * time.Hour).Unix(),
"expires_at": s.Clock.Now().Add(2 * time.Hour).Unix(),
}
resp2 := assert.JSONObject{
"id": 4,
"service_type": "second",
"resource_name": "capacity_portion",
"availability_zone": "az-two",
"amount": 1,
"unit": "B",
"duration": "2 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(2 * time.Hour).Unix(),
"expires_at": s.Clock.Now().Add(4 * time.Hour).Unix(),
}

// Renew applicable commitments successfully
assert.HTTPRequest{
Method: http.MethodPost,
Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/renew",
Body: assert.JSONObject{"commitment_ids": []int{1, 2}},
ExpectBody: assert.JSONObject{"commitments": []assert.JSONObject{resp1, resp2}},
ExpectStatus: http.StatusAccepted,
}.Check(t, s.Handler)

// Ensure that already renewed commitments can't be renewed again
assert.HTTPRequest{
Method: http.MethodPost,
Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/renew",
Body: assert.JSONObject{"commitment_ids": []int{1, 2}},
ExpectStatus: http.StatusConflict,
}.Check(t, s.Handler)

// Do not allow to renew already expired commitments (that are not tagged as ones yet)
req3 := assert.JSONObject{
"id": 5,
"service_type": "second",
"resource_name": "capacity",
"availability_zone": "az-one",
"amount": 1,
"duration": "1 hour",
}
assert.HTTPRequest{
Method: http.MethodPost,
Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/new",
Body: assert.JSONObject{"commitment": req3},
ExpectStatus: http.StatusCreated,
}.Check(t, s.Handler)

s.Clock.StepBy(2 * time.Hour)

assert.HTTPRequest{
Method: http.MethodPost,
Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/renew",
Body: assert.JSONObject{"commitment_ids": []int{5}},
ExpectStatus: http.StatusConflict,
}.Check(t, s.Handler)

s.Clock.StepBy(-2 * time.Hour)
// Do not allow to renew explicit expired commitments
_, err := s.DB.Exec("UPDATE project_commitments SET state = $1 WHERE id = 5", db.CommitmentStateExpired)
if err != nil {
t.Fatal(err)
}
assert.HTTPRequest{
Method: http.MethodPost,
Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/renew",
Body: assert.JSONObject{"commitment_ids": []int{5}},
ExpectStatus: http.StatusConflict,
}.Check(t, s.Handler)

// Reject requests that try to renew commitments too early (more than 3 month before expiring date)
_, err = s.DB.Exec("UPDATE project_commitments SET duration = $1, expires_at = $2, state = $3 WHERE id = 5",
"4 months", s.Clock.Now().Add(4*30*24*time.Hour), db.CommitmentStateActive,
)
if err != nil {
t.Fatal(err)
}
assert.HTTPRequest{
Method: http.MethodPost,
Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/renew",
Body: assert.JSONObject{"commitment_ids": []int{5}},
ExpectStatus: http.StatusConflict,
}.Check(t, s.Handler)

// Do not allow to renew commitments that are still in transferring state
_, err = s.DB.Exec("UPDATE project_commitments SET transfer_status = 'public' WHERE id = 5")
if err != nil {
t.Fatal(err)
}
assert.HTTPRequest{
Method: http.MethodPost,
Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/renew",
Body: assert.JSONObject{"commitment_ids": []int{5}},
ExpectStatus: http.StatusConflict,
}.Check(t, s.Handler)
}
1 change: 1 addition & 0 deletions internal/api/core.go
Original file line number Diff line number Diff line change
@@ -163,6 +163,7 @@ func (p *v1Provider) AddTo(r *mux.Router) {
r.Methods("GET").Path("/v1/domains/{domain_id}/projects/{project_id}/commitments").HandlerFunc(p.GetProjectCommitments)
r.Methods("POST").Path("/v1/domains/{domain_id}/projects/{project_id}/commitments/new").HandlerFunc(p.CreateProjectCommitment)
r.Methods("POST").Path("/v1/domains/{domain_id}/projects/{project_id}/commitments/merge").HandlerFunc(p.MergeProjectCommitments)
r.Methods("POST").Path("/v1/domains/{domain_id}/projects/{project_id}/commitments/renew").HandlerFunc(p.RenewProjectCommitments)
r.Methods("POST").Path("/v1/domains/{domain_id}/projects/{project_id}/commitments/can-confirm").HandlerFunc(p.CanConfirmNewProjectCommitment)
r.Methods("DELETE").Path("/v1/domains/{domain_id}/projects/{project_id}/commitments/{id}").HandlerFunc(p.DeleteProjectCommitment)
r.Methods("POST").Path("/v1/domains/{domain_id}/projects/{project_id}/commitments/{id}/start-transfer").HandlerFunc(p.StartCommitmentTransfer)
8 changes: 8 additions & 0 deletions internal/db/migrations.go
Original file line number Diff line number Diff line change
@@ -309,4 +309,12 @@ var sqlMigrations = map[string]string{
ALTER TABLE project_commitments
DROP COLUMN predecessor_id;
`,
"051_commitment_renwal.down.sql": `
ALTER TABLE project_commitments
DROP COLUMN was_extended;
`,
"051_commitment_renewal.up.sql": `
ALTER TABLE project_commitments
ADD COLUMN was_extended BOOLEAN NOT NULL DEFAULT FALSE;
`,
}
5 changes: 5 additions & 0 deletions internal/db/models.go
Original file line number Diff line number Diff line change
@@ -203,6 +203,10 @@ type ProjectCommitment struct {
// If commitments are about to expire, they get added into the mail queue.
// This attribute helps to identify commitments that are already queued.
NotifiedForExpiration bool `db:"notified_for_expiration"`

// Identify if a commitment was already renewed.
// This attribute prevents more than one renewal for the same commitment.
WasExtended bool `db:"was_extended"`
}

// CommitmentState is an enum. The possible values below are sorted in roughly chronological order.
@@ -231,6 +235,7 @@ const (
CommitmentReasonSplit CommitmentReason = "split"
CommitmentReasonConvert CommitmentReason = "convert"
CommitmentReasonMerge CommitmentReason = "merge"
CommitmentReasonRenew CommitmentReason = "renew"
)

type MailNotification struct {
Loading
Loading