Skip to content

Commit

Permalink
add api endpoint for commitment merging
Browse files Browse the repository at this point in the history
  • Loading branch information
Varsius committed Feb 11, 2025
1 parent 644f2aa commit 486c016
Show file tree
Hide file tree
Showing 8 changed files with 448 additions and 13 deletions.
11 changes: 11 additions & 0 deletions docs/users/api-spec-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,17 @@ Returns 201 (Created) on success. Result is a JSON document like:
The `commitment` object has the same structure as the `commitments[]` objects in `GET /v1/domains/:domain_id/projects/:project_id/commitments`.
If `confirm_by` was given, a successful response will include the `confirmed_at` timestamp.

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

Merges active commitments on the same resource within the given project. The newly created merged commitment receives the latest expiration date of all given commitments. 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 and returns the merged commitment 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.
Expand Down
16 changes: 8 additions & 8 deletions internal/api/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,12 @@ func (t rateLimitEventTarget) Render() cadf.Resource {
// commitmentEventTarget contains the structure for rendering a cadf.Event.Target for
// changes regarding commitments.
type commitmentEventTarget struct {
DomainID string
DomainName string
ProjectID string
ProjectName string
SupersededCommitment *limesresources.Commitment
Commitments []limesresources.Commitment // must have at least one entry
DomainID string
DomainName string
ProjectID string
ProjectName string
SupersededCommitments *[]limesresources.Commitment
Commitments []limesresources.Commitment // must have at least one entry
}

// Render implements the audittools.Target interface.
Expand All @@ -131,8 +131,8 @@ func (t commitmentEventTarget) Render() cadf.Resource {
attachment := must.Return(cadf.NewJSONAttachment(name, commitment))
res.Attachments = append(res.Attachments, attachment)
}
if t.SupersededCommitment != nil {
attachment := must.Return(cadf.NewJSONAttachment("superseded-payload", *t.SupersededCommitment))
if t.SupersededCommitments != nil {
attachment := must.Return(cadf.NewJSONAttachment("superseded-payload", *t.SupersededCommitments))
res.Attachments = append(res.Attachments, attachment)
}
return res
Expand Down
148 changes: 143 additions & 5 deletions internal/api/commitment.go
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,144 @@ func (p *v1Provider) CreateProjectCommitment(w http.ResponseWriter, r *http.Requ
respondwith.JSON(w, http.StatusCreated, map[string]any{"commitment": c})
}

// MergeProjectCommitments handles POST /v1/domains/:domain_id/projects/:project_id/commitments/merge.
func (p *v1Provider) MergeProjectCommitments(w http.ResponseWriter, r *http.Request) {
httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/merge")
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
}
commitmentIDs := parseTarget.CommitmentIDs
if len(commitmentIDs) < 2 {
http.Error(w, fmt.Sprintf("merging requires at least two commitments, but %d were given", len(commitmentIDs)), http.StatusBadRequest)
return
}

// Load commitments
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
}
}

// Verify that all commitments agree on resource and AZ and are active
azResourceID := dbCommitments[0].AZResourceID
for _, dbCommitment := range dbCommitments {
if dbCommitment.AZResourceID != azResourceID {
http.Error(w, "all commitments must be on the same resource and AZ", http.StatusConflict)
return
}
if dbCommitment.State != db.CommitmentStateActive {
http.Error(w, "only active commits can be merged", http.StatusConflict)
return
}
}

var loc datamodel.AZResourceLocation
err := p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, 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
}

// Start transaction for creating new commitment and marking merged commitments as superseded
tx, err := p.DB.Begin()
if respondwith.ErrorText(w, err) {
return
}
defer sqlext.RollbackUnlessCommitted(tx)

// Create merged template
now := p.timeNow()
dbMergedCommitment := db.ProjectCommitment{
AZResourceID: azResourceID,
Amount: 0, // overwritten below
Duration: limesresources.CommitmentDuration{}, // overwritten below
CreatedAt: now,
CreatorUUID: token.UserUUID(),
CreatorName: fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
ConfirmedAt: &now,
ExpiresAt: time.Time{}, // overwritten below
State: db.CommitmentStateActive,
}

// Fill amount and latest expiration date
for _, dbCommitment := range dbCommitments {
dbMergedCommitment.Amount += dbCommitment.Amount
if dbCommitment.ExpiresAt.After(dbMergedCommitment.ExpiresAt) {
dbMergedCommitment.ExpiresAt = dbCommitment.ExpiresAt
dbMergedCommitment.Duration = dbCommitment.Duration
}
}

// Insert into database
err = tx.Insert(&dbMergedCommitment)
if respondwith.ErrorText(w, err) {
return
}

// Mark merged commits as superseded
for _, dbCommitment := range dbCommitments {
dbCommitment.SupersededAt = &now
dbCommitment.SuccessorID = &dbMergedCommitment.ID
dbCommitment.State = db.CommitmentStateSuperseded
_, err = tx.Update(&dbCommitment)
if respondwith.ErrorText(w, err) {
return
}
}

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

c := p.convertCommitmentToDisplayForm(dbMergedCommitment, loc, token)
auditEvent := commitmentEventTarget{
DomainID: dbDomain.UUID,
DomainName: dbDomain.Name,
ProjectID: dbProject.UUID,
ProjectName: dbProject.Name,
Commitments: []limesresources.Commitment{c},
}
auditEvent.SupersededCommitments = liquids.PointerTo(make([]limesresources.Commitment, len(dbCommitments)))
for _, dbCommitment := range dbCommitments {
*auditEvent.SupersededCommitments = append(*auditEvent.SupersededCommitments, p.convertCommitmentToDisplayForm(dbCommitment, loc, token))
}
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{"commitment": c})
}

// 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")
Expand Down Expand Up @@ -1025,11 +1163,11 @@ func (p *v1Provider) ConvertCommitment(w http.ResponseWriter, r *http.Request) {
}

auditEvent := commitmentEventTarget{
DomainID: dbDomain.UUID,
DomainName: dbDomain.Name,
ProjectID: dbProject.UUID,
ProjectName: dbProject.Name,
SupersededCommitment: liquids.PointerTo(p.convertCommitmentToDisplayForm(dbCommitment, sourceLoc, token)),
DomainID: dbDomain.UUID,
DomainName: dbDomain.Name,
ProjectID: dbProject.UUID,
ProjectName: dbProject.Name,
SupersededCommitments: liquids.PointerTo([]limesresources.Commitment{p.convertCommitmentToDisplayForm(dbCommitment, sourceLoc, token)}),
}

remainingAmount := dbCommitment.Amount - req.SourceAmount
Expand Down
Loading

0 comments on commit 486c016

Please sign in to comment.