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
Introduce TransferCommitment API #410
Changes from 38 commits
fe61148
fcaab34
e3c70cf
98aa497
51c0c8a
dcea033
6c43ca1
91bb534
c8a94a8
4cc8f6c
811f866
79a988f
b70b3de
30b6ad4
1278203
94c4816
f4e5423
810c25a
fe53bb5
42b6992
94703ba
d4c3635
ddc1bca
2f94e47
6c3fbe8
66b7e54
ccc016d
726f248
00d9ddb
5652337
c777d8d
abf3a0f
7a233cc
92de216
8af9563
944f2e6
f49743e
0cfc807
2553e6c
fa512f6
fc307ae
7765102
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -83,6 +83,20 @@ var ( | |
JOIN project_services ps ON pr.service_id = ps.id | ||
WHERE par.id = $1 | ||
`) | ||
getCommitmentWithMatchingTransferTokenQuery = sqlext.SimplifyWhitespace(` | ||
SELECT * FROM project_commitments WHERE transfer_token = $1 | ||
`) | ||
findTargetAZResourceIDBySourceIDQuery = sqlext.SimplifyWhitespace(` | ||
SELECT par.id | ||
FROM project_az_resources as par | ||
JOIN project_resources pr ON par.resource_id = pr.id | ||
JOIN project_services ps ON pr.service_id = ps.id | ||
WHERE ps.project_id = $1 AND az = ( | ||
SELECT az | ||
FROM project_az_resources | ||
WHERE id = $2 | ||
) | ||
`) | ||
|
||
forceImmediateCapacityScrapeQuery = sqlext.SimplifyWhitespace(` | ||
UPDATE cluster_capacitors SET next_scrape_at = $1 WHERE capacitor_id = ( | ||
|
@@ -434,3 +448,218 @@ func (p *v1Provider) DeleteProjectCommitment(w http.ResponseWriter, r *http.Requ | |
}) | ||
w.WriteHeader(http.StatusNoContent) | ||
} | ||
|
||
// StartCommitmentTransfer handles POST /v1/domains/:id/projects/:id/commitments/:id/start-transfer | ||
func (p *v1Provider) StartCommitmentTransfer(w http.ResponseWriter, r *http.Request) { | ||
httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id/start-transfer") | ||
token := p.CheckToken(r) | ||
if !token.Require(w, "project:edit") { | ||
return | ||
} | ||
dbDomain := p.FindDomainFromRequest(w, r) | ||
if dbDomain == nil { | ||
http.Error(w, "domain not found.", http.StatusNotFound) | ||
return | ||
} | ||
dbProject := p.FindProjectFromRequest(w, r, dbDomain) | ||
if dbProject == nil { | ||
http.Error(w, "project not found.", http.StatusNotFound) | ||
return | ||
} | ||
// TODO: eventually migrate this struct into go-api-declarations | ||
var parseTarget struct { | ||
Request struct { | ||
Amount uint64 `json:"amount"` | ||
TransferStatus limesresources.CommitmentTransferStatus `json:"transfer_status,omitempty"` | ||
} `json:"commitment"` | ||
} | ||
if !RequireJSON(w, r, &parseTarget) { | ||
http.Error(w, "json not parsable.", http.StatusBadRequest) | ||
return | ||
} | ||
req := parseTarget.Request | ||
|
||
if req.TransferStatus != limesresources.CommitmentTransferStatusUnlisted && req.TransferStatus != limesresources.CommitmentTransferStatusPublic { | ||
http.Error(w, fmt.Sprintf("Invalid transfer_status code. Must be %s or %s.", limesresources.CommitmentTransferStatusUnlisted, limesresources.CommitmentTransferStatusPublic), http.StatusBadRequest) | ||
return | ||
} | ||
|
||
if req.Amount <= 0 { | ||
http.Error(w, "delivered amount needs to be a positive value.", http.StatusBadRequest) | ||
return | ||
} | ||
|
||
//load commitment | ||
var dbCommitment db.ProjectCommitment | ||
err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID) | ||
if errors.Is(err, sql.ErrNoRows) { | ||
http.Error(w, "no such commitment", http.StatusNotFound) | ||
return | ||
} else if respondwith.ErrorText(w, err) { | ||
return | ||
} | ||
|
||
// reject commitments that are not confirmed yet. | ||
if dbCommitment.ConfirmedAt == nil { | ||
http.Error(w, "commitment needs to be confirmed in order to transfer it.", http.StatusUnprocessableEntity) | ||
return | ||
} | ||
|
||
// Mark whole commitment or a newly created, splitted one as transferrable. | ||
tx, err := p.DB.Begin() | ||
if respondwith.ErrorText(w, err) { | ||
return | ||
} | ||
defer sqlext.RollbackUnlessCommitted(tx) | ||
transferToken := p.generateTransferToken() | ||
|
||
// Deny requests with a greater amount than the commitment. | ||
if req.Amount > dbCommitment.Amount { | ||
http.Error(w, "delivered amount exceeds the commitment amount.", http.StatusBadRequest) | ||
return | ||
} | ||
|
||
if req.Amount == dbCommitment.Amount { | ||
dbCommitment.TransferStatus = req.TransferStatus | ||
dbCommitment.TransferToken = transferToken | ||
_, err = tx.Update(&dbCommitment) | ||
if respondwith.ErrorText(w, err) { | ||
return | ||
} | ||
} else { | ||
now := p.timeNow() | ||
transferAmount := req.Amount | ||
remainingAmount := dbCommitment.Amount - req.Amount | ||
transferCommitment := p.buildSplitCommitment(dbCommitment, transferAmount) | ||
transferCommitment.TransferStatus = req.TransferStatus | ||
transferCommitment.TransferToken = transferToken | ||
remainingCommitment := p.buildSplitCommitment(dbCommitment, remainingAmount) | ||
err = tx.Insert(&transferCommitment) | ||
if respondwith.ErrorText(w, err) { | ||
return | ||
} | ||
err = tx.Insert(&remainingCommitment) | ||
if respondwith.ErrorText(w, err) { | ||
return | ||
} | ||
dbCommitment.SupersededAt = &now | ||
_, err = tx.Update(&dbCommitment) | ||
if respondwith.ErrorText(w, err) { | ||
return | ||
} | ||
dbCommitment = transferCommitment | ||
} | ||
err = tx.Commit() | ||
if respondwith.ErrorText(w, err) { | ||
return | ||
} | ||
|
||
var loc 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 | ||
} | ||
|
||
c := p.convertCommitmentToDisplayForm(dbCommitment, loc) | ||
logAndPublishEvent(p.timeNow(), r, token, http.StatusAccepted, commitmentEventTarget{ | ||
DomainID: dbDomain.UUID, | ||
DomainName: dbDomain.Name, | ||
ProjectID: dbProject.UUID, | ||
ProjectName: dbProject.Name, | ||
Commitment: c, | ||
}) | ||
respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c}) | ||
} | ||
|
||
func (p *v1Provider) buildSplitCommitment(dbCommitment db.ProjectCommitment, amount uint64) db.ProjectCommitment { | ||
now := p.timeNow() | ||
return db.ProjectCommitment{ | ||
AZResourceID: dbCommitment.AZResourceID, | ||
Amount: amount, | ||
Duration: dbCommitment.Duration, | ||
CreatedAt: now, | ||
CreatorUUID: dbCommitment.CreatorUUID, | ||
CreatorName: dbCommitment.CreatorName, | ||
ConfirmBy: dbCommitment.ConfirmBy, | ||
ConfirmedAt: dbCommitment.ConfirmedAt, | ||
ExpiresAt: dbCommitment.ExpiresAt, | ||
PredecessorID: &dbCommitment.ID, | ||
} | ||
} | ||
|
||
// TransferCommitment handles POST /v1/domains/{domain_id}/projects/{project_id}/transfer-commitment/{id}?token={token} | ||
func (p *v1Provider) TransferCommitment(w http.ResponseWriter, r *http.Request) { | ||
httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/transfer-commitment/:id?token=:token") | ||
token := p.CheckToken(r) | ||
if !token.Require(w, "project:edit") { | ||
http.Error(w, "insufficient access rights.", http.StatusForbidden) | ||
return | ||
} | ||
transferToken := r.URL.Query().Get("token") | ||
if transferToken == "" { | ||
http.Error(w, "no transfer token provided", http.StatusBadRequest) | ||
return | ||
} | ||
dbDomain := p.FindDomainFromRequest(w, r) | ||
if dbDomain == nil { | ||
http.Error(w, "domain not found.", http.StatusNotFound) | ||
return | ||
} | ||
targetProject := p.FindProjectFromRequest(w, r, dbDomain) | ||
if targetProject == nil { | ||
http.Error(w, "project not found.", http.StatusNotFound) | ||
return | ||
} | ||
|
||
// find commitment by transfer_token | ||
var dbCommitment db.ProjectCommitment | ||
err := p.DB.SelectOne(&dbCommitment, getCommitmentWithMatchingTransferTokenQuery, transferToken) | ||
if errors.Is(err, sql.ErrNoRows) { | ||
http.Error(w, "no matching commitment found", http.StatusNotFound) | ||
return | ||
} else if respondwith.ErrorText(w, err) { | ||
return | ||
} | ||
|
||
// get target AZ_RESOURCE_ID | ||
var targetResourceID db.ProjectAZResourceID | ||
err = p.DB.QueryRow(findTargetAZResourceIDBySourceIDQuery, targetProject.ID, dbCommitment.AZResourceID).Scan(&targetResourceID) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This looks like it does not match on service type and resource name. How is the commitment matched to the correct service and resource on the receiving side? The tests can plausibly work "by accident" because they all use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You are absolutely correct. I fixed this with a Common Table Expression that queries the service type, resource name and AZ from the source commitment. |
||
if respondwith.ErrorText(w, err) { | ||
return | ||
} | ||
|
||
dbCommitment.TransferStatus = "" | ||
dbCommitment.TransferToken = "" | ||
dbCommitment.AZResourceID = targetResourceID | ||
_, err = p.DB.Update(&dbCommitment) | ||
if respondwith.ErrorText(w, err) { | ||
return | ||
} | ||
|
||
var loc 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 | ||
} | ||
|
||
c := p.convertCommitmentToDisplayForm(dbCommitment, loc) | ||
logAndPublishEvent(p.timeNow(), r, token, http.StatusAccepted, commitmentEventTarget{ | ||
DomainID: dbDomain.UUID, | ||
DomainName: dbDomain.Name, | ||
ProjectID: targetProject.UUID, | ||
ProjectName: targetProject.Name, | ||
Commitment: c, | ||
}) | ||
|
||
respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c}) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should also match on ID, as defense in depth.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The query now respects the commitment ID (read out of the URL)