diff --git a/docs/users/api-spec-resources.md b/docs/users/api-spec-resources.md index 88bce6615..b78aa3964 100644 --- a/docs/users/api-spec-resources.md +++ b/docs/users/api-spec-resources.md @@ -752,6 +752,37 @@ Returns 200 (OK) on success, and a JSON document like `{"result":true}` or `{"re The `result` field indicates whether this commitment can be created without a `confirm_by` attribute, that is, confirmed immediately upon creation. +### POST /v1/domains/:id/projects/:id/commitments/:id/start-transfer +Prepares a commitment to be transferred from a source project to a target project. Requires a project-admin token, and a request body that is a JSON document like: +```json +{ + "commitment": { + "amount": 100, + "transfer_status": "unlisted" + } +} +``` +If the amount to transfer is equal to the commitment, the whole commitment will be marked as transferrable. If the amount is less than the commitment, the commitment will be split in two and the requested amount will be marked as transferrable. +The transfer status indicates if the commitment stays `unlisted` (private) or `public`. +The response is a JSON of the commitment including the following fields that identify a commitment in its transferrable state: +```json +{ + "commitment": { + "transfer_token": "token", + "transfer_status": "unlisted" + } +} +``` +### POST /v1/domains/:id/projects/:id/transfer-commitment/:id +Transfers the commitment from a source project to a target project. +Requires a project-admin token. +Requires a transfer token in the request header: +`Transfer-Token: [value]`. +This endpoint receives the target project ID, but the commitment ID from the source project. +Requires a generated token from the API: `/v1/domains/:id/projects/:id/commitments/:id/start-transfer`. +On success the API clears the `transfer_token` and `transfer_status` from the commitment. +After that, it returns the 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). diff --git a/internal/api/commitment.go b/internal/api/commitment.go index 9e9eb8cf8..0ef3446a0 100644 --- a/internal/api/commitment.go +++ b/internal/api/commitment.go @@ -83,6 +83,24 @@ var ( JOIN project_services ps ON pr.service_id = ps.id WHERE par.id = $1 `) + getCommitmentWithMatchingTransferTokenQuery = sqlext.SimplifyWhitespace(` + SELECT * FROM project_commitments WHERE id = $1 AND transfer_token = $2 + `) + findTargetAZResourceIDBySourceIDQuery = sqlext.SimplifyWhitespace(` + WITH source as ( + SELECT ps.type, pr.name, par.az + 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 par.id = $1 + ) + 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 + JOIN source s ON ps.type = s.type AND pr.name = s.name AND par.az = s.az + WHERE ps.project_id = $2 + `) forceImmediateCapacityScrapeQuery = sqlext.SimplifyWhitespace(` UPDATE cluster_capacitors SET next_scrape_at = $1 WHERE capacitor_id = ( @@ -434,3 +452,223 @@ 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 := p.CheckToken(r) + if !token.Require(w, "project:edit") { + http.Error(w, "insufficient access rights.", http.StatusForbidden) + return + } + transferToken := r.Header.Get("Transfer-Token") + if transferToken == "" { + http.Error(w, "no transfer token provided", http.StatusBadRequest) + return + } + commitmentID := mux.Vars(r)["id"] + if commitmentID == "" { + 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, commitmentID, 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, dbCommitment.AZResourceID, targetProject.ID).Scan(&targetResourceID) + 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}) +} diff --git a/internal/api/commitment_test.go b/internal/api/commitment_test.go index 94ce2586d..58bf3bb41 100644 --- a/internal/api/commitment_test.go +++ b/internal/api/commitment_test.go @@ -51,6 +51,24 @@ const testCommitmentsYAML = ` - resource: first/capacity commitment_is_az_aware: true ` +const testCommitmentsYAMLWithoutMinConfirmDate = ` + availability_zones: [ az-one, az-two ] + discovery: + method: --test-static + services: + - service_type: first + type: --test-generic + - service_type: second + type: --test-generic + resource_behavior: + # the resources in "first" have commitments, the ones in "second" do not + - resource: second/.* + commitment_durations: ["1 hour", "2 hours"] + - resource: second/things + commitment_is_az_aware: false + - resource: second/capacity + commitment_is_az_aware: true +` func TestCommitmentLifecycleWithDelayedConfirmation(t *testing.T) { s := test.NewSetup(t, @@ -542,3 +560,275 @@ func TestDeleteCommitmentErrorCases(t *testing.T) { ExpectBody: assert.StringData("no such commitment\n"), }.Check(t, s.Handler) } + +func Test_StartCommitmentTransfer(t *testing.T) { + s := test.NewSetup(t, + test.WithDBFixtureFile("fixtures/start-data-commitments.sql"), + test.WithConfig(testCommitmentsYAMLWithoutMinConfirmDate), + test.WithAPIHandler(NewV1API), + ) + + var transferToken = test.GenerateDummyToken() + + // Test on confirmed commitment should succeed. + // TransferAmount >= CommitmentAmount + req1 := func(transferStatus string) assert.JSONObject { + return assert.JSONObject{ + "id": 1, + "service_type": "second", + "resource_name": "capacity", + "availability_zone": "az-two", + "amount": 10, + "duration": "1 hour", + "transfer_status": transferStatus, + "transfer_token": "", + } + } + + resp1 := assert.JSONObject{ + "id": 1, + "service_type": "second", + "resource_name": "capacity", + "availability_zone": "az-two", + "amount": 10, + "unit": "B", + "duration": "1 hour", + "created_at": s.Clock.Now().Unix(), + "creator_uuid": "uuid-for-alice", + "creator_name": "alice@Default", + "confirmed_at": 0, + "expires_at": 3600, + "transfer_status": "unlisted", + "transfer_token": transferToken, + } + + 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: "POST", + Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/1/start-transfer", + ExpectStatus: http.StatusAccepted, + ExpectBody: assert.JSONObject{"commitment": resp1}, + Body: assert.JSONObject{"commitment": assert.JSONObject{"amount": 10, "transfer_status": "unlisted"}}, + }.Check(t, s.Handler) + + // TransferAmount < CommitmentAmount + resp2 := assert.JSONObject{ + "id": 2, + "service_type": "second", + "resource_name": "capacity", + "availability_zone": "az-two", + "amount": 9, + "unit": "B", + "duration": "1 hour", + "created_at": s.Clock.Now().Unix(), + "creator_uuid": "uuid-for-alice", + "creator_name": "alice@Default", + "confirmed_at": 0, + "expires_at": 3600, + "transfer_status": "public", + "transfer_token": transferToken, + } + + assert.HTTPRequest{ + Method: "POST", + Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/1/start-transfer", + ExpectStatus: http.StatusAccepted, + ExpectBody: assert.JSONObject{"commitment": resp2}, + Body: assert.JSONObject{"commitment": assert.JSONObject{"amount": 9, "transfer_status": "public"}}, + }.Check(t, s.Handler) + + // Test on unconfirmed commitment should fail. + // ID is 4, because 2 additional commitments were created previously. + var confirmBy = s.Clock.Now().Unix() + req2 := assert.JSONObject{ + "id": 4, + "service_type": "second", + "resource_name": "capacity", + "availability_zone": "az-two", + "amount": 10, + "duration": "1 hour", + "transfer_status": "", + "transfer_token": "", + "confirm_by": confirmBy, + } + + 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) + + assert.HTTPRequest{ + Method: "POST", + Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/4/start-transfer", + ExpectStatus: http.StatusUnprocessableEntity, + Body: assert.JSONObject{"commitment": assert.JSONObject{"amount": 10, "transfer_status": "unlisted"}}, + }.Check(t, s.Handler) + + // Negative Test, amount = 0. + assert.HTTPRequest{ + Method: "POST", + Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/1/start-transfer", + ExpectStatus: http.StatusBadRequest, + ExpectBody: assert.StringData("delivered amount needs to be a positive value.\n"), + Body: assert.JSONObject{"commitment": assert.JSONObject{"amount": 0, "transfer_status": "public"}}, + }.Check(t, s.Handler) + + // Negative Test, delivered amount > commitment amount + assert.HTTPRequest{ + Method: "POST", + Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/1/start-transfer", + ExpectStatus: http.StatusBadRequest, + ExpectBody: assert.StringData("delivered amount exceeds the commitment amount.\n"), + Body: assert.JSONObject{"commitment": assert.JSONObject{"amount": 11, "transfer_status": "public"}}, + }.Check(t, s.Handler) +} + +func Test_TransferCommitment(t *testing.T) { + s := test.NewSetup(t, + test.WithDBFixtureFile("fixtures/start-data-commitments.sql"), + test.WithConfig(testCommitmentsYAMLWithoutMinConfirmDate), + test.WithAPIHandler(NewV1API), + ) + + var transferToken = test.GenerateDummyToken() + req1 := assert.JSONObject{ + "id": 1, + "service_type": "second", + "resource_name": "capacity", + "availability_zone": "az-two", + "amount": 10, + "duration": "1 hour", + "transfer_status": "", + } + + resp1 := assert.JSONObject{ + "id": 1, + "service_type": "second", + "resource_name": "capacity", + "availability_zone": "az-two", + "amount": 10, + "unit": "B", + "duration": "1 hour", + "created_at": s.Clock.Now().Unix(), + "creator_uuid": "uuid-for-alice", + "creator_name": "alice@Default", + "confirmed_at": 0, + "expires_at": 3600, + "transfer_status": "unlisted", + "transfer_token": transferToken, + } + + resp2 := assert.JSONObject{ + "id": 1, + "service_type": "second", + "resource_name": "capacity", + "availability_zone": "az-two", + "amount": 10, + "unit": "B", + "duration": "1 hour", + "created_at": s.Clock.Now().Unix(), + "creator_uuid": "uuid-for-alice", + "creator_name": "alice@Default", + "confirmed_at": 0, + "expires_at": 3600, + } + + // Split commitment + resp3 := assert.JSONObject{ + "id": 2, + "service_type": "second", + "resource_name": "capacity", + "availability_zone": "az-two", + "amount": 9, + "unit": "B", + "duration": "1 hour", + "created_at": s.Clock.Now().Unix(), + "creator_uuid": "uuid-for-alice", + "creator_name": "alice@Default", + "confirmed_at": 0, + "expires_at": 3600, + "transfer_status": "unlisted", + "transfer_token": transferToken, + } + resp4 := assert.JSONObject{ + "id": 2, + "service_type": "second", + "resource_name": "capacity", + "availability_zone": "az-two", + "amount": 9, + "unit": "B", + "duration": "1 hour", + "created_at": s.Clock.Now().Unix(), + "creator_uuid": "uuid-for-alice", + "creator_name": "alice@Default", + "confirmed_at": 0, + "expires_at": 3600, + } + + // Transfer Commitment to target AZ_RESOURCE_ID (SOURCE_ID=3 TARGET_ID=17) + 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) + + // Transfer full amount + assert.HTTPRequest{ + Method: "POST", + Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/1/start-transfer", + ExpectStatus: http.StatusAccepted, + ExpectBody: assert.JSONObject{"commitment": resp1}, + Body: assert.JSONObject{"commitment": assert.JSONObject{"amount": 10, "transfer_status": "unlisted"}}, + }.Check(t, s.Handler) + + assert.HTTPRequest{ + Method: http.MethodPost, + Path: "/v1/domains/uuid-for-germany/projects/uuid-for-dresden/transfer-commitment/1", + Header: map[string]string{"Transfer-Token": transferToken}, + ExpectBody: assert.JSONObject{"commitment": resp2}, + ExpectStatus: http.StatusAccepted, + }.Check(t, s.Handler) + + // Split and transfer commitment. + assert.HTTPRequest{ + Method: "POST", + Path: "/v1/domains/uuid-for-germany/projects/uuid-for-dresden/commitments/1/start-transfer", + ExpectStatus: http.StatusAccepted, + ExpectBody: assert.JSONObject{"commitment": resp3}, + Body: assert.JSONObject{"commitment": assert.JSONObject{"amount": 9, "transfer_status": "unlisted"}}, + }.Check(t, s.Handler) + + assert.HTTPRequest{ + Method: http.MethodPost, + Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/transfer-commitment/2", + Header: map[string]string{"Transfer-Token": transferToken}, + ExpectBody: assert.JSONObject{"commitment": resp4}, + ExpectStatus: http.StatusAccepted, + }.Check(t, s.Handler) + + // wrong token + assert.HTTPRequest{ + Method: http.MethodPost, + Path: "/v1/domains/uuid-for-germany/projects/uuid-for-dresden/transfer-commitment/1", + Header: map[string]string{"Transfer-Token": "wrongToken"}, + ExpectStatus: http.StatusNotFound, + ExpectBody: assert.StringData("no matching commitment found\n"), + }.Check(t, s.Handler) + + // No token provided + assert.HTTPRequest{ + Method: "POST", + Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/transfer-commitment/1", + ExpectStatus: http.StatusBadRequest, + ExpectBody: assert.StringData("no transfer token provided\n"), + }.Check(t, s.Handler) +} diff --git a/internal/api/core.go b/internal/api/core.go index d32cbfe4d..bd752c20e 100644 --- a/internal/api/core.go +++ b/internal/api/core.go @@ -70,12 +70,14 @@ type v1Provider struct { listProjectsMutex sync.Mutex // slots for test doubles timeNow func() time.Time + //identifies commitments that will be transferred to other projects. + generateTransferToken func() string } // NewV1API creates an httpapi.API that serves the Limes v1 API. // It also returns the VersionData for this API version which is needed for the // version advertisement on "GET /". -func NewV1API(cluster *core.Cluster, dbm *gorp.DbMap, tokenValidator gopherpolicy.Validator, timeNow func() time.Time) httpapi.API { +func NewV1API(cluster *core.Cluster, dbm *gorp.DbMap, tokenValidator gopherpolicy.Validator, timeNow func() time.Time, generateTransferToken func() string) httpapi.API { p := &v1Provider{Cluster: cluster, DB: dbm, tokenValidator: tokenValidator, timeNow: timeNow} p.VersionData = VersionData{ Status: "CURRENT", @@ -92,6 +94,7 @@ func NewV1API(cluster *core.Cluster, dbm *gorp.DbMap, tokenValidator gopherpolic }, }, } + p.generateTransferToken = generateTransferToken return p } @@ -110,6 +113,11 @@ func NewTokenValidator(provider *gophercloud.ProviderClient, eo gophercloud.Endp return &tv, err } +func (p *v1Provider) OverrideGenerateTransferToken(generateTransferToken func() string) *v1Provider { + p.generateTransferToken = generateTransferToken + return p +} + // AddTo implements the httpapi.API interface. func (p *v1Provider) AddTo(r *mux.Router) { r.Methods("HEAD", "GET").Path("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -153,6 +161,8 @@ func (p *v1Provider) AddTo(r *mux.Router) { 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/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) + r.Methods("POST").Path("/v1/domains/{domain_id}/projects/{project_id}/transfer-commitment/{id}").HandlerFunc(p.TransferCommitment) } // RequireJSON will parse the request body into the given data structure, or diff --git a/internal/api/utils.go b/internal/api/utils.go index 6ecae47dc..9f922690f 100644 --- a/internal/api/utils.go +++ b/internal/api/utils.go @@ -19,6 +19,8 @@ package api import ( + "crypto/rand" + "encoding/hex" "time" "github.com/sapcc/go-api-declarations/limes" @@ -44,3 +46,14 @@ func unwrapOrDefault[T any](value *T, defaultValue T) T { } return *value } + +// Generates a token that is used to transfer a commitment from a source to a target project. +// The token will be attached to the commitment that will be transferred and stored in the database until the transfer is concluded. +func GenerateTransferToken() string { + tokenBytes := make([]byte, 24) + _, err := rand.Read(tokenBytes) + if err != nil { + panic(err.Error()) + } + return hex.EncodeToString(tokenBytes) +} diff --git a/internal/test/setup.go b/internal/test/setup.go index f24860562..e114248f7 100644 --- a/internal/test/setup.go +++ b/internal/test/setup.go @@ -46,7 +46,7 @@ import ( type setupParams struct { DBFixtureFile string ConfigYAML string - APIBuilder func(*core.Cluster, *gorp.DbMap, gopherpolicy.Validator, func() time.Time) httpapi.API + APIBuilder func(*core.Cluster, *gorp.DbMap, gopherpolicy.Validator, func() time.Time, func() string) httpapi.API APIMiddlewares []httpapi.API } @@ -74,7 +74,7 @@ func WithConfig(yamlStr string) SetupOption { // Limes API. The `apiBuilder` function signature matches NewV1API(). We cannot // directly call this function because that would create an import cycle, so it // must be given by the caller here. -func WithAPIHandler(apiBuilder func(*core.Cluster, *gorp.DbMap, gopherpolicy.Validator, func() time.Time) httpapi.API, middlewares ...httpapi.API) SetupOption { +func WithAPIHandler(apiBuilder func(*core.Cluster, *gorp.DbMap, gopherpolicy.Validator, func() time.Time, func() string) httpapi.API, middlewares ...httpapi.API) SetupOption { return func(params *setupParams) { params.APIBuilder = apiBuilder params.APIMiddlewares = middlewares @@ -101,6 +101,10 @@ type Setup struct { Handler http.Handler } +func GenerateDummyToken() string { + return "dummyToken" +} + // NewSetup prepares most or all pieces of Keppel for a test. func NewSetup(t *testing.T, opts ...SetupOption) Setup { logg.ShowDebug = osext.GetenvBool("LIMES_DEBUG") @@ -144,7 +148,7 @@ func NewSetup(t *testing.T, opts ...SetupOption) Setup { if params.APIBuilder != nil { s.Handler = httpapi.Compose( append([]httpapi.API{ - params.APIBuilder(s.Cluster, s.DB, s.TokenValidator, s.Clock.Now), + params.APIBuilder(s.Cluster, s.DB, s.TokenValidator, s.Clock.Now, GenerateDummyToken), httpapi.WithoutLogging(), }, params.APIMiddlewares...)..., ) diff --git a/main.go b/main.go index b8f887030..a889a4106 100644 --- a/main.go +++ b/main.go @@ -214,7 +214,7 @@ func taskServe(cluster *core.Cluster, args []string, provider *gophercloud.Provi }) mux := http.NewServeMux() mux.Handle("/", httpapi.Compose( - api.NewV1API(cluster, dbm, tokenValidator, time.Now), + api.NewV1API(cluster, dbm, tokenValidator, time.Now, api.GenerateTransferToken), pprofapi.API{IsAuthorized: pprofapi.IsRequestFromLocalhost}, httpapi.WithGlobalMiddleware(api.ForbidClusterIDHeader), httpapi.WithGlobalMiddleware(corsMiddleware.Handler),