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

sapcc / limes / 15387745732

02 Jun 2025 08:38AM UTC coverage: 78.479% (-0.6%) from 79.123%
15387745732

Pull #725

github

wagnerd3
channel ServiceInfo calls through single function
Pull Request #725: utilize ServiceInfo from database for collect-startup and serve-tasks

537 of 683 new or added lines in 31 files covered. (78.62%)

10 existing lines in 5 files now uncovered.

6637 of 8457 relevant lines covered (78.48%)

53.47 hits per line

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

78.16
/internal/api/commitment.go
1
// SPDX-FileCopyrightText: 2023 SAP SE or an SAP affiliate company
2
// SPDX-License-Identifier: Apache-2.0
3

4
package api
5

6
import (
7
        "cmp"
8
        "database/sql"
9
        "encoding/json"
10
        "errors"
11
        "fmt"
12
        "net/http"
13
        "slices"
14
        "strings"
15
        "time"
16

17
        "github.com/gorilla/mux"
18
        . "github.com/majewsky/gg/option"
19
        "github.com/majewsky/gg/options"
20
        "github.com/sapcc/go-api-declarations/cadf"
21
        "github.com/sapcc/go-api-declarations/limes"
22
        limesresources "github.com/sapcc/go-api-declarations/limes/resources"
23
        "github.com/sapcc/go-api-declarations/liquid"
24
        "github.com/sapcc/go-bits/audittools"
25
        "github.com/sapcc/go-bits/errext"
26
        "github.com/sapcc/go-bits/gopherpolicy"
27
        "github.com/sapcc/go-bits/httpapi"
28
        "github.com/sapcc/go-bits/must"
29
        "github.com/sapcc/go-bits/respondwith"
30
        "github.com/sapcc/go-bits/sqlext"
31

32
        "github.com/sapcc/limes/internal/core"
33
        "github.com/sapcc/limes/internal/datamodel"
34
        "github.com/sapcc/limes/internal/db"
35
        "github.com/sapcc/limes/internal/reports"
36
)
37

38
var (
39
        getProjectCommitmentsQuery = sqlext.SimplifyWhitespace(`
40
                SELECT pc.*
41
                  FROM project_commitments pc
42
                  JOIN project_az_resources par ON pc.az_resource_id = par.id
43
                  JOIN project_resources pr ON par.resource_id = pr.id {{AND pr.name = $resource_name}}
44
                  JOIN project_services ps ON pr.service_id = ps.id {{AND ps.type = $service_type}}
45
                 WHERE %s AND pc.state NOT IN ('superseded', 'expired')
46
                 ORDER BY pc.id
47
        `)
48

49
        getProjectAZResourceLocationsQuery = sqlext.SimplifyWhitespace(`
50
                SELECT par.id, ps.type, pr.name, par.az
51
                  FROM project_az_resources par
52
                  JOIN project_resources pr ON par.resource_id = pr.id {{AND pr.name = $resource_name}}
53
                  JOIN project_services ps ON pr.service_id = ps.id {{AND ps.type = $service_type}}
54
                 WHERE %s
55
        `)
56

57
        findProjectCommitmentByIDQuery = sqlext.SimplifyWhitespace(`
58
                SELECT pc.*
59
                  FROM project_commitments pc
60
                  JOIN project_az_resources par ON pc.az_resource_id = par.id
61
                  JOIN project_resources pr ON par.resource_id = pr.id
62
                  JOIN project_services ps ON pr.service_id = ps.id
63
                 WHERE pc.id = $1 AND ps.project_id = $2
64
        `)
65

66
        // NOTE: The third output column is `resourceAllowsCommitments`.
67
        findProjectAZResourceIDByLocationQuery = sqlext.SimplifyWhitespace(`
68
                SELECT pr.id, par.id, pr.forbidden IS NOT TRUE
69
                  FROM project_az_resources par
70
                  JOIN project_resources pr ON par.resource_id = pr.id
71
                  JOIN project_services ps ON pr.service_id = ps.id
72
                 WHERE ps.project_id = $1 AND ps.type = $2 AND pr.name = $3 AND par.az = $4
73
        `)
74

75
        findProjectAZResourceLocationByIDQuery = sqlext.SimplifyWhitespace(`
76
                SELECT ps.type, pr.name, par.az
77
                  FROM project_az_resources par
78
                  JOIN project_resources pr ON par.resource_id = pr.id
79
                  JOIN project_services ps ON pr.service_id = ps.id
80
                 WHERE par.id = $1
81
        `)
82
        getCommitmentWithMatchingTransferTokenQuery = sqlext.SimplifyWhitespace(`
83
                SELECT * FROM project_commitments WHERE id = $1 AND transfer_token = $2
84
        `)
85
        findCommitmentByTransferToken = sqlext.SimplifyWhitespace(`
86
                SELECT * FROM project_commitments WHERE transfer_token = $1
87
        `)
88
        findTargetAZResourceIDBySourceIDQuery = sqlext.SimplifyWhitespace(`
89
                WITH source as (
90
                SELECT pr.id AS resource_id, ps.type, pr.name, par.az
91
                  FROM project_az_resources as par
92
                  JOIN project_resources pr ON par.resource_id = pr.id
93
                  JOIN project_services ps ON pr.service_id = ps.id
94
                 WHERE par.id = $1
95
                )
96
                SELECT s.resource_id, pr.id, par.id
97
                  FROM project_az_resources as par
98
                  JOIN project_resources pr ON par.resource_id = pr.id
99
                  JOIN project_services ps ON pr.service_id = ps.id
100
                  JOIN source s ON ps.type = s.type AND pr.name = s.name AND par.az = s.az
101
                 WHERE ps.project_id = $2
102
        `)
103
        findTargetAZResourceByTargetProjectQuery = sqlext.SimplifyWhitespace(`
104
                SELECT pr.id, par.id
105
                  FROM project_az_resources par
106
                  JOIN project_resources pr ON par.resource_id = pr.id
107
                  JOIN project_services ps ON pr.service_id = ps.id
108
                 WHERE ps.project_id = $1 AND ps.type = $2 AND pr.name = $3 AND par.az = $4
109
        `)
110
)
111

112
// GetProjectCommitments handles GET /v1/domains/:domain_id/projects/:project_id/commitments.
113
func (p *v1Provider) GetProjectCommitments(w http.ResponseWriter, r *http.Request) {
15✔
114
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments")
15✔
115
        token := p.CheckToken(r)
15✔
116
        if !token.Require(w, "project:show") {
16✔
117
                return
1✔
118
        }
1✔
119
        dbDomain := p.FindDomainFromRequest(w, r)
14✔
120
        if dbDomain == nil {
15✔
121
                return
1✔
122
        }
1✔
123
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
13✔
124
        if dbProject == nil {
14✔
125
                return
1✔
126
        }
1✔
127
        serviceInfos, err := p.Cluster.AllServiceInfos()
12✔
128
        if respondwith.ErrorText(w, err) {
12✔
NEW
129
                return
×
NEW
130
        }
×
131

132
        // enumerate project AZ resources
133
        filter := reports.ReadFilter(r, p.Cluster, serviceInfos)
12✔
134
        queryStr, joinArgs := filter.PrepareQuery(getProjectAZResourceLocationsQuery)
12✔
135
        whereStr, whereArgs := db.BuildSimpleWhereClause(map[string]any{"ps.project_id": dbProject.ID}, len(joinArgs))
12✔
136
        azResourceLocationsByID := make(map[db.ProjectAZResourceID]core.AZResourceLocation)
12✔
137
        err = sqlext.ForeachRow(p.DB, fmt.Sprintf(queryStr, whereStr), append(joinArgs, whereArgs...), func(rows *sql.Rows) error {
137✔
138
                var (
125✔
139
                        id  db.ProjectAZResourceID
125✔
140
                        loc core.AZResourceLocation
125✔
141
                )
125✔
142
                err := rows.Scan(&id, &loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
125✔
143
                if err != nil {
125✔
144
                        return err
×
145
                }
×
146
                // this check is defense in depth (the DB should be consistent with our config)
147
                if core.HasResource(serviceInfos, loc.ServiceType, loc.ResourceName) {
223✔
148
                        azResourceLocationsByID[id] = loc
98✔
149
                }
98✔
150
                return nil
125✔
151
        })
152
        if respondwith.ErrorText(w, err) {
12✔
153
                return
×
154
        }
×
155

156
        // enumerate relevant project commitments
157
        queryStr, joinArgs = filter.PrepareQuery(getProjectCommitmentsQuery)
12✔
158
        whereStr, whereArgs = db.BuildSimpleWhereClause(map[string]any{"ps.project_id": dbProject.ID}, len(joinArgs))
12✔
159
        var dbCommitments []db.ProjectCommitment
12✔
160
        _, err = p.DB.Select(&dbCommitments, fmt.Sprintf(queryStr, whereStr), append(joinArgs, whereArgs...)...)
12✔
161
        if respondwith.ErrorText(w, err) {
12✔
162
                return
×
163
        }
×
164

165
        // render response
166
        result := make([]limesresources.Commitment, 0, len(dbCommitments))
12✔
167
        for _, c := range dbCommitments {
26✔
168
                loc, exists := azResourceLocationsByID[c.AZResourceID]
14✔
169
                if !exists {
14✔
170
                        // defense in depth (the DB should not change that much between those two queries above)
×
171
                        continue
×
172
                }
173
                resInfo := core.InfoForResource(serviceInfos, loc.ServiceType, loc.ResourceName)
14✔
174
                result = append(result, p.convertCommitmentToDisplayForm(c, loc, token, resInfo.Unit))
14✔
175
        }
176

177
        respondwith.JSON(w, http.StatusOK, map[string]any{"commitments": result})
12✔
178
}
179

180
func (p *v1Provider) convertCommitmentToDisplayForm(c db.ProjectCommitment, loc core.AZResourceLocation, token *gopherpolicy.Token, unit limes.Unit) limesresources.Commitment {
59✔
181
        apiIdentity := p.Cluster.BehaviorForResource(loc.ServiceType, loc.ResourceName).IdentityInV1API
59✔
182
        return limesresources.Commitment{
59✔
183
                ID:               int64(c.ID),
59✔
184
                ServiceType:      apiIdentity.ServiceType,
59✔
185
                ResourceName:     apiIdentity.Name,
59✔
186
                AvailabilityZone: loc.AvailabilityZone,
59✔
187
                Amount:           c.Amount,
59✔
188
                Unit:             unit,
59✔
189
                Duration:         c.Duration,
59✔
190
                CreatedAt:        limes.UnixEncodedTime{Time: c.CreatedAt},
59✔
191
                CreatorUUID:      c.CreatorUUID,
59✔
192
                CreatorName:      c.CreatorName,
59✔
193
                CanBeDeleted:     p.canDeleteCommitment(token, c),
59✔
194
                ConfirmBy:        options.Map(c.ConfirmBy, intoUnixEncodedTime).AsPointer(),
59✔
195
                ConfirmedAt:      options.Map(c.ConfirmedAt, intoUnixEncodedTime).AsPointer(),
59✔
196
                ExpiresAt:        limes.UnixEncodedTime{Time: c.ExpiresAt},
59✔
197
                TransferStatus:   c.TransferStatus,
59✔
198
                TransferToken:    c.TransferToken.AsPointer(),
59✔
199
                NotifyOnConfirm:  c.NotifyOnConfirm,
59✔
200
                WasRenewed:       c.RenewContextJSON.IsSome(),
59✔
201
        }
59✔
202
}
59✔
203

204
// parseAndValidateCommitmentRequest parses and validates the request body for a commitment creation or confirmation.
205
// This function in its current form should only be used if the serviceInfo is not necessary to be used outside
206
// of this validation to avoid unnecessary database queries.
207
func (p *v1Provider) parseAndValidateCommitmentRequest(w http.ResponseWriter, r *http.Request, dbDomain db.Domain) (*limesresources.CommitmentRequest, *core.AZResourceLocation, *core.ScopedCommitmentBehavior) {
46✔
208
        // parse request
46✔
209
        var parseTarget struct {
46✔
210
                Request limesresources.CommitmentRequest `json:"commitment"`
46✔
211
        }
46✔
212
        if !RequireJSON(w, r, &parseTarget) {
47✔
213
                return nil, nil, nil
1✔
214
        }
1✔
215
        req := parseTarget.Request
45✔
216

45✔
217
        // validate request
45✔
218
        serviceInfos, err := p.Cluster.AllServiceInfos()
45✔
219
        if err != nil {
45✔
NEW
220
                msg := "db connection error"
×
NEW
221
                http.Error(w, msg, http.StatusInternalServerError)
×
NEW
222
                return nil, nil, nil
×
NEW
223
        }
×
224
        nm := core.BuildResourceNameMapping(p.Cluster, serviceInfos)
45✔
225
        dbServiceType, dbResourceName, ok := nm.MapFromV1API(req.ServiceType, req.ResourceName)
45✔
226
        if !ok {
47✔
227
                msg := fmt.Sprintf("no such service and/or resource: %s/%s", req.ServiceType, req.ResourceName)
2✔
228
                http.Error(w, msg, http.StatusUnprocessableEntity)
2✔
229
                return nil, nil, nil
2✔
230
        }
2✔
231
        behavior := p.Cluster.CommitmentBehaviorForResource(dbServiceType, dbResourceName).ForDomain(dbDomain.Name)
43✔
232
        resInfo := core.InfoForResource(serviceInfos, dbServiceType, dbResourceName)
43✔
233
        if len(behavior.Durations) == 0 {
44✔
234
                http.Error(w, "commitments are not enabled for this resource", http.StatusUnprocessableEntity)
1✔
235
                return nil, nil, nil
1✔
236
        }
1✔
237
        if resInfo.Topology == liquid.FlatTopology {
45✔
238
                if req.AvailabilityZone != limes.AvailabilityZoneAny {
4✔
239
                        http.Error(w, `resource does not accept AZ-aware commitments, so the AZ must be set to "any"`, http.StatusUnprocessableEntity)
1✔
240
                        return nil, nil, nil
1✔
241
                }
1✔
242
        } else {
39✔
243
                if !slices.Contains(p.Cluster.Config.AvailabilityZones, req.AvailabilityZone) {
43✔
244
                        http.Error(w, "no such availability zone", http.StatusUnprocessableEntity)
4✔
245
                        return nil, nil, nil
4✔
246
                }
4✔
247
        }
248
        if !slices.Contains(behavior.Durations, req.Duration) {
38✔
249
                buf := must.Return(json.Marshal(behavior.Durations)) // panic on error is acceptable here, marshals should never fail
1✔
250
                msg := "unacceptable commitment duration for this resource, acceptable values: " + string(buf)
1✔
251
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
252
                return nil, nil, nil
1✔
253
        }
1✔
254
        if req.Amount == 0 {
37✔
255
                http.Error(w, "amount of committed resource must be greater than zero", http.StatusUnprocessableEntity)
1✔
256
                return nil, nil, nil
1✔
257
        }
1✔
258

259
        loc := core.AZResourceLocation{
35✔
260
                ServiceType:      dbServiceType,
35✔
261
                ResourceName:     dbResourceName,
35✔
262
                AvailabilityZone: req.AvailabilityZone,
35✔
263
        }
35✔
264
        return &req, &loc, &behavior
35✔
265
}
266

267
// CanConfirmNewProjectCommitment handles POST /v1/domains/:domain_id/projects/:project_id/commitments/can-confirm.
268
func (p *v1Provider) CanConfirmNewProjectCommitment(w http.ResponseWriter, r *http.Request) {
7✔
269
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/can-confirm")
7✔
270
        token := p.CheckToken(r)
7✔
271
        if !token.Require(w, "project:edit") {
7✔
272
                return
×
273
        }
×
274
        dbDomain := p.FindDomainFromRequest(w, r)
7✔
275
        if dbDomain == nil {
7✔
276
                return
×
277
        }
×
278
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
7✔
279
        if dbProject == nil {
7✔
280
                return
×
281
        }
×
282
        req, loc, behavior := p.parseAndValidateCommitmentRequest(w, r, *dbDomain)
7✔
283
        if req == nil {
7✔
284
                return
×
285
        }
×
286

287
        var (
7✔
288
                resourceID                db.ProjectResourceID
7✔
289
                azResourceID              db.ProjectAZResourceID
7✔
290
                resourceAllowsCommitments bool
7✔
291
        )
7✔
292
        err := p.DB.QueryRow(findProjectAZResourceIDByLocationQuery, dbProject.ID, loc.ServiceType, loc.ResourceName, loc.AvailabilityZone).
7✔
293
                Scan(&resourceID, &azResourceID, &resourceAllowsCommitments)
7✔
294
        if respondwith.ErrorText(w, err) {
7✔
295
                return
×
296
        }
×
297
        if !resourceAllowsCommitments {
7✔
298
                msg := fmt.Sprintf("resource %s/%s is not enabled in this project", req.ServiceType, req.ResourceName)
×
299
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
300
                return
×
301
        }
×
302
        _ = azResourceID // returned by the above query, but not used in this function
7✔
303

7✔
304
        // commitments can never be confirmed immediately if we are before the min_confirm_date
7✔
305
        now := p.timeNow()
7✔
306
        if !behavior.CanConfirmCommitmentsAt(now) {
8✔
307
                respondwith.JSON(w, http.StatusOK, map[string]bool{"result": false})
1✔
308
                return
1✔
309
        }
1✔
310

311
        // check for committable capacity
312
        result, err := datamodel.CanConfirmNewCommitment(*loc, resourceID, req.Amount, p.Cluster, p.DB)
6✔
313
        if respondwith.ErrorText(w, err) {
6✔
314
                return
×
315
        }
×
316
        respondwith.JSON(w, http.StatusOK, map[string]bool{"result": result})
6✔
317
}
318

319
// CreateProjectCommitment handles POST /v1/domains/:domain_id/projects/:project_id/commitments/new.
320
func (p *v1Provider) CreateProjectCommitment(w http.ResponseWriter, r *http.Request) {
42✔
321
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/new")
42✔
322
        token := p.CheckToken(r)
42✔
323
        if !token.Require(w, "project:edit") {
43✔
324
                return
1✔
325
        }
1✔
326
        dbDomain := p.FindDomainFromRequest(w, r)
41✔
327
        if dbDomain == nil {
42✔
328
                return
1✔
329
        }
1✔
330
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
40✔
331
        if dbProject == nil {
41✔
332
                return
1✔
333
        }
1✔
334
        req, loc, behavior := p.parseAndValidateCommitmentRequest(w, r, *dbDomain)
39✔
335
        if req == nil {
50✔
336
                return
11✔
337
        }
11✔
338

339
        var (
28✔
340
                resourceID                db.ProjectResourceID
28✔
341
                azResourceID              db.ProjectAZResourceID
28✔
342
                resourceAllowsCommitments bool
28✔
343
        )
28✔
344
        err := p.DB.QueryRow(findProjectAZResourceIDByLocationQuery, dbProject.ID, loc.ServiceType, loc.ResourceName, loc.AvailabilityZone).
28✔
345
                Scan(&resourceID, &azResourceID, &resourceAllowsCommitments)
28✔
346
        if respondwith.ErrorText(w, err) {
28✔
347
                return
×
348
        }
×
349
        if !resourceAllowsCommitments {
29✔
350
                msg := fmt.Sprintf("resource %s/%s is not enabled in this project", req.ServiceType, req.ResourceName)
1✔
351
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
352
                return
1✔
353
        }
1✔
354

355
        // if given, confirm_by must definitely after time.Now(), and also after the MinConfirmDate if configured
356
        now := p.timeNow()
27✔
357
        if req.ConfirmBy != nil && req.ConfirmBy.Before(now) {
28✔
358
                http.Error(w, "confirm_by must not be set in the past", http.StatusUnprocessableEntity)
1✔
359
                return
1✔
360
        }
1✔
361
        if minConfirmBy, ok := behavior.MinConfirmDate.Unpack(); ok && minConfirmBy.After(now) {
31✔
362
                if req.ConfirmBy == nil || req.ConfirmBy.Before(minConfirmBy) {
6✔
363
                        msg := "this commitment needs a `confirm_by` timestamp at or after " + minConfirmBy.Format(time.RFC3339)
1✔
364
                        http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
365
                        return
1✔
366
                }
1✔
367
        }
368

369
        // we want to validate committable capacity in the same transaction that creates the commitment
370
        tx, err := p.DB.Begin()
25✔
371
        if respondwith.ErrorText(w, err) {
25✔
372
                return
×
373
        }
×
374
        defer sqlext.RollbackUnlessCommitted(tx)
25✔
375

25✔
376
        // prepare commitment
25✔
377
        confirmBy := options.Map(options.FromPointer(req.ConfirmBy), fromUnixEncodedTime)
25✔
378
        creationContext := db.CommitmentWorkflowContext{Reason: db.CommitmentReasonCreate}
25✔
379
        buf, err := json.Marshal(creationContext)
25✔
380
        if respondwith.ErrorText(w, err) {
25✔
381
                return
×
382
        }
×
383
        dbCommitment := db.ProjectCommitment{
25✔
384
                AZResourceID:        azResourceID,
25✔
385
                Amount:              req.Amount,
25✔
386
                Duration:            req.Duration,
25✔
387
                CreatedAt:           now,
25✔
388
                CreatorUUID:         token.UserUUID(),
25✔
389
                CreatorName:         fmt.Sprintf("%s@%s", token.UserName(), token.UserDomainName()),
25✔
390
                ConfirmBy:           confirmBy,
25✔
391
                ConfirmedAt:         None[time.Time](), // may be set below
25✔
392
                ExpiresAt:           req.Duration.AddTo(confirmBy.UnwrapOr(now)),
25✔
393
                CreationContextJSON: json.RawMessage(buf),
25✔
394
        }
25✔
395
        if req.NotifyOnConfirm && req.ConfirmBy == nil {
26✔
396
                http.Error(w, "notification on confirm cannot be set for commitments with immediate confirmation", http.StatusConflict)
1✔
397
                return
1✔
398
        }
1✔
399
        dbCommitment.NotifyOnConfirm = req.NotifyOnConfirm
24✔
400

24✔
401
        if req.ConfirmBy == nil {
42✔
402
                // if not planned for confirmation in the future, confirm immediately (or fail)
18✔
403
                ok, err := datamodel.CanConfirmNewCommitment(*loc, resourceID, req.Amount, p.Cluster, tx)
18✔
404
                if respondwith.ErrorText(w, err) {
18✔
405
                        return
×
406
                }
×
407
                if !ok {
18✔
408
                        http.Error(w, "not enough capacity available for immediate confirmation", http.StatusConflict)
×
409
                        return
×
410
                }
×
411
                dbCommitment.ConfirmedAt = Some(now)
18✔
412
                dbCommitment.State = db.CommitmentStateActive
18✔
413
        } else {
6✔
414
                dbCommitment.State = db.CommitmentStatePlanned
6✔
415
        }
6✔
416

417
        // create commitment
418
        err = tx.Insert(&dbCommitment)
24✔
419
        if respondwith.ErrorText(w, err) {
24✔
420
                return
×
421
        }
×
422
        err = tx.Commit()
24✔
423
        if respondwith.ErrorText(w, err) {
24✔
424
                return
×
425
        }
×
426
        serviceInfos, err := p.Cluster.InfoForService(loc.ServiceType)
24✔
427
        if respondwith.ErrorText(w, err) {
24✔
NEW
428
                return
×
NEW
429
        }
×
430
        resourceInfo := core.InfoForResource(serviceInfos, loc.ServiceType, loc.ResourceName)
24✔
431
        commitment := p.convertCommitmentToDisplayForm(dbCommitment, *loc, token, resourceInfo.Unit)
24✔
432
        p.auditor.Record(audittools.Event{
24✔
433
                Time:       now,
24✔
434
                Request:    r,
24✔
435
                User:       token,
24✔
436
                ReasonCode: http.StatusCreated,
24✔
437
                Action:     cadf.CreateAction,
24✔
438
                Target: commitmentEventTarget{
24✔
439
                        DomainID:        dbDomain.UUID,
24✔
440
                        DomainName:      dbDomain.Name,
24✔
441
                        ProjectID:       dbProject.UUID,
24✔
442
                        ProjectName:     dbProject.Name,
24✔
443
                        Commitments:     []limesresources.Commitment{commitment},
24✔
444
                        WorkflowContext: Some(creationContext),
24✔
445
                },
24✔
446
        })
24✔
447

24✔
448
        // if the commitment is immediately confirmed, trigger a capacity scrape in
24✔
449
        // order to ApplyComputedProjectQuotas based on the new commitment
24✔
450
        if dbCommitment.ConfirmedAt.IsSome() {
42✔
451
                _, err := p.DB.Exec(`UPDATE cluster_services SET next_scrape_at = $1 WHERE type = $2`, now, loc.ServiceType)
18✔
452
                if respondwith.ErrorText(w, err) {
18✔
453
                        return
×
454
                }
×
455
        }
456

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

463
        respondwith.JSON(w, http.StatusCreated, map[string]any{"commitment": commitment})
24✔
464
}
465

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

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

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

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

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

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

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

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

1✔
569
        // Insert into database
1✔
570
        err = tx.Insert(&dbMergedCommitment)
1✔
571
        if respondwith.ErrorText(w, err) {
1✔
572
                return
×
573
        }
×
574

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

594
        err = tx.Commit()
1✔
595
        if respondwith.ErrorText(w, err) {
1✔
596
                return
×
597
        }
×
598

599
        serviceInfos, err := p.Cluster.InfoForService(loc.ServiceType)
1✔
600
        if respondwith.ErrorText(w, err) {
1✔
NEW
601
                return
×
NEW
602
        }
×
603
        resourceInfo := core.InfoForResource(serviceInfos, loc.ServiceType, loc.ResourceName)
1✔
604

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

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

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

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

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

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

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

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

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

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

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

719
        renewContext := db.CommitmentWorkflowContext{
2✔
720
                Reason:               db.CommitmentReasonRenew,
2✔
721
                RelatedCommitmentIDs: []db.ProjectCommitmentID{dbRenewedCommitment.ID},
2✔
722
        }
2✔
723
        buf, err = json.Marshal(renewContext)
2✔
724
        if respondwith.ErrorText(w, err) {
2✔
725
                return
×
726
        }
×
727
        dbCommitment.RenewContextJSON = Some(json.RawMessage(buf))
2✔
728
        _, err = tx.Update(&dbCommitment)
2✔
729
        if respondwith.ErrorText(w, err) {
2✔
730
                return
×
731
        }
×
732

733
        err = tx.Commit()
2✔
734
        if respondwith.ErrorText(w, err) {
2✔
735
                return
×
736
        }
×
737

738
        serviceInfos, err := p.Cluster.InfoForService(loc.ServiceType)
2✔
739
        if respondwith.ErrorText(w, err) {
2✔
NEW
740
                return
×
NEW
741
        }
×
742
        resourceInfo := core.InfoForResource(serviceInfos, loc.ServiceType, loc.ResourceName)
2✔
743

2✔
744
        // Create resultset and auditlogs
2✔
745
        c := p.convertCommitmentToDisplayForm(dbRenewedCommitment, loc, token, resourceInfo.Unit)
2✔
746
        auditEvent := commitmentEventTarget{
2✔
747
                DomainID:        dbDomain.UUID,
2✔
748
                DomainName:      dbDomain.Name,
2✔
749
                ProjectID:       dbProject.UUID,
2✔
750
                ProjectName:     dbProject.Name,
2✔
751
                Commitments:     []limesresources.Commitment{c},
2✔
752
                WorkflowContext: Some(creationContext),
2✔
753
        }
2✔
754

2✔
755
        p.auditor.Record(audittools.Event{
2✔
756
                Time:       p.timeNow(),
2✔
757
                Request:    r,
2✔
758
                User:       token,
2✔
759
                ReasonCode: http.StatusAccepted,
2✔
760
                Action:     cadf.UpdateAction,
2✔
761
                Target:     auditEvent,
2✔
762
        })
2✔
763

2✔
764
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
765
}
766

767
// DeleteProjectCommitment handles DELETE /v1/domains/:domain_id/projects/:project_id/commitments/:id.
768
func (p *v1Provider) DeleteProjectCommitment(w http.ResponseWriter, r *http.Request) {
8✔
769
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id")
8✔
770
        token := p.CheckToken(r)
8✔
771
        if !token.Require(w, "project:edit") { // NOTE: There is a more specific AuthZ check further down below.
8✔
772
                return
×
773
        }
×
774
        dbDomain := p.FindDomainFromRequest(w, r)
8✔
775
        if dbDomain == nil {
9✔
776
                return
1✔
777
        }
1✔
778
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
7✔
779
        if dbProject == nil {
8✔
780
                return
1✔
781
        }
1✔
782

783
        // load commitment
784
        var dbCommitment db.ProjectCommitment
6✔
785
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
6✔
786
        if errors.Is(err, sql.ErrNoRows) {
7✔
787
                http.Error(w, "no such commitment", http.StatusNotFound)
1✔
788
                return
1✔
789
        } else if respondwith.ErrorText(w, err) {
6✔
790
                return
×
791
        }
×
792
        var loc core.AZResourceLocation
5✔
793
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
5✔
794
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
5✔
795
        if errors.Is(err, sql.ErrNoRows) {
5✔
796
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
797
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
798
                return
×
799
        } else if respondwith.ErrorText(w, err) {
5✔
800
                return
×
801
        }
×
802

803
        // check authorization for this specific commitment
804
        if !p.canDeleteCommitment(token, dbCommitment) {
6✔
805
                http.Error(w, "Forbidden", http.StatusForbidden)
1✔
806
                return
1✔
807
        }
1✔
808

809
        // perform deletion
810
        _, err = p.DB.Delete(&dbCommitment)
4✔
811
        if respondwith.ErrorText(w, err) {
4✔
812
                return
×
813
        }
×
814
        serviceInfos, err := p.Cluster.InfoForService(loc.ServiceType)
4✔
815
        if respondwith.ErrorText(w, err) {
4✔
NEW
816
                return
×
NEW
817
        }
×
818
        resourceInfo := core.InfoForResource(serviceInfos, loc.ServiceType, loc.ResourceName)
4✔
819
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
4✔
820
        p.auditor.Record(audittools.Event{
4✔
821
                Time:       p.timeNow(),
4✔
822
                Request:    r,
4✔
823
                User:       token,
4✔
824
                ReasonCode: http.StatusNoContent,
4✔
825
                Action:     cadf.DeleteAction,
4✔
826
                Target: commitmentEventTarget{
4✔
827
                        DomainID:    dbDomain.UUID,
4✔
828
                        DomainName:  dbDomain.Name,
4✔
829
                        ProjectID:   dbProject.UUID,
4✔
830
                        ProjectName: dbProject.Name,
4✔
831
                        Commitments: []limesresources.Commitment{c},
4✔
832
                },
4✔
833
        })
4✔
834

4✔
835
        w.WriteHeader(http.StatusNoContent)
4✔
836
}
837

838
func (p *v1Provider) canDeleteCommitment(token *gopherpolicy.Token, commitment db.ProjectCommitment) bool {
64✔
839
        // up to 24 hours after creation of fresh commitments, future commitments can still be deleted by their creators
64✔
840
        if commitment.State == db.CommitmentStatePlanned || commitment.State == db.CommitmentStatePending || commitment.State == db.CommitmentStateActive {
128✔
841
                var creationContext db.CommitmentWorkflowContext
64✔
842
                err := json.Unmarshal(commitment.CreationContextJSON, &creationContext)
64✔
843
                if err == nil && creationContext.Reason == db.CommitmentReasonCreate && p.timeNow().Before(commitment.CreatedAt.Add(24*time.Hour)) {
104✔
844
                        if token.Check("project:edit") {
80✔
845
                                return true
40✔
846
                        }
40✔
847
                }
848
        }
849

850
        // afterwards, a more specific permission is required to delete it
851
        //
852
        // This protects cloud admins making capacity planning decisions based on future commitments
853
        // from having their forecasts ruined by project admins suffering from buyer's remorse.
854
        return token.Check("project:uncommit")
24✔
855
}
856

857
// StartCommitmentTransfer handles POST /v1/domains/:id/projects/:id/commitments/:id/start-transfer
858
func (p *v1Provider) StartCommitmentTransfer(w http.ResponseWriter, r *http.Request) {
8✔
859
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/commitments/:id/start-transfer")
8✔
860
        token := p.CheckToken(r)
8✔
861
        if !token.Require(w, "project:edit") {
8✔
862
                return
×
863
        }
×
864
        dbDomain := p.FindDomainFromRequest(w, r)
8✔
865
        if dbDomain == nil {
8✔
866
                return
×
867
        }
×
868
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
8✔
869
        if dbProject == nil {
8✔
870
                return
×
871
        }
×
872
        // TODO: eventually migrate this struct into go-api-declarations
873
        var parseTarget struct {
8✔
874
                Request struct {
8✔
875
                        Amount         uint64                                  `json:"amount"`
8✔
876
                        TransferStatus limesresources.CommitmentTransferStatus `json:"transfer_status,omitempty"`
8✔
877
                } `json:"commitment"`
8✔
878
        }
8✔
879
        if !RequireJSON(w, r, &parseTarget) {
8✔
880
                return
×
881
        }
×
882
        req := parseTarget.Request
8✔
883

8✔
884
        if req.TransferStatus != limesresources.CommitmentTransferStatusUnlisted && req.TransferStatus != limesresources.CommitmentTransferStatusPublic {
8✔
885
                http.Error(w, fmt.Sprintf("Invalid transfer_status code. Must be %s or %s.", limesresources.CommitmentTransferStatusUnlisted, limesresources.CommitmentTransferStatusPublic), http.StatusBadRequest)
×
886
                return
×
887
        }
×
888

889
        if req.Amount <= 0 {
9✔
890
                http.Error(w, "delivered amount needs to be a positive value.", http.StatusBadRequest)
1✔
891
                return
1✔
892
        }
1✔
893

894
        // load commitment
895
        var dbCommitment db.ProjectCommitment
7✔
896
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, mux.Vars(r)["id"], dbProject.ID)
7✔
897
        if errors.Is(err, sql.ErrNoRows) {
7✔
898
                http.Error(w, "no such commitment", http.StatusNotFound)
×
899
                return
×
900
        } else if respondwith.ErrorText(w, err) {
7✔
901
                return
×
902
        }
×
903

904
        // Mark whole commitment or a newly created, splitted one as transferrable.
905
        tx, err := p.DB.Begin()
7✔
906
        if respondwith.ErrorText(w, err) {
7✔
907
                return
×
908
        }
×
909
        defer sqlext.RollbackUnlessCommitted(tx)
7✔
910
        transferToken := p.generateTransferToken()
7✔
911

7✔
912
        // Deny requests with a greater amount than the commitment.
7✔
913
        if req.Amount > dbCommitment.Amount {
8✔
914
                http.Error(w, "delivered amount exceeds the commitment amount.", http.StatusBadRequest)
1✔
915
                return
1✔
916
        }
1✔
917

918
        if req.Amount == dbCommitment.Amount {
10✔
919
                dbCommitment.TransferStatus = req.TransferStatus
4✔
920
                dbCommitment.TransferToken = Some(transferToken)
4✔
921
                _, err = tx.Update(&dbCommitment)
4✔
922
                if respondwith.ErrorText(w, err) {
4✔
923
                        return
×
924
                }
×
925
        } else {
2✔
926
                now := p.timeNow()
2✔
927
                transferAmount := req.Amount
2✔
928
                remainingAmount := dbCommitment.Amount - req.Amount
2✔
929
                transferCommitment, err := p.buildSplitCommitment(dbCommitment, transferAmount)
2✔
930
                if respondwith.ErrorText(w, err) {
2✔
931
                        return
×
932
                }
×
933
                transferCommitment.TransferStatus = req.TransferStatus
2✔
934
                transferCommitment.TransferToken = Some(transferToken)
2✔
935
                remainingCommitment, err := p.buildSplitCommitment(dbCommitment, remainingAmount)
2✔
936
                if respondwith.ErrorText(w, err) {
2✔
937
                        return
×
938
                }
×
939
                err = tx.Insert(&transferCommitment)
2✔
940
                if respondwith.ErrorText(w, err) {
2✔
941
                        return
×
942
                }
×
943
                err = tx.Insert(&remainingCommitment)
2✔
944
                if respondwith.ErrorText(w, err) {
2✔
945
                        return
×
946
                }
×
947
                supersedeContext := db.CommitmentWorkflowContext{
2✔
948
                        Reason:               db.CommitmentReasonSplit,
2✔
949
                        RelatedCommitmentIDs: []db.ProjectCommitmentID{transferCommitment.ID, remainingCommitment.ID},
2✔
950
                }
2✔
951
                buf, err := json.Marshal(supersedeContext)
2✔
952
                if respondwith.ErrorText(w, err) {
2✔
953
                        return
×
954
                }
×
955
                dbCommitment.State = db.CommitmentStateSuperseded
2✔
956
                dbCommitment.SupersededAt = Some(now)
2✔
957
                dbCommitment.SupersedeContextJSON = Some(json.RawMessage(buf))
2✔
958
                _, err = tx.Update(&dbCommitment)
2✔
959
                if respondwith.ErrorText(w, err) {
2✔
960
                        return
×
961
                }
×
962
                dbCommitment = transferCommitment
2✔
963
        }
964
        err = tx.Commit()
6✔
965
        if respondwith.ErrorText(w, err) {
6✔
966
                return
×
967
        }
×
968

969
        var loc core.AZResourceLocation
6✔
970
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
6✔
971
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
6✔
972
        if errors.Is(err, sql.ErrNoRows) {
6✔
973
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
974
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
975
                return
×
976
        } else if respondwith.ErrorText(w, err) {
6✔
977
                return
×
978
        }
×
979

980
        serviceInfos, err := p.Cluster.InfoForService(loc.ServiceType)
6✔
981
        if respondwith.ErrorText(w, err) {
6✔
NEW
982
                return
×
NEW
983
        }
×
984
        resourceInfo := core.InfoForResource(serviceInfos, loc.ServiceType, loc.ResourceName)
6✔
985
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
6✔
986
        if respondwith.ErrorText(w, err) {
6✔
NEW
987
                return
×
NEW
988
        }
×
989
        p.auditor.Record(audittools.Event{
6✔
990
                Time:       p.timeNow(),
6✔
991
                Request:    r,
6✔
992
                User:       token,
6✔
993
                ReasonCode: http.StatusAccepted,
6✔
994
                Action:     cadf.UpdateAction,
6✔
995
                Target: commitmentEventTarget{
6✔
996
                        DomainID:    dbDomain.UUID,
6✔
997
                        DomainName:  dbDomain.Name,
6✔
998
                        ProjectID:   dbProject.UUID,
6✔
999
                        ProjectName: dbProject.Name,
6✔
1000
                        Commitments: []limesresources.Commitment{c},
6✔
1001
                },
6✔
1002
        })
6✔
1003
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
6✔
1004
}
1005

1006
func (p *v1Provider) buildSplitCommitment(dbCommitment db.ProjectCommitment, amount uint64) (db.ProjectCommitment, error) {
5✔
1007
        now := p.timeNow()
5✔
1008
        creationContext := db.CommitmentWorkflowContext{
5✔
1009
                Reason:               db.CommitmentReasonSplit,
5✔
1010
                RelatedCommitmentIDs: []db.ProjectCommitmentID{dbCommitment.ID},
5✔
1011
        }
5✔
1012
        buf, err := json.Marshal(creationContext)
5✔
1013
        if err != nil {
5✔
1014
                return db.ProjectCommitment{}, err
×
1015
        }
×
1016
        return db.ProjectCommitment{
5✔
1017
                AZResourceID:        dbCommitment.AZResourceID,
5✔
1018
                Amount:              amount,
5✔
1019
                Duration:            dbCommitment.Duration,
5✔
1020
                CreatedAt:           now,
5✔
1021
                CreatorUUID:         dbCommitment.CreatorUUID,
5✔
1022
                CreatorName:         dbCommitment.CreatorName,
5✔
1023
                ConfirmBy:           dbCommitment.ConfirmBy,
5✔
1024
                ConfirmedAt:         dbCommitment.ConfirmedAt,
5✔
1025
                ExpiresAt:           dbCommitment.ExpiresAt,
5✔
1026
                CreationContextJSON: json.RawMessage(buf),
5✔
1027
                State:               dbCommitment.State,
5✔
1028
        }, nil
5✔
1029
}
1030

1031
func (p *v1Provider) buildConvertedCommitment(dbCommitment db.ProjectCommitment, azResourceID db.ProjectAZResourceID, amount uint64) (db.ProjectCommitment, error) {
2✔
1032
        now := p.timeNow()
2✔
1033
        creationContext := db.CommitmentWorkflowContext{
2✔
1034
                Reason:               db.CommitmentReasonConvert,
2✔
1035
                RelatedCommitmentIDs: []db.ProjectCommitmentID{dbCommitment.ID},
2✔
1036
        }
2✔
1037
        buf, err := json.Marshal(creationContext)
2✔
1038
        if err != nil {
2✔
1039
                return db.ProjectCommitment{}, err
×
1040
        }
×
1041
        return db.ProjectCommitment{
2✔
1042
                AZResourceID:        azResourceID,
2✔
1043
                Amount:              amount,
2✔
1044
                Duration:            dbCommitment.Duration,
2✔
1045
                CreatedAt:           now,
2✔
1046
                CreatorUUID:         dbCommitment.CreatorUUID,
2✔
1047
                CreatorName:         dbCommitment.CreatorName,
2✔
1048
                ConfirmBy:           dbCommitment.ConfirmBy,
2✔
1049
                ConfirmedAt:         dbCommitment.ConfirmedAt,
2✔
1050
                ExpiresAt:           dbCommitment.ExpiresAt,
2✔
1051
                CreationContextJSON: json.RawMessage(buf),
2✔
1052
                State:               dbCommitment.State,
2✔
1053
        }, nil
2✔
1054
}
1055

1056
// GetCommitmentByTransferToken handles GET /v1/commitments/{token}
1057
func (p *v1Provider) GetCommitmentByTransferToken(w http.ResponseWriter, r *http.Request) {
2✔
1058
        httpapi.IdentifyEndpoint(r, "/v1/commitments/:token")
2✔
1059
        token := p.CheckToken(r)
2✔
1060
        if !token.Require(w, "cluster:show_basic") {
2✔
1061
                return
×
1062
        }
×
1063
        transferToken := mux.Vars(r)["token"]
2✔
1064

2✔
1065
        // The token column is a unique key, so we expect only one result.
2✔
1066
        var dbCommitment db.ProjectCommitment
2✔
1067
        err := p.DB.SelectOne(&dbCommitment, findCommitmentByTransferToken, transferToken)
2✔
1068
        if errors.Is(err, sql.ErrNoRows) {
3✔
1069
                http.Error(w, "no matching commitment found.", http.StatusNotFound)
1✔
1070
                return
1✔
1071
        } else if respondwith.ErrorText(w, err) {
2✔
1072
                return
×
1073
        }
×
1074

1075
        var loc core.AZResourceLocation
1✔
1076
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
1✔
1077
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
1✔
1078
        if errors.Is(err, sql.ErrNoRows) {
1✔
1079
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1080
                http.Error(w, "location data not found.", http.StatusNotFound)
×
1081
                return
×
1082
        } else if respondwith.ErrorText(w, err) {
1✔
1083
                return
×
1084
        }
×
1085

1086
        serviceInfos, err := p.Cluster.InfoForService(loc.ServiceType)
1✔
1087
        if respondwith.ErrorText(w, err) {
1✔
NEW
1088
                return
×
NEW
1089
        }
×
1090
        resourceInfo := core.InfoForResource(serviceInfos, loc.ServiceType, loc.ResourceName)
1✔
1091
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
1✔
1092
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
1✔
1093
}
1094

1095
// TransferCommitment handles POST /v1/domains/{domain_id}/projects/{project_id}/transfer-commitment/{id}?token={token}
1096
func (p *v1Provider) TransferCommitment(w http.ResponseWriter, r *http.Request) {
5✔
1097
        httpapi.IdentifyEndpoint(r, "/v1/domains/:id/projects/:id/transfer-commitment/:id")
5✔
1098
        token := p.CheckToken(r)
5✔
1099
        if !token.Require(w, "project:edit") {
5✔
1100
                return
×
1101
        }
×
1102
        transferToken := r.Header.Get("Transfer-Token")
5✔
1103
        if transferToken == "" {
6✔
1104
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
1✔
1105
                return
1✔
1106
        }
1✔
1107
        commitmentID := mux.Vars(r)["id"]
4✔
1108
        if commitmentID == "" {
4✔
1109
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
1110
                return
×
1111
        }
×
1112
        dbDomain := p.FindDomainFromRequest(w, r)
4✔
1113
        if dbDomain == nil {
4✔
1114
                return
×
1115
        }
×
1116
        targetProject := p.FindProjectFromRequest(w, r, dbDomain)
4✔
1117
        if targetProject == nil {
4✔
1118
                return
×
1119
        }
×
1120

1121
        // find commitment by transfer_token
1122
        var dbCommitment db.ProjectCommitment
4✔
1123
        err := p.DB.SelectOne(&dbCommitment, getCommitmentWithMatchingTransferTokenQuery, commitmentID, transferToken)
4✔
1124
        if errors.Is(err, sql.ErrNoRows) {
5✔
1125
                http.Error(w, "no matching commitment found", http.StatusNotFound)
1✔
1126
                return
1✔
1127
        } else if respondwith.ErrorText(w, err) {
4✔
1128
                return
×
1129
        }
×
1130

1131
        var loc core.AZResourceLocation
3✔
1132
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
3✔
1133
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
3✔
1134
        if errors.Is(err, sql.ErrNoRows) {
3✔
1135
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1136
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
1137
                return
×
1138
        } else if respondwith.ErrorText(w, err) {
3✔
1139
                return
×
1140
        }
×
1141

1142
        // get target service and AZ resource
1143
        var (
3✔
1144
                sourceResourceID   db.ProjectResourceID
3✔
1145
                targetResourceID   db.ProjectResourceID
3✔
1146
                targetAZResourceID db.ProjectAZResourceID
3✔
1147
        )
3✔
1148
        err = p.DB.QueryRow(findTargetAZResourceIDBySourceIDQuery, dbCommitment.AZResourceID, targetProject.ID).
3✔
1149
                Scan(&sourceResourceID, &targetResourceID, &targetAZResourceID)
3✔
1150
        if respondwith.ErrorText(w, err) {
3✔
1151
                return
×
1152
        }
×
1153

1154
        // validate that we have enough committable capacity on the receiving side
1155
        tx, err := p.DB.Begin()
3✔
1156
        if respondwith.ErrorText(w, err) {
3✔
1157
                return
×
1158
        }
×
1159
        defer sqlext.RollbackUnlessCommitted(tx)
3✔
1160
        ok, err := datamodel.CanMoveExistingCommitment(dbCommitment.Amount, loc, sourceResourceID, targetResourceID, p.Cluster, tx)
3✔
1161
        if respondwith.ErrorText(w, err) {
3✔
1162
                return
×
1163
        }
×
1164
        if !ok {
4✔
1165
                http.Error(w, "not enough committable capacity on the receiving side", http.StatusConflict)
1✔
1166
                return
1✔
1167
        }
1✔
1168

1169
        dbCommitment.TransferStatus = ""
2✔
1170
        dbCommitment.TransferToken = None[string]()
2✔
1171
        dbCommitment.AZResourceID = targetAZResourceID
2✔
1172
        _, err = tx.Update(&dbCommitment)
2✔
1173
        if respondwith.ErrorText(w, err) {
2✔
1174
                return
×
1175
        }
×
1176
        err = tx.Commit()
2✔
1177
        if respondwith.ErrorText(w, err) {
2✔
1178
                return
×
1179
        }
×
1180

1181
        serviceInfos, err := p.Cluster.InfoForService(loc.ServiceType)
2✔
1182
        if respondwith.ErrorText(w, err) {
2✔
NEW
1183
                return
×
NEW
1184
        }
×
1185
        resourceInfo := core.InfoForResource(serviceInfos, loc.ServiceType, loc.ResourceName)
2✔
1186
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
2✔
1187
        p.auditor.Record(audittools.Event{
2✔
1188
                Time:       p.timeNow(),
2✔
1189
                Request:    r,
2✔
1190
                User:       token,
2✔
1191
                ReasonCode: http.StatusAccepted,
2✔
1192
                Action:     cadf.UpdateAction,
2✔
1193
                Target: commitmentEventTarget{
2✔
1194
                        DomainID:    dbDomain.UUID,
2✔
1195
                        DomainName:  dbDomain.Name,
2✔
1196
                        ProjectID:   targetProject.UUID,
2✔
1197
                        ProjectName: targetProject.Name,
2✔
1198
                        Commitments: []limesresources.Commitment{c},
2✔
1199
                },
2✔
1200
        })
2✔
1201

2✔
1202
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
1203
}
1204

1205
// GetCommitmentConversion handles GET /v1/commitment-conversion/{service_type}/{resource_name}
1206
func (p *v1Provider) GetCommitmentConversions(w http.ResponseWriter, r *http.Request) {
2✔
1207
        httpapi.IdentifyEndpoint(r, "/v1/commitment-conversion/:service_type/:resource_name")
2✔
1208
        token := p.CheckToken(r)
2✔
1209
        if !token.Require(w, "cluster:show_basic") {
2✔
1210
                return
×
1211
        }
×
1212

1213
        // TODO v2 API: This endpoint should be project-scoped in order to make it
1214
        // easier to select the correct domain scope for the CommitmentBehavior.
1215
        forTokenScope := func(behavior core.CommitmentBehavior) core.ScopedCommitmentBehavior {
12✔
1216
                name := cmp.Or(token.ProjectScopeDomainName(), token.DomainScopeName(), "")
10✔
1217
                if name != "" {
20✔
1218
                        return behavior.ForDomain(name)
10✔
1219
                }
10✔
1220
                return behavior.ForCluster()
×
1221
        }
1222

1223
        // validate request
1224
        vars := mux.Vars(r)
2✔
1225
        serviceInfos, err := p.Cluster.AllServiceInfos()
2✔
1226
        if respondwith.ErrorText(w, err) {
2✔
NEW
1227
                return
×
NEW
1228
        }
×
1229

1230
        nm := core.BuildResourceNameMapping(p.Cluster, serviceInfos)
2✔
1231
        sourceServiceType, sourceResourceName, exists := nm.MapFromV1API(
2✔
1232
                limes.ServiceType(vars["service_type"]),
2✔
1233
                limesresources.ResourceName(vars["resource_name"]),
2✔
1234
        )
2✔
1235
        if !exists {
2✔
1236
                msg := fmt.Sprintf("no such service and/or resource: %s/%s", vars["service_type"], vars["resource_name"])
×
1237
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
1238
                return
×
1239
        }
×
1240
        sourceBehavior := forTokenScope(p.Cluster.CommitmentBehaviorForResource(sourceServiceType, sourceResourceName))
2✔
1241

2✔
1242
        sourceResInfo := core.InfoForResource(serviceInfos, sourceServiceType, sourceResourceName)
2✔
1243

2✔
1244
        // enumerate possible conversions
2✔
1245
        conversions := make([]limesresources.CommitmentConversionRule, 0)
2✔
1246
        if sourceBehavior.ConversionRule.IsSome() {
4✔
1247
                for _, targetServiceType := range core.ServiceTypesInAlphabeticalOrder(serviceInfos) {
8✔
1248
                        for targetResourceName, targetResInfo := range core.ResourcesForService(serviceInfos, targetServiceType) {
26✔
1249
                                if sourceServiceType == targetServiceType && sourceResourceName == targetResourceName {
22✔
1250
                                        continue
2✔
1251
                                }
1252
                                if sourceResInfo.Unit != targetResInfo.Unit {
28✔
1253
                                        continue
10✔
1254
                                }
1255

1256
                                targetBehavior := forTokenScope(p.Cluster.CommitmentBehaviorForResource(targetServiceType, targetResourceName))
8✔
1257
                                if rate, ok := sourceBehavior.GetConversionRateTo(targetBehavior).Unpack(); ok {
12✔
1258
                                        apiServiceType, apiResourceName, ok := nm.MapToV1API(targetServiceType, targetResourceName)
4✔
1259
                                        if ok {
8✔
1260
                                                conversions = append(conversions, limesresources.CommitmentConversionRule{
4✔
1261
                                                        FromAmount:     rate.FromAmount,
4✔
1262
                                                        ToAmount:       rate.ToAmount,
4✔
1263
                                                        TargetService:  apiServiceType,
4✔
1264
                                                        TargetResource: apiResourceName,
4✔
1265
                                                })
4✔
1266
                                        }
4✔
1267
                                }
1268
                        }
1269
                }
1270
        }
1271

1272
        // use a defined sorting to ensure deterministic behavior in tests
1273
        slices.SortFunc(conversions, func(lhs, rhs limesresources.CommitmentConversionRule) int {
5✔
1274
                result := strings.Compare(string(lhs.TargetService), string(rhs.TargetService))
3✔
1275
                if result != 0 {
5✔
1276
                        return result
2✔
1277
                }
2✔
1278
                return strings.Compare(string(lhs.TargetResource), string(rhs.TargetResource))
1✔
1279
        })
1280

1281
        respondwith.JSON(w, http.StatusOK, map[string]any{"conversions": conversions})
2✔
1282
}
1283

1284
// ConvertCommitment handles POST /v1/domains/{domain_id}/projects/{project_id}/commitments/{commitment_id}/convert
1285
func (p *v1Provider) ConvertCommitment(w http.ResponseWriter, r *http.Request) {
9✔
1286
        httpapi.IdentifyEndpoint(r, "/v1/domains/:domain_id/projects/:project_id/commitments/:commitment_id/convert")
9✔
1287
        token := p.CheckToken(r)
9✔
1288
        if !token.Require(w, "project:edit") {
9✔
1289
                return
×
1290
        }
×
1291
        commitmentID := mux.Vars(r)["commitment_id"]
9✔
1292
        if commitmentID == "" {
9✔
1293
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
1294
                return
×
1295
        }
×
1296
        dbDomain := p.FindDomainFromRequest(w, r)
9✔
1297
        if dbDomain == nil {
9✔
1298
                return
×
1299
        }
×
1300
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
9✔
1301
        if dbProject == nil {
9✔
1302
                return
×
1303
        }
×
1304

1305
        // section: sourceBehavior
1306
        var dbCommitment db.ProjectCommitment
9✔
1307
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
9✔
1308
        if errors.Is(err, sql.ErrNoRows) {
10✔
1309
                http.Error(w, "no such commitment", http.StatusNotFound)
1✔
1310
                return
1✔
1311
        } else if respondwith.ErrorText(w, err) {
9✔
1312
                return
×
1313
        }
×
1314
        var sourceLoc core.AZResourceLocation
8✔
1315
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
8✔
1316
                Scan(&sourceLoc.ServiceType, &sourceLoc.ResourceName, &sourceLoc.AvailabilityZone)
8✔
1317
        if errors.Is(err, sql.ErrNoRows) {
8✔
1318
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1319
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
1320
                return
×
1321
        } else if respondwith.ErrorText(w, err) {
8✔
1322
                return
×
1323
        }
×
1324
        sourceBehavior := p.Cluster.CommitmentBehaviorForResource(sourceLoc.ServiceType, sourceLoc.ResourceName).ForDomain(dbDomain.Name)
8✔
1325

8✔
1326
        // section: targetBehavior
8✔
1327
        var parseTarget struct {
8✔
1328
                Request struct {
8✔
1329
                        TargetService  limes.ServiceType           `json:"target_service"`
8✔
1330
                        TargetResource limesresources.ResourceName `json:"target_resource"`
8✔
1331
                        SourceAmount   uint64                      `json:"source_amount"`
8✔
1332
                        TargetAmount   uint64                      `json:"target_amount"`
8✔
1333
                } `json:"commitment"`
8✔
1334
        }
8✔
1335
        if !RequireJSON(w, r, &parseTarget) {
8✔
1336
                return
×
1337
        }
×
1338
        req := parseTarget.Request
8✔
1339
        serviceInfos, err := p.Cluster.AllServiceInfos()
8✔
1340
        if respondwith.ErrorText(w, err) {
8✔
NEW
1341
                return
×
NEW
1342
        }
×
1343
        nm := core.BuildResourceNameMapping(p.Cluster, serviceInfos)
8✔
1344
        targetServiceType, targetResourceName, exists := nm.MapFromV1API(req.TargetService, req.TargetResource)
8✔
1345
        if !exists {
8✔
1346
                msg := fmt.Sprintf("no such service and/or resource: %s/%s", req.TargetService, req.TargetResource)
×
1347
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
1348
                return
×
1349
        }
×
1350
        targetBehavior := p.Cluster.CommitmentBehaviorForResource(targetServiceType, targetResourceName).ForDomain(dbDomain.Name)
8✔
1351
        if sourceLoc.ResourceName == targetResourceName && sourceLoc.ServiceType == targetServiceType {
9✔
1352
                http.Error(w, "conversion attempt to the same resource.", http.StatusConflict)
1✔
1353
                return
1✔
1354
        }
1✔
1355
        if len(targetBehavior.Durations) == 0 {
7✔
1356
                msg := fmt.Sprintf("commitments are not enabled for resource %s/%s", req.TargetService, req.TargetResource)
×
1357
                http.Error(w, msg, http.StatusUnprocessableEntity)
×
1358
                return
×
1359
        }
×
1360
        rate, ok := sourceBehavior.GetConversionRateTo(targetBehavior).Unpack()
7✔
1361
        if !ok {
8✔
1362
                msg := fmt.Sprintf("commitment is not convertible into resource %s/%s", req.TargetService, req.TargetResource)
1✔
1363
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
1364
                return
1✔
1365
        }
1✔
1366

1367
        // section: conversion
1368
        if req.SourceAmount > dbCommitment.Amount {
6✔
1369
                msg := fmt.Sprintf("unprocessable source amount. provided: %v, commitment: %v", req.SourceAmount, dbCommitment.Amount)
×
1370
                http.Error(w, msg, http.StatusConflict)
×
1371
                return
×
1372
        }
×
1373
        conversionAmount := (req.SourceAmount / rate.FromAmount) * rate.ToAmount
6✔
1374
        remainderAmount := req.SourceAmount % rate.FromAmount
6✔
1375
        if remainderAmount > 0 {
8✔
1376
                msg := fmt.Sprintf("amount: %v does not fit into conversion rate of: %v", req.SourceAmount, rate.FromAmount)
2✔
1377
                http.Error(w, msg, http.StatusConflict)
2✔
1378
                return
2✔
1379
        }
2✔
1380
        if conversionAmount != req.TargetAmount {
5✔
1381
                msg := fmt.Sprintf("conversion mismatch. provided: %v, calculated: %v", req.TargetAmount, conversionAmount)
1✔
1382
                http.Error(w, msg, http.StatusConflict)
1✔
1383
                return
1✔
1384
        }
1✔
1385

1386
        tx, err := p.DB.Begin()
3✔
1387
        if respondwith.ErrorText(w, err) {
3✔
1388
                return
×
1389
        }
×
1390
        defer sqlext.RollbackUnlessCommitted(tx)
3✔
1391

3✔
1392
        var (
3✔
1393
                targetResourceID   db.ProjectResourceID
3✔
1394
                targetAZResourceID db.ProjectAZResourceID
3✔
1395
        )
3✔
1396
        err = p.DB.QueryRow(findTargetAZResourceByTargetProjectQuery, dbProject.ID, targetServiceType, targetResourceName, sourceLoc.AvailabilityZone).
3✔
1397
                Scan(&targetResourceID, &targetAZResourceID)
3✔
1398
        if respondwith.ErrorText(w, err) {
3✔
1399
                return
×
1400
        }
×
1401
        // defense in depth. ServiceType and ResourceName of source and target are already checked. Here it's possible to explicitly check the ID's.
1402
        if dbCommitment.AZResourceID == targetAZResourceID {
3✔
1403
                http.Error(w, "conversion attempt to the same resource.", http.StatusConflict)
×
1404
                return
×
1405
        }
×
1406
        targetLoc := core.AZResourceLocation{
3✔
1407
                ServiceType:      targetServiceType,
3✔
1408
                ResourceName:     targetResourceName,
3✔
1409
                AvailabilityZone: sourceLoc.AvailabilityZone,
3✔
1410
        }
3✔
1411
        // The commitment at the source resource was already confirmed and checked.
3✔
1412
        // Therefore only the addition to the target resource has to be checked against.
3✔
1413
        if dbCommitment.ConfirmedAt.IsSome() {
5✔
1414
                ok, err := datamodel.CanConfirmNewCommitment(targetLoc, targetResourceID, conversionAmount, p.Cluster, p.DB)
2✔
1415
                if respondwith.ErrorText(w, err) {
2✔
1416
                        return
×
1417
                }
×
1418
                if !ok {
3✔
1419
                        http.Error(w, "not enough capacity to confirm the commitment", http.StatusUnprocessableEntity)
1✔
1420
                        return
1✔
1421
                }
1✔
1422
        }
1423

1424
        auditEvent := commitmentEventTarget{
2✔
1425
                DomainID:    dbDomain.UUID,
2✔
1426
                DomainName:  dbDomain.Name,
2✔
1427
                ProjectID:   dbProject.UUID,
2✔
1428
                ProjectName: dbProject.Name,
2✔
1429
        }
2✔
1430

2✔
1431
        relatedCommitmentIDs := make([]db.ProjectCommitmentID, 0)
2✔
1432
        remainingAmount := dbCommitment.Amount - req.SourceAmount
2✔
1433
        resourceInfo := core.InfoForResource(serviceInfos, sourceLoc.ServiceType, sourceLoc.ResourceName)
2✔
1434
        if remainingAmount > 0 {
3✔
1435
                remainingCommitment, err := p.buildSplitCommitment(dbCommitment, remainingAmount)
1✔
1436
                if respondwith.ErrorText(w, err) {
1✔
1437
                        return
×
1438
                }
×
1439
                relatedCommitmentIDs = append(relatedCommitmentIDs, remainingCommitment.ID)
1✔
1440
                err = tx.Insert(&remainingCommitment)
1✔
1441
                if respondwith.ErrorText(w, err) {
1✔
1442
                        return
×
1443
                }
×
1444
                auditEvent.Commitments = append(auditEvent.Commitments,
1✔
1445
                        p.convertCommitmentToDisplayForm(remainingCommitment, sourceLoc, token, resourceInfo.Unit),
1✔
1446
                )
1✔
1447
        }
1448

1449
        convertedCommitment, err := p.buildConvertedCommitment(dbCommitment, targetAZResourceID, conversionAmount)
2✔
1450
        if respondwith.ErrorText(w, err) {
2✔
1451
                return
×
1452
        }
×
1453
        relatedCommitmentIDs = append(relatedCommitmentIDs, convertedCommitment.ID)
2✔
1454
        err = tx.Insert(&convertedCommitment)
2✔
1455
        if respondwith.ErrorText(w, err) {
2✔
1456
                return
×
1457
        }
×
1458

1459
        // supersede the original commitment
1460
        now := p.timeNow()
2✔
1461
        supersedeContext := db.CommitmentWorkflowContext{
2✔
1462
                Reason:               db.CommitmentReasonConvert,
2✔
1463
                RelatedCommitmentIDs: relatedCommitmentIDs,
2✔
1464
        }
2✔
1465
        buf, err := json.Marshal(supersedeContext)
2✔
1466
        if respondwith.ErrorText(w, err) {
2✔
1467
                return
×
1468
        }
×
1469
        dbCommitment.State = db.CommitmentStateSuperseded
2✔
1470
        dbCommitment.SupersededAt = Some(now)
2✔
1471
        dbCommitment.SupersedeContextJSON = Some(json.RawMessage(buf))
2✔
1472
        _, err = tx.Update(&dbCommitment)
2✔
1473
        if respondwith.ErrorText(w, err) {
2✔
1474
                return
×
1475
        }
×
1476

1477
        err = tx.Commit()
2✔
1478
        if respondwith.ErrorText(w, err) {
2✔
1479
                return
×
1480
        }
×
1481

1482
        c := p.convertCommitmentToDisplayForm(convertedCommitment, targetLoc, token, resourceInfo.Unit)
2✔
1483
        auditEvent.Commitments = append([]limesresources.Commitment{c}, auditEvent.Commitments...)
2✔
1484
        auditEvent.WorkflowContext = Some(db.CommitmentWorkflowContext{
2✔
1485
                Reason:               db.CommitmentReasonSplit,
2✔
1486
                RelatedCommitmentIDs: []db.ProjectCommitmentID{dbCommitment.ID},
2✔
1487
        })
2✔
1488
        p.auditor.Record(audittools.Event{
2✔
1489
                Time:       p.timeNow(),
2✔
1490
                Request:    r,
2✔
1491
                User:       token,
2✔
1492
                ReasonCode: http.StatusAccepted,
2✔
1493
                Action:     cadf.UpdateAction,
2✔
1494
                Target:     auditEvent,
2✔
1495
        })
2✔
1496

2✔
1497
        respondwith.JSON(w, http.StatusAccepted, map[string]any{"commitment": c})
2✔
1498
}
1499

1500
// ExtendCommitmentDuration handles POST /v1/domains/{domain_id}/projects/{project_id}/commitments/{commitment_id}/update-duration
1501
func (p *v1Provider) UpdateCommitmentDuration(w http.ResponseWriter, r *http.Request) {
6✔
1502
        httpapi.IdentifyEndpoint(r, "/v1/domains/:domain_id/projects/:project_id/commitments/:commitment_id/update-duration")
6✔
1503
        token := p.CheckToken(r)
6✔
1504
        if !token.Require(w, "project:edit") {
6✔
1505
                return
×
1506
        }
×
1507
        commitmentID := mux.Vars(r)["commitment_id"]
6✔
1508
        if commitmentID == "" {
6✔
1509
                http.Error(w, "no transfer token provided", http.StatusBadRequest)
×
1510
                return
×
1511
        }
×
1512
        dbDomain := p.FindDomainFromRequest(w, r)
6✔
1513
        if dbDomain == nil {
6✔
1514
                return
×
1515
        }
×
1516
        dbProject := p.FindProjectFromRequest(w, r, dbDomain)
6✔
1517
        if dbProject == nil {
6✔
1518
                return
×
1519
        }
×
1520
        var Request struct {
6✔
1521
                Duration limesresources.CommitmentDuration `json:"duration"`
6✔
1522
        }
6✔
1523
        req := Request
6✔
1524
        if !RequireJSON(w, r, &req) {
6✔
1525
                return
×
1526
        }
×
1527

1528
        var dbCommitment db.ProjectCommitment
6✔
1529
        err := p.DB.SelectOne(&dbCommitment, findProjectCommitmentByIDQuery, commitmentID, dbProject.ID)
6✔
1530
        if errors.Is(err, sql.ErrNoRows) {
6✔
1531
                http.Error(w, "no such commitment", http.StatusNotFound)
×
1532
                return
×
1533
        } else if respondwith.ErrorText(w, err) {
6✔
1534
                return
×
1535
        }
×
1536

1537
        now := p.timeNow()
6✔
1538
        if dbCommitment.ExpiresAt.Before(now) || dbCommitment.ExpiresAt.Equal(now) {
7✔
1539
                http.Error(w, "unable to process expired commitment", http.StatusForbidden)
1✔
1540
                return
1✔
1541
        }
1✔
1542

1543
        if dbCommitment.State == db.CommitmentStateSuperseded {
6✔
1544
                msg := fmt.Sprintf("unable to operate on commitment with a state of %s", dbCommitment.State)
1✔
1545
                http.Error(w, msg, http.StatusForbidden)
1✔
1546
                return
1✔
1547
        }
1✔
1548

1549
        var loc core.AZResourceLocation
4✔
1550
        err = p.DB.QueryRow(findProjectAZResourceLocationByIDQuery, dbCommitment.AZResourceID).
4✔
1551
                Scan(&loc.ServiceType, &loc.ResourceName, &loc.AvailabilityZone)
4✔
1552
        if errors.Is(err, sql.ErrNoRows) {
4✔
1553
                // defense in depth: this should not happen because all the relevant tables are connected by FK constraints
×
1554
                http.Error(w, "no route to this commitment", http.StatusNotFound)
×
1555
                return
×
1556
        } else if respondwith.ErrorText(w, err) {
4✔
1557
                return
×
1558
        }
×
1559
        behavior := p.Cluster.CommitmentBehaviorForResource(loc.ServiceType, loc.ResourceName).ForDomain(dbDomain.Name)
4✔
1560
        if !slices.Contains(behavior.Durations, req.Duration) {
5✔
1561
                msg := fmt.Sprintf("provided duration: %s does not match the config %v", req.Duration, behavior.Durations)
1✔
1562
                http.Error(w, msg, http.StatusUnprocessableEntity)
1✔
1563
                return
1✔
1564
        }
1✔
1565

1566
        newExpiresAt := req.Duration.AddTo(dbCommitment.ConfirmBy.UnwrapOr(dbCommitment.CreatedAt))
3✔
1567
        if newExpiresAt.Before(dbCommitment.ExpiresAt) {
4✔
1568
                msg := fmt.Sprintf("duration change from %s to %s forbidden", dbCommitment.Duration, req.Duration)
1✔
1569
                http.Error(w, msg, http.StatusForbidden)
1✔
1570
                return
1✔
1571
        }
1✔
1572

1573
        dbCommitment.Duration = req.Duration
2✔
1574
        dbCommitment.ExpiresAt = newExpiresAt
2✔
1575
        _, err = p.DB.Update(&dbCommitment)
2✔
1576
        if respondwith.ErrorText(w, err) {
2✔
1577
                return
×
1578
        }
×
1579

1580
        serviceInfos, err := p.Cluster.InfoForService(loc.ServiceType)
2✔
1581
        if respondwith.ErrorText(w, err) {
2✔
NEW
1582
                return
×
NEW
1583
        }
×
1584
        resourceInfo := core.InfoForResource(serviceInfos, loc.ServiceType, loc.ResourceName)
2✔
1585
        c := p.convertCommitmentToDisplayForm(dbCommitment, loc, token, resourceInfo.Unit)
2✔
1586
        p.auditor.Record(audittools.Event{
2✔
1587
                Time:       p.timeNow(),
2✔
1588
                Request:    r,
2✔
1589
                User:       token,
2✔
1590
                ReasonCode: http.StatusOK,
2✔
1591
                Action:     cadf.UpdateAction,
2✔
1592
                Target: commitmentEventTarget{
2✔
1593
                        DomainID:    dbDomain.UUID,
2✔
1594
                        DomainName:  dbDomain.Name,
2✔
1595
                        ProjectID:   dbProject.UUID,
2✔
1596
                        ProjectName: dbProject.Name,
2✔
1597
                        Commitments: []limesresources.Commitment{c},
2✔
1598
                },
2✔
1599
        })
2✔
1600

2✔
1601
        respondwith.JSON(w, http.StatusOK, map[string]any{"commitment": c})
2✔
1602
}
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