• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

sapcc / limes / 14037823660

24 Mar 2025 02:26PM UTC coverage: 79.435% (-0.01%) from 79.445%
14037823660

Pull #674

github

majewsky
rework renew endpoint to only renew one commitment at a time
Pull Request #674: Add commitment renewal endpoint

89 of 113 new or added lines in 2 files covered. (78.76%)

170 existing lines in 3 files now uncovered.

6018 of 7576 relevant lines covered (79.44%)

61.73 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

79.39
/internal/api/commitment.go
1
/******************************************************************************
2
*
3
*  Copyright 2023 SAP SE
4
*
5
*  Licensed under the Apache License, Version 2.0 (the "License");
6
*  you may not use this file except in compliance with the License.
7
*  You may obtain a copy of the License at
8
*
9
*      http://www.apache.org/licenses/LICENSE-2.0
10
*
11
*  Unless required by applicable law or agreed to in writing, software
12
*  distributed under the License is distributed on an "AS IS" BASIS,
13
*  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
*  See the License for the specific language governing permissions and
15
*  limitations under the License.
16
*
17
******************************************************************************/
18

19
package api
20

21
import (
22
        "database/sql"
23
        "encoding/json"
24
        "errors"
25
        "fmt"
26
        "net/http"
27
        "slices"
28
        "strings"
29
        "time"
30

31
        "github.com/gorilla/mux"
32
        "github.com/sapcc/go-api-declarations/cadf"
33
        "github.com/sapcc/go-api-declarations/limes"
34
        limesresources "github.com/sapcc/go-api-declarations/limes/resources"
35
        "github.com/sapcc/go-api-declarations/liquid"
36
        "github.com/sapcc/go-bits/audittools"
37
        "github.com/sapcc/go-bits/errext"
38
        "github.com/sapcc/go-bits/gopherpolicy"
39
        "github.com/sapcc/go-bits/httpapi"
40
        "github.com/sapcc/go-bits/must"
41
        "github.com/sapcc/go-bits/respondwith"
42
        "github.com/sapcc/go-bits/sqlext"
43

44
        "github.com/sapcc/limes/internal/core"
45
        "github.com/sapcc/limes/internal/datamodel"
46
        "github.com/sapcc/limes/internal/db"
47
        "github.com/sapcc/limes/internal/liquids"
48
        "github.com/sapcc/limes/internal/reports"
49
)
50

51
var (
52
        getProjectCommitmentsQuery = sqlext.SimplifyWhitespace(`
53
                SELECT pc.*
54
                  FROM project_commitments pc
55
                  JOIN project_az_resources par ON pc.az_resource_id = par.id
56
                  JOIN project_resources pr ON par.resource_id = pr.id {{AND pr.name = $resource_name}}
57
                  JOIN project_services ps ON pr.service_id = ps.id {{AND ps.type = $service_type}}
58
                 WHERE %s AND pc.state NOT IN ('superseded', 'expired')
59
                 ORDER BY pc.id
60
        `)
61

62
        getProjectAZResourceLocationsQuery = sqlext.SimplifyWhitespace(`
63
                SELECT par.id, ps.type, pr.name, par.az
64
                  FROM project_az_resources par
65
                  JOIN project_resources pr ON par.resource_id = pr.id {{AND pr.name = $resource_name}}
66
                  JOIN project_services ps ON pr.service_id = ps.id {{AND ps.type = $service_type}}
67
                 WHERE %s
68
        `)
69

70
        findProjectCommitmentByIDQuery = sqlext.SimplifyWhitespace(`
71
                SELECT pc.*
72
                  FROM project_commitments pc
73
                  JOIN project_az_resources par ON pc.az_resource_id = par.id
74
                  JOIN project_resources pr ON par.resource_id = pr.id
75
                  JOIN project_services ps ON pr.service_id = ps.id
76
                 WHERE pc.id = $1 AND ps.project_id = $2
77
        `)
78

79
        // NOTE: The third output column is `resourceAllowsCommitments`.
80
        // We should be checking for `ResourceUsageReport.Forbidden == true`, but
81
        // since the `Forbidden` field is not persisted in the DB, we need to use
82
        // `max_quota_from_backend` as a proxy.
83
        findProjectAZResourceIDByLocationQuery = sqlext.SimplifyWhitespace(`
84
                SELECT pr.id, par.id, pr.max_quota_from_backend IS NULL
85
                  FROM project_az_resources par
86
                  JOIN project_resources pr ON par.resource_id = pr.id
87
                  JOIN project_services ps ON pr.service_id = ps.id
88
                 WHERE ps.project_id = $1 AND ps.type = $2 AND pr.name = $3 AND par.az = $4
89
        `)
90

91
        findProjectAZResourceLocationByIDQuery = sqlext.SimplifyWhitespace(`
92
                SELECT ps.type, pr.name, par.az
93
                  FROM project_az_resources par
94
                  JOIN project_resources pr ON par.resource_id = pr.id
95
                  JOIN project_services ps ON pr.service_id = ps.id
96
                 WHERE par.id = $1
97
        `)
98
        getCommitmentWithMatchingTransferTokenQuery = sqlext.SimplifyWhitespace(`
99
                SELECT * FROM project_commitments WHERE id = $1 AND transfer_token = $2
100
        `)
101
        findCommitmentByTransferToken = sqlext.SimplifyWhitespace(`
102
                SELECT * FROM project_commitments WHERE transfer_token = $1
103
        `)
104
        findTargetAZResourceIDBySourceIDQuery = sqlext.SimplifyWhitespace(`
105
                WITH source as (
106
                SELECT pr.id AS resource_id, ps.type, pr.name, par.az
107
                  FROM project_az_resources as par
108
                  JOIN project_resources pr ON par.resource_id = pr.id
109
                  JOIN project_services ps ON pr.service_id = ps.id
110
                 WHERE par.id = $1
111
                )
112
                SELECT s.resource_id, pr.id, par.id
113
                  FROM project_az_resources as par
114
                  JOIN project_resources pr ON par.resource_id = pr.id
115
                  JOIN project_services ps ON pr.service_id = ps.id
116
                  JOIN source s ON ps.type = s.type AND pr.name = s.name AND par.az = s.az
117
                 WHERE ps.project_id = $2
118
        `)
119
        findTargetAZResourceByTargetProjectQuery = sqlext.SimplifyWhitespace(`
120
                SELECT pr.id, par.id
121
                  FROM project_az_resources par
122
                  JOIN project_resources pr ON par.resource_id = pr.id
123
                  JOIN project_services ps ON pr.service_id = ps.id
124
                 WHERE ps.project_id = $1 AND ps.type = $2 AND pr.name = $3 AND par.az = $4
125
        `)
126
        forceImmediateCapacityScrapeQuery = sqlext.SimplifyWhitespace(`
127
                UPDATE cluster_capacitors SET next_scrape_at = $1 WHERE capacitor_id = (
128
                        SELECT capacitor_id FROM cluster_services cs JOIN cluster_resources cr ON cs.id = cr.service_id
129
                        WHERE cs.type = $2 AND cr.name = $3
130
                )
131
        `)
132
)
133

134
// GetProjectCommitments handles GET /v1/domains/:domain_id/projects/:project_id/commitments.
135
func (p *v1Provider) GetProjectCommitments(w http.ResponseWriter, r *http.Request) {
15✔
136
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments")
15✔
137
        token := p.CheckToken(r)
15✔
138
        if !token.Require(w, "project:show") {
16✔
139
                return
1✔
140
        }
1✔
141
        dbDomain := p.FindDomainFromRequest(w, r)
14✔
142
        if dbDomain == nil {
15✔
143
                return
1✔
144
        }
1✔
145
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
13✔
146
        if dbProject == nil {
14✔
147
                return
1✔
148
        }
1✔
149

150
        // enumerate project AZ resources
151
        filter := reports.ReadFilter(r, p.Cluster)
12✔
152
        queryStr, joinArgs := filter.PrepareQuery(getProjectAZResourceLocationsQuery)
12✔
153
        whereStr, whereArgs := db.BuildSimpleWhereClause(map[string]any{"ps.project_id": dbProject.ID}, len(joinArgs))
12✔
154
        azResourceLocationsByID := make(map[db.ProjectAZResourceID]core.AZResourceLocation)
12✔
155
        err := sqlext.ForeachRow(p.DB, fmt.Sprintf(queryStr, whereStr), append(joinArgs, whereArgs...), func(rows *sql.Rows) error {
137✔
156
                var (
125✔
157
                        id  db.ProjectAZResourceID
125✔
158
                        loc core.AZResourceLocation
125✔
159
                )
125✔
160
                err := rows.Scan(&id, &loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
125✔
161
                if err != nil {
125✔
UNCOV
162
                        return err
×
163
                }
×
164
                // this check is defense in depth (the DB should be consistent with our config)
165
                if p.Cluster.HasResource(loc.ServiceType, loc.ResourceName) {
250✔
166
                        azResourceLocationsByID[id] = loc
125✔
167
                }
125✔
168
                return nil
125✔
169
        })
170
        if respondwith.ErrorText(w, err) {
12✔
UNCOV
171
                return
×
172
        }
×
173

174
        // enumerate relevant project commitments
175
        queryStr, joinArgs = filter.PrepareQuery(getProjectCommitmentsQuery)
12✔
176
        whereStr, whereArgs = db.BuildSimpleWhereClause(map[string]any{"ps.project_id": dbProject.ID}, len(joinArgs))
12✔
177
        var dbCommitments []db.ProjectCommitment
12✔
178
        _, err = p.DB.Select(&dbCommitments, fmt.Sprintf(queryStr, whereStr), append(joinArgs, whereArgs...)...)
12✔
179
        if respondwith.ErrorText(w, err) {
12✔
UNCOV
180
                return
×
181
        }
×
182

183
        // render response
184
        result := make([]limesresources.Commitment, 0, len(dbCommitments))
12✔
185
        for _, c := range dbCommitments {
26✔
186
                loc, exists := azResourceLocationsByID[c.AZResourceID]
14✔
187
                if !exists {
14✔
UNCOV
188
                        // defense in depth (the DB should not change that much between those two queries above)
×
189
                        continue
×
190
                }
191
                result = append(result, p.convertCommitmentToDisplayForm(c, loc, token))
14✔
192
        }
193

194
        respondwith.JSON(w, http.StatusOK, map[string]any{"commitments": result})
12✔
195
}
196

197
func (p *v1Provider) convertCommitmentToDisplayForm(c db.ProjectCommitment, loc core.AZResourceLocation, token *gopherpolicy.Token) limesresources.Commitment {
83✔
198
        resInfo := p.Cluster.InfoForResource(loc.ServiceType, loc.ResourceName)
83✔
199
        apiIdentity := p.Cluster.BehaviorForResource(loc.ServiceType, loc.ResourceName).IdentityInV1API
83✔
200
        return limesresources.Commitment{
83✔
201
                ID:               int64(c.ID),
83✔
202
                ServiceType:      apiIdentity.ServiceType,
83✔
203
                ResourceName:     apiIdentity.Name,
83✔
204
                AvailabilityZone: loc.AvailabilityZone,
83✔
205
                Amount:           c.Amount,
83✔
206
                Unit:             resInfo.Unit,
83✔
207
                Duration:         c.Duration,
83✔
208
                CreatedAt:        limes.UnixEncodedTime{Time: c.CreatedAt},
83✔
209
                CreatorUUID:      c.CreatorUUID,
83✔
210
                CreatorName:      c.CreatorName,
83✔
211
                CanBeDeleted:     p.canDeleteCommitment(token, c),
83✔
212
                ConfirmBy:        maybeUnixEncodedTime(c.ConfirmBy),
83✔
213
                ConfirmedAt:      maybeUnixEncodedTime(c.ConfirmedAt),
83✔
214
                ExpiresAt:        limes.UnixEncodedTime{Time: c.ExpiresAt},
83✔
215
                TransferStatus:   c.TransferStatus,
83✔
216
                TransferToken:    c.TransferToken,
83✔
217
                NotifyOnConfirm:  c.NotifyOnConfirm,
83✔
218
                WasRenewed:       c.WasRenewed,
83✔
219
        }
83✔
220
}
83✔
221

222
func (p *v1Provider) parseAndValidateCommitmentRequest(w http.ResponseWriter, r *http.Request) (*limesresources.CommitmentRequest, *core.AZResourceLocation, *core.ResourceBehavior) {
46✔
223
        // parse request
46✔
224
        var parseTarget struct {
46✔
225
                Request limesresources.CommitmentRequest `json:"commitment"`
46✔
226
        }
46✔
227
        if !RequireJSON(w, r, &parseTarget) {
47✔
228
                return nil, nil, nil
1✔
229
        }
1✔
230
        req := parseTarget.Request
45✔
231

45✔
232
        // validate request
45✔
233
        nm := core.BuildResourceNameMapping(p.Cluster)
45✔
234
        dbServiceType, dbResourceName, ok := nm.MapFromV1API(req.ServiceType, req.ResourceName)
45✔
235
        if !ok {
47✔
236
                msg := fmt.Sprintf("no such service and/or resource: %s/%s", req.ServiceType, req.ResourceName)
2✔
237
                http.Error(w, msg, http.StatusUnprocessableEntity)
2✔
238
                return nil, nil, nil
2✔
239
        }
2✔
240
        behavior := p.Cluster.BehaviorForResource(dbServiceType, dbResourceName)
43✔
241
        resInfo := p.Cluster.InfoForResource(dbServiceType, dbResourceName)
43✔
242
        if len(behavior.CommitmentDurations) == 0 {
44✔
243
                http.Error(w, "commitments are not enabled for this resource", http.StatusUnprocessableEntity)
1✔
244
                return nil, nil, nil
1✔
245
        }
1✔
246
        if resInfo.Topology == liquid.FlatTopology {
45✔
247
                if req.AvailabilityZone != limes.AvailabilityZoneAny {
4✔
248
                        http.Error(w, `resource does not accept AZ-aware commitments, so the AZ must be set to "any"`, http.StatusUnprocessableEntity)
1✔
249
                        return nil, nil, nil
1✔
250
                }
1✔
251
        } else {
39✔
252
                if !slices.Contains(p.Cluster.Config.AvailabilityZones, req.AvailabilityZone) {
43✔
253
                        http.Error(w, "no such availability zone", http.StatusUnprocessableEntity)
4✔
254
                        return nil, nil, nil
4✔
255
                }
4✔
256
        }
257
        if !slices.Contains(behavior.CommitmentDurations, req.Duration) {
38✔
258
                buf := must.Return(json.Marshal(behavior.CommitmentDurations)) // panic on error is acceptable here, marshals should never fail
1✔
259
                msg := "unacceptable commitment duration for this resource, acceptable values: " + string(buf)
1✔
260
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
261
                return nil, nil, nil
1✔
262
        }
1✔
263
        if req.Amount == 0 {
37✔
264
                http.Error(w, "amount of committed resource must be greater than zero", http.StatusUnprocessableEntity)
1✔
265
                return nil, nil, nil
1✔
266
        }
1✔
267

268
        loc := core.AZResourceLocation{
35✔
269
                ServiceType:      dbServiceType,
35✔
270
                ResourceName:     dbResourceName,
35✔
271
                AvailabilityZone: req.AvailabilityZone,
35✔
272
        }
35✔
273
        return &req, &loc, &behavior
35✔
274
}
275

276
// CanConfirmNewProjectCommitment handles POST /v1/domains/:domain_id/projects/:project_id/commitments/can-confirm.
277
func (p *v1Provider) CanConfirmNewProjectCommitment(w http.ResponseWriter, r *http.Request) {
7✔
278
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/can-confirm")
7✔
279
        token := p.CheckToken(r)
7✔
280
        if !token.Require(w, "project:edit") {
7✔
UNCOV
281
                return
×
282
        }
×
283
        dbDomain := p.FindDomainFromRequest(w, r)
7✔
284
        if dbDomain == nil {
7✔
UNCOV
285
                return
×
286
        }
×
287
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
7✔
288
        if dbProject == nil {
7✔
UNCOV
289
                return
×
290
        }
×
291
        req, loc, behavior := p.parseAndValidateCommitmentRequest(w, r)
7✔
292
        if req == nil {
7✔
UNCOV
293
                return
×
294
        }
×
295

296
        var (
7✔
297
                resourceID                db.ProjectResourceID
7✔
298
                azResourceID              db.ProjectAZResourceID
7✔
299
                resourceAllowsCommitments bool
7✔
300
        )
7✔
301
        err := p.DB.QueryRow(findProjectAZResourceIDByLocationQuery, dbProject.ID, loc.ServiceType, loc.ResourceName, loc.AvailabilityZone).
7✔
302
                Scan(&resourceID, &azResourceID, &resourceAllowsCommitments)
7✔
303
        if respondwith.ErrorText(w, err) {
7✔
UNCOV
304
                return
×
305
        }
×
306
        if !resourceAllowsCommitments {
7✔
UNCOV
307
                msg := fmt.Sprintf("resource %s/%s is not enabled in this project", req.ServiceType, req.ResourceName)
×
308
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
309
                return
×
310
        }
×
311
        _ = azResourceID // returned by the above query, but not used in this function
7✔
312

7✔
313
        // commitments can never be confirmed immediately if we are before the min_confirm_date
7✔
314
        now := p.timeNow()
7✔
315
        if behavior.CommitmentMinConfirmDate != nil && behavior.CommitmentMinConfirmDate.After(now) {
8✔
316
                respondwith.JSON(w, http.StatusOK, map[string]bool{"result": false})
1✔
317
                return
1✔
318
        }
1✔
319

320
        // check for committable capacity
321
        result, err := datamodel.CanConfirmNewCommitment(*loc, resourceID, req.Amount, p.Cluster, p.DB)
6✔
322
        if respondwith.ErrorText(w, err) {
6✔
UNCOV
323
                return
×
324
        }
×
325
        respondwith.JSON(w, http.StatusOK, map[string]bool{"result": result})
6✔
326
}
327

328
// CreateProjectCommitment handles POST /v1/domains/:domain_id/projects/:project_id/commitments/new.
329
func (p *v1Provider) CreateProjectCommitment(w http.ResponseWriter, r *http.Request) {
42✔
330
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/new")
42✔
331
        token := p.CheckToken(r)
42✔
332
        if !token.Require(w, "project:edit") {
43✔
333
                return
1✔
334
        }
1✔
335
        dbDomain := p.FindDomainFromRequest(w, r)
41✔
336
        if dbDomain == nil {
42✔
337
                return
1✔
338
        }
1✔
339
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
40✔
340
        if dbProject == nil {
41✔
341
                return
1✔
342
        }
1✔
343
        req, loc, behavior := p.parseAndValidateCommitmentRequest(w, r)
39✔
344
        if req == nil {
50✔
345
                return
11✔
346
        }
11✔
347

348
        var (
28✔
349
                resourceID                db.ProjectResourceID
28✔
350
                azResourceID              db.ProjectAZResourceID
28✔
351
                resourceAllowsCommitments bool
28✔
352
        )
28✔
353
        err := p.DB.QueryRow(findProjectAZResourceIDByLocationQuery, dbProject.ID, loc.ServiceType, loc.ResourceName, loc.AvailabilityZone).
28✔
354
                Scan(&resourceID, &azResourceID, &resourceAllowsCommitments)
28✔
355
        if respondwith.ErrorText(w, err) {
28✔
UNCOV
356
                return
×
357
        }
×
358
        if !resourceAllowsCommitments {
29✔
359
                msg := fmt.Sprintf("resource %s/%s is not enabled in this project", req.ServiceType, req.ResourceName)
1✔
360
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
361
                return
1✔
362
        }
1✔
363

364
        // if given, confirm_by must definitely after time.Now(), and also after the MinConfirmDate if configured
365
        now := p.timeNow()
27✔
366
        if req.ConfirmBy != nil && req.ConfirmBy.Before(now) {
28✔
367
                http.Error(w, "confirm_by must not be set in the past", http.StatusUnprocessableEntity)
1✔
368
                return
1✔
369
        }
1✔
370
        if minConfirmBy := behavior.CommitmentMinConfirmDate; minConfirmBy != nil && minConfirmBy.After(now) {
31✔
371
                if req.ConfirmBy == nil || req.ConfirmBy.Before(*minConfirmBy) {
6✔
372
                        msg := "this commitment needs a `confirm_by` timestamp at or after " + behavior.CommitmentMinConfirmDate.Format(time.RFC3339)
1✔
373
                        http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
374
                        return
1✔
375
                }
1✔
376
        }
377

378
        // we want to validate committable capacity in the same transaction that creates the commitment
379
        tx, err := p.DB.Begin()
25✔
380
        if respondwith.ErrorText(w, err) {
25✔
UNCOV
381
                return
×
382
        }
×
383
        defer sqlext.RollbackUnlessCommitted(tx)
25✔
384

25✔
385
        // prepare commitment
25✔
386
        confirmBy := maybeUnpackUnixEncodedTime(req.ConfirmBy)
25✔
387
        creationContext := db.CommitmentWorkflowContext{Reason: db.CommitmentReasonCreate}
25✔
388
        buf, err := json.Marshal(creationContext)
25✔
389
        if respondwith.ErrorText(w, err) {
25✔
UNCOV
390
                return
×
391
        }
×
392
        dbCommitment := db.ProjectCommitment{
25✔
393
                AZResourceID:        azResourceID,
25✔
394
                Amount:              req.Amount,
25✔
395
                Duration:            req.Duration,
25✔
396
                CreatedAt:           now,
25✔
397
                CreatorUUID:         token.UserUUID(),
25✔
398
                CreatorName:         fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
25✔
399
                ConfirmBy:           confirmBy,
25✔
400
                ConfirmedAt:         nil, // may be set below
25✔
401
                ExpiresAt:           req.Duration.AddTo(unwrapOrDefault(confirmBy, now)),
25✔
402
                CreationContextJSON: json.RawMessage(buf),
25✔
403
        }
25✔
404
        if req.NotifyOnConfirm && req.ConfirmBy == nil {
26✔
405
                http.Error(w, "notification on confirm cannot be set for commitments with immediate confirmation", http.StatusConflict)
1✔
406
                return
1✔
407
        }
1✔
408
        dbCommitment.NotifyOnConfirm = req.NotifyOnConfirm
24✔
409

24✔
410
        if req.ConfirmBy == nil {
42✔
411
                // if not planned for confirmation in the future, confirm immediately (or fail)
18✔
412
                ok, err := datamodel.CanConfirmNewCommitment(*loc, resourceID, req.Amount, p.Cluster, tx)
18✔
413
                if respondwith.ErrorText(w, err) {
18✔
UNCOV
414
                        return
×
415
                }
×
416
                if !ok {
18✔
UNCOV
417
                        http.Error(w, "not enough capacity available for immediate confirmation", http.StatusConflict)
×
418
                        return
×
419
                }
×
420
                dbCommitment.ConfirmedAt = &now
18✔
421
                dbCommitment.State = db.CommitmentStateActive
18✔
422
        } else {
6✔
423
                dbCommitment.State = db.CommitmentStatePlanned
6✔
424
        }
6✔
425

426
        // create commitment
427
        err = tx.Insert(&dbCommitment)
24✔
428
        if respondwith.ErrorText(w, err) {
24✔
UNCOV
429
                return
×
430
        }
×
431
        err = tx.Commit()
24✔
432
        if respondwith.ErrorText(w, err) {
24✔
UNCOV
433
                return
×
434
        }
×
435
        p.auditor.Record(audittools.Event{
24✔
436
                Time:       now,
24✔
437
                Request:    r,
24✔
438
                User:       token,
24✔
439
                ReasonCode: http.StatusCreated,
24✔
440
                Action:     cadf.CreateAction,
24✔
441
                Target: commitmentEventTarget{
24✔
442
                        DomainID:        dbDomain.UUID,
24✔
443
                        DomainName:      dbDomain.Name,
24✔
444
                        ProjectID:       dbProject.UUID,
24✔
445
                        ProjectName:     dbProject.Name,
24✔
446
                        Commitments:     []limesresources.Commitment{p.convertCommitmentToDisplayForm(dbCommitment, *loc, token)},
24✔
447
                        WorkflowContext: &creationContext,
24✔
448
                },
24✔
449
        })
24✔
450

24✔
451
        // if the commitment is immediately confirmed, trigger a capacity scrape in
24✔
452
        // order to ApplyComputedProjectQuotas based on the new commitment
24✔
453
        if dbCommitment.ConfirmedAt != nil {
42✔
454
                _, err := p.DB.Exec(forceImmediateCapacityScrapeQuery, now, loc.ServiceType, loc.ResourceName)
18✔
455
                if respondwith.ErrorText(w, err) {
18✔
UNCOV
456
                        return
×
457
                }
×
458
        }
459

460
        // display the possibly confirmed commitment to the user
461
        err = p.DB.SelectOne(&dbCommitment, `SELECT * FROM project_commitments WHERE id = $1`, dbCommitment.ID)
24✔
462
        if respondwith.ErrorText(w, err) {
24✔
UNCOV
463
                return
×
464
        }
×
465

466
        c := p.convertCommitmentToDisplayForm(dbCommitment, *loc, token)
24✔
467
        respondwith.JSON(w, http.StatusCreated, map[string]any{"commitment": c})
24✔
468
}
469

470
// MergeProjectCommitments handles POST /v1/domains/:domain_id/projects/:project_id/commitments/merge.
471
func (p *v1Provider) MergeProjectCommitments(w http.ResponseWriter, r *http.Request) {
12✔
472
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/merge")
12✔
473
        token := p.CheckToken(r)
12✔
474
        if !token.Require(w, "project:edit") {
13✔
475
                return
1✔
476
        }
1✔
477
        dbDomain := p.FindDomainFromRequest(w, r)
11✔
478
        if dbDomain == nil {
12✔
479
                return
1✔
480
        }
1✔
481
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
10✔
482
        if dbProject == nil {
11✔
483
                return
1✔
484
        }
1✔
485
        var parseTarget struct {
9✔
486
                CommitmentIDs []db.ProjectCommitmentID `json:"commitment_ids"`
9✔
487
        }
9✔
488
        if !RequireJSON(w, r, &parseTarget) {
9✔
UNCOV
489
                return
×
490
        }
×
491
        commitmentIDs := parseTarget.CommitmentIDs
9✔
492
        if len(commitmentIDs) < 2 {
10✔
493
                http.Error(w, fmt.Sprintf("merging requires at least two commitments, but %d were given", len(commitmentIDs)), http.StatusBadRequest)
1✔
494
                return
1✔
495
        }
1✔
496

497
        // Load commitments
498
        dbCommitments := make([]db.ProjectCommitment, len(commitmentIDs))
8✔
499
        for i, commitmentID := range commitmentIDs {
24✔
500
                err := p.DB.SelectOne(&dbCommitments[i], findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
16✔
501
                if errors.Is(err, sql.ErrNoRows) {
17✔
502
                        http.Error(w, "no such commitment", http.StatusNotFound)
1✔
503
                        return
1✔
504
                } else if respondwith.ErrorText(w, err) {
16✔
UNCOV
505
                        return
×
506
                }
×
507
        }
508

509
        // Verify that all commitments agree on resource and AZ and are active
510
        azResourceID := dbCommitments[0].AZResourceID
7✔
511
        for _, dbCommitment := range dbCommitments {
21✔
512
                if dbCommitment.AZResourceID != azResourceID {
16✔
513
                        http.Error(w, "all commitments must be on the same resource and AZ", http.StatusConflict)
2✔
514
                        return
2✔
515
                }
2✔
516
                if dbCommitment.State != db.CommitmentStateActive {
16✔
517
                        http.Error(w, "only active commitments may be merged", http.StatusConflict)
4✔
518
                        return
4✔
519
                }
4✔
520
        }
521

522
        var loc core.AZResourceLocation
1✔
523
        err := p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, azResourceID).
1✔
524
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
1✔
525
        if errors.Is(err, sql.ErrNoRows) {
1✔
UNCOV
526
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
527
                return
×
528
        } else if respondwith.ErrorText(w, err) {
1✔
UNCOV
529
                return
×
530
        }
×
531

532
        // Start transaction for creating new commitment and marking merged commitments as superseded
533
        tx, err := p.DB.Begin()
1✔
534
        if respondwith.ErrorText(w, err) {
1✔
UNCOV
535
                return
×
536
        }
×
537
        defer sqlext.RollbackUnlessCommitted(tx)
1✔
538

1✔
539
        // Create merged template
1✔
540
        now := p.timeNow()
1✔
541
        dbMergedCommitment := db.ProjectCommitment{
1✔
542
                AZResourceID: azResourceID,
1✔
543
                Amount:       0,                                   // overwritten below
1✔
544
                Duration:     limesresources.CommitmentDuration{}, // overwritten below
1✔
545
                CreatedAt:    now,
1✔
546
                CreatorUUID:  token.UserUUID(),
1✔
547
                CreatorName:  fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
1✔
548
                ConfirmedAt:  &now,
1✔
549
                ExpiresAt:    time.Time{}, // overwritten below
1✔
550
                State:        db.CommitmentStateActive,
1✔
551
        }
1✔
552

1✔
553
        // Fill amount and latest expiration date
1✔
554
        for _, dbCommitment := range dbCommitments {
3✔
555
                dbMergedCommitment.Amount += dbCommitment.Amount
2✔
556
                if dbCommitment.ExpiresAt.After(dbMergedCommitment.ExpiresAt) {
4✔
557
                        dbMergedCommitment.ExpiresAt = dbCommitment.ExpiresAt
2✔
558
                        dbMergedCommitment.Duration = dbCommitment.Duration
2✔
559
                }
2✔
560
        }
561

562
        // Fill workflow context
563
        creationContext := db.CommitmentWorkflowContext{
1✔
564
                Reason:               db.CommitmentReasonMerge,
1✔
565
                RelatedCommitmentIDs: commitmentIDs,
1✔
566
        }
1✔
567
        buf, err := json.Marshal(creationContext)
1✔
568
        if respondwith.ErrorText(w, err) {
1✔
UNCOV
569
                return
×
570
        }
×
571
        dbMergedCommitment.CreationContextJSON = json.RawMessage(buf)
1✔
572

1✔
573
        // Insert into database
1✔
574
        err = tx.Insert(&dbMergedCommitment)
1✔
575
        if respondwith.ErrorText(w, err) {
1✔
UNCOV
576
                return
×
577
        }
×
578

579
        // Mark merged commits as superseded
580
        supersedeContext := db.CommitmentWorkflowContext{
1✔
581
                Reason:               db.CommitmentReasonMerge,
1✔
582
                RelatedCommitmentIDs: []db.ProjectCommitmentID{dbMergedCommitment.ID},
1✔
583
        }
1✔
584
        buf, err = json.Marshal(supersedeContext)
1✔
585
        if respondwith.ErrorText(w, err) {
1✔
UNCOV
586
                return
×
587
        }
×
588
        for _, dbCommitment := range dbCommitments {
3✔
589
                dbCommitment.SupersededAt = &now
2✔
590
                dbCommitment.SupersedeContextJSON = liquids.PointerTo(json.RawMessage(buf))
2✔
591
                dbCommitment.State = db.CommitmentStateSuperseded
2✔
592
                _, err = tx.Update(&dbCommitment)
2✔
593
                if respondwith.ErrorText(w, err) {
2✔
UNCOV
594
                        return
×
595
                }
×
596
        }
597

598
        err = tx.Commit()
1✔
599
        if respondwith.ErrorText(w, err) {
1✔
UNCOV
600
                return
×
601
        }
×
602

603
        c := p.convertCommitmentToDisplayForm(dbMergedCommitment, loc, token)
1✔
604
        auditEvent := commitmentEventTarget{
1✔
605
                DomainID:        dbDomain.UUID,
1✔
606
                DomainName:      dbDomain.Name,
1✔
607
                ProjectID:       dbProject.UUID,
1✔
608
                ProjectName:     dbProject.Name,
1✔
609
                Commitments:     []limesresources.Commitment{c},
1✔
610
                WorkflowContext: &creationContext,
1✔
611
        }
1✔
612
        p.auditor.Record(audittools.Event{
1✔
613
                Time:       p.timeNow(),
1✔
614
                Request:    r,
1✔
615
                User:       token,
1✔
616
                ReasonCode: http.StatusAccepted,
1✔
617
                Action:     cadf.UpdateAction,
1✔
618
                Target:     auditEvent,
1✔
619
        })
1✔
620

1✔
621
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
1✔
622
}
623

624
// As per the API spec, commitments can be renewed 90 days in advance at the earliest.
625
const commitmentRenewalPeriod = 90 * 24 * time.Hour
626

627
// RenewProjectCommitments handles POST /v1/domains/:domain_id/projects/:project_id/commitments/:id/renew.
628
func (p *v1Provider) RenewProjectCommitments(w http.ResponseWriter, r *http.Request) {
6✔
629
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id/renew")
6✔
630
        token := p.CheckToken(r)
6✔
631
        if !token.Require(w, "project:edit") {
6✔
NEW
632
                return
×
NEW
633
        }
×
634
        dbDomain := p.FindDomainFromRequest(w, r)
6✔
635
        if dbDomain == nil {
6✔
NEW
636
                return
×
NEW
637
        }
×
638
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
6✔
639
        if dbProject == nil {
6✔
NEW
640
                return
×
NEW
641
        }
×
642

643
        // Load commitment
644
        var dbCommitment db.ProjectCommitment
6✔
645
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
6✔
646
        if errors.Is(err, sql.ErrNoRows) {
6✔
NEW
647
                http.Error(w, "no such commitment", http.StatusNotFound)
×
NEW
648
                return
×
649
        } else if respondwith.ErrorText(w, err) {
6✔
NEW
650
                return
×
NEW
651
        }
×
652
        now := p.timeNow()
6✔
653

6✔
654
        // Check if commitment can be renewed
6✔
655
        var errs errext.ErrorSet
6✔
656
        if dbCommitment.State != db.CommitmentStateActive {
7✔
657
                errs.Addf("invalid state %q", dbCommitment.State)
1✔
658
        } else if now.After(dbCommitment.ExpiresAt) {
7✔
659
                errs.Addf("invalid state %q", db.CommitmentStateExpired)
1✔
660
        }
1✔
661
        if now.Before(dbCommitment.ExpiresAt.Add(-commitmentRenewalPeriod)) {
7✔
662
                errs.Addf("renewal attempt too early")
1✔
663
        }
1✔
664
        if dbCommitment.WasRenewed {
7✔
665
                errs.Addf("already renewed")
1✔
666
        }
1✔
667

668
        if !errs.IsEmpty() {
10✔
669
                msg := "cannot renew this commitment: " + errs.Join(", ")
4✔
670
                http.Error(w, msg, http.StatusConflict)
4✔
671
                return
4✔
672
        }
4✔
673

674
        // Create renewed commitment
675
        tx, err := p.DB.Begin()
2✔
676
        if respondwith.ErrorText(w, err) {
2✔
NEW
677
                return
×
NEW
678
        }
×
679
        defer sqlext.RollbackUnlessCommitted(tx)
2✔
680

2✔
681
        var loc core.AZResourceLocation
2✔
682
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
2✔
683
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
2✔
684
        if errors.Is(err, sql.ErrNoRows) {
2✔
NEW
685
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
NEW
686
                return
×
687
        } else if respondwith.ErrorText(w, err) {
2✔
NEW
688
                return
×
NEW
689
        }
×
690

691
        creationContext := db.CommitmentWorkflowContext{
2✔
692
                Reason:               db.CommitmentReasonRenew,
2✔
693
                RelatedCommitmentIDs: []db.ProjectCommitmentID{dbCommitment.ID},
2✔
694
        }
2✔
695
        buf, err := json.Marshal(creationContext)
2✔
696
        if respondwith.ErrorText(w, err) {
2✔
NEW
697
                return
×
NEW
698
        }
×
699
        dbRenewedCommitment := db.ProjectCommitment{
2✔
700
                AZResourceID:        dbCommitment.AZResourceID,
2✔
701
                Amount:              dbCommitment.Amount,
2✔
702
                Duration:            dbCommitment.Duration,
2✔
703
                CreatedAt:           now,
2✔
704
                CreatorUUID:         token.UserUUID(),
2✔
705
                CreatorName:         fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
2✔
706
                ConfirmBy:           &dbCommitment.ExpiresAt,
2✔
707
                ExpiresAt:           dbCommitment.Duration.AddTo(dbCommitment.ExpiresAt),
2✔
708
                State:               db.CommitmentStatePlanned,
2✔
709
                CreationContextJSON: json.RawMessage(buf),
2✔
710
        }
2✔
711

2✔
712
        err = tx.Insert(&dbRenewedCommitment)
2✔
713
        if respondwith.ErrorText(w, err) {
2✔
NEW
714
                return
×
NEW
715
        }
×
716

717
        dbCommitment.WasRenewed = true
2✔
718
        _, err = tx.Update(&dbCommitment)
2✔
719
        if respondwith.ErrorText(w, err) {
2✔
NEW
720
                return
×
NEW
721
        }
×
722

723
        err = tx.Commit()
2✔
724
        if respondwith.ErrorText(w, err) {
2✔
NEW
725
                return
×
NEW
726
        }
×
727

728
        // Create resultset and auditlogs
729
        c := p.convertCommitmentToDisplayForm(dbRenewedCommitment, loc, token)
2✔
730
        auditEvent := commitmentEventTarget{
2✔
731
                DomainID:        dbDomain.UUID,
2✔
732
                DomainName:      dbDomain.Name,
2✔
733
                ProjectID:       dbProject.UUID,
2✔
734
                ProjectName:     dbProject.Name,
2✔
735
                Commitments:     []limesresources.Commitment{c},
2✔
736
                WorkflowContext: &creationContext,
2✔
737
        }
2✔
738

2✔
739
        p.auditor.Record(audittools.Event{
2✔
740
                Time:       p.timeNow(),
2✔
741
                Request:    r,
2✔
742
                User:       token,
2✔
743
                ReasonCode: http.StatusAccepted,
2✔
744
                Action:     cadf.UpdateAction,
2✔
745
                Target:     auditEvent,
2✔
746
        })
2✔
747

2✔
748
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
749
}
750

751
// DeleteProjectCommitment handles DELETE /v1/domains/:domain_id/projects/:project_id/commitments/:id.
752
func (p *v1Provider) DeleteProjectCommitment(w http.ResponseWriter, r *http.Request) {
8✔
753
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id")
8✔
754
        token := p.CheckToken(r)
8✔
755
        if !token.Require(w, "project:edit") { //NOTE: There is a more specific AuthZ check further down below.
8✔
756
                return
×
757
        }
×
758
        dbDomain := p.FindDomainFromRequest(w, r)
8✔
759
        if dbDomain == nil {
9✔
760
                return
1✔
761
        }
1✔
762
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
7✔
763
        if dbProject == nil {
8✔
764
                return
1✔
765
        }
1✔
766

767
        // load commitment
768
        var dbCommitment db.ProjectCommitment
6✔
769
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
6✔
770
        if errors.Is(err, sql.ErrNoRows) {
7✔
771
                http.Error(w, "no such commitment", http.StatusNotFound)
1✔
772
                return
1✔
773
        } else if respondwith.ErrorText(w, err) {
6✔
UNCOV
774
                return
×
UNCOV
775
        }
×
776
        var loc core.AZResourceLocation
5✔
777
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
5✔
778
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
5✔
779
        if errors.Is(err, sql.ErrNoRows) {
5✔
UNCOV
780
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
UNCOV
781
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
UNCOV
782
                return
×
783
        } else if respondwith.ErrorText(w, err) {
5✔
784
                return
×
UNCOV
785
        }
×
786

787
        // check authorization for this specific commitment
788
        if !p.canDeleteCommitment(token, dbCommitment) {
6✔
789
                http.Error(w, "Forbidden", http.StatusForbidden)
1✔
790
                return
1✔
791
        }
1✔
792

793
        // perform deletion
794
        _, err = p.DB.Delete(&dbCommitment)
4✔
795
        if respondwith.ErrorText(w, err) {
4✔
UNCOV
796
                return
×
UNCOV
797
        }
×
798
        p.auditor.Record(audittools.Event{
4✔
799
                Time:       p.timeNow(),
4✔
800
                Request:    r,
4✔
801
                User:       token,
4✔
802
                ReasonCode: http.StatusNoContent,
4✔
803
                Action:     cadf.DeleteAction,
4✔
804
                Target: commitmentEventTarget{
4✔
805
                        DomainID:    dbDomain.UUID,
4✔
806
                        DomainName:  dbDomain.Name,
4✔
807
                        ProjectID:   dbProject.UUID,
4✔
808
                        ProjectName: dbProject.Name,
4✔
809
                        Commitments: []limesresources.Commitment{p.convertCommitmentToDisplayForm(dbCommitment, loc, token)},
4✔
810
                },
4✔
811
        })
4✔
812

4✔
813
        w.WriteHeader(http.StatusNoContent)
4✔
814
}
815

816
func (p *v1Provider) canDeleteCommitment(token *gopherpolicy.Token, commitment db.ProjectCommitment) bool {
88✔
817
        // up to 24 hours after creation of fresh commitments, future commitments can still be deleted by their creators
88✔
818
        if commitment.State == db.CommitmentStatePlanned || commitment.State == db.CommitmentStatePending || commitment.State == db.CommitmentStateActive {
176✔
819
                var creationContext db.CommitmentWorkflowContext
88✔
820
                err := json.Unmarshal(commitment.CreationContextJSON, &creationContext)
88✔
821
                if err == nil && creationContext.Reason == db.CommitmentReasonCreate && p.timeNow().Before(commitment.CreatedAt.Add(24*time.Hour)) {
152✔
822
                        if token.Check("project:edit") {
128✔
823
                                return true
64✔
824
                        }
64✔
825
                }
826
        }
827

828
        // afterwards, a more specific permission is required to delete it
829
        //
830
        // This protects cloud admins making capacity planning decisions based on future commitments
831
        // from having their forecasts ruined by project admins suffering from buyer's remorse.
832
        return token.Check("project:uncommit")
24✔
833
}
834

835
// StartCommitmentTransfer handles POST /v1/domains/:id/projects/:id/commitments/:id/start-transfer
836
func (p *v1Provider) StartCommitmentTransfer(w http.ResponseWriter, r *http.Request) {
8✔
837
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id/start-transfer")
8✔
838
        token := p.CheckToken(r)
8✔
839
        if !token.Require(w, "project:edit") {
8✔
UNCOV
840
                return
×
UNCOV
841
        }
×
842
        dbDomain := p.FindDomainFromRequest(w, r)
8✔
843
        if dbDomain == nil {
8✔
UNCOV
844
                return
×
UNCOV
845
        }
×
846
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
8✔
847
        if dbProject == nil {
8✔
UNCOV
848
                return
×
849
        }
×
850
        // TODO: eventually migrate this struct into go-api-declarations
851
        var parseTarget struct {
8✔
852
                Request struct {
8✔
853
                        Amount         uint64                                  `json:"amount"`
8✔
854
                        TransferStatus limesresources.CommitmentTransferStatus `json:"transfer_status,omitempty"`
8✔
855
                } `json:"commitment"`
8✔
856
        }
8✔
857
        if !RequireJSON(w, r, &parseTarget) {
8✔
858
                return
×
UNCOV
859
        }
×
860
        req := parseTarget.Request
8✔
861

8✔
862
        if req.TransferStatus != limesresources.CommitmentTransferStatusUnlisted && req.TransferStatus != limesresources.CommitmentTransferStatusPublic {
8✔
UNCOV
863
                http.Error(w, fmt.Sprintf("Invalid transfer_status code. Must be %s or %s.", limesresources.CommitmentTransferStatusUnlisted, limesresources.CommitmentTransferStatusPublic), http.StatusBadRequest)
×
UNCOV
864
                return
×
UNCOV
865
        }
×
866

867
        if req.Amount <= 0 {
9✔
868
                http.Error(w, "delivered amount needs to be a positive value.", http.StatusBadRequest)
1✔
869
                return
1✔
870
        }
1✔
871

872
        // load commitment
873
        var dbCommitment db.ProjectCommitment
7✔
874
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
7✔
875
        if errors.Is(err, sql.ErrNoRows) {
7✔
UNCOV
876
                http.Error(w, "no such commitment", http.StatusNotFound)
×
UNCOV
877
                return
×
878
        } else if respondwith.ErrorText(w, err) {
7✔
UNCOV
879
                return
×
UNCOV
880
        }
×
881

882
        // Mark whole commitment or a newly created, splitted one as transferrable.
883
        tx, err := p.DB.Begin()
7✔
884
        if respondwith.ErrorText(w, err) {
7✔
885
                return
×
886
        }
×
887
        defer sqlext.RollbackUnlessCommitted(tx)
7✔
888
        transferToken := p.generateTransferToken()
7✔
889

7✔
890
        // Deny requests with a greater amount than the commitment.
7✔
891
        if req.Amount > dbCommitment.Amount {
8✔
892
                http.Error(w, "delivered amount exceeds the commitment amount.", http.StatusBadRequest)
1✔
893
                return
1✔
894
        }
1✔
895

896
        if req.Amount == dbCommitment.Amount {
10✔
897
                dbCommitment.TransferStatus = req.TransferStatus
4✔
898
                dbCommitment.TransferToken = &transferToken
4✔
899
                _, err = tx.Update(&dbCommitment)
4✔
900
                if respondwith.ErrorText(w, err) {
4✔
UNCOV
901
                        return
×
UNCOV
902
                }
×
903
        } else {
2✔
904
                now := p.timeNow()
2✔
905
                transferAmount := req.Amount
2✔
906
                remainingAmount := dbCommitment.Amount - req.Amount
2✔
907
                transferCommitment, err := p.buildSplitCommitment(dbCommitment, transferAmount)
2✔
908
                if respondwith.ErrorText(w, err) {
2✔
UNCOV
909
                        return
×
910
                }
×
911
                transferCommitment.TransferStatus = req.TransferStatus
2✔
912
                transferCommitment.TransferToken = &transferToken
2✔
913
                remainingCommitment, err := p.buildSplitCommitment(dbCommitment, remainingAmount)
2✔
914
                if respondwith.ErrorText(w, err) {
2✔
UNCOV
915
                        return
×
UNCOV
916
                }
×
917
                err = tx.Insert(&transferCommitment)
2✔
918
                if respondwith.ErrorText(w, err) {
2✔
919
                        return
×
UNCOV
920
                }
×
921
                err = tx.Insert(&remainingCommitment)
2✔
922
                if respondwith.ErrorText(w, err) {
2✔
UNCOV
923
                        return
×
924
                }
×
925
                supersedeContext := db.CommitmentWorkflowContext{
2✔
926
                        Reason:               db.CommitmentReasonSplit,
2✔
927
                        RelatedCommitmentIDs: []db.ProjectCommitmentID{transferCommitment.ID, remainingCommitment.ID},
2✔
928
                }
2✔
929
                buf, err := json.Marshal(supersedeContext)
2✔
930
                if respondwith.ErrorText(w, err) {
2✔
UNCOV
931
                        return
×
932
                }
×
933
                dbCommitment.State = db.CommitmentStateSuperseded
2✔
934
                dbCommitment.SupersededAt = &now
2✔
935
                dbCommitment.SupersedeContextJSON = liquids.PointerTo(json.RawMessage(buf))
2✔
936
                _, err = tx.Update(&dbCommitment)
2✔
937
                if respondwith.ErrorText(w, err) {
2✔
UNCOV
938
                        return
×
UNCOV
939
                }
×
940
                dbCommitment = transferCommitment
2✔
941
        }
942
        err = tx.Commit()
6✔
943
        if respondwith.ErrorText(w, err) {
6✔
UNCOV
944
                return
×
UNCOV
945
        }
×
946

947
        var loc core.AZResourceLocation
6✔
948
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
6✔
949
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
6✔
950
        if errors.Is(err, sql.ErrNoRows) {
6✔
UNCOV
951
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
UNCOV
952
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
953
                return
×
954
        } else if respondwith.ErrorText(w, err) {
6✔
UNCOV
955
                return
×
UNCOV
956
        }
×
957

958
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token)
6✔
959
        p.auditor.Record(audittools.Event{
6✔
960
                Time:       p.timeNow(),
6✔
961
                Request:    r,
6✔
962
                User:       token,
6✔
963
                ReasonCode: http.StatusAccepted,
6✔
964
                Action:     cadf.UpdateAction,
6✔
965
                Target: commitmentEventTarget{
6✔
966
                        DomainID:    dbDomain.UUID,
6✔
967
                        DomainName:  dbDomain.Name,
6✔
968
                        ProjectID:   dbProject.UUID,
6✔
969
                        ProjectName: dbProject.Name,
6✔
970
                        Commitments: []limesresources.Commitment{c},
6✔
971
                },
6✔
972
        })
6✔
973
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
6✔
974
}
975

976
func (p *v1Provider) buildSplitCommitment(dbCommitment db.ProjectCommitment, amount uint64) (db.ProjectCommitment, error) {
5✔
977
        now := p.timeNow()
5✔
978
        creationContext := db.CommitmentWorkflowContext{
5✔
979
                Reason:               db.CommitmentReasonSplit,
5✔
980
                RelatedCommitmentIDs: []db.ProjectCommitmentID{dbCommitment.ID},
5✔
981
        }
5✔
982
        buf, err := json.Marshal(creationContext)
5✔
983
        if err != nil {
5✔
UNCOV
984
                return db.ProjectCommitment{}, err
×
UNCOV
985
        }
×
986
        return db.ProjectCommitment{
5✔
987
                AZResourceID:        dbCommitment.AZResourceID,
5✔
988
                Amount:              amount,
5✔
989
                Duration:            dbCommitment.Duration,
5✔
990
                CreatedAt:           now,
5✔
991
                CreatorUUID:         dbCommitment.CreatorUUID,
5✔
992
                CreatorName:         dbCommitment.CreatorName,
5✔
993
                ConfirmBy:           dbCommitment.ConfirmBy,
5✔
994
                ConfirmedAt:         dbCommitment.ConfirmedAt,
5✔
995
                ExpiresAt:           dbCommitment.ExpiresAt,
5✔
996
                CreationContextJSON: json.RawMessage(buf),
5✔
997
                State:               dbCommitment.State,
5✔
998
        }, nil
5✔
999
}
1000

1001
func (p *v1Provider) buildConvertedCommitment(dbCommitment db.ProjectCommitment, azResourceID db.ProjectAZResourceID, amount uint64) (db.ProjectCommitment, error) {
2✔
1002
        now := p.timeNow()
2✔
1003
        creationContext := db.CommitmentWorkflowContext{
2✔
1004
                Reason:               db.CommitmentReasonConvert,
2✔
1005
                RelatedCommitmentIDs: []db.ProjectCommitmentID{dbCommitment.ID},
2✔
1006
        }
2✔
1007
        buf, err := json.Marshal(creationContext)
2✔
1008
        if err != nil {
2✔
UNCOV
1009
                return db.ProjectCommitment{}, err
×
UNCOV
1010
        }
×
1011
        return db.ProjectCommitment{
2✔
1012
                AZResourceID:        azResourceID,
2✔
1013
                Amount:              amount,
2✔
1014
                Duration:            dbCommitment.Duration,
2✔
1015
                CreatedAt:           now,
2✔
1016
                CreatorUUID:         dbCommitment.CreatorUUID,
2✔
1017
                CreatorName:         dbCommitment.CreatorName,
2✔
1018
                ConfirmBy:           dbCommitment.ConfirmBy,
2✔
1019
                ConfirmedAt:         dbCommitment.ConfirmedAt,
2✔
1020
                ExpiresAt:           dbCommitment.ExpiresAt,
2✔
1021
                CreationContextJSON: json.RawMessage(buf),
2✔
1022
                State:               dbCommitment.State,
2✔
1023
        }, nil
2✔
1024
}
1025

1026
// GetCommitmentByTransferToken handles GET /v1/commitments/{token}
1027
func (p *v1Provider) GetCommitmentByTransferToken(w http.ResponseWriter, r *http.Request) {
2✔
1028
        httpapi.IdentifyEndpoint(r, "/v1/commitments/:token")
2✔
1029
        token := p.CheckToken(r)
2✔
1030
        if !token.Require(w, "cluster:show_basic") {
2✔
UNCOV
1031
                return
×
UNCOV
1032
        }
×
1033
        transferToken := mux.Vars(r)["token"]
2✔
1034

2✔
1035
        // The token column is a unique key, so we expect only one result.
2✔
1036
        var dbCommitment db.ProjectCommitment
2✔
1037
        err := p.DB.SelectOne(&dbCommitment, findCommitmentByTransferToken, transferToken)
2✔
1038
        if errors.Is(err, sql.ErrNoRows) {
3✔
1039
                http.Error(w, "no matching commitment found.", http.StatusNotFound)
1✔
1040
                return
1✔
1041
        } else if respondwith.ErrorText(w, err) {
2✔
UNCOV
1042
                return
×
UNCOV
1043
        }
×
1044

1045
        var loc core.AZResourceLocation
1✔
1046
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
1✔
1047
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
1✔
1048
        if errors.Is(err, sql.ErrNoRows) {
1✔
UNCOV
1049
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
UNCOV
1050
                http.Error(w, "location data not found.", http.StatusNotFound)
×
1051
                return
×
1052
        } else if respondwith.ErrorText(w, err) {
1✔
UNCOV
1053
                return
×
UNCOV
1054
        }
×
1055

1056
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token)
1✔
1057
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
1✔
1058
}
1059

1060
// TransferCommitment handles POST /v1/domains/{domain_id}/projects/{project_id}/transfer-commitment/{id}?token={token}
1061
func (p *v1Provider) TransferCommitment(w http.ResponseWriter, r *http.Request) {
5✔
1062
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/transfer-commitment/:id")
5✔
1063
        token := p.CheckToken(r)
5✔
1064
        if !token.Require(w, "project:edit") {
5✔
UNCOV
1065
                return
×
UNCOV
1066
        }
×
1067
        transferToken := r.Header.Get("Transfer-Token")
5✔
1068
        if transferToken == "" {
6✔
1069
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
1✔
1070
                return
1✔
1071
        }
1✔
1072
        commitmentID := mux.Vars(r)["id"]
4✔
1073
        if commitmentID == "" {
4✔
1074
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
1075
                return
×
UNCOV
1076
        }
×
1077
        dbDomain := p.FindDomainFromRequest(w, r)
4✔
1078
        if dbDomain == nil {
4✔
UNCOV
1079
                return
×
UNCOV
1080
        }
×
1081
        targetProject := p.FindProjectFromRequest(w, r, dbDomain)
4✔
1082
        if targetProject == nil {
4✔
1083
                return
×
1084
        }
×
1085

1086
        // find commitment by transfer_token
1087
        var dbCommitment db.ProjectCommitment
4✔
1088
        err := p.DB.SelectOne(&dbCommitment, getCommitmentWithMatchingTransferTokenQuery, commitmentID, transferToken)
4✔
1089
        if errors.Is(err, sql.ErrNoRows) {
5✔
1090
                http.Error(w, "no matching commitment found", http.StatusNotFound)
1✔
1091
                return
1✔
1092
        } else if respondwith.ErrorText(w, err) {
4✔
1093
                return
×
UNCOV
1094
        }
×
1095

1096
        var loc core.AZResourceLocation
3✔
1097
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
3✔
1098
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
3✔
1099
        if errors.Is(err, sql.ErrNoRows) {
3✔
UNCOV
1100
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
UNCOV
1101
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
1102
                return
×
1103
        } else if respondwith.ErrorText(w, err) {
3✔
UNCOV
1104
                return
×
UNCOV
1105
        }
×
1106

1107
        // get target service and AZ resource
1108
        var (
3✔
1109
                sourceResourceID   db.ProjectResourceID
3✔
1110
                targetResourceID   db.ProjectResourceID
3✔
1111
                targetAZResourceID db.ProjectAZResourceID
3✔
1112
        )
3✔
1113
        err = p.DB.QueryRow(findTargetAZResourceIDBySourceIDQuery, dbCommitment.AZResourceID, targetProject.ID).
3✔
1114
                Scan(&sourceResourceID, &targetResourceID, &targetAZResourceID)
3✔
1115
        if respondwith.ErrorText(w, err) {
3✔
UNCOV
1116
                return
×
UNCOV
1117
        }
×
1118

1119
        // validate that we have enough committable capacity on the receiving side
1120
        tx, err := p.DB.Begin()
3✔
1121
        if respondwith.ErrorText(w, err) {
3✔
UNCOV
1122
                return
×
UNCOV
1123
        }
×
1124
        defer sqlext.RollbackUnlessCommitted(tx)
3✔
1125
        ok, err := datamodel.CanMoveExistingCommitment(dbCommitment.Amount, loc, sourceResourceID, targetResourceID, p.Cluster, tx)
3✔
1126
        if respondwith.ErrorText(w, err) {
3✔
UNCOV
1127
                return
×
UNCOV
1128
        }
×
1129
        if !ok {
4✔
1130
                http.Error(w, "not enough committable capacity on the receiving side", http.StatusConflict)
1✔
1131
                return
1✔
1132
        }
1✔
1133

1134
        dbCommitment.TransferStatus = ""
2✔
1135
        dbCommitment.TransferToken = nil
2✔
1136
        dbCommitment.AZResourceID = targetAZResourceID
2✔
1137
        _, err = tx.Update(&dbCommitment)
2✔
1138
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1139
                return
×
UNCOV
1140
        }
×
1141
        err = tx.Commit()
2✔
1142
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1143
                return
×
UNCOV
1144
        }
×
1145

1146
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token)
2✔
1147
        p.auditor.Record(audittools.Event{
2✔
1148
                Time:       p.timeNow(),
2✔
1149
                Request:    r,
2✔
1150
                User:       token,
2✔
1151
                ReasonCode: http.StatusAccepted,
2✔
1152
                Action:     cadf.UpdateAction,
2✔
1153
                Target: commitmentEventTarget{
2✔
1154
                        DomainID:    dbDomain.UUID,
2✔
1155
                        DomainName:  dbDomain.Name,
2✔
1156
                        ProjectID:   targetProject.UUID,
2✔
1157
                        ProjectName: targetProject.Name,
2✔
1158
                        Commitments: []limesresources.Commitment{c},
2✔
1159
                },
2✔
1160
        })
2✔
1161

2✔
1162
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
1163
}
1164

1165
// GetCommitmentConversion handles GET /v1/commitment-conversion/{service_type}/{resource_name}
1166
func (p *v1Provider) GetCommitmentConversions(w http.ResponseWriter, r *http.Request) {
2✔
1167
        httpapi.IdentifyEndpoint(r, "/v1/commitment-conversion/:service_type/:resource_name")
2✔
1168
        token := p.CheckToken(r)
2✔
1169
        if !token.Require(w, "cluster:show_basic") {
2✔
UNCOV
1170
                return
×
UNCOV
1171
        }
×
1172

1173
        // validate request
1174
        vars := mux.Vars(r)
2✔
1175
        nm := core.BuildResourceNameMapping(p.Cluster)
2✔
1176
        sourceServiceType, sourceResourceName, exists := nm.MapFromV1API(
2✔
1177
                limes.ServiceType(vars["service_type"]),
2✔
1178
                limesresources.ResourceName(vars["resource_name"]),
2✔
1179
        )
2✔
1180
        if !exists {
2✔
UNCOV
1181
                msg := fmt.Sprintf("no such service and/or resource: %s/%s", vars["service_type"], vars["resource_name"])
×
UNCOV
1182
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
UNCOV
1183
                return
×
UNCOV
1184
        }
×
1185
        sourceBehavior := p.Cluster.BehaviorForResource(sourceServiceType, sourceResourceName)
2✔
1186
        sourceResInfo := p.Cluster.InfoForResource(sourceServiceType, sourceResourceName)
2✔
1187

2✔
1188
        // enumerate possible conversions
2✔
1189
        conversions := make([]limesresources.CommitmentConversionRule, 0)
2✔
1190
        for targetServiceType, quotaPlugin := range p.Cluster.QuotaPlugins {
8✔
1191
                for targetResourceName, targetResInfo := range quotaPlugin.Resources() {
28✔
1192
                        targetBehavior := p.Cluster.BehaviorForResource(targetServiceType, targetResourceName)
22✔
1193
                        if targetBehavior.CommitmentConversion == (core.CommitmentConversion{}) {
30✔
1194
                                continue
8✔
1195
                        }
1196
                        if sourceServiceType == targetServiceType && sourceResourceName == targetResourceName {
16✔
1197
                                continue
2✔
1198
                        }
1199
                        if sourceResInfo.Unit != targetResInfo.Unit {
19✔
1200
                                continue
7✔
1201
                        }
1202
                        if sourceBehavior.CommitmentConversion.Identifier != targetBehavior.CommitmentConversion.Identifier {
6✔
1203
                                continue
1✔
1204
                        }
1205

1206
                        fromAmount, toAmount := p.getCommitmentConversionRate(sourceBehavior, targetBehavior)
4✔
1207
                        apiServiceType, apiResourceName, ok := nm.MapToV1API(targetServiceType, targetResourceName)
4✔
1208
                        if ok {
8✔
1209
                                conversions = append(conversions, limesresources.CommitmentConversionRule{
4✔
1210
                                        FromAmount:     fromAmount,
4✔
1211
                                        ToAmount:       toAmount,
4✔
1212
                                        TargetService:  apiServiceType,
4✔
1213
                                        TargetResource: apiResourceName,
4✔
1214
                                })
4✔
1215
                        }
4✔
1216
                }
1217
        }
1218

1219
        // use a defined sorting to ensure deterministic behavior in tests
1220
        slices.SortFunc(conversions, func(lhs, rhs limesresources.CommitmentConversionRule) int {
6✔
1221
                result := strings.Compare(string(lhs.TargetService), string(rhs.TargetService))
4✔
1222
                if result != 0 {
7✔
1223
                        return result
3✔
1224
                }
3✔
1225
                return strings.Compare(string(lhs.TargetResource), string(rhs.TargetResource))
1✔
1226
        })
1227

1228
        respondwith.JSON(w, http.StatusOK, map[string]any{"conversions": conversions})
2✔
1229
}
1230

1231
// ConvertCommitment handles POST /v1/domains/{domain_id}/projects/{project_id}/commitments/{commitment_id}/convert
1232
func (p *v1Provider) ConvertCommitment(w http.ResponseWriter, r *http.Request) {
9✔
1233
        httpapi.IdentifyEndpoint(r, "/v1/domains/:domain_id/projects/:project_id/commitments/:commitment_id/convert")
9✔
1234
        token := p.CheckToken(r)
9✔
1235
        if !token.Require(w, "project:edit") {
9✔
UNCOV
1236
                return
×
UNCOV
1237
        }
×
1238
        commitmentID := mux.Vars(r)["commitment_id"]
9✔
1239
        if commitmentID == "" {
9✔
UNCOV
1240
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
UNCOV
1241
                return
×
UNCOV
1242
        }
×
1243
        dbDomain := p.FindDomainFromRequest(w, r)
9✔
1244
        if dbDomain == nil {
9✔
1245
                return
×
1246
        }
×
1247
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
9✔
1248
        if dbProject == nil {
9✔
1249
                return
×
1250
        }
×
1251

1252
        // section: sourceBehavior
1253
        var dbCommitment db.ProjectCommitment
9✔
1254
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
9✔
1255
        if errors.Is(err, sql.ErrNoRows) {
10✔
1256
                http.Error(w, "no such commitment", http.StatusNotFound)
1✔
1257
                return
1✔
1258
        } else if respondwith.ErrorText(w, err) {
9✔
1259
                return
×
UNCOV
1260
        }
×
1261
        var sourceLoc core.AZResourceLocation
8✔
1262
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
8✔
1263
                Scan(&sourceLoc.ServiceType, &sourceLoc.ResourceName, &sourceLoc.AvailabilityZone)
8✔
1264
        if errors.Is(err, sql.ErrNoRows) {
8✔
UNCOV
1265
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
UNCOV
1266
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
UNCOV
1267
                return
×
1268
        } else if respondwith.ErrorText(w, err) {
8✔
1269
                return
×
UNCOV
1270
        }
×
1271
        sourceBehavior := p.Cluster.BehaviorForResource(sourceLoc.ServiceType, sourceLoc.ResourceName)
8✔
1272

8✔
1273
        // section: targetBehavior
8✔
1274
        var parseTarget struct {
8✔
1275
                Request struct {
8✔
1276
                        TargetService  limes.ServiceType           `json:"target_service"`
8✔
1277
                        TargetResource limesresources.ResourceName `json:"target_resource"`
8✔
1278
                        SourceAmount   uint64                      `json:"source_amount"`
8✔
1279
                        TargetAmount   uint64                      `json:"target_amount"`
8✔
1280
                } `json:"commitment"`
8✔
1281
        }
8✔
1282
        if !RequireJSON(w, r, &parseTarget) {
8✔
UNCOV
1283
                return
×
UNCOV
1284
        }
×
1285
        req := parseTarget.Request
8✔
1286
        nm := core.BuildResourceNameMapping(p.Cluster)
8✔
1287
        targetServiceType, targetResourceName, exists := nm.MapFromV1API(req.TargetService, req.TargetResource)
8✔
1288
        if !exists {
8✔
UNCOV
1289
                msg := fmt.Sprintf("no such service and/or resource: %s/%s", req.TargetService, req.TargetResource)
×
UNCOV
1290
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
UNCOV
1291
                return
×
1292
        }
×
1293
        targetBehavior := p.Cluster.BehaviorForResource(targetServiceType, targetResourceName)
8✔
1294
        if sourceLoc.ResourceName == targetResourceName && sourceLoc.ServiceType == targetServiceType {
9✔
1295
                http.Error(w, "conversion attempt to the same resource.", http.StatusConflict)
1✔
1296
                return
1✔
1297
        }
1✔
1298
        if len(targetBehavior.CommitmentDurations) == 0 {
7✔
1299
                msg := fmt.Sprintf("commitments are not enabled for resource %s/%s", req.TargetService, req.TargetResource)
×
1300
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
1301
                return
×
UNCOV
1302
        }
×
1303
        if sourceBehavior.CommitmentConversion.Identifier == "" || sourceBehavior.CommitmentConversion.Identifier != targetBehavior.CommitmentConversion.Identifier {
8✔
1304
                msg := fmt.Sprintf("commitment is not convertible into resource %s/%s", req.TargetService, req.TargetResource)
1✔
1305
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
1306
                return
1✔
1307
        }
1✔
1308

1309
        // section: conversion
1310
        if req.SourceAmount > dbCommitment.Amount {
6✔
1311
                msg := fmt.Sprintf("unprocessable source amount. provided: %v, commitment: %v", req.SourceAmount, dbCommitment.Amount)
×
UNCOV
1312
                http.Error(w, msg, http.StatusConflict)
×
UNCOV
1313
                return
×
UNCOV
1314
        }
×
1315
        fromAmount, toAmount := p.getCommitmentConversionRate(sourceBehavior, targetBehavior)
6✔
1316
        conversionAmount := (req.SourceAmount / fromAmount) * toAmount
6✔
1317
        remainderAmount := req.SourceAmount % fromAmount
6✔
1318
        if remainderAmount > 0 {
8✔
1319
                msg := fmt.Sprintf("amount: %v does not fit into conversion rate of: %v", req.SourceAmount, fromAmount)
2✔
1320
                http.Error(w, msg, http.StatusConflict)
2✔
1321
                return
2✔
1322
        }
2✔
1323
        if conversionAmount != req.TargetAmount {
5✔
1324
                msg := fmt.Sprintf("conversion mismatch. provided: %v, calculated: %v", req.TargetAmount, conversionAmount)
1✔
1325
                http.Error(w, msg, http.StatusConflict)
1✔
1326
                return
1✔
1327
        }
1✔
1328

1329
        tx, err := p.DB.Begin()
3✔
1330
        if respondwith.ErrorText(w, err) {
3✔
UNCOV
1331
                return
×
UNCOV
1332
        }
×
1333
        defer sqlext.RollbackUnlessCommitted(tx)
3✔
1334

3✔
1335
        var (
3✔
1336
                targetResourceID   db.ProjectResourceID
3✔
1337
                targetAZResourceID db.ProjectAZResourceID
3✔
1338
        )
3✔
1339
        err = p.DB.QueryRow(findTargetAZResourceByTargetProjectQuery, dbProject.ID, targetServiceType, targetResourceName, sourceLoc.AvailabilityZone).
3✔
1340
                Scan(&targetResourceID, &targetAZResourceID)
3✔
1341
        if respondwith.ErrorText(w, err) {
3✔
UNCOV
1342
                return
×
UNCOV
1343
        }
×
1344
        // defense in depth. ServiceType and ResourceName of source and target are already checked. Here it's possible to explicitly check the ID's.
1345
        if dbCommitment.AZResourceID == targetAZResourceID {
3✔
UNCOV
1346
                http.Error(w, "conversion attempt to the same resource.", http.StatusConflict)
×
UNCOV
1347
                return
×
UNCOV
1348
        }
×
1349
        targetLoc := core.AZResourceLocation{
3✔
1350
                ServiceType:      targetServiceType,
3✔
1351
                ResourceName:     targetResourceName,
3✔
1352
                AvailabilityZone: sourceLoc.AvailabilityZone,
3✔
1353
        }
3✔
1354
        // The commitment at the source resource was already confirmed and checked.
3✔
1355
        // Therefore only the addition to the target resource has to be checked against.
3✔
1356
        if dbCommitment.ConfirmedAt != nil {
5✔
1357
                ok, err := datamodel.CanConfirmNewCommitment(targetLoc, targetResourceID, conversionAmount, p.Cluster, p.DB)
2✔
1358
                if respondwith.ErrorText(w, err) {
2✔
UNCOV
1359
                        return
×
UNCOV
1360
                }
×
1361
                if !ok {
3✔
1362
                        http.Error(w, "not enough capacity to confirm the commitment", http.StatusUnprocessableEntity)
1✔
1363
                        return
1✔
1364
                }
1✔
1365
        }
1366

1367
        auditEvent := commitmentEventTarget{
2✔
1368
                DomainID:    dbDomain.UUID,
2✔
1369
                DomainName:  dbDomain.Name,
2✔
1370
                ProjectID:   dbProject.UUID,
2✔
1371
                ProjectName: dbProject.Name,
2✔
1372
        }
2✔
1373

2✔
1374
        relatedCommitmentIDs := make([]db.ProjectCommitmentID, 0)
2✔
1375
        remainingAmount := dbCommitment.Amount - req.SourceAmount
2✔
1376
        if remainingAmount > 0 {
3✔
1377
                remainingCommitment, err := p.buildSplitCommitment(dbCommitment, remainingAmount)
1✔
1378
                if respondwith.ErrorText(w, err) {
1✔
UNCOV
1379
                        return
×
UNCOV
1380
                }
×
1381
                relatedCommitmentIDs = append(relatedCommitmentIDs, remainingCommitment.ID)
1✔
1382
                err = tx.Insert(&remainingCommitment)
1✔
1383
                if respondwith.ErrorText(w, err) {
1✔
UNCOV
1384
                        return
×
UNCOV
1385
                }
×
1386
                auditEvent.Commitments = append(auditEvent.Commitments,
1✔
1387
                        p.convertCommitmentToDisplayForm(remainingCommitment, sourceLoc, token),
1✔
1388
                )
1✔
1389
        }
1390

1391
        convertedCommitment, err := p.buildConvertedCommitment(dbCommitment, targetAZResourceID, conversionAmount)
2✔
1392
        if respondwith.ErrorText(w, err) {
2✔
1393
                return
×
1394
        }
×
1395
        relatedCommitmentIDs = append(relatedCommitmentIDs, convertedCommitment.ID)
2✔
1396
        err = tx.Insert(&convertedCommitment)
2✔
1397
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1398
                return
×
UNCOV
1399
        }
×
1400

1401
        // supersede the original commitment
1402
        now := p.timeNow()
2✔
1403
        supersedeContext := db.CommitmentWorkflowContext{
2✔
1404
                Reason:               db.CommitmentReasonConvert,
2✔
1405
                RelatedCommitmentIDs: relatedCommitmentIDs,
2✔
1406
        }
2✔
1407
        buf, err := json.Marshal(supersedeContext)
2✔
1408
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1409
                return
×
UNCOV
1410
        }
×
1411
        dbCommitment.State = db.CommitmentStateSuperseded
2✔
1412
        dbCommitment.SupersededAt = &now
2✔
1413
        dbCommitment.SupersedeContextJSON = liquids.PointerTo(json.RawMessage(buf))
2✔
1414
        _, err = tx.Update(&dbCommitment)
2✔
1415
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1416
                return
×
UNCOV
1417
        }
×
1418

1419
        err = tx.Commit()
2✔
1420
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1421
                return
×
UNCOV
1422
        }
×
1423

1424
        c := p.convertCommitmentToDisplayForm(convertedCommitment, targetLoc, token)
2✔
1425
        auditEvent.Commitments = append([]limesresources.Commitment{c}, auditEvent.Commitments...)
2✔
1426
        auditEvent.WorkflowContext = &db.CommitmentWorkflowContext{
2✔
1427
                Reason:               db.CommitmentReasonSplit,
2✔
1428
                RelatedCommitmentIDs: []db.ProjectCommitmentID{dbCommitment.ID},
2✔
1429
        }
2✔
1430
        p.auditor.Record(audittools.Event{
2✔
1431
                Time:       p.timeNow(),
2✔
1432
                Request:    r,
2✔
1433
                User:       token,
2✔
1434
                ReasonCode: http.StatusAccepted,
2✔
1435
                Action:     cadf.UpdateAction,
2✔
1436
                Target:     auditEvent,
2✔
1437
        })
2✔
1438

2✔
1439
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
1440
}
1441

1442
func (p *v1Provider) getCommitmentConversionRate(source, target core.ResourceBehavior) (fromAmount, toAmount uint64) {
10✔
1443
        divisor := GetGreatestCommonDivisor(source.CommitmentConversion.Weight, target.CommitmentConversion.Weight)
10✔
1444
        fromAmount = target.CommitmentConversion.Weight / divisor
10✔
1445
        toAmount = source.CommitmentConversion.Weight / divisor
10✔
1446
        return fromAmount, toAmount
10✔
1447
}
10✔
1448

1449
// ExtendCommitmentDuration handles POST /v1/domains/{domain_id}/projects/{project_id}/commitments/{commitment_id}/update-duration
1450
func (p *v1Provider) UpdateCommitmentDuration(w http.ResponseWriter, r *http.Request) {
6✔
1451
        httpapi.IdentifyEndpoint(r, "/v1/domains/:domain_id/projects/:project_id/commitments/:commitment_id/update-duration")
6✔
1452
        token := p.CheckToken(r)
6✔
1453
        if !token.Require(w, "project:edit") {
6✔
UNCOV
1454
                return
×
UNCOV
1455
        }
×
1456
        commitmentID := mux.Vars(r)["commitment_id"]
6✔
1457
        if commitmentID == "" {
6✔
UNCOV
1458
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
UNCOV
1459
                return
×
UNCOV
1460
        }
×
1461
        dbDomain := p.FindDomainFromRequest(w, r)
6✔
1462
        if dbDomain == nil {
6✔
1463
                return
×
1464
        }
×
1465
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
6✔
1466
        if dbProject == nil {
6✔
1467
                return
×
1468
        }
×
1469
        var Request struct {
6✔
1470
                Duration limesresources.CommitmentDuration `json:"duration"`
6✔
1471
        }
6✔
1472
        req := Request
6✔
1473
        if !RequireJSON(w, r, &req) {
6✔
UNCOV
1474
                return
×
UNCOV
1475
        }
×
1476

1477
        var dbCommitment db.ProjectCommitment
6✔
1478
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
6✔
1479
        if errors.Is(err, sql.ErrNoRows) {
6✔
UNCOV
1480
                http.Error(w, "no such commitment", http.StatusNotFound)
×
UNCOV
1481
                return
×
1482
        } else if respondwith.ErrorText(w, err) {
6✔
1483
                return
×
1484
        }
×
1485

1486
        now := p.timeNow()
6✔
1487
        if dbCommitment.ExpiresAt.Before(now) || dbCommitment.ExpiresAt.Equal(now) {
7✔
1488
                http.Error(w, "unable to process expired commitment", http.StatusForbidden)
1✔
1489
                return
1✔
1490
        }
1✔
1491

1492
        if dbCommitment.State == db.CommitmentStateSuperseded {
6✔
1493
                msg := fmt.Sprintf("unable to operate on commitment with a state of %s", dbCommitment.State)
1✔
1494
                http.Error(w, msg, http.StatusForbidden)
1✔
1495
                return
1✔
1496
        }
1✔
1497

1498
        var loc core.AZResourceLocation
4✔
1499
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
4✔
1500
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
4✔
1501
        if errors.Is(err, sql.ErrNoRows) {
4✔
UNCOV
1502
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
UNCOV
1503
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
UNCOV
1504
                return
×
1505
        } else if respondwith.ErrorText(w, err) {
4✔
UNCOV
1506
                return
×
UNCOV
1507
        }
×
1508
        behavior := p.Cluster.BehaviorForResource(loc.ServiceType, loc.ResourceName)
4✔
1509
        if !slices.Contains(behavior.CommitmentDurations, req.Duration) {
5✔
1510
                msg := fmt.Sprintf("provided duration: %s does not match the config %v", req.Duration, behavior.CommitmentDurations)
1✔
1511
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
1512
                return
1✔
1513
        }
1✔
1514

1515
        newExpiresAt := req.Duration.AddTo(unwrapOrDefault(dbCommitment.ConfirmBy, dbCommitment.CreatedAt))
3✔
1516
        if newExpiresAt.Before(dbCommitment.ExpiresAt) {
4✔
1517
                msg := fmt.Sprintf("duration change from %s to %s forbidden", dbCommitment.Duration, req.Duration)
1✔
1518
                http.Error(w, msg, http.StatusForbidden)
1✔
1519
                return
1✔
1520
        }
1✔
1521

1522
        dbCommitment.Duration = req.Duration
2✔
1523
        dbCommitment.ExpiresAt = newExpiresAt
2✔
1524
        _, err = p.DB.Update(&dbCommitment)
2✔
1525
        if respondwith.ErrorText(w, err) {
2✔
UNCOV
1526
                return
×
UNCOV
1527
        }
×
1528

1529
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token)
2✔
1530
        p.auditor.Record(audittools.Event{
2✔
1531
                Time:       p.timeNow(),
2✔
1532
                Request:    r,
2✔
1533
                User:       token,
2✔
1534
                ReasonCode: http.StatusOK,
2✔
1535
                Action:     cadf.UpdateAction,
2✔
1536
                Target: commitmentEventTarget{
2✔
1537
                        DomainID:    dbDomain.UUID,
2✔
1538
                        DomainName:  dbDomain.Name,
2✔
1539
                        ProjectID:   dbProject.UUID,
2✔
1540
                        ProjectName: dbProject.Name,
2✔
1541
                        Commitments: []limesresources.Commitment{c},
2✔
1542
                },
2✔
1543
        })
2✔
1544

2✔
1545
        respondwith.JSON(w, http.StatusOK, map[string]any{"commitment": c})
2✔
1546
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc